Zum Inhalt
wannacode

Strukturierte Ausgabe: JSON nach einem Schema

Kurz gesagt: Strukturierte Ausgabe ist eine Antwort des Modells als JSON nach einem Schema mit Typen und erlaubten Werten, die das Programm prüft und bei einem Fehler ans Modell zurückschickt, damit es sie korrigiert.

Problem

Der Code, der JSON erwartet

Im Support-Postfach landen jeden Tag hunderte E-Mails. Ein Programm sortiert sie in Warteschlangen: Lieferungen, Rückgaben, Zahlungen, Allgemeines. Es fragt ein Modell nach dem Thema der E-Mail und erwartet JSON mit dem Feld intent. Hier sind drei Antworten des Modells auf dieselbe E-Mail:

const answers = [
'Klar! Hier sind die Daten:\n```json\n{"intent": "delivery", "order_id": "1042", "urgent": true}\n```',
'{"intent": "Lieferung", "order_id": "1042", "urgent": "ja", "summary": "Der Kurier ist nicht gekommen."}',
'{"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "Der Kurier ist nicht gekommen, der Kunde reist morgen ab."}',
];
for (const answer of answers) {
const data = parseJson(answer);
if (data === null) {
console.log(`kein JSON: ${answer.split("\n")[0]}`);
continue;
}
const queue = QUEUES[data.intent];
console.log(queue === undefined
? `keine Warteschlange für "${data.intent}"`
: `Warteschlange: ${queue}`);
}
Ausgabe
kein JSON: Klar! Hier sind die Daten:
keine Warteschlange für "Lieferung"
Warteschlange: Lieferungen

Nur die dritte funktioniert. Die erste ist höflich, aber es ist Text um das JSON herum, und das Programm kann ihn nicht lesen. Die zweite ist JSON, aber das Thema ist ein Wort, das das Programm nicht kennt, und es gibt keine solche Warteschlange. In der Lektion über den Prompt hast du gesehen, dass Beispiele helfen. Aber das Programm muss auch in der Nacht funktionieren, in der das Modell beschließt, höflich zu sein.

Die Idee

Ein Schema, eine Prüfung, ein zweiter Versuch

Wenn die Antwort in Code geht und nicht zu einem Menschen, hat sie einen Vertrag. Der Vertrag ist ein Schema: welche Felder es gibt, welchen Typ jedes hat und welche Werte erlaubt sind.

  1. Der Prompt nennt das Schema. Die Felder, die Typen und die erlaubten Werte stehen in der Anweisung.
  2. Das Programm liest das JSON. Ist es kein JSON, ist das ein Fehler, kein Absturz.
  3. Das Programm prüft es gegen das Schema: Ist jedes Feld da, stimmt der Typ, ist der Wert erlaubt, gibt es keine überflüssigen Felder.
  4. Die Fehler gehen zurück ans Modell als neue Nachricht, und es versucht es noch einmal.
  5. Es gibt höchstens drei Versuche. Dann geht die E-Mail an einen Menschen.

Eine Antwort nach einem Schema, die das Programm prüft, ist strukturierte Ausgabe.

Die Ausgabe des Modells ist nicht vertrauenswürdige Eingabe wie alles, was von außen kommt: Das Programm vertraut nicht darauf, dass sie den Vertrag einhält, es prüft.

Sieh es dir an

Lesen und prüfen

Das Schema der E-Mail: vier Felder, intent mit vier erlaubten Werten:

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

Das Lesen gibt das Objekt zurück, oder null, wenn der Text nicht genau ein JSON-Objekt ist:

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 wirft SyntaxError für Text, der kein JSON ist. Ein Array und null sind ebenfalls gültiges JSON, deshalb prüft die Funktion, dass es ein Objekt ist.

Der Typ eines Werts in den Worten von JSON selbst:

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

Die Prüfung wirft keine Exception. Sie gibt eine Liste von Fehlern zurück, weil das Programm etwas damit anfangen kann:

