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

Структуриран изход: JSON по схема

Накратко: Структурираният изход е отговор на модела като JSON по схема с типове и разрешени стойности, който програмата проверява и при грешка връща на модела за поправка.

Проблем

Кодът, който чака JSON

Пощата на поддръжката получава стотици писма на ден. Програма ги разпределя по опашки: доставки, връщания, плащания, общи. Тя пита модел за темата на писмото и чака JSON с полето intent. Ето три отговора на модела за едно и също писмо:

const answers = [
'Разбира се! Ето данните:\n```json\n{"intent": "delivery", "order_id": "1042", "urgent": true}\n```',
'{"intent": "доставка", "order_id": "1042", "urgent": "да", "summary": "Куриерът не е дошъл."}',
'{"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "Куриерът не е дошъл, клиентът заминава утре."}',
];
for (const answer of answers) {
const data = parseJson(answer);
if (data === null) {
console.log(`не е JSON: ${answer.split("\n")[0]}`);
continue;
}
const queue = QUEUES[data.intent];
console.log(queue === undefined
? `няма опашка за "${data.intent}"`
: `опашка: ${queue}`);
}
Изход
не е JSON: Разбира се! Ето данните:
няма опашка за "доставка"
опашка: Доставки

Само третият става. Първият е учтив, но е текст около JSON и програмата не го чете. Вторият е JSON, но темата е на български и няма такава опашка. В урока за промпта видя, че примерите помагат. Но програмата трябва да работи и в нощта, в която моделът реши да е учтив.

Идеята

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

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

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

Отговор по схема, който програмата проверява, е структуриран изход (structured output).

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

Виж го

Четене и проверка

Схемата на писмото: четири полета, intent с четири разрешени стойности:

const TICKET = {
intent: { type: "string", enum: ["delivery", "return", "payment", "other"] },
order_id: { type: "string" },
urgent: { type: "boolean" },
summary: { type: "string" },
};

Четенето връща обекта или null, ако текстът не е точно един JSON обект:

function parseJson(text) {
try {
const data = JSON.parse(text);
const isObject = data !== null && typeof data === "object" && !Array.isArray(data);
return isObject ? data : null;
} catch {
return null;
}
}
JSON.parse хвърля SyntaxError за текст, който не е JSON. Масивът и null също са валиден JSON, затова функцията проверява, че е обект.

Типът на стойността в думите на JSON:

function jsonType(value) {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}

Проверката не хвърля изключение. Тя връща списък с грешките, защото програмата има какво да прави с тях:

function validate(data, schema) {
const errors = [];
for (const [name, rule] of Object.entries(schema)) {
if (!Object.hasOwn(data, name)) {
errors.push(`липсва "${name}"`);
continue;
}
const kind = jsonType(data[name]);
if (kind !== rule.type) {
errors.push(`"${name}" е ${kind}, а трябва да е ${rule.type}`);
} else if (rule.enum && !rule.enum.includes(data[name])) {
const allowed = rule.enum.join(", ");
errors.push(`"${name}": "${data[name]}" не е от ${allowed}`);
}
}
for (const key of Object.keys(data)) {
if (!Object.hasOwn(schema, key)) errors.push(`излишно поле "${key}"`);
}
return errors;
}

Четири отговора на модела:

const answers = [
'{"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "Куриерът не е дошъл, клиентът заминава утре."}',
'{"intent": "доставка", "order_id": "1042", "urgent": "да", "summary": "Куриерът не е дошъл."}',
'{"intent": "return", "urgent": false, "summary": "Иска да върне яке.", "mood": "спокоен"}',
'Разбира се! Ето данните:\n```json\n{"intent": "delivery", "order_id": "1042", "urgent": true}\n```',
];
for (const answer of answers) {
const data = parseJson(answer);
const errors = data === null ? ["не е JSON"] : validate(data, TICKET);
console.log(errors.length ? errors.join("\n") : "ок");
console.log("---");
}
Изход
ок
---
"intent": "доставка" не е от delivery, return, payment, other
"urgent" е string, а трябва да е boolean
---
липсва "order_id"
излишно поле "mood"
---
не е JSON
---

Пробвай

Предвиди изхода

Какво печата проверката за този отговор?

const answers = [
'{"intent": "refund", "order_id": 1042, "urgent": false, "summary": "Иска парите си обратно."}',
];
for (const answer of answers) {
const data = parseJson(answer);
const errors = data === null ? ["не е JSON"] : validate(data, TICKET);
console.log(errors.length ? errors.join("\n") : "ок");
console.log("---");
}
Отговор

Пробвай

