Структуриран изход: JSON по схема
Накратко: Структурираният изход е отговор на модела като JSON по схема с типове и разрешени стойности, който програмата проверява и при грешка връща на модела за поправка.
Проблем
Кодът, който чака JSON
Пощата на поддръжката получава стотици писма на ден. Програма ги разпределя по опашки: доставки, връщания, плащания, общи. Тя пита модел за темата на писмото и чака JSON с полето intent. Ето три отговора на модела за едно и също писмо:
List<String> answers = List.of( """ Разбира се! Ето данните: ```json {"intent": "delivery", "order_id": "1042", "urgent": true} ```""", """ {"intent": "доставка", "order_id": "1042", "urgent": "да", "summary": "Куриерът не е дошъл."}""", """ {"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "Куриерът не е дошъл, клиентът заминава утре."}""");for (String answer : answers) { Map<String, Object> json = parseJson(answer); if (json == null) { System.out.println("не е JSON: " + answer.lines().findFirst().get()); continue; } String queue = QUEUES.get(json.get("intent")); System.out.println(queue == null ? "няма опашка за \"" + json.get("intent") + "\"" : "опашка: " + queue);}не е JSON: Разбира се! Ето данните:няма опашка за "доставка"опашка: ДоставкиСамо третият става. Първият е учтив, но е текст около JSON и програмата не го чете. Вторият е JSON, но темата е на български и няма такава опашка. В урока за промпта видя, че примерите помагат. Но програмата трябва да работи и в нощта, в която моделът реши да е учтив.
Идеята
Схема, проверка, втори опит
Когато отговорът отива в код, а не при човек, той има договор. Договорът е схема: кои полета има, какъв е типът на всяко и кои стойности са разрешени.
- Промптът казва схемата. Полетата, типовете и разрешените стойности са в инструкцията.
- Програмата чете JSON-а. Ако не е JSON, това е грешка, а не срив.
- Програмата проверява по схемата: има ли всяко поле, верен ли е типът, от разрешените ли е стойността, няма ли излишни полета.
- Грешките отиват обратно към модела като ново съобщение, и той опитва пак.
- Опитите са най-много три. После писмото отива при човек.
Отговор по схема, който програмата проверява, е структуриран изход (structured output).
Изходът на модела е недоверен вход като всичко, което идва отвън: програмата не вярва, че е по договора, а го проверява.Виж го
Четене и проверка
Схемата на писмото: четири полета, intent с четири разрешени стойности:
static final List<Field> TICKET = List.of( new Field("intent", "string", List.of("delivery", "return", "payment", "other")), new Field("order_id", "string", List.of()), new Field("urgent", "boolean", List.of()), new Field("summary", "string", List.of()));Четенето връща обекта или null, ако текстът не е точно един JSON обект:
static Map<String, Object> parseJson(String text) { return FlatJson.parse(text);}parseJson вика малкия четец FlatJson от „По-точно“: той разбира плосък обект с текст, числа, true, false и null, колкото трябва за урока.Типът на стойността в думите на JSON:
static String jsonType(Object value) { return switch (value) { case null -> "null"; case String s -> "string"; case Boolean b -> "boolean"; default -> "number"; };}Проверката не хвърля изключение. Тя връща списък с грешките, защото програмата има какво да прави с тях:
static List<String> validate(Map<String, Object> json, List<Field> schema) { List<String> errors = new ArrayList<>(); for (Field f : schema) { if (!json.containsKey(f.name())) { errors.add("липсва \"" + f.name() + "\""); continue; } Object value = json.get(f.name()); String type = jsonType(value); if (!type.equals(f.type())) { errors.add("\"%s\" е %s, а трябва да е %s" .formatted(f.name(), type, f.type())); } else if (!f.allowed().isEmpty() && !f.allowed().contains(value)) { errors.add("\"%s\": \"%s\" не е от %s".formatted( f.name(), value, String.join(", ", f.allowed()))); } } for (String key : json.keySet()) { if (schema.stream().noneMatch(f -> f.name().equals(key))) { errors.add("излишно поле \"" + key + "\""); } } return errors;}Четири отговора на модела:
List<String> answers = List.of( """ {"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "Куриерът не е дошъл, клиентът заминава утре."}""", """ {"intent": "доставка", "order_id": "1042", "urgent": "да", "summary": "Куриерът не е дошъл."}""", """ {"intent": "return", "urgent": false, "summary": "Иска да върне яке.", "mood": "спокоен"}""", """ Разбира се! Ето данните: ```json {"intent": "delivery", "order_id": "1042", "urgent": true} ```""");for (String answer : answers) { Map<String, Object> json = parseJson(answer); List<String> errors = json == null ? List.of("не е JSON") : validate(json, TICKET); System.out.println(errors.isEmpty() ? "ок" : String.join("\n", errors)); System.out.println("---");}ок---"intent": "доставка" не е от delivery, return, payment, other"urgent" е string, а трябва да е boolean---липсва "order_id"излишно поле "mood"---не е JSON---Пробвай
Предвиди изхода
Какво печата проверката за този отговор?
List<String> answers = List.of( """ {"intent": "refund", "order_id": 1042, "urgent": false, "summary": "Иска парите си обратно."}""");for (String answer : answers) { Map<String, Object> json = parseJson(answer); List<String> errors = json == null ? List.of("не е JSON") : validate(json, TICKET); System.out.println(errors.isEmpty() ? "ок" : String.join("\n", errors)); System.out.println("---");}Две грешки. „refund“ не е от четирите разрешени стойности, а 1042 без кавички е число, не текст. Номерът на поръчка изглежда като число, но е идентификатор: „0042“ като число би станал 42. Затова схемата иска текст и проверката държи на това.
Пробвай
Попълни
Ред от проверката: типът е верен, остава стойността да е от разрешените. Какво липсва?
} else if (!f.allowed().isEmpty() && !f.allowed().(value)) {contains. Без тази проверка „доставка“ минава, защото е текст, и стига до опашка, която я няма.
Идеята
Грешките обратно към модела
Какво правиш с отговор, който не минава? Ако зададеш въпроса още веднъж, моделът често повтаря същата грешка. По-добре е да му кажеш какво не е наред.
Отговорът на модела влиза в историята с роля assistant, а след него ново съобщение user с грешките: „Отговорът не е по схемата: intent… Върни само JSON по схемата.“ Моделът вижда своя опит и точните грешки и поправя тях. Това е втори опит с обратна връзка.
Всеки опит праща цялата история. Вторият е по-скъп от първия, а третият от втория: от урока за контекста знаеш, че API-то няма памет и всичко тръгва наново. Затова опитите са малко, а след тях писмото отива при човек.
Виж го
Три писма, три съдби
static Ticket triage(FakeClient client, String email) { List<Message> messages = new ArrayList<>(); messages.add(new Message("user", email)); for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { Reply reply = client.create(SYSTEM, messages); Map<String, Object> json = parseJson(reply.text()); List<String> errors = json == null ? List.of("не е JSON") : validate(json, TICKET); String verdict = errors.isEmpty() ? "ок" : String.join("; ", errors); System.out.printf(" опит %d (~%d токена): %s%n", attempt, reply.inputTokens(), verdict); if (errors.isEmpty()) { return toTicket(json); } messages.add(new Message("assistant", reply.text())); messages.add(new Message("user", "Отговорът не е по схемата: " + verdict + ". Върни само JSON по схемата.")); } return null;}static List<String> validate(Map<String, Object> json, List<Field> schema) { List<String> errors = new ArrayList<>(); for (Field f : schema) { if (!json.containsKey(f.name())) { errors.add("липсва \"" + f.name() + "\""); continue; } Object value = json.get(f.name()); String type = jsonType(value); if (!type.equals(f.type())) { errors.add("\"%s\" е %s, а трябва да е %s" .formatted(f.name(), type, f.type())); } else if (!f.allowed().isEmpty() && !f.allowed().contains(value)) { errors.add("\"%s\": \"%s\" не е от %s".formatted( f.name(), value, String.join(", ", f.allowed()))); } } for (String key : json.keySet()) { if (schema.stream().noneMatch(f -> f.name().equals(key))) { errors.add("излишно поле \"" + key + "\""); } } return errors;}Стъпка по стъпка, с думи
- Писмото отива при модела с инструкция: върни само JSON с четири полета.
- Отговорът е валиден JSON и се прочита. Това още не значи, че е верен.
- Всяко поле се сверява със схемата: има ли го, верен ли е типът, от разрешените стойности ли е.
- Две грешки: intent и urgent. Те са списък от текстове, не изключение: програмата решава какво да прави.
- Грешките отиват обратно към модела като ново съобщение. Опит 2 праща цялата история: ~97 токена вместо ~46.
- Вторият отговор минава всички проверки. Едва сега става Ticket.
- Писмото отива в опашката за доставки като спешно. Най-много 3 опита, после поема човек.
Инструкцията и колко опита има програмата:
static final String SYSTEM = "Разпредели писмото от клиента. Върни само JSON с полетата " + "intent (delivery, return, payment или other), order_id (текст), " + "urgent (true или false) и summary (едно изречение).";static final int MAX_ATTEMPTS = 3;Едно писмо: питане, четене, проверка и при грешка нов опит с грешките. Когато всичко мине, речникът става record Ticket, и оттук нататък кодът работи с полета, не с текст:
static Ticket triage(FakeClient client, String email) { List<Message> messages = new ArrayList<>(); messages.add(new Message("user", email)); for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { Reply reply = client.create(SYSTEM, messages); Map<String, Object> json = parseJson(reply.text()); List<String> errors = json == null ? List.of("не е JSON") : validate(json, TICKET); String verdict = errors.isEmpty() ? "ок" : String.join("; ", errors); System.out.printf(" опит %d (~%d токена): %s%n", attempt, reply.inputTokens(), verdict); if (errors.isEmpty()) { return toTicket(json); } messages.add(new Message("assistant", reply.text())); messages.add(new Message("user", "Отговорът не е по схемата: " + verdict + ". Върни само JSON по схемата.")); } return null;}static Ticket toTicket(Map<String, Object> json) { return new Ticket((String) json.get("intent"), (String) json.get("order_id"), (Boolean) json.get("urgent"), (String) json.get("summary"));}FakeClient client = new FakeClient();String[] inbox = { "Куриерът не дойде вчера, поръчка 1042. Спешно е, заминавам утре!", "Искам да върна поръчка 1187, якето ми е голямо.", "здр, нещо не става",};for (String email : inbox) { System.out.println("писмо: " + email); Ticket ticket = triage(client, email); if (ticket == null) { System.out.println(" → към човек"); } else { String urgent = ticket.urgent() ? ", спешно" : ""; System.out.println(" → " + ticket.intent() + ", " + ticket.orderId() + urgent); }}писмо: Куриерът не дойде вчера, поръчка 1042. Спешно е, заминавам утре! опит 1 (~46 токена): "intent": "доставка" не е от delivery, return, payment, other; "urgent" е string, а трябва да е boolean опит 2 (~97 токена): ок → delivery, 1042, спешнописмо: Искам да върна поръчка 1187, якето ми е голямо. опит 1 (~45 токена): ок → return, 1187писмо: здр, нещо не става опит 1 (~39 токена): не е JSON опит 2 (~66 токена): не е JSON опит 3 (~93 токена): не е JSON → към човек- Писмото за 1042: първият отговор има две грешки, вторият минава. Вторият опит струва ~97 токена вместо ~46.
- Писмото за 1187: минава от първия път.
- „здр, нещо не става“: моделът не намира тема и трите пъти отговаря с изречение. Програмата не измисля категория, а дава писмото на човек.
Моделът тук е фалшив клиент като в урока за промпта: отговорите са записани и всеки път са същите. Неговото правило е в „По-точно“.
Пробвай
Намери грешката
Колега пише по-кратка проверка. Отговорът {"intent": "return", "urgent": false, "summary": "…"} минава като „ок“, а след минута програмата пада някъде далеч, при търсенето на поръчката. На кой ред е грешката?
Избери реда с грешката.
Липсващото поле се прескача мълчаливо: continue без грешка. Отговорът без order_id минава проверката, а програмата пада по-късно, там където търси поръчката по номер. Грешка, хваната на входа, се обяснява с две думи: „липсва order_id“. Същата грешка три метода по-навътре е загадка. Поправката: преди continue добави грешката „липсва“.
Идеята
Схемата в самото API
Новите API-та приемат схемата като част от заявката. Тогава моделът генерира само текст, който отговаря на нея: всеки следващ токен се избира измежду тези, които пазят JSON-а по схемата. Учтивото „Разбира се!“ и "urgent": "да" стават невъзможни.
Проверката в кода обаче остава:
- Отказ или отрязан отговор. Ако моделът откаже (
stop_reasonеrefusal) или стигнеmax_tokens, текстът може да не е по схемата. - Правилата на магазина. Схемата казва „текст“, но не знае дали поръчка 1187 съществува и дали е на този клиент. Това е проверка в програмата.
- Друг модел или доставчик. Кодът, който проверява, не зависи от това кой е генерирал JSON-а.
Пусни при теб. Тази програма не е в проверката на урока: трябва ти профил при доставчик на модели, ключ и пари за всяко извикване. Ключът е в LLM_API_KEY, никога в кода. Доставчиците имат официални SDK-та за Java, C#, Python и JavaScript със същата форма (съобщения, роли, токени, инструменти); точните имена на класовете и методите са в документацията на доставчика. Заявката като JSON: схемата е в полето output_config, а името на модела е заместител:
{ "model": "<името на модела>", "max_tokens": 1024, "system": "Разпредели писмото от клиента. Върни само JSON с полетата intent (delivery, return, payment или other), order_id (текст), urgent (true или false) и summary (едно изречение).", "messages": [ { "role": "user", "content": "Куриерът не дойде вчера, поръчка 1042. Спешно е, заминавам утре!" } ], "output_config": { "format": { "type": "json_schema", "schema": { "type": "object", "properties": { "intent": { "type": "string", "enum": ["delivery", "return", "payment", "other"] }, "order_id": {"type": "string"}, "urgent": {"type": "boolean"}, "summary": {"type": "string"} }, "required": ["intent", "order_id", "urgent", "summary"], "additionalProperties": false } } }}Примерен отговор. JSON-ът по схемата е текст в текстовия блок и програмата пак го чете и проверява:
{ "content": [ { "type": "text", "text": "{\"intent\": \"delivery\", \"order_id\": \"1042\", \"urgent\": true, \"summary\": \"Куриерът не е дошъл, клиентът заминава утре.\"}" } ], "stop_reason": "end_turn", "usage": {"input_tokens": 46, "output_tokens": 19}}Без SDK заявката се праща със стандартния HTTP клиент на езика. Адресът е заместител:
// requestJson е JSON-ът на заявката отгоре.// Точната заглавка за ключа е в документацията на доставчика.HttpClient http = HttpClient.newHttpClient();HttpRequest request = HttpRequest.newBuilder(URI.create("https://<доставчик>/messages")) .header("Authorization", "Bearer " + System.getenv("LLM_API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(requestJson)) .build();HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());System.out.println(response.body());Всеки обект в схемата има additionalProperties: false: така излишно поле като mood изобщо не може да се появи.
Идеята
Кога вреди
- JSON за всичко. Отговорът към клиента е текст. Схема трябва там, където отговорът отива в код: опашка, база, друг сервиз.
- Дълбока схема. Пет нива вложени обекти и двайсет полета дават повече грешки и по-скъпи опити. По-добре две прости заявки, отколкото една сложна.
- Кръпки вместо проверка. „Изрежи от първата
{до последната}“ работи до първия отговор с две{. Ако API-то поддържа схема, ползвай нея. Ако не, проверявай и опитвай пак. - Вярната форма не е верен отговор.
"order_id": "1043"минава схемата, но моделът може да е сбъркал цифра: моделите измислят правдоподобно. Номерът се сверява с базата, преди да се ползва. - Опитите имат цена. Всеки опит е още токени и още секунди за клиента. Колко точно струва това и как се пази бюджет, е в урока за цената и бързината.
- Схемата и промптът се менят заедно. Нова категория в схемата без нова категория в промпта значи модел, който никога не я казва. Версията на схемата е в кода, до промпта.
По-точно, за любопитните
Думите на JSON Schema. Схемата в API-то е JSON Schema: type (object, string, number, boolean, array, null), properties с полетата, required със задължителните, enum с разрешените стойности и additionalProperties: false срещу излишните. Нашата проверка прави подмножество от това, колкото да се види механизмът. В проект се ползва готова библиотека за JSON Schema.
От текст към тип. След проверката речникът става Ticket и останалият код не проверява нищо повече: компилаторът или полетата казват какво има. Това е идеята „прочети и провери на входа, а навътре работи с типове“. Проверката е на едно място, а не разпиляна из програмата.
Инструментите ползват същото. В следващия урок моделът иска програмата да изпълни функция. Аргументите на функцията също идват като JSON по схема (input_schema) и също се проверяват.
Фалшивият клиент отвътре. Отговорите му са записани по номера на поръчката в писмото: за 1042 първият е грешен, а след поправка е верен; за 1187 е верен веднага; за всичко друго е изречение. Токените са думите × 1,3.
// Стои на мястото на истинския модел: записани отговори,// без мрежа и всеки път едни и същи.class FakeClient { static final String WRONG_1042 = """ {"intent": "доставка", "order_id": "1042", "urgent": "да", "summary": "Куриерът не е дошъл."}"""; static final String GOOD_1042 = """ {"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "Куриерът не е дошъл, клиентът заминава утре."}"""; static final String GOOD_1187 = """ {"intent": "return", "order_id": "1187", "urgent": false, "summary": "Иска да върне якето, голямо е."}"""; static final String PROSE = "Не мога да разбера какво иска клиентът.";
Reply create(String system, List<Message> messages) { String email = messages.get(0).content(); boolean corrected = messages.size() > 1; String text; if (email.contains("1042")) { text = corrected ? GOOD_1042 : WRONG_1042; } else if (email.contains("1187")) { text = GOOD_1187; } else { text = PROSE; } int input = tokens(system); for (Message m : messages) { input += tokens(m.content()); } return new Reply(text, "end_turn", input, tokens(text)); }
// Грубо: думите × 1,3, закръглено нагоре. static int tokens(String text) { String t = text.strip(); int words = t.isEmpty() ? 0 : t.split("\\s+").length; return (words * 13 + 9) / 10; }}Четецът FlatJson. Рекурсивно спускане на един ред: object чете {, двойки ключ–стойност и }, а value разпознава текст, true, false, null и число. Вложени обекти и масиви не чете, и \n в текст също. Всяко неочаквано място хвърля изключение, а parse го превръща в null.
// Java няма JSON в стандартната библиотека. Този четец разбира// плосък обект: текст, число, true, false и null. В проект: Jackson.class FlatJson { private final String s; private int i;
private FlatJson(String s) { this.s = s; }
// Обектът или null, ако текстът не е точно един JSON обект. static Map<String, Object> parse(String text) { FlatJson p = new FlatJson(text.strip()); try { Map<String, Object> obj = p.object(); return p.i == p.s.length() ? obj : null; } catch (RuntimeException e) { return null; } }
private Map<String, Object> object() { Map<String, Object> obj = new LinkedHashMap<>(); expect('{'); skip(); if (peek() == '}') { i++; return obj; } while (true) { skip(); String key = string(); skip(); expect(':'); skip(); obj.put(key, value()); skip(); if (peek() == '}') { i++; return obj; } expect(','); } }
private Object value() { if (peek() == '"') { return string(); } for (String word : new String[] {"true", "false", "null"}) { if (s.startsWith(word, i)) { i += word.length(); return word.equals("null") ? null : Boolean.valueOf(word); } } int start = i; while (i < s.length() && "+-.0123456789eE".indexOf(s.charAt(i)) >= 0) { i++; } return Double.parseDouble(s.substring(start, i)); }
private String string() { expect('"'); StringBuilder out = new StringBuilder(); while (peek() != '"') { char c = s.charAt(i++); out.append(c == '\\' ? s.charAt(i++) : c); } i++; return out.toString(); }
private void skip() { while (i < s.length() && Character.isWhitespace(s.charAt(i))) { i++; } }
private char peek() { return s.charAt(i); }
private void expect(char c) { if (peek() != c) { throw new IllegalStateException("очаквах " + c); } i++; }}Упражнение
Номерът е четири цифри
Реши задачата в редактора си.
Добави в проверката правило: order_id е точно 4 цифри. Ако не е, грешката е "order_id": "10420" не е 4 цифри. Пусни проверката за трите отговора, които се различават само по номера: 1042, 10420 и №1042.
ок---"order_id": "10420" не е 4 цифри---"order_id": "№1042" не е 4 цифри---Още задачи
- Празно резюме.
summaryне може да е празен текст или само интервали. Добави правилото и проба с"summary": " ". - Ограничен брой токени. Спри опитите и когато общите токени на писмото минат 100, дори да не са минали три опита. Колко опита прави тогава писмото „здр, нещо не става“?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Прозорецът на контекста · Границите на модела · Извикване на модел през API · Промптът е код
- Продължава в: Инструменти: моделът пита, програмата прави · Цикълът на агента: план, действие, наблюдение · Prompt injection: писмото, което дава нареждания · Предпазители: проверки преди и след модела
- Задачи по темата: Приложения с LLM