function validate(data, schema) {
const errors = [];
for (const [name, rule] of Object.entries(schema)) {
if (!Object.hasOwn(data, name)) {
errors.push(`fehlt "${name}"`);
continue;
}
const kind = jsonType(data[name]);
if (kind !== rule.type) {
errors.push(`"${name}" ist ${kind}, erwartet ${rule.type}`);
} else if (rule.enum && !rule.enum.includes(data[name])) {
const allowed = rule.enum.join(", ");
errors.push(`"${name}": "${data[name]}" gehört nicht zu ${allowed}`);
}
}
for (const key of Object.keys(data)) {
if (!Object.hasOwn(schema, key)) errors.push(`überflüssiges Feld "${key}"`);
}
return errors;
}

Vier Antworten des Modells:

const answers = [
'{"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "Der Kurier ist nicht gekommen, der Kunde reist morgen ab."}',
'{"intent": "Lieferung", "order_id": "1042", "urgent": "ja", "summary": "Der Kurier ist nicht gekommen."}',
'{"intent": "return", "urgent": false, "summary": "Will eine Jacke zurückgeben.", "mood": "ruhig"}',
'Klar! Hier sind die Daten:\n```json\n{"intent": "delivery", "order_id": "1042", "urgent": true}\n```',
];
for (const answer of answers) {
const data = parseJson(answer);
const errors = data === null ? ["kein JSON"] : validate(data, TICKET);
console.log(errors.length ? errors.join("\n") : "ok");
console.log("---");
}
Ausgabe
ok
---
"intent": "Lieferung" gehört nicht zu delivery, return, payment, other
"urgent" ist string, erwartet boolean
---
fehlt "order_id"
überflüssiges Feld "mood"
---
kein JSON
---

Probier es aus

Sag die Ausgabe voraus

Was gibt die Prüfung für diese Antwort aus?

const answers = [
'{"intent": "refund", "order_id": 1042, "urgent": false, "summary": "Will sein Geld zurück."}',
];
for (const answer of answers) {
const data = parseJson(answer);
const errors = data === null ? ["kein JSON"] : validate(data, TICKET);
console.log(errors.length ? errors.join("\n") : "ok");
console.log("---");
}
Antwort

Probier es aus

Fülle die Lücke

Eine Zeile aus der Prüfung: Der Typ stimmt, jetzt muss der Wert einer der erlaubten sein. Was fehlt?

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

Die Idee

Die Fehler zurück ans Modell

Was machst du mit einer Antwort, die nicht durchkommt? Wenn du die Frage noch einmal stellst, wiederholt das Modell oft denselben Fehler. Besser ist, ihm zu sagen, was falsch ist.

Die Antwort des Modells kommt mit der Rolle assistant in den Verlauf, danach eine neue user-Nachricht mit den Fehlern: „Die Antwort passt nicht zum Schema: intent … Gib nur JSON nach dem Schema zurück.“ Das Modell sieht seinen Versuch und die genauen Fehler und korrigiert sie. Das ist ein zweiter Versuch mit Rückmeldung.

Jeder Versuch schickt den ganzen Verlauf. Der zweite kostet mehr als der erste, der dritte mehr als der zweite: Aus der Lektion über den Kontext weißt du, dass die API kein Gedächtnis hat und alles von vorn beginnt. Darum sind die Versuche wenige, und danach geht die E-Mail an einen Menschen.

Sieh es dir an

Drei E-Mails, drei Schicksale

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
? ["kein JSON"]
: validate(data, TICKET);
const verdict = errors.length === 0 ? "ok" : errors.join("; ");
console.log(
` Versuch ${attempt} (~${reply.inputTokens} Tokens): ${verdict}`);
if (errors.length === 0) {
return toTicket(data);
}
messages.push({ role: "assistant", content: reply.text });
messages.push({ role: "user",
content: "Die Antwort passt nicht zum Schema: " + verdict + ". Gib nur JSON nach dem Schema zurück." });
}
return null;
}
Schritt für Schritt, in Worten
  1. Die E-Mail geht mit einer Anweisung ans Modell: Gib nur JSON mit vier Feldern zurück.
  2. Die Antwort ist gültiges JSON und wird gelesen. Das heißt noch nicht, dass sie stimmt.
  3. Jedes Feld wird gegen das Schema geprüft: Ist es da, stimmt der Typ, ist der Wert einer der erlaubten.
  4. Zwei Fehler: intent und urgent. Sie sind eine Liste von Texten, keine Exception: Das Programm entscheidet, was es tut.
  5. Die Fehler gehen als neue Nachricht zurück ans Modell. Versuch 2 schickt den ganzen Verlauf: ~110 Tokens statt ~58.
  6. Die zweite Antwort besteht alle Prüfungen. Erst jetzt wird sie zu einem Ticket.
  7. Die E-Mail geht als dringend in die Warteschlange für Lieferungen. Höchstens 3 Versuche, danach übernimmt ein Mensch.

