Извикване на модел през 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;
}Отговорите на модела са с ролята "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) { 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 и 5xxstatic boolean retryable(int status) { return 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) { 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 и 5xxstatic 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);}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