Zuverlässiges JSON aus einem Agenten holen (Schemas, Eingeschränktes Dekodieren, Wiederholungsversuche)
Eine Pipeline, die ich letztes Jahr gebaut hatte, fiel um 2 Uhr nachts aus, weil ein Modell beschlossen hatte, sein JSON in eine gesprächige Präambel zu verpacken: „Natürlich! Hier sind die Daten, die Sie angefordert haben:" gefolgt von einem Code-Block. Mein Parser, der erwartete, dass der Response-Body mit { beginnt, warf eine Exception. Der Wiederholungsversuch traf dasselbe freundliche Modell, bekam dieselbe freundliche Präambel, und warf erneut. Drei Versuche, drei Fehler, ein geweckter Mensch.
Das Ärgerliche ist, dass das JSON innerhalb des Code-Blocks perfekt war. Das Modell hatte den schwierigen Teil — die Extraktion — korrekt erledigt. Es konnte sich nur nicht davon abhalten, erst mal Hallo zu sagen. Ich hatte meinen Aufwand in den Prompt gesteckt und nichts davon in den Vertrag.
Das ist das gesamte Problem in einer Anekdote. Das Modell ist normalerweise in der Lage, die gewünschten Daten zu produzieren. Das Scheitern liegt an den Nähten: die Präambel, das abschließende Komma, das umbenannte Feld, der erfundene Enum-Wert, die Antwort, die mitten in einem Objekt abgeschnitten wurde, weil man vergessen hatte, das Token-Limit zu erhöhen. Nichts davon ist ein Denkfehler. Es sind Vertragsfehler, und man verteidigt sich dagegen mit Mechanismen, nicht mit besserem Prompting.
Die Kurzversion: JSON nicht erbitten, sondern das Modell dazu zwingen, es zu produzieren. Den Schema-gebundenen Structured-Output-Modus des Providers nutzen (jetzt Standard bei OpenAI, Anthropic und Google), das Ergebnis gegen ein eigenes Schema validieren und eine Reparatur-Wiederholungsschleife für die Fälle vorhalten, die trotzdem durchrutschen. Im Folgenden die Leiter vom Schwächsten zum Stärksten, und bis wohin ich wirklich klettere.
Die Leiter, vom Schwächsten zum Stärksten
Es gibt ungefähr fünf Sprossen, und die meisten Menschen befinden sich eine Sprosse tiefer als sie denken.
Sprosse eins: Prompt-and-Pray. Man schreibt „antworte nur mit JSON, kein anderer Text" und hofft. Das funktioniert in einer Demo oft genug, um gefährlich irreführend zu sein. Es scheitert an Präambeln, Code-Blöcken, abschließender Prosa und jedem Modell, das gerade einen ausdrucksstarken Tag hat. Das nicht ausliefern.
Sprosse zwei: JSON-Modus. Ein Provider-Flag, das garantiert, dass die Ausgabe syntaktisch gültiges JSON ist — es lässt sich parsen. Es garantiert nicht, dass die Ausgabe zur eigenen Form passt. OpenAIs Dokumentation ist direkt, dass dies die ältere, schwächere Option ist: „Structured Outputs ist die Weiterentwicklung des JSON-Modus. Während beide die Produktion gültigen JSONs sicherstellen, garantiert nur Structured Outputs die Schema-Einhaltung" (OpenAI Structured Outputs Guide). Der JSON-Modus stoppt das Präambel-Problem. Er tut nichts gegen ein fehlendes Feld oder einen halluzinierten Enum.
Sprosse drei: Schema-gebundene Structured Outputs. Man übergibt dem Provider ein JSON Schema, und dieser schränkt die Generierung so ein, dass die Ausgabe konform ist — jedes erforderliche Feld vorhanden, jeder Typ korrekt, jeder Enum-Wert aus der eigenen Liste. Dies ist die Sprosse, auf der man bei der Datenextraktion wohnt.
Sprosse vier: Tool-/Funktionsaufruf. Das Modell gibt einen Aufruf einer Funktion aus, deren Parameter ein JSON Schema sind. Mechanisch ist das dieselbe Einschränkung wie bei Sprosse drei, angewandt auf Tool-Argumente statt auf einen Response-Body. Man will ihn, wenn das Modell wählt, welche Aktion ausgeführt werden soll, nicht nur ein Formular ausfüllt.
Sprosse fünf: Eingeschränktes/grammatikbasiertes Dekodieren, das man selbst ausführt. Bei Open-Weight-Modellen kontrolliert man den Decoder, sodass man die Token-Logits bei jedem Schritt maskieren kann, um jeden Token zu verbieten, der die Grammatik brechen würde. Das ist die stärkste Garantie — das Modell kann buchstäblich keinen ungültigen Token ausgeben — und die aufwändigste Arbeit.
Der meiste Produktionscode sollte auf Sprosse drei oder vier mit einem gehosteten Provider sitzen. Im Folgenden zeige ich, wie jede aussieht.
Sprosse drei: Schema-gebundene Structured Output
Alle drei großen Provider liefern das jetzt aus, und die Form ist ähnlich genug, um sie einmal zu lernen.
OpenAI verwendet response_format mit json_schema und strict: true. Das strict-Flag ist es, das „bitte" in „garantiert" verwandelt. Seine Anforderungen sind spezifisch und leicht zu verletzen: additionalProperties muss auf jedem Objekt false sein, und jede Eigenschaft muss im required-Array erscheinen. Optionale Felder werden als Union mit null ausgedrückt, nicht durch Weglassung (OpenAI Structured Outputs Guide).
Anthropic hat dieselbe Fähigkeit zur Claude API hinzugefügt — jetzt allgemein verfügbar, der Beta-Header (structured-outputs-2025-11-13) ist nicht mehr erforderlich. Man übergibt ein output_config.format für einen JSON-Response-Body und setzt strict: true bei Tool-Definitionen für garantiert gültige Tool-Eingaben. Das Schema-Subset hat denselben Charakter wie OpenAIs: additionalProperties: false auf jedem Objekt, jede Eigenschaft im required aufgeführt (Anthropic Structured Outputs Docs).
Googles Gemini macht es über die Generierungskonfiguration: responseMimeType auf application/json setzen und das Schema als responseSchema übergeben. Die SDKs erlauben es, das Schema mit Pydantic in Python oder Zod in TypeScript zu definieren, anstatt JSON Schema von Hand zu schreiben (Gemini Structured Output Docs).
Hier ist die TypeScript-Form mit OpenAI. Ich definiere das Schema in Zod und konvertiere es, weil das manuelle Pflegen von JSON Schema dafür sorgt, dass Schema und Typen auseinanderdriften.
import OpenAI from "openai";
import * as z from "zod";
const Ticket = z.object({
priority: z.enum(["low", "medium", "high", "urgent"]),
category: z.enum(["billing", "bug", "feature", "other"]),
summary: z.string().max(280),
needs_human: z.boolean(),
});
const client = new OpenAI();
const res = await client.chat.completions.create({
model: "gpt-4o-2024-08-06",
messages: [
{ role: "system", content: "Classify the support ticket." },
{ role: "user", content: ticketText },
],
response_format: {
type: "json_schema",
json_schema: {
name: "ticket",
strict: true,
schema: z.toJSONSchema(Ticket),
},
},
});
const parsed = Ticket.parse(JSON.parse(res.choices[0].message.content));
z.toJSONSchema() ist in Zod 4 eingebaut und zielt standardmäßig auf Draft 2020-12 ab (Zod JSON Schema Docs). Man beachte, dass die Felder priority und category Enums sind — mit aktiviertem strict-Modus kann das Modell nicht "critical" zurückgeben, selbst wenn es das wollte, weil dieser Token nicht in der erlaubten Menge ist. Diese eine Eigenschaft eliminiert eine ganze Fehlerklasse.
Wovor die Einschränkung nicht schützt: Sie garantiert die Form, nicht die Bedeutung. Ein eingeschränktes Modell wird fröhlich eine echte Kategorie auf ein Ticket setzen, das es falsch gelesen hat. Deshalb führe ich am Ende trotzdem Ticket.parse() aus — teils gegen Schema-Drift zwischen meinen Typen und dem Drahtformat, teils weil ich manchmal semantische Prüfungen hinzufüge, die JSON Schema nicht ausdrücken kann.
Sprosse vier: Tool-Aufruf, und wann man ihn bevorzugt
Tool-Aufruf ist dieselbe Einschränkung, die auf ein anderes Ziel gerichtet ist. Anstatt „füll diesen Response-Body aus" lautet es: „wenn du ein Tool aufrufst, müssen deine Argumente seinem Schema entsprechen." Anthropics strikter Tool-Einsatz macht diese Garantie explizit: Mit strict: true „entsprechen Claudes Tool-Eingaben exakt Ihrem Schema" (Anthropic Structured Outputs Docs).
Der Grund, Tools einer einfachen strukturierten Antwort vorzuziehen, ist die Auswahl. Wenn das Modell zwischen search_orders, issue_refund und escalate wählt, jedes mit eigener Argumentform, will man Funktionsaufruf — die diskriminierte Union aus welchem Tool, plus validierte Args für dieses Tool, ist genau das Agent-Loop-Primitiv. Wenn man nur eine feste Form von einem Aufruf zurückbraucht, ist ein strukturierter Response-Body einfacher und man überspringt die Tool-Aufruf-Zeremonie.
Ein praktischer TypeScript-artiger Loop-Entwurf:
// model returns tool_calls; each has a name + JSON arguments
for (const call of message.tool_calls ?? []) {
const tool = tools[call.function.name];
if (!tool) throw new Error(`hallucinated tool: ${call.function.name}`);
// strict mode guarantees this parses to the tool's shape,
// but validate anyway — providers differ, and you own the contract
const args = tool.schema.parse(JSON.parse(call.function.arguments));
const result = await tool.run(args);
// feed result back into the conversation, continue the loop
}
Den hallucinated tool-Guard behalte ich auch im strict-Modus, weil die Menge welche Tools existieren und die Menge gültige Argumente je Tool von verschiedenen Teilen des Stacks durchgesetzt werden, und ich nicht annehmen will, dass beides bei jedem Provider wasserdicht ist.
Sprosse fünf: Eingeschränktes Dekodieren, das man selbst ausführt
Wenn man ein Open-Weight-Modell betreibt, besitzt man den Decoder, was bedeutet, dass man bei jedem Schritt die Grammatik auf Token-Ebene durchsetzen kann: jeden Token maskieren, der die Teilausgabe gegen das Schema unparsierbar machen würde. Das Modell kann kein ungültiges JSON produzieren, weil ungültige Tokens nie auf dem Speiseplan stehen.
Die Bibliothek, mit der die meisten anfangen, ist Outlines, die Regex, JSON Schema und vollständige kontextfreie Grammatiken in EBNF unterstützt. Das ist eine echte Fähigkeit mit echten Kosten: Der frühe Finite-State-Machine-Ansatz kann lange Zeit damit verbringen, komplexe Schemas zu kompilieren, und ein JSON-Schema-Benchmark stellte fest, dass Kompilierungszeitüberschreitungen die Konformitätsrate bei schweren Schemas nach unten zogen. Das Backend, auf das die meisten Hochdurchsatz-Serving-Stacks umgestiegen sind, ist XGrammar — das Standard-Backend für strukturierte Generierung in Engines wie vLLM und SGLang, mit sehr geringem Overhead pro Token (XGrammar). Wenn man selbst hostet und strukturierter Output auf dem heißen Pfad liegt, ist das die Richtung, in die das Ökosystem gegangen ist.
Ein Vorbehalt, den man kennen sollte, bevor man damit anfängt: Harte Grammatikeinschränkungen können schlecht mit dem Denken interagieren. Es gibt veröffentlichte Arbeiten, die argumentieren, dass das zu frühe Erzwingen von Struktur die tatsächliche Antwortqualität des Modells verschlechtern kann, verglichen damit, es in Prosa denken zu lassen und danach zu strukturieren — die sogenannte Ausrichtungssteuer des eingeschränkten Dekodierens (arXiv). Die praktische Schlussfolgerung: Den Output einschränken, aber dem Modell Raum zum Denken geben, wenn die Aufgabe schwer ist. Das Modell nicht zwingen, in JSON zu denken.
Die Fehlermodi, und wie man sich wirklich dagegen verteidigt
Die Einschränkung behandelt die Form. Das hier sind die Dinge, die sie nicht behandelt, und die einem Nachts den Schlaf rauben.
Abschneiden. Der mit Abstand häufigste Fehler. Das Modell trifft das Ausgabe-Token-Limit mitten in einem Objekt, und man bekommt die Hälfte eines JSON-Dokuments, das keine Einschränkung retten kann — es war gültig, bis es aufhörte. Verteidigungen: max_tokens großzügig für den schlimmsten Fall des Schemas setzen, und bei einem Parse-Fehler prüfen, ob die Antwort wegen der Länge gestoppt hat, bevor man blind wiederholt. Das Wiederholen eines Abschneidens mit demselben Limit verbrennt nur Geld.
Schema-Drift. Die erwartete Form im Code und das an das Modell gesendete Schema laufen auseinander — meist, weil jemand das JSON Schema manuell editiert hat. Die Lösung ist, es nie von Hand zu schreiben: das JSON Schema aus demselben Zod- oder Pydantic-Typ ableiten, mit dem man parst, damit es eine einzige Wahrheitsquelle gibt. Instructor verlässt sich genau darauf, indem er den Client so umhüllt, dass ein Pydantic-Modell das Schema definiert, die Antwort validiert und bei Fehler wiederholt — alles in einem Objekt.
Halluzinierte Enum-Werte. Ein Modell unter Prompt-and-Pray erfindet "critical", wenn der Enum low|medium|high ist. Strikte Structured Outputs töten das direkt auf Sprosse drei — der ungültige Token ist unerreichbar. Wenn man nicht im eingeschränkten Modus ist, macht ein validierender Parser daraus einen abfangbaren Fehler statt einer Überraschung weiter unten.
Über-Verschachtelung und stilles Umbenennen. Das Modell gibt die richtigen Daten unter customer_name zurück, wenn man name angefordert hat, oder hüllt alles in eine extra { "result": ... }-Schicht ein. additionalProperties: false plus eine erschöpfende required-Liste ist das, was das Modell daran hindert, Felder hinzuzufügen oder umzubenennen; genau deshalb machen sowohl OpenAI als auch Anthropic diese beiden Einschränkungen für den strict-Modus verpflichtend.
Die Reparaturschleife bindet das zusammen. Wenn die Validierung fehlschlägt, nicht einfach denselben Aufruf wiederholen — den Fehler zurückfüttern:
import instructor
from pydantic import BaseModel
client = instructor.from_provider("openai/gpt-4o")
class Ticket(BaseModel):
priority: str
summary: str
# on a validation error, Instructor sends the Pydantic error message
# back to the model and asks it to fix the specific field — then re-validates
ticket = client.chat.completions.create(
response_model=Ticket,
max_retries=2,
messages=[{"role": "user", "content": ticket_text}],
)
Den spezifischen Validierungsfehler dem Modell zu zeigen („priority muss eines von low, medium, high sein") ist weit effektiver als ein blinder Wiederholungsversuch, weil man ihm genau gesagt hat, was zu reparieren ist. Aber die Wiederholungen begrenzen — zwei sind genug. Ein Modell, das dreimal in Folge bei der Validierung scheitert, scheitert normalerweise aus einem Grund, den mehr Wiederholungen nicht lösen werden, und man will elegant degradieren: einen typisierten Fehler zurückgeben, auf einen Standard zurückfallen oder zu einem Menschen leiten. Endlos auf einer vergifteten Eingabe zu schleifen ist der Grund für meinen 2-Uhr-nachts-Alarm.
Was ich tatsächlich verwende, nach Anwendungsfall
Für Datenextraktion oder -klassifizierung gegen ein gehostetes Modell — der häufige Fall — den strikten Schema-gebundenen Modus des Providers verwenden, das Schema aus Zod oder Pydantic ableiten und das Ergebnis mit demselben Typ validieren. Das sind Sprosse drei plus ein validierender Parser, und es deckt den Großteil dessen ab, was die Leute bauen.
Für Agenten, die Aktionen wählen, den strikten Tool-Aufruf verwenden. Die Form welches-Tool-plus-validierte-Args ist genau das, was man will, und das strict-Flag schließt die Argument-Validierungslücke. Einen Guard für Tool-Namen vorhalten, die man nicht erkennt.
Für selbst gehostete Open-Weight-Modelle auf einem heißen Pfad das grammatikbasierte eingeschränkte Dekodieren über die eigene Serving-Engine verwenden — XGrammar-Klasse-Backends sind jetzt schnell genug, dass es kaum Grund gibt, das von Hand zu bauen. Das Modell erst in Prosa nachdenken lassen, wenn die Aufgabe schwer ist, dann den strukturierten Teil einschränken.
Und unabhängig von der Sprosse: an der Grenze mit einem eigenen Schema validieren und eine begrenzte Reparaturschleife mit eleganter Degradierung vorhalten. Die Einschränkung hindert das Modell daran, Müll auszugeben. Der Validator hindert die eigenen Annahmen daran, der Müll zu sein. Sowohl meine Pipeline als auch mein Schlaf haben sich verbessert, seit ich aufgehört habe, dem Prompt zu vertrauen, und angefangen habe, den Vertrag durchzusetzen.