Die Anweisung und wie viele Versuche das Programm hat:

const SYSTEM = "Sortiere die E-Mail des Kunden ein. Gib nur JSON mit den Feldern "
+ "intent (delivery, return, payment oder other), order_id (Text), "
+ "urgent (true oder false) und summary (ein Satz) zurück.";
const MAX_ATTEMPTS = 3;

Eine E-Mail: fragen, lesen, prüfen, und bei einem Fehler ein neuer Versuch mit den Fehlern. Wenn alles durchkommt, wird die Map zu einem Objekt mit den vier Feldern, und der Code arbeitet ab da mit Feldern, nicht mit Text:

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
? ["kein JSON"]
: validate(data, TICKET);
const verdict = errors.length === 0 ? "ok" : errors.join("; ");
console.log(
` Versuch ${attempt} (~${reply.inputTokens} Tokens): ${verdict}`);
if (errors.length === 0) {
return toTicket(data);
}
messages.push({ role: "assistant", content: reply.text });
messages.push({ role: "user",
content: "Die Antwort passt nicht zum Schema: " + verdict + ". Gib nur JSON nach dem Schema zurück." });
}
return null;
}
function toTicket(data) {
const { intent, order_id: orderId, urgent, summary } = data;
return { intent, orderId, urgent, summary };
}
const client = new FakeClient();
const inbox = [
"Der Kurier ist gestern nicht gekommen, Bestellung 1042. Es ist dringend, ich reise morgen ab!",
"Ich möchte Bestellung 1187 zurückgeben, die Jacke ist zu groß.",
"hallo, irgendwas stimmt nicht",
];
for (const email of inbox) {
console.log(`E-Mail: ${email}`);
const ticket = triage(client, email);
if (ticket === null) {
console.log(" → an einen Menschen");
} else {
const urgent = ticket.urgent ? ", dringend" : "";
console.log(` → ${ticket.intent}, ${ticket.orderId}${urgent}`);
}
}
Ausgabe
E-Mail: Der Kurier ist gestern nicht gekommen, Bestellung 1042. Es ist dringend, ich reise morgen ab!
Versuch 1 (~58 Tokens): "intent": "Lieferung" gehört nicht zu delivery, return, payment, other; "urgent" ist string, erwartet boolean
Versuch 2 (~110 Tokens): ok
→ delivery, 1042, dringend
E-Mail: Ich möchte Bestellung 1187 zurückgeben, die Jacke ist zu groß.
Versuch 1 (~51 Tokens): ok
→ return, 1187
E-Mail: hallo, irgendwas stimmt nicht
Versuch 1 (~44 Tokens): kein JSON
Versuch 2 (~74 Tokens): kein JSON
Versuch 3 (~104 Tokens): kein JSON
→ an einen Menschen
  • Die E-Mail zu 1042: Die erste Antwort hat zwei Fehler, die zweite kommt durch. Der zweite Versuch kostet ~110 Tokens statt ~58.
  • Die E-Mail zu 1187: kommt beim ersten Mal durch.
  • „hallo, irgendwas stimmt nicht“: Das Modell findet kein Thema und antwortet alle drei Male mit einem Satz. Das Programm erfindet keine Kategorie; es gibt die E-Mail an einen Menschen.

Das Modell ist hier ein falscher Client, wie in der Lektion über den Prompt: Die Antworten sind aufgezeichnet und jedes Mal gleich. Seine Regel steht in „Mehr dazu“.

Probier es aus

Finde den Fehler

Ein Kollege schreibt eine kürzere Prüfung. Die Antwort {"intent": "return", "urgent": false, "summary": "…"} kommt als „ok“ durch, und eine Minute später stürzt das Programm irgendwo weit weg ab, wenn es die Bestellung nachschlägt. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Das Schema in der API selbst

