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

Структуриран изход: 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, но темата е на български и няма такава опашка. В урока за промпта видя, че примерите помагат. Но програмата трябва да работи и в нощта, в която моделът реши да е учтив.

Идеята

Схема, проверка, втори опит

Когато отговорът отива в код, а не при човек, той има договор. Договорът е схема: кои полета има, какъв е типът на всяко и кои стойности са разрешени.

  1. Промптът казва схемата. Полетата, типовете и разрешените стойности са в инструкцията.
  2. Програмата чете JSON-а. Ако не е JSON, това е грешка, а не срив.
  3. Програмата проверява по схемата: има ли всяко поле, верен ли е типът, от разрешените ли е стойността, няма ли излишни полета.
  4. Грешките отиват обратно към модела като ново съобщение, и той опитва пак.
  5. Опитите са най-много три. После писмото отива при човек.

Отговор по схема, който програмата проверява, е структуриран изход (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);
}
Java няма JSON в стандартната библиотека. В проект се ползва Jackson или Gson. Тук 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("---");
}
Отговор

Пробвай

Попълни

Ред от проверката: типът е верен, остава стойността да е от разрешените. Какво липсва?

} else if (!f.allowed().isEmpty() && !f.allowed().(value)) {

Идеята

Грешките обратно към модела

Какво правиш с отговор, който не минава? Ако зададеш въпроса още веднъж, моделът често повтаря същата грешка. По-добре е да му кажеш какво не е наред.

Отговорът на модела влиза в историята с роля 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;
}
Стъпка по стъпка, с думи
  1. Писмото отива при модела с инструкция: върни само JSON с четири полета.
  2. Отговорът е валиден JSON и се прочита. Това още не значи, че е верен.
  3. Всяко поле се сверява със схемата: има ли го, верен ли е типът, от разрешените стойности ли е.
  4. Две грешки: intent и urgent. Те са списък от текстове, не изключение: програмата решава какво да прави.
  5. Грешките отиват обратно към модела като ново съобщение. Опит 2 праща цялата история: ~97 токена вместо ~46.
  6. Вторият отговор минава всички проверки. Едва сега става Ticket.
  7. Писмото отива в опашката за доставки като спешно. Най-много 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": "…"} минава като „ок“, а след минута програмата пада някъде далеч, при търсенето на поръчката. На кой ред е грешката?

Избери реда с грешката.

Идеята

Схемата в самото 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, дори да не са минали три опита. Колко опита прави тогава писмото „здр, нещо не става“?

Тест

Провери се

1. Моделът върна валиден JSON. Значи ли това, че програмата може да го ползва?
2. Защо при втория опит грешките се пращат на модела, вместо въпросът да се зададе още веднъж?
3. Писмото е неразбираемо и моделът три пъти връща изречение вместо JSON. Какво прави програмата?
4. API-то вече гарантира JSON по схемата. Остава ли проверката в кода?

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