Към съдържанието
wannacode

Извикване на модел през 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;
}

Идеята

Грешките на 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 и 5xx
static bool Retryable(int status) =>
status == 408 || status == 409
|| status == 429 || status >= 500;
Стъпка по стъпка, с думи
  1. Заявката: моделът, системният промпт и съобщенията с ролите им. Тук е едно, от клиента (user).
  2. Първият опит връща 429: „твърде много заявки“. Временна грешка.
  3. 429 е от временните, затова програмата чака 1 с и опитва пак.
  4. Вторият опит: 500, „грешка на сървъра“. Също временна.
  5. Паузата се удвоява: 2 с. Претовареният сървър получава въздух.
  6. Третият опит минава.
  7. Отговорът: текстов блок, stop_reason end_turn и usage: 21 входни и 15 изходни токена.
  8. 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 и 5xx
static 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}");
}
Отговор

Идеята

Какво вижда клиентът

Повторенията не помагат винаги. Когато и последният опит падне, клиентът не трябва да вижда „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.

Тест

Провери се

1. Клиентът пише „Здравейте, имам поръчка 1042.“, а после „Кога ще пристигне?“. Програмата праща на втората заявка само новия въпрос. Какво става?
2. Отговорът идва със stop_reason: max_tokens. Какво значи?
3. Кои грешки си струва да повториш?
4. Защо паузата между опитите се удвоява (1, 2, 4 с)?
5. Къде е мястото на ключа за API-то?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·