Neuere APIs nehmen das Schema als Teil der Anfrage an. Dann erzeugt das Modell nur Text, der dazu passt: Jedes nächste Token wird unter denen gewählt, die das JSON nach dem Schema halten. Das höfliche „Klar!“ und "urgent": "ja" werden unmöglich.

Die Prüfung im Code bleibt aber:

  • Eine Ablehnung oder eine abgeschnittene Antwort. Lehnt das Modell ab (stop_reason ist refusal) oder erreicht es max_tokens, passt der Text vielleicht nicht zum Schema.
  • Die Regeln des Shops. Das Schema sagt „Text“, aber es weiß nicht, ob Bestellung 1187 existiert und diesem Kunden gehört. Das ist eine Prüfung im Programm.
  • Ein anderes Modell oder ein anderer Anbieter. Der Code, der prüft, hängt nicht davon ab, wer das JSON erzeugt hat.

Bei dir ausführen. Dieses Programm gehört nicht zur Prüfung der Lektion: Du brauchst ein Konto bei einem Anbieter von Modellen, einen Schlüssel und Geld für jeden Aufruf. Der Schlüssel steht in LLM_API_KEY, nie im Code. Anbieter liefern offizielle SDKs für Java, C#, Python und JavaScript mit derselben Form (Nachrichten, Rollen, Tokens, Tools); die genauen Namen der Klassen und Methoden stehen in der Dokumentation des Anbieters. Die Anfrage als JSON: Das Schema steht im Feld output_config, und der Name des Modells ist ein Platzhalter:

{
"model": "<Name des Modells>",
"max_tokens": 1024,
"system": "Sortiere die E-Mail des Kunden ein. Gib nur JSON mit den Feldern intent (delivery, return, payment oder other), order_id (Text), urgent (true oder false) und summary (ein Satz) zurück.",
"messages": [
{
"role": "user",
"content": "Der Kurier ist gestern nicht gekommen, Bestellung 1042. Es ist dringend, ich reise morgen ab!"
}
],
"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
}
}
}
}

Eine Beispielantwort. Das JSON nach dem Schema ist Text im Textblock, und das Programm liest und prüft es trotzdem:

{
"content": [
{
"type": "text",
"text": "{\"intent\": \"delivery\", \"order_id\": \"1042\", \"urgent\": true, \"summary\": \"Der Kurier ist nicht gekommen, der Kunde reist morgen ab.\"}"
}
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 58, "output_tokens": 23}
}

Ohne SDK geht die Anfrage mit dem Standard-HTTP-Client der Sprache raus. Die Adresse ist ein Platzhalter:

// requestJson ist das JSON der Anfrage oben.
// Der genaue Header für den Schlüssel steht in der Doku des Anbieters.
const response = await fetch("https://<anbieter>/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: requestJson,
});
console.log(await response.text());

Jedes Objekt im Schema hat additionalProperties: false: So kann ein überflüssiges Feld wie mood gar nicht erst auftauchen.

Die Idee

