Цена и бавност: бюджет, кеш, по-малък модел
Накратко: Цената на езиков модел е токени × цена, отделно за входа и за изхода; бюджет на токени, кеш на отговорите и по-малък модел свалят сметката, а кешът и малкият модел и времето за отговор.
Проблем
Сметката в края на месеца
Асистентът на поддръжката работи от месец. Отговаря вярно, а наборът от урока за оценяването минава. После идват две оплаквания: сметката за модела е по-висока от очакваното, а клиентите чакат за отговор около секунда.
Ето едно извикване, както го прави асистентът. Цените на токените са примерни, в евро за милион токена:
var question = "Колко струва доставката?";var r = client.Create(large, 1024, prompt + " " + allDocs, [new Message("user", question)]);var m = models[large];var euro = Cost(m, r.Usage);var input = r.Usage.InputTokens * m.InPrice / 1_000_000;Console.WriteLine($"вход: {r.Usage.InputTokens} токена, изход: {r.Usage.OutputTokens} токена");Console.WriteLine($"цена: {euro:F5} € (входът е {100 * input / euro:F0}%)");Console.WriteLine($"примерно време: {Latency(m, r.Usage)} ms");вход: 314 токена, изход: 32 токенацена: 0.00177 € (входът е 71%)примерно време: 1071 msВъпросът „Колко струва доставката?“ е 6 токена, а входът е 314. Част от сметката е отговорът, а по-голямата част е входът: системният промпт и всички документи на магазина пътуват с всеки въпрос. А в дневника половината въпроси са едни и същи: колко струва доставката, кога работи поддръжката.
Идеята
Откъде идват парите и времето
Моделът се плаща на токени (урокът за токените), отделно за входа и за изхода:
- входът е всичко, което пращаш: системният промпт, документите, историята на разговора, въпросът. API-то няма памет, затова това се праща всеки път (урокът за контекста);
- изходът е отговорът. Токен изход обикновено струва няколко пъти повече от токен вход, тук 4 пъти.
Времето е подобно, но с друг баланс. Моделът чете входа бързо, а отговора го пише токен по токен. Затова дългият отговор е бавен, а дългият вход е най-вече скъп.
Оттук и трите поправки, които урокът мери с програма:
- бюджет на токени: по-малко вход (само нужното) и таван за изхода;
- кеш: еднаквият въпрос не стига до модела;
- по-малък модел за лесните въпроси: по-евтин и по-бърз токен.
Бюджетът на токени е горна граница на токените на едно извикване, планирана предварително, както планираш паметта или времето за отговор на една заявка.
Виж го
Асистентът в код
Фалшивият клиент е същият като в урока за оценяването: формата на истинското API, записани отговори и груби токени (всяка дума е 2 токена). Отговорите са записани по дума във въпроса:
// фалшив клиент: формата на истинското API, отговорите са записаниrecord Message(string Role, string Content);
record Usage(int InputTokens, int OutputTokens);
record Response(string Text, string StopReason, Usage Usage);
// записан отговор: модел ("" = всеки), дума във въпроса, нужен текст в промптаrecord Recorded(string Model, string Keyword, string Needs, string Text);
class FakeLlm(List<Recorded> recorded){ // грубо: всяка дума е 2 токена public static int Tokens(string text) => 2 * text.Split([' ', '\n'], StringSplitOptions.RemoveEmptyEntries).Length;
public Response Create(string model, int maxTokens, string system, List<Message> messages) { var question = messages[^1].Content; var input = Tokens(system) + messages.Sum(m => Tokens(m.Content)); foreach (var r in recorded) { var forModel = r.Model == "" || r.Model == model; if (forModel && question.Contains(r.Keyword) && system.Contains(r.Needs)) return Reply(r.Text, maxTokens, input); } return Reply("(няма записан отговор)", maxTokens, input); }
// max_tokens реже отговора static Response Reply(string text, int maxTokens, int input) { var words = text.Split(' '); if (2 * words.Length <= maxTokens) return new Response(text, "end_turn", new Usage(input, 2 * words.Length)); var kept = maxTokens / 2; var cut = string.Join(' ', words[..kept]); return new Response(cut, "max_tokens", new Usage(input, 2 * kept)); }}List<Recorded> recorded =[ new("", "струва", "", "Доставката до адрес е 4,90 €, до офис 3,50 €, а над 50 € е безплатна."), new("", "наложен", "", "Да, може с наложен платеж. Таксата е 1,50 €."), new("", "върна", "", "Можеш да върнеш стоката до 14 дни след доставката, ако е запазена и с етикета."), new("", "работи", "", "Поддръжката работи от понеделник до петък, от 9 до 18 ч."), new("", "1042", "", "Поръчка 1042 е изпратена и пристига в четвъртък."), new("", "1077", "", "Поръчка 1077 се приготвя и тръгва утре."), new("", "повредена", "", "Съжалявам! Снимай пратката и етикета и ни пиши с номера на поръчката. Ще ти изпратим нова или ще върнем парите до 5 работни дни."), new("", "дни", "", "Пратките пристигат за 2 до 4 работни дни."), new("", "едро", "", "Не знам, ще те свържа с колега."), new("", "Mastercard", "", "Да, приемаме Visa и Mastercard."),];Документите на магазина са пет раздела. Всеки има думи, по които се познава:
var prompt = "Ти си асистентът на поддръжката на онлайн магазин. Отговаряй кратко и само от документите. Ако отговорът го няма, кажи: „Не знам, ще те свържа с колега.“";List<Doc> docs =[ new(["доставка", "пратка"], "Доставка: пратките пристигат за 2 до 4 работни дни. До адрес доставката струва 4,90 €, до офис на куриер 3,50 €, а над 50 € е безплатна. Пратката се следи по номера ѝ."), new(["върна", "повредена"], "Връщане: стоката се връща до 14 дни след доставката, ако е запазена и с етикета. Повредена пратка се снима и се пише на поддръжката. Парите се връщат по картата до 5 работни дни."), new(["плат", "карт", "mastercard"], "Плащане: приемаме Visa и Mastercard. Наложеният платеж е с такса 1,50 €. Плащането с карта е защитено, а сумата се тегли при потвърждаване на поръчката."), new(["поръчка"], "Поръчки: статусът на поръчката се вижда в профила. Поддръжката проверява поръчка само по номера ѝ и никога не иска данните на картата."), new(["работи", "работно"], "Поддръжка: работим от понеделник до петък, от 9 до 18 ч. Пиши ни на help@shop.example."),];var allDocs = string.Join(" ", docs.Select(doc => doc.Text));
// раздел от документите и думите, по които се познаваrecord Doc(List<string> Triggers, string Text);Моделите, цените и времената. Всички числа тук са примерни, а времето е по формула, не е мерено:
// примерни цени в € за 1 000 000 токена и примерно време (не са мерени)const string large = "model-large", small = "model-small";var models = new Dictionary<string, ModelInfo>{ [large] = new(4.00, 16.00, 400, 10, 20), [small] = new(0.40, 1.60, 150, 40, 5),};const int cacheMs = 5;
record ModelInfo(double InPrice, double OutPrice, int BaseMs, int InputPerMs, int MsPerToken);// цената на едно извикване в €static double Cost(ModelInfo m, Usage u) => (u.InputTokens * m.InPrice + u.OutputTokens * m.OutPrice) / 1_000_000;
// примерно време в ms: начало + прочитане на входа + генериранеstatic int Latency(ModelInfo m, Usage u) => m.BaseMs + u.InputTokens / m.InputPerMs + u.OutputTokens * m.MsPerToken;Отговорът за поръчка 1042 е записан. В истинския асистент статусът идва от инструмент (урокът за инструментите).
1_000_000 е същото като 1000000. u.InputTokens / m.InputPerMs е деление на цели числа и реже остатъка.Пробвай
Попълни
Цената е токените вход по цената на входа плюс токените изход по…?
(u.InputTokens * m.InPrice + u.OutputTokens * ) / 1_000_000;Изходът има своя цена, тук 16 € за милион срещу 4 € за входа. Ако сметнеш изхода по цената на входа, сметката излиза четири пъти по-ниска за отговорите, а те са по-скъпата част на токен.
Виж го
Един ден
Ден в поддръжката е мостра от 20 въпроса. Някои се повтарят, понякога с малка буква или с излишен интервал. Три са за конкретна поръчка:
List<string> questions =[ "Колко струва доставката?", "Кога работи поддръжката?", "Къде е поръчка 1042?", "колко струва доставката?", "Мога ли да платя с наложен платеж?", "До кога мога да върна стоката?", "Колко струва доставката?", "Пратката ми дойде повредена, какво да правя?", "Кога работи поддръжката?", "Къде е поръчка 1077?", "Приемате ли Mastercard?", "Колко струва доставката? ", "За колко дни идва пратка до Бургас?", "Мога ли да платя с наложен платеж?", "Продавате ли на едро?", "До кога мога да върна стоката?", "Къде е поръчка 1042?", "Кога работи поддръжката?", "Колко струва доставката до офис?", "Как да върна стоката?",];day пуска въпросите и събира цената и времето. Трите флага включват трите поправки, засега са изключени:
Totals Day(List<string> questions, bool budget, bool cache, bool route){ var answers = new Dictionary<string, string>(); double euro = 0; int ms = 0, hits = 0, onSmall = 0; foreach (var question in questions) { var k = Key(question); // попадение: без модел if (cache && answers.ContainsKey(k)) { hits++; ms += cacheMs; continue; } var model = route ? PickModel(question) : large; var docs = budget ? Relevant(question) : allDocs; var maxTokens = budget ? 200 : 1024; var msg = new Message("user", question); var r = client.Create(model, maxTokens, prompt + " " + docs, [msg]); var m = models[model]; euro += Cost(m, r.Usage); ms += Latency(m, r.Usage); if (model == small) onSmall++; // поръчката е на един клиент: не се кешира if (cache && !k.Contains("поръчка")) answers[k] = r.Text; } return new Totals(euro, ms, hits, onSmall);}static void Row(string name, Totals t, int n) => Console.WriteLine($"{name}: {t.Euro / n * 1000:F2} € за 1000 въпроса, средно {t.Ms / n} ms");
static void Month(Totals t, int n) => Console.WriteLine($"месец при 3000 въпроса на ден: {t.Euro / n * 3000 * 30:F2} €");var all = Day(questions, false, false, false);Row("голям модел, всички документи", all, questions.Count);Month(all, questions.Count);голям модел, всички документи: 1.66 € за 1000 въпроса, средно 917 msмесец при 3000 въпроса на ден: 149.08 €Цената за 1000 въпроса е под 2 €. При 3000 въпроса на ден месецът е около 150 €, а един въпрос чака средно почти секунда.
Идеята
Бюджет на токени
Първата поправка е входът. На въпроса за доставката трябва само разделът за доставката. relevant взима разделите, чиито думи има във въпроса, а ако няма нито един, праща всички (въпросът „на едро“ трябва да види всичко, за да каже „Не знам“):
// бюджет: само разделите, чиито думи има във въпросаstring Relevant(string question){ var low = question.ToLowerInvariant(); var parts = docs.Where(doc => doc.Triggers.Any(low.Contains)).Select(doc => doc.Text).ToList(); return parts.Count == 0 ? allDocs : string.Join(" ", parts);}Вторият таван е max_tokens: най-много 200 токена за отговор. Той не прави отговорите по-кратки. Той казва колко най-много може да струва едно извикване.
var all = Day(questions, false, false, false);Row("голям модел, всички документи", all, questions.Count);var budget = Day(questions, true, false, false);Row("+ бюджет на токени", budget, questions.Count);голям модел, всички документи: 1.66 € за 1000 въпроса, средно 917 ms+ бюджет на токени: 0.90 € за 1000 въпроса, средно 898 msЦената пада почти наполовина. Времето почти не мърда: входът се чете бързо, а отговорът е същият.
Тук разделите се избират по думи. Как се избират по смисъл, с вектори, е в модула за търсене по смисъл (RAG).
doc.Triggers.Any(low.Contains) е вярно, ако поне една дума от раздела се съдържа във въпроса. low.Contains е подаден като функция.Пробвай
Предвиди изхода
max_tokens е 10, а въпросът е за повредена пратка. Какво ще отпечата?
var r = client.Create(large, 10, prompt, [new Message("user", "Пратката ми дойде повредена, какво да правя?")]);Console.WriteLine(r.Text);Console.WriteLine($"{r.StopReason}, {r.Usage.OutputTokens}");Таванът спира отговора на 10 токена, тук 5 думи, насред изречението. stop_reason е max_tokens, а не end_turn. Таванът пази сметката, но твърде нисък таван дава половин отговор. Програмата трябва да проверява stop_reason, преди да покаже отговора на клиента.
Идеята
Кеш на отговорите
Втората поправка е повторението. Ако същият въпрос е задаван преди малко, отговорът е същият: не е нужно да питаш модела пак. Това е cache-aside от урока „Кеш“: гледаш в кеша, а при пропуск питаш модела и пазиш копие.
Ключът е въпросът с малки букви и по един интервал, така „колко струва доставката? “ е същото като „Колко струва доставката?“:
// ключът на кеша: малки букви, по един интервалstatic string Key(string question) => string.Join(' ', question.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries));В day това са редовете с answers: попадението връща записания отговор за 5 ms и без пари. Въпрос за поръчка не се кешира. Отговорът е на един клиент, а статусът се мени.
var all = Day(questions, false, false, false);Row("голям модел, всички документи", all, questions.Count);var budget = Day(questions, true, false, false);Row("+ бюджет на токени", budget, questions.Count);var cached = Day(questions, true, true, false);Row("+ кеш", cached, questions.Count);Console.WriteLine($"от кеша: {cached.Hits} от {questions.Count}");голям модел, всички документи: 1.66 € за 1000 въпроса, средно 917 ms+ бюджет на токени: 0.90 € за 1000 въпроса, средно 898 ms+ кеш: 0.59 € за 1000 въпроса, средно 568 msот кеша: 7 от 207 от 20 въпроса идват от кеша. Сега пада и времето, защото попадението не чака модела.
Пробвай
Намери грешката
Колега опростява кеша. Тестовете за цена минават, а след ден клиент се оплаква, че асистентът му е казал статуса на чужда поръчка. На кой ред е грешката?
Избери реда с грешката.
Кешът пази всеки отговор, и отговорите за поръчки. В истинския асистент въпросът „Къде ми е поръчката?“ е еднакъв за всички клиенти, а отговорът е на първия, който е питал. Следващият клиент получава чужд статус, а първият след ден получава стар. Отговорът за поръчка не се кешира: това е проверката, която колегата е махнал.
Идеята
По-малък модел за лесното
Третата поправка е моделът. Малкият модел е 10 пъти по-евтин на токен и пише по-бързо. В урока за оценяването наборът показа къде се справя: цената на доставката, плащането, връщането. И къде не: срока на доставката и въпроса извън документите. Затова при него отиват само одобрените теми:
// лесните теми: тестовият набор показа, че малкият модел ги поемаstring PickModel(string question){ string[] easy = ["струва", "наложен", "върна", "Mastercard"]; return easy.Any(question.Contains) ? small : large;}var all = Day(questions, false, false, false);Row("голям модел, всички документи", all, questions.Count);var budget = Day(questions, true, false, false);Row("+ бюджет на токени", budget, questions.Count);var cached = Day(questions, true, true, false);Row("+ кеш", cached, questions.Count);var routed = Day(questions, true, true, true);Row("+ малък модел за лесното", routed, questions.Count);Console.WriteLine($"при малкия модел: {routed.OnSmall} от {questions.Count}");Month(all, questions.Count);Month(routed, questions.Count);голям модел, всички документи: 1.66 € за 1000 въпроса, средно 917 ms+ бюджет на токени: 0.90 € за 1000 въпроса, средно 898 ms+ кеш: 0.59 € за 1000 въпроса, средно 568 ms+ малък модел за лесното: 0.35 € за 1000 въпроса, средно 376 msпри малкия модел: 6 от 20месец при 3000 въпроса на ден: 149.08 €месец при 3000 въпроса на ден: 31.75 €Месецът пада от около 149 € до около 32 €, а средното време от 917 до 376 ms. Числата са от примерни цени и примерна формула, но редът на подобренията е истински.
Виж го
Сметката, поправка по поправка
Totals Day(List<string> questions, bool budget, bool cache, bool route){ var answers = new Dictionary<string, string>(); double euro = 0; int ms = 0, hits = 0, onSmall = 0; foreach (var question in questions) { var k = Key(question); // попадение: без модел if (cache && answers.ContainsKey(k)) { hits++; ms += cacheMs; continue; } var model = route ? PickModel(question) : large; var docs = budget ? Relevant(question) : allDocs; var maxTokens = budget ? 200 : 1024; var msg = new Message("user", question); var r = client.Create(model, maxTokens, prompt + " " + docs, [msg]); var m = models[model]; euro += Cost(m, r.Usage); ms += Latency(m, r.Usage); if (model == small) onSmall++; // поръчката е на един клиент: не се кешира if (cache && !k.Contains("поръчка")) answers[k] = r.Text; } return new Totals(euro, ms, hits, onSmall);}Стъпка по стъпка, с думи
- Едно извикване: промптът, всички документи и въпросът влизат (314 токена), отговорът излиза (32). Документите са по-голямата част от сметката: 0,00177 €.
- Бюджетът на токени: само разделът, за който пита въпросът, и таван за отговора. 124 токена вход, 0,00101 € за същия отговор.
- Ден от 20 въпроса, на 1000 въпроса: 1,66 € с всички документи, 0,90 € с бюджета. Времето почти не мърда: то е най-вече отговорът.
- Кешът: 7 от 20 въпроса са повторение и не стигат до модела. Въпрос за поръчка не се кешира.
- Малкият модел поема лесните теми, които тестовият набор одобри: 6 от 20 въпроса. Той е по-евтин и по-бърз.
- От 1,66 € до 0,35 € за 1000 въпроса (79% по-малко) и от 917 до 376 ms. Цените и времената са примерни.
Идеята
Пусни при теб
Истинският отговор казва колко токена е използвал. Ето заявката с бюджета за въпроса за доставката и отговора. Текстът и бройките са от фалшивия клиент:
{ "model": "<името на модела>", "max_tokens": 200, "system": "<системният промпт>", "messages": [ { "role": "user", "content": "Колко струва доставката?" } ]}{ "content": [ { "type": "text", "text": "Доставката до адрес е 4,90 €, до офис 3,50 €, а над 50 € е безплатна." } ], "stop_reason": "end_turn", "usage": { "input_tokens": 124, "output_tokens": 32 }}Полетата са usage.input_tokens и usage.output_tokens, а stop_reason казва дали отговорът е цял (end_turn) или таванът го е отрязал (max_tokens). С тях програмата смята същата сметка като тук, само че с истински бройки.
Доставчиците имат официални SDK-та за Java, C#, Python и JavaScript със същата форма: съобщения, роли, токени, инструменти. Точните имена на класовете и методите са в документацията на доставчика. Тук заявката е пратена със стандартния HTTP клиент на езика, без SDK. Адресът и моделът са заместители:
using System.Text;using System.Text.Json;
// само за показ: адресът и заглавката за ключа са в документацията на доставчикаconst string Endpoint = "https://<доставчик>/...";var http = new HttpClient();
async Task<JsonElement> Ask(string system, string question){ var body = JsonSerializer.Serialize(new { model = "<името на модела>", max_tokens = 200, system, messages = new[] { new { role = "user", content = question } }, }); var request = new HttpRequestMessage(HttpMethod.Post, Endpoint) { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; var key = Environment.GetEnvironmentVariable("LLM_API_KEY"); request.Headers.Add("authorization", "Bearer " + key); var response = await http.SendAsync(request); var json = await response.Content.ReadAsStringAsync(); return JsonDocument.Parse(json).RootElement; // JSON: content, stop_reason, usage}
var reply = await Ask(prompt + " " + Relevant(question), question);var usage = reply.GetProperty("usage");Console.WriteLine($"{reply.GetProperty("stop_reason")} " + $"{usage.GetProperty("input_tokens")} {usage.GetProperty("output_tokens")}");Този код не се пуска в проверката на сайта. Ключът е в променливата на средата LLM_API_KEY, а не в кода (урокът „Тайни в кода и в git“), ти трябва акаунт при доставчик и всяко пускане струва пари.
JsonSerializer и JsonDocument са от System.Text.Json, а SendAsync е асинхронен, затова await.Идеята
Кога вреди
- Бюджет, който реже нужното. Ако
relevantпропусне раздела с отговора, моделът казва „Не знам“ или, по-лошо, измисля. Твърде нисъкmax_tokensдава половин отговор. Всяка смяна на бюджета минава през тестовия набор. - Кеш, който лъже. Цената на доставката се сменя, а кешът дава старата. Кешът има срок (TTL) и се трие, когато документите се сменят. Отговори, които зависят от клиента или от времето, не влизат в него.
- Малък модел без набор. „Изглежда по-просто“ не е довод. Малкият модел поема тема само след като наборът я е минал, и наборът се пуска пак при всяка смяна.
- Пестене, което не си струва. Ако асистентът получава 50 въпроса на ден, сметката е няколко евро на месец. Сложността на кеша и на рутера може да струва повече от тях. Първо мери.
По-точно, за любопитните
Кеш на началото на промпта. Много доставчици пазят повтарящото се начало на промпта (системния промпт и документите) и таксуват повторното му четене по-евтино. Това е друг кеш: моделът пак отговаря, но входът е по-евтин. Той работи, ако началото е еднакво до последния символ, затова променливите части (въпросът, името на клиента) са накрая.
Кеш по смисъл. „Как да върна стоката?“ и „До кога мога да върна стоката?“ са различни ключове. Кеш с вектори (урокът за векторите) ги смята за един въпрос. Той намира повече попадения, но и грешни: „Мога ли да върна обувки?“ и „Мога ли да върна подарък?“ са близки, а отговорите може да са различни.
Поточен отговор. Отговорът може да идва дума по дума (streaming). Общото време е същото, но клиентът вижда първите думи след стотина ms вместо след секунда. Това е бързина за човека, не за сметката.
Партиди. Работа, която не чака клиент (обобщение на 10 000 отзива през нощта), може да мине през партиден режим: по-бавно и по-евтино.
Времето в продукция. Средното време крие опашката: 95% от отговорите може да са под секунда, а 5% над 5 секунди. Мери процентили, както в урока „Нефункционални изисквания, които се мерят“ от „Софтуерно инженерство“. Самото следене на цената и времето в продукция е част от MLOps и се учи в ZeroToOps.
[large] = new(...) е инициализатор на речник по ключ, а string.Join(" ", ...) слепва текстовете с интервал.Упражнение
Коя поправка пести най-много
Реши задачата в редактора си.
Програмата пуска поправките натрупано, затова не се вижда колко дава всяка сама. Пусни day три пъти, всеки път само с един флаг, и отпечатай редовете до този без поправки:
голям модел, всички документи: 1.66 € за 1000 въпроса, средно 917 msсамо бюджет: 0.90 € за 1000 въпроса, средно 898 msсамо кеш: 1.06 € за 1000 въпроса, средно 580 msсамо малък модел: 0.82 € за 1000 въпроса, средно 544 msСам по себе си най-евтин е малкият модел, а бюджетът е малко след него. Бюджетът почти не пести време, а кешът и малкият модел го смъкват най-много, защото не чакат големия модел да пише. Трите пестят от различни места, затова заедно дават най-много.
Още задачи
- Още една лесна тема. Добави „работи“ в лесните теми. С колко пада цената? Какво трябва да направиш, преди да го пуснеш? (Подсказка: урокът за оценяването.)
- Таван за деня. Спри да питаш големия модел, когато цената за деня мине 0,01 €, и отговаряй „Ще те свържа с колега“. Колко въпроса остават без отговор от модела?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Текстът на парчета: токени · Прозорецът на контекста · Извикване на модел през API · Оценяване: тестов набор за асистента
- Продължава в: Нарязване на документи (chunking) · RAG: отговор от документите · Оценяване на RAG: намира ли и стъпва ли · Цикълът на агента: план, действие, наблюдение
- Задачи по темата: Приложения с LLM