Към съдържанието
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 extends RuntimeException {
final int status;
ApiException(int status, String message) {
super(message);
this.status = status;
}
}
// заместител на истинския клиент: записани отговори, без мрежа
class FakeClient {
private final Deque<Integer> failures;
FakeClient(List<Integer> failures) {
this.failures = new ArrayDeque<>(failures);
}
Response create(String model, int maxTokens, String system,
List<Message> messages) {
if (!failures.isEmpty()) {
int status = failures.poll();
throw new ApiException(status, reason(status));
}
String text = answer(messages);
String stopReason = "end_turn";
if (tokens(text) > maxTokens) {
// max_tokens: отговорът спира по средата
String[] words = text.split(" ");
text = String.join(" ", Arrays.copyOf(words, maxTokens * 3 / 4));
stopReason = "max_tokens";
}
int input = tokens(system);
for (Message m : messages) {
input += tokens(m.content());
}
Usage usage = new Usage(input, tokens(text));
return new Response(List.of(new Block("text", text)), stopReason, usage);
}
// записаният отговор: избира се по дума в последното съобщение
private static String answer(List<Message> messages) {
String last = messages.get(messages.size() - 1).content();
boolean hasOrder = messages.stream().anyMatch(m -> m.content().contains("1042"));
if (last.contains("върн")) return "Можете да върнете стоката до 14 дни от получаването.";
if (last.contains("пристиг")) {
return hasOrder ? "Поръчка 1042 е при куриера и пристига утре до 18 ч." : "Кой е номерът на поръчката ви?";
}
return "С какво да помогна: статус на поръчка, доставка или връщане?";
}
// текстът на грешката по кода ѝ
private static String reason(int status) {
return switch (status) {
case 400 -> "невалидна заявка";
case 401 -> "невалиден ключ";
case 429 -> "твърде много заявки";
default -> "грешка на сървъра";
};
}
// грубо: 4 токена на 3 думи (истинския брой дава токенизаторът)
static int tokens(String text) {
int words = text.trim().split("\\s+").length;
return (words * 4 + 2) / 3;
}
}

Едно извикване, после същото с max_tokens 8:

// текстовите блокове, после защо спря и колко токена
static void show(Response response) {
for (Block block : response.content()) {
if (block.type().equals("text")) System.out.println(block.text());
}
Usage u = response.usage();
System.out.printf("stop_reason: %s, input_tokens: %d, output_tokens: %d%n",
response.stopReason(), u.inputTokens(), u.outputTokens());
}
FakeClient client = new FakeClient(List.of());
String system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.";
List<Message> messages = List.of(new Message("user", "Кога ще пристигне поръчка 1042?"));
Response 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.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
String key = System.getenv("LLM_API_KEY");
String body = """
{
"model": "<името на модела>",
"max_tokens": 1024,
"system": "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.",
"messages": [
{"role": "user", "content": "Кога ще пристигне поръчка 1042?"}
]
}
""";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://<доставчик>/messages"))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body()); // JSON: content, stop_reason, usage

Доставчиците имат и официални библиотеки (SDK) за Java, C#, Python и JavaScript със същата форма: съобщения с роли, токени, инструменти. Точните имена на класовете и методите са в документацията им.

Идеята

Ключът е в средата, не в кода

Ключът за API-то е като парола за сметката: който го има, харчи от нея. В кода той стига до git, до всеки колега и понякога до публично хранилище. Затова се чете от променлива на средата, тук LLM_API_KEY.

String key = System.getenv("LLM_API_KEY");
if (key == null || key.isBlank()) {
System.out.println("Няма ключ: задай LLM_API_KEY в средата.");
} else {
System.out.println("Ключът е зареден от средата. Не го печатай и не го пиши в кода.");
}
Изход
Няма ключ: задай 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));
Response r = client.create(MODEL, 1024, system, history);
String reply = r.content().get(0).text();
history.add(new Message("assistant", reply));
return reply;
}
FakeClient client = new FakeClient(List.of());
String system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.";
List<Message> history = new ArrayList<>();
for (String text : List.of("Здравейте, имам поръчка 1042.", "Кога ще пристигне?")) {
System.out.println("клиент: " + text);
System.out.println("асистент: " + ask(client, system, history, text));
}
Response alone = client.create(MODEL, 1024, system,
List.of(new Message("user", "Кога ще пристигне?")));
System.out.println("само въпросът, без историята: " + alone.content().get(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));
    Response r = client.create(MODEL, 1024, system, history);
    String reply = r.content().get(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) {
int maxAttempts = 4;
int delay = 1;
for (int attempt = 1; ; attempt++) {
try {
return client.create(MODEL, 1024,
system, messages);
} catch (ApiException e) {
boolean last = attempt == maxAttempts;
if (last || !retryable(e.status)) throw e;
System.out.printf(
" опит %d: %d, чакам %d с%n",
attempt, e.status, delay);
// истинската програма чака тук:
// Thread.sleep(delay * 1000L)
delay *= 2;
}
}
}
// временни: 408, 409, 429 и 5xx
static boolean retryable(int status) {
return 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) {
int maxAttempts = 4;
int delay = 1;
for (int attempt = 1; ; attempt++) {
try {
return client.create(MODEL, 1024,
system, messages);
} catch (ApiException e) {
boolean last = attempt == maxAttempts;
if (last || !retryable(e.status)) throw e;
System.out.printf(
" опит %d: %d, чакам %d с%n",
attempt, e.status, delay);
// истинската програма чака тук:
// Thread.sleep(delay * 1000L)
delay *= 2;
}
}
}
// временни: 408, 409, 429 и 5xx
static boolean retryable(int status) {
return status == 408 || status == 409
|| status == 429 || status >= 500;
}

Три сценария: 429 и 500, после отговор; 401, без повторение; четири пъти 429, отказ след четвъртия опит. Паузата само се печата, за да върви програмата веднага.

String system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.";
List<Message> messages = List.of(new Message("user", "Кога ще пристигне поръчка 1042?"));
List<List<Integer>> scenarios = List.of(
List.of(429, 500),
List.of(401),
List.of(429, 429, 429, 429));
for (List<Integer> failures : scenarios) {
System.out.println("сценарий " + failures + ":");
try {
Response r = createWithRetry(new FakeClient(failures), system, messages);
System.out.println(" отговор: " + r.content().get(0).text());
} catch (ApiException e) {
System.out.println(" отказ: " + e.status + " " + e.getMessage());
}
}
Изход
сценарий [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 са както по-горе. Какво ще отпечата програмата?

FakeClient client = new FakeClient(List.of(429, 400));
try {
Response r = createWithRetry(client, system, messages);
System.out.println("отговор: " + r.content().get(0).text());
} catch (ApiException e) {
System.out.println("отказ: " + 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 за достъпно обучение по програмиране ·