Chat-Modelle wollen reden. Deine Pipeline will einen Datensatz. Die Lücke zwischen „hier ist ein freundlicher Absatz über das Ticket" und {"category": "billing", "priority": "high"} ist die Stelle, an der die meisten LLM-Integrationen still und leise kaputtgehen — ein verirrter Markdown-Zaun, ein nachgestelltes Komma, ein halluzinierter Key, und das json.loads weiter unten fliegt um 3 Uhr morgens auf die Nase. In diesem Beitrag geht es darum, gültiges, brauchbares JSON aus Claude und Gemini herauszuholen — mit derselben OpenAI-Request-Struktur hinter einer einzigen base_url — und um die Validierungsschicht, die das Ganze produktionsreif statt demoreif macht.
Es gibt drei Werkzeuge für die Aufgabe: response_format mit json_object, response_format mit json_schema und natives Tool-/Function-Calling. Sie sind nicht austauschbar, und das falsche zu wählen ist der häufigste Grund, warum sich ein Structured-Output-Feature wacklig anfühlt. Wir gehen jedes davon durch: wann du dazu greifst, wie du das Schema entwirfst und wie du das Zurückkommende validierst und reparierst.
JSON-Modus: garantiert parsebar, nicht garantiert korrekt
Der einfachste Hebel ist response_format={"type": "json_object"}. Er zwingt das Modell, syntaktisch gültiges JSON auszugeben — keine Prosa-Vorrede, keinen ```json Zaun, keine Entschuldigung. Was er nicht tut, ist deine Struktur zu erzwingen. Die Felder musst du weiterhin im Prompt beschreiben, und das Modell kann nach wie vor einen Key weglassen, einen erfinden oder einen String setzen, wo du einen Boolean wolltest.
# response_format=json_object: Das Modell wird gezwungen, syntaktisch gültiges
# JSON auszugeben. Es erzwingt NICHT DEINE Struktur — die Felder musst du
# weiterhin im Prompt beschreiben. Identischer Call für Claude und Gemini hinter einer base_url.
from openai import OpenAI
import json
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # oder "gemini-2.5-flash" — derselbe Code
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Extract the support ticket fields. Reply ONLY with a JSON object "
"with keys: category (one of billing|bug|feature|other), "
"priority (low|medium|high), summary (string), "
"needs_human (boolean)."
),
},
{"role": "user", "content": "I was charged twice this month, please refund."},
],
)
data = json.loads(resp.choices[0].message.content) # garantiert parsebar
print(data["category"], data["priority"]) # "billing" "high"
# json_object garantiert: Es parst. Es garantiert NICHT, dass deine Keys existieren,
# die Enums gültig sind oder die Typen stimmen. Genau dafür ist die Validierung da.Das ist das richtige Werkzeug, wenn die Struktur einfach ist, wenn du den Prompt eng kontrollierst oder wenn du ohnehin validierst (das tust du). Das mentale Modell: json_object verschafft dir die Garantie, dass json.loads nicht fliegt. Es verschafft dir keine Garantie, dass das Objekt das bedeutet, was du denkst. Behandle diesen Unterschied als den ganzen Kern der Sache.
JSON-Schema-Modus: die Struktur einschränken, nicht nur die Syntax
Wenn du die Feldnamen, Typen und Enums erzwungen haben willst — nicht nur erbeten — greif zu json_schema. Das Schema reist mit dem Request mit, und mit strict: true (wo die Modellfamilie es unterstützt) wird die Ausgabe darauf eingeschränkt. Die zwei Felder, die „strict" überhaupt erst Bedeutung verleihen, sind additionalProperties: false (keine überraschenden Keys) und ein vollständiges required-Array (keine fehlenden Keys).
# response_format=json_schema: Das Schema wird an das Modell gesendet und die Ausgabe
# wird darauf eingeschränkt. Setze strict=True für die harte Garantie, wo unterstützt.
# additionalProperties=False + required-Keys machen "strict" erst bedeutsam.
schema = {
"name": "support_ticket",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "feature", "other"]},
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
"needs_human": {"type": "boolean"},
},
"required": ["category", "priority", "summary", "needs_human"],
},
}
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
response_format={"type": "json_schema", "json_schema": schema},
messages=[
{"role": "system", "content": "Extract the support ticket fields."},
{"role": "user", "content": "I was charged twice this month, please refund."},
],
)
ticket = json.loads(resp.choices[0].message.content)
# Mit strict json_schema ist category nachweislich einer der vier Enums —
# kein defensives "if category not in (...)" auf dem Happy Path nötig.Hier ist die ehrliche Einschränkung, und sie ist wichtig: Die strikte json_schema-Unterstützung variiert je nach Modellfamilie. Manche Modelle befolgen jede Einschränkung inklusive verschachteltem additionalProperties: false; andere behandeln das Schema eher als starken Hinweis denn als harte Grammatik, besonders bei tief verschachtelten Objekten, Unions (anyOf) oder rekursiven Strukturen. Brievio reicht dein response_format unverändert an das echte First-Party-Modell durch, sodass du das reale Verhalten des echten Modells bekommst — keine verwässerte Emulation. Aber das bedeutet auch: Die nativen Grenzen des Modells sind deine Grenzen. Die praktische Regel: Fordere das Schema an, und validiere dann trotzdem. Lass dir von „strict" niemals den Validierungsschritt ausreden.
JSON-Modus vs. Tool-Calling: wann welches
Tool-/Function-Calling liefert ebenfalls strukturiertes JSON zurück — die Argumente kommen als JSON-String zurück, der einem Funktionsnamen zugeordnet ist. Also welches nimmst du? Der Unterschied dreht sich um Intention, nicht um Formatierung:
- Nimm den JSON-Modus, wenn das JSON die Antwort ist. Du extrahierst Felder, klassifizierst, fasst zu einem Datensatz zusammen oder generierst ein Config-Objekt. Es gibt genau eine Struktur, die du zurück willst, jedes Mal.
response_formatpasst sauberer — eine Ausgabe, kein Function-Call-Zeremoniell, keinetool_choice-Verkabelung. - Nimm Tool-Calling, wenn das Modell eine Aktion auswählt. Es könnte
get_weatheraufrufen, odersearch_db, oder in Prosa antworten — und du willst, dass das Modell entscheidet, welches, möglicherweise mehrere aufrufend. Function-Calling ist für Dispatch gebaut: viele Kandidaten-Strukturen, das Modell wählt. Das durch ein einziges JSON-Objekt zu zwingen, ist umständlich. - Die Grauzone: ein einzelner erzwungener Tool-Call als strukturierte Ausgabe.
tool_choiceso zu setzen, dass eine bestimmte Funktion verlangt wird, ist ein altbewährter Weg, strukturierte Ausgabe auf Modellen zu bekommen, die älter sind alsjson_schema. Er funktioniert weiterhin und ist ein guter Fallback. Aber wenn ein Modelljson_schemaunterstützt, ist dieser Weg direkter und weniger zum Nachdenken.
Wenn es bei deiner Arbeitslast echt um Aktionen und Dispatch geht statt um einen festen Datensatz, findest du die Mechanik und die modellübergreifenden Fallstricke in Tool-Nutzung über Claude und Gemini hinweg. Für alles, was „gib mir dieses Objekt" ist, bleib bei response_format.
Ein Schema entwerfen, das das Modell wirklich treffen kann
Ein Schema ist ein Prompt. Wie du es formst, beeinflusst die Trefferquote genauso sehr wie die Modellwahl. Ein paar Regeln, die sich bei beiden Familien auszahlen:
- Bevorzuge flach gegenüber tief verschachtelt. Drei Verschachtelungsebenen mit optionalen Objekten sind die Stelle, an der der Strict-Modus wackelt. Wenn du
address.cityzucityflachklopfen kannst, tu es, und forme nach der Validierung um. - Nutze Enums für jede geschlossene Menge.
"priority": {"enum": ["low","medium","high"]}ist weit zuverlässiger als ein freierstring, den du nachbearbeitest. Enums sind das einzelne Schema-Feature mit dem höchsten Hebel. - Benenne Felder so, wie ein Mensch es täte.
needs_human_reviewschlägtnh_flag. Das Modell füllt gut benannte Felder genauer, weil der Name die Anweisung trägt. - Setze eine
descriptionan mehrdeutige Felder. Eine Zeile pro Feld innerhalb des Schemas löst die meisten „das Modell hat falsch geraten"-Fälle ohne Prompt-Umschreibung. - Mach Optionalität explizit. Wenn ein Feld fehlen kann, lass es entweder aus
requiredweg oder modelliere es als nullbare Union — erwarte nicht, dass das Modell einen Platzhalterwert erfindet. Entscheide, wer den „fehlt"-Fall besitzt, du oder das Modell. - Vermeide freie Zahlen, wenn ein begrenzter Typ reicht. Eine 1–5-Integer-Bewertung als Enum von
[1,2,3,4,5]übertrifft „eine Zahl von 1 bis 5" im Prompt.
Validieren und reparieren: die Schicht, die in Produktion geht
Das einzelne größte Zuverlässigkeits-Upgrade ist, die Modellausgabe wie einen nicht vertrauenswürdigen Client-Request zu behandeln: sie parsen, gegen dein echtes Schema validieren und bei Fehler einmal mit zurückgespiegeltem Fehler erneut versuchen. Ein Pydantic-Modell (oder zod, oder JSON-Schema-Validierung in deiner Sprache) fängt die Fälle ab, die selbst durch den Strict-Modus rutschen — und die Reparatur-Runde behebt die meisten davon, weil das Modell gut darin ist, einen Fehler zu korrigieren, auf den du direkt zeigst.
# Vertraue niemals einer Ausgabe, die du nicht validiert hast. Behandle das Modell
# wie einen nicht vertrauenswürdigen Client: parsen -> gegen dein Schema validieren ->
# einmal mit zurückgespiegeltem Fehler erneut versuchen. Diese Schicht macht aus
# "läuft meistens" ein "geht in Produktion".
from pydantic import BaseModel, ValidationError
from typing import Literal
class Ticket(BaseModel):
category: Literal["billing", "bug", "feature", "other"]
priority: Literal["low", "medium", "high"]
summary: str
needs_human: bool
def extract(text: str, model: str, retries: int = 1) -> Ticket:
messages = [
{"role": "system", "content": "Extract the support ticket fields as JSON."},
{"role": "user", "content": text},
]
for attempt in range(retries + 1):
resp = client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
messages=messages,
)
raw = resp.choices[0].message.content
try:
return Ticket.model_validate_json(raw) # parsen + validieren in einem Schritt
except ValidationError as e:
if attempt == retries:
raise
# Reparatur-Runde: zeig dem Modell genau, was falsch war.
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"That failed validation: {e}. Re-emit valid JSON only."},
]
raise RuntimeError("unreachable")Beachte, was die Reparatur-Runde tut: Sie zeigt dem Modell seine eigene fehlerhafte Ausgabe und den exakten Validierungsfehler und bittet dann um eine erneute Ausgabe. Ein einziger erneuter Versuch löst die überwältigende Mehrheit der Fehler; wenn er trotzdem fehlschlägt, willst du es wissen, also lass ihn werfen. Schleife nicht ewig und verbrenne Tokens — begrenze die Versuche, logge die rohe Payload und alarmiere bei den harten Fehlern. Ein hartnäckiger Validierungsfehler bedeutet meist, dass das Schema etwas verlangt, was der Input nicht hergeben kann, nicht dass das Modell kaputt ist.
Zwei Produktionshinweise. Erstens: Setz ein großzügiges max_tokens: JSON, das mitten im Objekt abgeschnitten wird, ist ungültiges JSON, und ein zu knappes Token-Limit ist eine Hauptursache für Parse-Fehler bei großen Datensätzen. Zweitens: Halt die temperature niedrig (0 bis 0,3) für Extraktion und Klassifikation — du willst, dass derselbe Input denselben Datensatz ergibt, und Kreativität ist keine Tugend, wenn du ein Struct füllst.
Eine Struktur, beide Modellfamilien
Jedes Snippet oben läuft gegen Claude Sonnet 4.6 und Gemini 2.5 Flash, indem du einen String änderst — das model-Feld. Genau das ist der Sinn, strukturierte Ausgabe über Brievio zu routen: Der OpenAI-geformte response_format-Vertrag ist identisch, sodass du ein günstigeres Modell für eine Extraktionsaufgabe A/B-testen oder während eines Vorfalls über Familien hinweg ausweichen kannst, ohne dein Parsing oder deine Validierung umzuschreiben. Der Request, den du sendest, ist der Request, den das echte First-Party-Modell empfängt — hier ist genau, was übereinstimmt und worauf zu achten ist , wenn du dich auf OpenAI-Kompatibilität verlässt.
Ein praktischer Workflow: Prototyp mit striktem json_schema auf Sonnet, prüf, dass dein Validator auf einer zurückgehaltenen Menge durchläuft, und probier dann dasselbe Schema auf Flash. Wenn das günstigere Modell deine Validierungsrate schafft, hast du Kosten ohne eine einzige Codeänderung gesenkt — und weil Brievio ehrliche Token-Zahlen meldet und fehlgeschlagene 4xx/5xx-Calls mit null abrechnet, verstecken deine erneuten Versuche und Reparatur-Runden keine Abrechnungsüberraschung. Vergleich die Modelle auf der Modelle-Seite, und der vollständige Request/Response-Vertrag für Chat liegt in den Chat-Docs.
Das Fazit
Greif zu json_object, wenn die Struktur einfach ist und du den Prompt besitzt; greif zu json_schema mit strict: true, additionalProperties: false und einem vollständigen required-Array, wenn du die Struktur erzwungen haben willst; greif zu Tool-Calling, wenn das Modell eine Aktion auswählt statt einen festen Datensatz zu produzieren. Was immer du wählst: Entwirf das Schema flach und enum-lastig, und dann immer parsen-validieren-reparieren — denn die Strict-Unterstützung variiert je nach Modellfamilie, und die Validierungsschicht ist der Unterschied zwischen einem Structured-Output-Feature, das demot, und einem, das echten Traffic übersteht. Derselbe Code, derselbe Vertrag, das echte Modell — über Claude und Gemini hinweg, hinter einer Base-URL.