Извикване на модел през API
Накратко: Извикването на езиков модел през API е заявка с модел, системен промпт и съобщения с роли; отговорът носи текста, причината за спиране и броя токени.
Проблем
Асистентът излиза пред клиентите
Досега моделите бяха малки и живееха в програмата. Асистентът на поддръжката трябва да отговаря на истински клиенти, а това прави голям модел при доставчик. Програмата го пита през API: праща заявка по мрежата и чака отговор.
Първият ден на разпродажбата клиентите виждат „Грешка 429“ вместо отговор. На друг клиент асистентът пита за номера на поръчката, който той току-що каза.
Как изглежда една заявка, какво връща моделът и какво правиш, когато отговорът е грешка?
Идеята
Заявка и отговор
Извикването на модел е HTTP заявка с JSON. Заявката носи:
model: кой модел;max_tokens: най-много колко токена да е отговорът;system: системният промпт, инструкцията за ролята и тона;messages: съобщенията с ролите им,user(клиентът) иassistant(предишните отговори на модела).
Отговорът носи:
content: списък от блокове сtype; текстът е в блокtext;stop_reason: защо моделът спря,end_turn(свърши) илиmax_tokens(стигна тавана);usage: колко токена влязоха (input_tokens) и колко излязоха (output_tokens). По тях се плаща.
API-то няма памет. Всяка заявка е сама за себе си и носи всичко, което моделът трябва да види, както в урока за прозореца на контекста. API-тата на различните доставчици имат същата форма: съобщения с роли, токени, инструменти.
Виж го
Фалшив клиент със записани отговори
Програмите в урока не викат истински модел: проверката им няма мрежа, а истинският отговор е различен всеки път (урокът за границите). Затова вместо клиента на доставчика има заместител със същата форма: взима model, max_tokens, system и messages и връща content, stop_reason и usage. Отговорите са записани предварително и се избират по дума в последното съобщение. Токените са преброени грубо, 4 на 3 думи.
record Message(string Role, string Content);
record Block(string Type, string Text);
record Usage(int InputTokens, int OutputTokens);
record Response(List<Block> Content, string StopReason, Usage Usage);
class ApiException(int status, string message) : Exception(message){ public int Status { get; } = status;}// заместител на истинския клиент: записани отговори, без мрежаclass FakeClient(List<int> script){ readonly Queue<int> failures = new(script);
public Response Create(string model, int maxTokens, string system, List<Message> messages) { if (failures.Count > 0) { var status = failures.Dequeue(); throw new ApiException(status, Reason(status)); } var text = Answer(messages); var stopReason = "end_turn"; if (Tokens(text) > maxTokens) { // max_tokens: отговорът спира по средата var words = text.Split(' '); text = string.Join(" ", words.Take(maxTokens * 3 / 4)); stopReason = "max_tokens"; } var input = Tokens(system) + messages.Sum(m => Tokens(m.Content)); var usage = new Usage(input, Tokens(text)); return new Response([new Block("text", text)], stopReason, usage); }
// записаният отговор: избира се по дума в последното съобщение static string Answer(List<Message> messages) { var last = messages[^1].Content; var hasOrder = messages.Any(m => m.Content.Contains("1042")); if (last.Contains("върн")) return "Можете да върнете стоката до 14 дни от получаването."; if (last.Contains("пристиг")) { return hasOrder ? "Поръчка 1042 е при куриера и пристига утре до 18 ч." : "Кой е номерът на поръчката ви?"; } return "С какво да помогна: статус на поръчка, доставка или връщане?"; }
// текстът на грешката по кода ѝ static string Reason(int status) => status switch { 400 => "невалидна заявка", 401 => "невалиден ключ", 429 => "твърде много заявки", _ => "грешка на сървъра", };
// грубо: 4 токена на 3 думи (истинския брой дава токенизаторът) public static int Tokens(string text) { var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; return (words * 4 + 2) / 3; }}Едно извикване, после същото с max_tokens 8:
// текстовите блокове, после защо спря и колко токенаstatic void Show(Response response){ foreach (var block in response.Content) { if (block.Type == "text") Console.WriteLine(block.Text); } var u = response.Usage; Console.WriteLine($"stop_reason: {response.StopReason}, input_tokens: {u.InputTokens}, output_tokens: {u.OutputTokens}");}var client = new FakeClient([]);var system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.";List<Message> messages = [new Message("user", "Кога ще пристигне поръчка 1042?")];
var response = client.Create(Model, 1024, system, messages);Show(response);
response = client.Create(Model, 8, system, messages);Show(response);Поръчка 1042 е при куриера и пристига утре до 18 ч.stop_reason: end_turn, input_tokens: 21, output_tokens: 15Поръчка 1042 е при куриера иstop_reason: max_tokens, input_tokens: 21, output_tokens: 8С 8 токена отговорът спира по средата на изречението, а stop_reason е max_tokens. Програмата трябва да го проверява: отрязан отговор, показан на клиент, изглежда като грешка на магазина.
Виж го
Пусни при теб: истинското извикване
Същото извикване към истински модел. То не е част от проверените програми и тук не се пуска: трябва ти профил при доставчик, ключ в средата, а всяко извикване струва пари.
По мрежата отива JSON. Заявката:
{ "model": "<името на модела>", "max_tokens": 1024, "system": "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.", "messages": [ { "role": "user", "content": "Кога ще пристигне поръчка 1042?" } ]}И отговорът (числата тук са от фалшивия клиент):
{ "content": [ { "type": "text", "text": "Поръчка 1042 е при куриера и пристига утре до 18 ч." } ], "stop_reason": "end_turn", "usage": { "input_tokens": 21, "output_tokens": 15 }}Със стандартния HTTP клиент на езика това е една POST заявка. Адресът, името на модела и заглавката с ключа са заместители: истинските са в документацията на доставчика. Ключът се чете от променливата LLM_API_KEY.
using System.Net.Http.Json;
var key = Environment.GetEnvironmentVariable("LLM_API_KEY");using var http = new HttpClient();using var request = new HttpRequestMessage(HttpMethod.Post, "https://<доставчик>/messages");request.Headers.Add("Authorization", $"Bearer {key}");request.Content = JsonContent.Create(new{ model = "<името на модела>", max_tokens = 1024, system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.", messages = new[] { new { role = "user", content = "Кога ще пристигне поръчка 1042?" } },});var response = await http.SendAsync(request);Console.WriteLine((int)response.StatusCode);Console.WriteLine(await response.Content.ReadAsStringAsync()); // JSON: content, stop_reason, usageДоставчиците имат и официални библиотеки (SDK) за Java, C#, Python и JavaScript със същата форма: съобщения с роли, токени, инструменти. Точните имена на класовете и методите са в документацията им. await: извикването ходи по мрежата и е асинхронно.
Идеята
Ключът е в средата, не в кода
Ключът за API-то е като парола за сметката: който го има, харчи от нея. В кода той стига до git, до всеки колега и понякога до публично хранилище. Затова се чете от променлива на средата, тук LLM_API_KEY.
var key = Environment.GetEnvironmentVariable("LLM_API_KEY");if (string.IsNullOrWhiteSpace(key)){ Console.WriteLine("Няма ключ: задай LLM_API_KEY в средата.");}else{ Console.WriteLine("Ключът е зареден от средата. Не го печатай и не го пиши в кода.");}Няма ключ: задай LLM_API_KEY в средата.Програмата казва дали ключът го има, но никога не го печата. Файлът с локалните тайни (например .env) е в .gitignore.
Виж го
API без памет
ask добавя въпроса към историята, праща цялата история и пази отговора с ролята assistant. Накрая същият въпрос, пратен сам:
// добавя въпроса, праща цялата история и пази отговораstatic string Ask(FakeClient client, string system, List<Message> history, string text){ history.Add(new Message("user", text)); var r = client.Create(Model, 1024, system, history); var reply = r.Content[0].Text; history.Add(new Message("assistant", reply)); return reply;}var client = new FakeClient([]);var system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.";var history = new List<Message>();foreach (var text in new[] { "Здравейте, имам поръчка 1042.", "Кога ще пристигне?" }){ Console.WriteLine($"клиент: {text}"); Console.WriteLine($"асистент: {Ask(client, system, history, text)}");}
var alone = client.Create(Model, 1024, system, [new Message("user", "Кога ще пристигне?")]);Console.WriteLine($"само въпросът, без историята: {alone.Content[0].Text}");клиент: Здравейте, имам поръчка 1042.асистент: С какво да помогна: статус на поръчка, доставка или връщане?клиент: Кога ще пристигне?асистент: Поръчка 1042 е при куриера и пристига утре до 18 ч.само въпросът, без историята: Кой е номерът на поръчката ви?С историята моделът вижда номера 1042 от първото съобщение. Без нея пита за него. Паметта на разговора е в програмата, не в модела, а историята расте и се плаща при всеки ход.
Пробвай
Намери грешката
Колега пише ask. На втория въпрос асистентът пак пита за номера на поръчката. Кой ред е грешен?
Избери реда с грешката.
Историята се пази, но не се праща: заявката носи само последното съобщение. Моделът не помни предишните заявки, затова вижда въпрос без номер. Трябва да се прати цялата история.
Пробвай
Попълни
С каква роля влиза в историята отговорът на модела?
static string Ask(FakeClient client, string system,
List<Message> history, string text)
{
history.Add(new Message("user", text));
var r = client.Create(Model, 1024, system, history);
var reply = r.Content[0].Text;
history.Add(new Message(, reply));
return reply;
}Отговорите на модела са с ролята "assistant", а съобщенията на клиента с "user". Така в следващата заявка моделът знае кое е казал той и кое клиентът.
Идеята
Грешките на API-то
Мрежата и сървърът на доставчика падат, както всяка външна услуга. Грешките идват с HTTP код и се делят на две:
- Временни: 429 (твърде много заявки, достигнат лимит), 500 и останалите 5xx (сървърът има проблем или е претоварен), 408 и 409. След малко същата заявка може да мине. Тях си струва да повториш.
- Постоянни: 400 (грешна заявка), 401 (невалиден ключ), 404. Повторението дава същото. Тях програмата казва веднага.
Повторението чака между опитите, а паузата се удвоява: 1, 2, 4 секунди. Така кратък проблем минава бързо, а претовареният сървър не получава поток от нови заявки. Това е експоненциалното чакане (exponential backoff). След определен брой опити програмата се отказва.
Виж го
Повторение с удвояваща се пауза
// повтаря временните грешки, паузата се удвояваstatic Response CreateWithRetry(FakeClient client, string system, List<Message> messages){ var maxAttempts = 4; var delay = 1; for (var attempt = 1; ; attempt++) { try { return client.Create(Model, 1024, system, messages); } catch (ApiException e) { var last = attempt == maxAttempts; if (last || !Retryable(e.Status)) throw; Console.WriteLine($" опит {attempt}: " + $"{e.Status}, чакам {delay} с"); // истинската програма чака тук: // Thread.Sleep(delay * 1000) delay *= 2; } }}
// временни: 408, 409, 429 и 5xxstatic bool Retryable(int status) => status == 408 || status == 409 || status == 429 || status >= 500;Стъпка по стъпка, с думи
- Заявката: моделът, системният промпт и съобщенията с ролите им. Тук е едно, от клиента (user).
- Първият опит връща 429: „твърде много заявки“. Временна грешка.
- 429 е от временните, затова програмата чака 1 с и опитва пак.
- Вторият опит: 500, „грешка на сървъра“. Също временна.
- Паузата се удвоява: 2 с. Претовареният сървър получава въздух.
- Третият опит минава.
- Отговорът: текстов блок, stop_reason end_turn и usage: 21 входни и 15 изходни токена.
- 401 и 400 не се повтарят: грешният ключ не се оправя с чакане. След 4 опита програмата се отказва.
// повтаря временните грешки, паузата се удвояваstatic Response CreateWithRetry(FakeClient client, string system, List<Message> messages){ var maxAttempts = 4; var delay = 1; for (var attempt = 1; ; attempt++) { try { return client.Create(Model, 1024, system, messages); } catch (ApiException e) { var last = attempt == maxAttempts; if (last || !Retryable(e.Status)) throw; Console.WriteLine($" опит {attempt}: " + $"{e.Status}, чакам {delay} с"); // истинската програма чака тук: // Thread.Sleep(delay * 1000) delay *= 2; } }}
// временни: 408, 409, 429 и 5xxstatic bool Retryable(int status) => status == 408 || status == 409 || status == 429 || status >= 500;Три сценария: 429 и 500, после отговор; 401, без повторение; четири пъти 429, отказ след четвъртия опит. Паузата само се печата, за да върви програмата веднага.
var system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.";List<Message> messages = [new Message("user", "Кога ще пристигне поръчка 1042?")];List<List<int>> scenarios = [[429, 500], [401], [429, 429, 429, 429]];foreach (var failures in scenarios){ Console.WriteLine($"сценарий [{string.Join(", ", failures)}]:"); try { var r = CreateWithRetry(new FakeClient(failures), system, messages); Console.WriteLine($" отговор: {r.Content[0].Text}"); } catch (ApiException e) { Console.WriteLine($" отказ: {e.Status} {e.Message}"); }}сценарий [429, 500]: опит 1: 429, чакам 1 с опит 2: 500, чакам 2 с отговор: Поръчка 1042 е при куриера и пристига утре до 18 ч.сценарий [401]: отказ: 401 невалиден ключсценарий [429, 429, 429, 429]: опит 1: 429, чакам 1 с опит 2: 429, чакам 2 с опит 3: 429, чакам 4 с отказ: 429 твърде много заявкиОфициалните SDK на доставчиците обикновено правят това вътре: сами повтарят временните грешки с нарастваща пауза. Провери в документацията и не слагай свой цикъл отгоре: опитите се умножават. Свой цикъл пишеш, когато викаш API-то направо по HTTP, както в блока „пусни при теб“.
Timeout, повторение с jitter и circuit breaker са в урока за устойчивостта.Пробвай
Предвиди изхода
Фалшивият клиент връща първо 429, после 400. system и messages са както по-горе. Какво ще отпечата програмата?
var client = new FakeClient([429, 400]);try{ var r = CreateWithRetry(client, system, messages); Console.WriteLine($"отговор: {r.Content[0].Text}");}catch (ApiException e){ Console.WriteLine($"отказ: {e.Status}");}429 е временна: програмата печата първия опит, чака 1 с и опитва пак. 400 е постоянна: retryable(400) е false, грешката излиза от createWithRetry веднага, без втори ред за чакане, и програмата печата отказа с кода 400. До отговор не се стига.
Идеята
Какво вижда клиентът
Повторенията не помагат винаги. Когато и последният опит падне, клиентът не трябва да вижда „ApiException: 503“. Програмата хваща грешката и казва нещо вежливо и вярно: „В момента не мога да отговоря. Ще ви свържа с колега.“ Грешката се записва в лога за екипа.
По-точно, за любопитните
Jitter. Когато хиляди програми паднат заедно и чакат точно 1, 2, 4 секунди, те опитват пак заедно. Към паузата се добавя случайна част (jitter), за да се разпръснат.
Timeout. Заявката може и да виси, затова всяко извикване по мрежата има таван на времето за отговор. Дълъг отговор е бавен, затова max_tokens пази и времето, и парите. Повече за бавността и цената е в урока за цената и бавността.
Лимитите. 429 значи, че си стигнал лимита на заявки или токени за минута. Повторението помага при пик. Ако го стигаш постоянно, решението е по-малко заявки, по-къси заявки или по-висок лимит, не повече повторения.
Какво следва. Системният промпт е първата стъпка към промпта като код. Отговор, който програмата чете, а не само показва, е структурираният изход, а модел, който сам иска данни от програмата, е използването на инструменти. Текст от потребител или документ, който се опитва да смени инструкциите (prompt injection), е в модула за агенти по-нататък в курса.
Упражнение
Отговор или вежлив отказ
Реши я в редактора си.
Напиши askSupport(client, system, question): вика createWithRetry, връща текста на отговора, а ако и последният опит падне (или грешката е постоянна), връща „В момента не мога да отговоря. Ще ви свържа с колега.“. Пусни я с въпроса „Мога ли да върна якето?“ и четири сценария на фалшивия клиент: без грешки; четири пъти 503; 401; един 429.
сценарий []: Можете да върнете стоката до 14 дни от получаването.сценарий [503, 503, 503, 503]: опит 1: 503, чакам 1 с опит 2: 503, чакам 2 с опит 3: 503, чакам 4 с В момента не мога да отговоря. Ще ви свържа с колега.сценарий [401]: В момента не мога да отговоря. Ще ви свържа с колега.сценарий [429]: опит 1: 429, чакам 1 с Можете да върнете стоката до 14 дни от получаването.Още задачи
- Лог за екипа. При отказ отпечатай отделен ред с кода и текста на грешката, преди вежливия отговор. Клиентът вижда само вежливия.
- Отрязаният отговор. Добави параметър за
max_tokensкъмcreateWithRetry. Акоstop_reasonеmax_tokens, върни отговора с „…“ накрая и запиши предупреждение. Пробвай с 8.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Текстът на парчета: токени · Предсказване на следващия токен · Прозорецът на контекста · Границите на модела
- Продължава в: Промптът е код · Структуриран изход: JSON по схема · Инструменти: моделът пита, програмата прави · Оценяване: тестов набор за асистента
- Задачи по темата: Приложения с LLM