Wann es wehtut

  • JSON für alles. Die Antwort an einen Kunden ist Text. Ein Schema gehört dorthin, wo die Antwort in Code geht: eine Warteschlange, eine Datenbank, ein anderer Dienst.
  • Ein tiefes Schema. Fünf Ebenen verschachtelter Objekte und zwanzig Felder ergeben mehr Fehler und teurere Versuche. Zwei einfache Anfragen sind besser als eine komplizierte.
  • Flicken statt Prüfung. „Schneide vom ersten { bis zum letzten }“ funktioniert bis zur ersten Antwort mit zwei {. Wenn die API ein Schema unterstützt, nimm es. Wenn nicht, prüfe und versuche es noch einmal.
  • Die richtige Form ist nicht die richtige Antwort. "order_id": "1043" besteht das Schema, aber das Modell kann sich bei einer Ziffer geirrt haben: Modelle erfinden plausibel. Die Nummer wird gegen die Datenbank geprüft, bevor man sie benutzt.
  • Versuche haben einen Preis. Jeder Versuch sind mehr Tokens und mehr Sekunden für den Kunden. Wie viel genau und wie man ein Budget hält, steht in der Lektion über Kosten und Geschwindigkeit.
  • Das Schema und der Prompt ändern sich gemeinsam. Eine neue Kategorie im Schema ohne neue Kategorie im Prompt ergibt ein Modell, das sie nie nennt. Die Version des Schemas steht im Code, neben dem Prompt.
Genauer, für Neugierige

Die Wörter von JSON Schema. Das Schema in der API ist JSON Schema: type (object, string, number, boolean, array, null), properties mit den Feldern, required mit den Pflichtfeldern, enum mit den erlaubten Werten und additionalProperties: false gegen überflüssige. Unsere Prüfung macht eine Teilmenge davon, genug, um den Mechanismus zu zeigen. Ein Projekt nimmt eine fertige Bibliothek für JSON Schema.

Vom Text zu einem Typ. Nach der Prüfung wird die Map zu Ticket, und der übrige Code prüft nichts mehr: Der Compiler oder die Felder sagen, was da ist. Das ist die Idee „am Eingang lesen und prüfen, innen mit Typen arbeiten“. Die Prüfung steht an einer Stelle, nicht im ganzen Programm verstreut.

Tools nutzen dasselbe. In der nächsten Lektion bittet das Modell das Programm, eine Funktion auszuführen. Auch die Argumente der Funktion kommen als JSON nach einem Schema (input_schema) und werden ebenfalls geprüft.

Der falsche Client von innen. Seine Antworten sind nach der Bestellnummer in der E-Mail aufgezeichnet: Bei 1042 ist die erste falsch und nach der Korrektur richtig; bei 1187 ist sie sofort richtig; bei allem anderen ist sie ein Satz. Die Tokens sind Wörter × 1,3.

// Steht an der Stelle des echten Modells: aufgezeichnete Antworten,
// ohne Netz und jedes Mal gleich.
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": "Der Kurier ist nicht gekommen, der Kunde reist morgen ab."}'
: '{"intent": "Lieferung", "order_id": "1042", "urgent": "ja", "summary": "Der Kurier ist nicht gekommen."}';
} else if (email.includes("1187")) {
text = '{"intent": "return", "order_id": "1187", "urgent": false, "summary": "Will die Jacke zurückgeben, sie ist zu groß."}';
} else {
text = "Ich verstehe nicht, was der Kunde will.";
}
let input = tokens(system);
for (const m of messages) input += tokens(m.content);
return { text, stopReason: "end_turn", inputTokens: input, outputTokens: tokens(text) };
}
}
// Grob: Wörter × 1,3, aufgerundet.
function tokens(text) {
const t = text.trim();
return Math.floor(((t ? t.split(/\s+/).length : 0) * 13 + 9) / 10);
}

Übung

Die Nummer hat vier Ziffern

Löse die Aufgabe in deinem Editor.

Füge der Prüfung eine Regel hinzu: order_id hat genau 4 Ziffern. Wenn nicht, lautet der Fehler "order_id": "10420" hat nicht 4 Ziffern. Lass die Prüfung für die drei Antworten laufen, die sich nur in der Nummer unterscheiden: 1042, 10420 und #1042.

Ausgabe
ok
---
"order_id": "10420" hat nicht 4 Ziffern
---
"order_id": "#1042" hat nicht 4 Ziffern
---

Weitere Aufgaben

  • Eine leere Zusammenfassung. summary darf kein leerer Text und nicht nur Leerzeichen sein. Füge die Regel und einen Test mit "summary": " " hinzu.
  • Eine begrenzte Zahl von Tokens. Beende die Versuche auch dann, wenn die Tokens der E-Mail zusammen 100 übersteigen, schon vor drei Versuchen. Wie viele Versuche macht dann die E-Mail „hallo, irgendwas stimmt nicht“?

Test

Teste dich

1. Das Modell hat gültiges JSON geliefert. Heißt das, das Programm kann es verwenden?
2. Warum schickt man beim zweiten Versuch die Fehler ans Modell, statt die Frage einfach noch einmal zu stellen?
3. Die E-Mail ergibt keinen Sinn, und das Modell liefert dreimal einen Satz statt JSON. Was tut das Programm?
4. Die API garantiert jetzt JSON nach dem Schema. Bleibt die Prüfung im Code?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·