Попълни

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

} else if (rule.enum && !rule.enum.(data[name])) {

Идеята

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

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

Отговорът на модела влиза в историята с роля assistant, а след него ново съобщение user с грешките: „Отговорът не е по схемата: intent… Върни само JSON по схемата.“ Моделът вижда своя опит и точните грешки и поправя тях. Това е втори опит с обратна връзка.

Всеки опит праща цялата история. Вторият е по-скъп от първия, а третият от втория: от урока за контекста знаеш, че API-то няма памет и всичко тръгва наново. Затова опитите са малко, а след тях писмото отива при човек.

Виж го

Три писма, три съдби

function triage(client, email) {
const messages = [{ role: "user", content: email }];
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const reply = client.create(SYSTEM, messages);
const data = parseJson(reply.text);
const errors = data === null
? ["не е JSON"]
: validate(data, TICKET);
const verdict = errors.length === 0 ? "ок" : errors.join("; ");
console.log(
` опит ${attempt} (~${reply.inputTokens} токена): ${verdict}`);
if (errors.length === 0) {
return toTicket(data);
}
messages.push({ role: "assistant", content: reply.text });
messages.push({ role: "user",
content: "Отговорът не е по схемата: " + verdict + ". Върни само JSON по схемата." });
}
return null;
}
Стъпка по стъпка, с думи
  1. Писмото отива при модела с инструкция: върни само JSON с четири полета.
  2. Отговорът е валиден JSON и се прочита. Това още не значи, че е верен.
  3. Всяко поле се сверява със схемата: има ли го, верен ли е типът, от разрешените стойности ли е.
  4. Две грешки: intent и urgent. Те са списък от текстове, не изключение: програмата решава какво да прави.
  5. Грешките отиват обратно към модела като ново съобщение. Опит 2 праща цялата история: ~97 токена вместо ~46.
  6. Вторият отговор минава всички проверки. Едва сега става Ticket.
  7. Писмото отива в опашката за доставки като спешно. Най-много 3 опита, после поема човек.

Инструкцията и колко опита има програмата:

const SYSTEM = "Разпредели писмото от клиента. Върни само JSON с полетата "
+ "intent (delivery, return, payment или other), order_id (текст), "
+ "urgent (true или false) и summary (едно изречение).";
const MAX_ATTEMPTS = 3;

Едно писмо: питане, четене, проверка и при грешка нов опит с грешките. Когато всичко мине, речникът става обект с четирите полета, и оттук нататък кодът работи с полета, не с текст:

function triage(client, email) {
const messages = [{ role: "user", content: email }];
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const reply = client.create(SYSTEM, messages);
const data = parseJson(reply.text);
const errors = data === null
? ["не е JSON"]
: validate(data, TICKET);
const verdict = errors.length === 0 ? "ок" : errors.join("; ");
console.log(
` опит ${attempt} (~${reply.inputTokens} токена): ${verdict}`);
if (errors.length === 0) {
return toTicket(data);
}
messages.push({ role: "assistant", content: reply.text });
messages.push({ role: "user",
content: "Отговорът не е по схемата: " + verdict + ". Върни само JSON по схемата." });
}
return null;
}
function toTicket(data) {
const { intent, order_id: orderId, urgent, summary } = data;
return { intent, orderId, urgent, summary };
}
const client = new FakeClient();
const inbox = [
"Куриерът не дойде вчера, поръчка 1042. Спешно е, заминавам утре!",
"Искам да върна поръчка 1187, якето ми е голямо.",
"здр, нещо не става",
];
for (const email of inbox) {
console.log(`писмо: ${email}`);
const ticket = triage(client, email);
if (ticket === null) {
console.log(" → към човек");
} else {
const urgent = ticket.urgent ? ", спешно" : "";
console.log(` → ${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-ът на заявката отгоре.
// Точната заглавка за ключа е в документацията на доставчика.
const response = await fetch("https://<доставчик>/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: requestJson,
});
console.log(await response.text());

Всеки обект в схемата има 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 {
create(system, messages) {
const email = messages[0].content;
const corrected = messages.length > 1;
let text;
if (email.includes("1042")) {
text = corrected
? '{"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "Куриерът не е дошъл, клиентът заминава утре."}'
: '{"intent": "доставка", "order_id": "1042", "urgent": "да", "summary": "Куриерът не е дошъл."}';
} else if (email.includes("1187")) {
text = '{"intent": "return", "order_id": "1187", "urgent": false, "summary": "Иска да върне якето, голямо е."}';
} else {
text = "Не мога да разбера какво иска клиентът.";
}
let input = tokens(system);
for (const m of messages) input += tokens(m.content);
return { text, stopReason: "end_turn", inputTokens: input, outputTokens: tokens(text) };
}
}
// Грубо: думите × 1,3, закръглено нагоре.
function tokens(text) {
const t = text.trim();
return Math.floor(((t ? t.split(/\s+/).length : 0) * 13 + 9) / 10);
}

Упражнение

Номерът е четири цифри

Реши задачата в редактора си.

Добави в проверката правило: 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 за достъпно обучение по програмиране ·