Structured Outputs: Modell-Antworten prüfen
Track KI · M1 Baustein 03 · Teil 1 von 2 · ca. 55 Min. plus optionale lokale Zusatzübung
Worum es geht
Ein Modell antwortet in Text. Dein Code will aber ein Objekt: einen Betrag, eine Währung, einen Kunden. Structured Outputs heißt: Du bringst das Modell dazu, JSON zu liefern, und prüfst es mit Pydantic, bevor du damit weiterarbeitest. Was das Modell liefert, ist Eingabe von außen und gehört an dieselbe Pydantic-Grenze wie jede API-Eingabe aus Lektion 02.
Was im Browser läuft: Kein echtes Modell. Du arbeitest mit einem Fake-Modell, das feste Antworten abspielt: sauberes JSON, JSON in einem Codezaun, abgebrochenes JSON, fehlende Felder. So sind alle Läufe gleich und kosten nichts. Der echte API-Aufruf ist eine lokale Zusatzübung im Lernlabor am Ende (Schlüssel nur aus einer Umgebungsvariable, ohne Schlüssel läuft ein Trockenlauf).
Danach geht es in Teil 2 um Tool Calling: dieselbe Mechanik in die andere Richtung.
Von JS/TS her gedacht
In TypeScript kennst du das Problem: JSON.parse liefert any, der Typ ist eine Behauptung, keine Prüfung. Ausgeführt mit Node 20:
const roh = '{"name": "Ada"}';
console.log(JSON.parse(roh).email); // undefined: kein Fehler, obwohl der Typ ein email verspricht
try { JSON.parse('```json\n{"name":"Ada"}\n```'); } catch (e) { console.log(e.name); }
try { JSON.parse('{"name": "Ada", "email": '); } catch (e) { console.log(e.name); }Ausgabe: undefined, dann zweimal SyntaxError. Genau diese drei Fälle (fehlendes Feld, Codezaun, abgebrochenes JSON) behandeln wir gleich.
| Idee | TypeScript | Python |
|---|---|---|
| Text zu JSON | JSON.parse(t) wirft SyntaxError |
json.loads(t) wirft JSONDecodeError; mit Pydantic: Modell.model_validate_json(t) |
| Form prüfen | zod: Schema.safeParse(x) |
Modell.model_validate(x) mit try / except ValidationError |
| JSON-Schema aus dem Typ | Bibliothek wie zod-to-json-schema (aus dem Gedächtnis, nicht ausgeführt) |
Modell.model_json_schema() (eingebaut) |
Konzept
Schritt 1: Das Fake-Modell
Ein Modell ist hier eine Funktion: Sie bekommt die Liste der Nachrichten (messages) und gibt eine Antwort zurück. FakeModell spielt eine Liste fester Antworten der Reihe nach ab (die letzte wiederholt sich) und merkt sich jede Anfrage, damit ein Test später prüfen kann, was du gesendet hast. Das Limit max_aufrufe ist ein Schutz gegen Endlosschleifen: Danach bricht das Fake-Modell mit einer Sonder-Exception ab (sie erbt von BaseException, damit ein breites except Exception sie nicht verschluckt).
Die Nachrichten haben ein role ("user" oder "assistant") und einen content. Bei Text ist der Inhalt ein String, bei Tools eine Liste von Blöcken (Schritt 4).
Schritt 2: Roh-Antwort zu validiertem Objekt
Wir nehmen ein Modell Kontakt und fünf typische Antworten, wie sie ein echtes Modell liefern kann:
model_validate_json parst und prüft in einem Schritt, und es wirft in allen Fehlerfällen dieselbe Exception: ValidationError. Kaputtes JSON hat den Typ json_invalid und eine leere Stelle loc, ein fehlendes Feld hat den Typ missing und die Stelle ('email',). Das ist der Unterschied zu json.loads, das nur die Syntax prüft.
Antwort 1 (Codezaun) ist technisch kein Fehler des Modells, nur Verpackung. Das reparierst du mit Code, ohne das Modell nochmal zu bezahlen. Antwort 2 bis 4 kann nur das Modell selbst reparieren. Dafür brauchst du den Fehlertext, und zwar so, dass er ein Mensch (und ein Modell) lesen kann. Wir bauen drei kleine Helfer. Die Details der Regex musst du nicht verstehen: Sie schneidet den Inhalt zwischen den Zäunen aus, falls einer da ist.
Beachte das Rückgabeformat: ein Paar (objekt, fehlertext), genau eines von beiden ist None. Das ist in Python üblich, wenn ein Fehler erwartet ist (wie safeParse in zod, nur ohne Wrapper-Objekt).
Schritt 3: Der Reparatur-Versuch (Retry mit Fehlertext)
Wenn die Antwort ungültig ist, gibst du dem Modell eine zweite Chance mit dem Fehlertext. Ein Retry ohne Fehlertext würde mit hoher Wahrscheinlichkeit dasselbe liefern. Die Anfrage wächst dabei: erst deine Frage, dann die schlechte Antwort des Modells (als assistant-Nachricht), dann dein Fehlerhinweis (als user-Nachricht). Einmal von Hand:
Die erste Anfrage hatte 1 Nachricht, die zweite 3. Aus diesem Ablauf machst du in Übung 2 eine Schleife mit Obergrenze: Jeder Versuch kostet Tokens und Zeit, und ein Modell, das dreimal nichts Gültiges liefert, tut es beim vierten Mal meist auch nicht (Retry-Grundsätze: Konzepte 07a).
Schritt 4: Das Schema bauen und das Tool erzwingen
Besser als “bitte antworte als JSON” im Prompt ist, das Modell ein Tool aufrufen zu lassen, dessen Eingabe genau deine Struktur hat (Quelle, Baustein 03). Das Schema dafür kommt aus dem Pydantic-Modell:
Das ist JSON Schema (der Standard, den auch OpenAPI und die Modell-APIs nutzen). title ist Beiwerk von Pydantic. Wichtig sind properties (Feld und Typ), required (Pflichtfelder) und type: object. Daraus wird die Tool-Beschreibung (aus der Quelle):
tool = {
"name": "rechnung_erfassen",
"description": "Erfasst Betrag und Währung einer Rechnung.",
"input_schema": Rechnung.model_json_schema(),
}
response = client.messages.create(
model="claude-sonnet-4-5", # Name aus der Quelle (bitte prüfen, ob aktuell)
max_tokens=256,
tools=[tool],
tool_choice={"type": "tool", "name": "rechnung_erfassen"},
messages=[{"role": "user", "content": text}],
)
block = next(b for b in response.content if b.type == "tool_use")
rechnung = Rechnung.model_validate(block.input)tool_choice zwingt das Modell, genau dieses Tool aufzurufen. Die Antwort enthält dann einen Block vom Typ tool_use, dessen input schon ein dict ist (kein JSON-Text, kein Codezaun). Das spart die Reparatur aus Schritt 2 und 3 meistens, aber nicht immer. Mit dem Fake-Modell siehst du, was trotzdem schiefgehen kann. Der Block ist so aufgebaut wie im echten SDK:
fall2 besteht die Validierung, obwohl der Betrag vielleicht falsch ist: Das Schema sagt nur “irgendeine Zahl”. Das ist der Merksatz der Quelle: Ein Tool-Schema erzwingt Struktur, keine Korrektheit. fall3 fällt durch, weil "12,50 EUR" keine Zahl ist (float_parsing) und die Währung fehlt (missing). Prüfe fachliche Plausibilität extra: Grenzen (Field(gt=0, le=...)), Abgleich mit dem Quelltext, bei Verdacht ein Mensch.
Wie Pydantic aus einem Modell ein Schema macht, kannst du auch aus einer Funktion selbst bauen. Du brauchst nur zwei Werkzeuge aus der Standardbibliothek: inspect.signature liefert die Parameter und ihre Defaults, get_type_hints (aus Lektion 02) die Typen. Ein Parameter ohne Default ist ein Pflichtfeld (required).
get_origin(list[str]) ist list, get_args(list[str]) ist (str,): So erkennst du, ob etwas eine Liste ist und was drin steckt. Die letzte Zeile ist eine Falle, die du in Übung 3 brauchst: In Python ist bool eine Unterklasse von int.
Soweit die Form der Antwort. Im nächsten Teil geht es um einen Ablauf mit Werkzeugen, die dein Code selbst ausführt.
Falle
- Schema ist keine Garantie für Richtigkeit.
{"betrag": 4990.0}ist gültig, auch wenn die Mail 49,90 sagte. Pydantic-Validierung ist Pflicht, fachliche Prüfung kommt dazu (Merksatz der Quelle). - Codezaun und Vortext. Ohne
tool_choiceund ohne Prompt-Disziplin kommt JSON oft in Markdown-Zäunen. Erst mechanisch säubern, nur bei echten Fehlern das Modell fragen. - Retry ohne Fehlertext oder ohne Limit. Ohne Fehlertext liefert das Modell meist dasselbe nochmal, ohne Limit zahlst du für eine Endlosschleife. Zwei bis drei Versuche, dann aufgeben und sauber melden.
boolist einint. Wer einen Typ mitisinstance(x, int)oderissubclassprüft, lässtTrueals Ganzzahl durch. Beim Bau eines Schemas aus Type Hints mussboolvorintgeprüft werden (Übung 3).
Übungen
Übung 1: Multiple Choice mit Begründung (leicht, ca. 3 Min.)
Eine Mail enthält den Satz “Mahngebühr 8,00 EUR”. Dein Code zwingt das Modell mit tool_choice zum Tool rechnung_erfassen (Schema: betrag: float, waehrung: str). Das Modell ruft das Tool auf mit {"betrag": 800.0, "waehrung": "EUR"}. Rechnung.model_validate läuft ohne Fehler durch. Was ist der beste nächste Schritt?
- a) Das Schema verbietet falsche Werte, also ist die Antwort schon korrekt.
- b) Eine fachliche Prüfung ergänzen, etwa Betrag und Mailtext abgleichen.
- c)
tool_choiceentfernen, damit das Modell den Betrag nochmal frei überlegt. - d) Das Schema um einen Maximalbetrag erweitern und so jeden Fehler ausschließen.
Trage den Buchstaben als String ein.
Was genau hat die Validierung geprüft, und was nicht? Prüfe bei jeder Antwort, ob sie wirklich leistet, was sie verspricht.
antwort = "b"
antwortÜbung 2: Roh-Antwort zu Objekt, mit Reparatur-Versuch (mittel, ca. 15 Min.)
Das ist Baustein 03 der Quelle (“Freitext zu validierter Rechnung, inklusive Fehlerfall”), jetzt mit Retry. Schreibe extrahiere(modell, text, Modell, max_versuche=3):
- Beginne mit
messages = [{"role": "user", "content": text}]und rufemodell(messages)auf. - Prüfe die Roh-Antwort mit
pruefe_antwort(roh, Modell)(siehe Setup, kennst du aus Schritt 2). Bei Erfolg gib das Objekt zurück. - Bei einem Fehler hängst du zwei Nachrichten an: die schlechte Antwort als
{"role": "assistant", ...}und einen Hinweis als{"role": "user", ...}, der den Fehlertext enthält. Dann der nächste Versuch. - Insgesamt höchstens
max_versucheAufrufe des Modells. Danachraise ExtraktionFehlgeschlagen(...)(steht im Setup).
Der Test: sofort gültige Antworten, JSON im Codezaun, abgebrochenes JSON, fehlendes Feld, zwei Fehlversuche hintereinander und eine Antwort, die nie gültig wird. Der Check zählt die Aufrufe und liest die Nachrichten, die du sendest.
Denke an die Reihenfolge: Modell fragen, Antwort prüfen, bei Fehler Verlauf erweitern, wieder fragen. Wann steht fest, dass du aufgeben musst, und welcher Wert ist dann noch nicht definiert?
def extrahiere(modell, text, Modell, max_versuche=3):
messages = [{"role": "user", "content": text}]
fehler = "kein Versuch"
for _ in range(max_versuche):
roh = modell(messages)
objekt, fehler = pruefe_antwort(roh, Modell)
if objekt is not None:
return objekt
messages.append({"role": "assistant", "content": roh})
messages.append({"role": "user", "content": f"Deine Antwort war ungültig: {fehler}. Antworte nur mit korrigiertem JSON."})
raise ExtraktionFehlgeschlagen(fehler)
extrahiereÜbung 3: Tool-Schema aus einer Funktion bauen (schwerer, ca. 20 Min.)
Schreibe schema_aus_funktion(f), die aus einer Python-Funktion eine Tool-Beschreibung als dict macht:
"name": der Funktionsname (f.__name__)"description": die erste Zeile des Docstrings. Hat die Funktion keinen Docstring, wirfValueError(eine Tool-Beschreibung ist Teil des Prompts, eine leere Beschreibung ist ein Fehler). Den Docstring liefertinspect.getdoc(f)(oderf.__doc__), Zeilen trennt.splitlines()."input_schema":{"type": "object", "properties": {...}, "required": [...]}. Jeder Parameter wird zu einer Eigenschaft:strzu{"type": "string"},intzu"integer",floatzu"number",boolzu"boolean",list[str]zu{"type": "array", "items": {"type": "string"}}. Parameter ohne Default stehen inrequired(in der Reihenfolge der Signatur).- Ein Parameter ohne Type Hint oder mit einem nicht unterstützten Typ (zum Beispiel
dict):ValueError.
Beispiel: Aus def wetter(stadt: str, tage: int = 3) mit Docstring """Liefert die Vorhersage für eine Stadt.""" wird {"name": "wetter", "description": "Liefert die Vorhersage für eine Stadt.", "input_schema": {"type": "object", "properties": {"stadt": {"type": "string"}, "tage": {"type": "integer"}}, "required": ["stadt"]}}. Der Check ignoriert Zusätze wie title und default und ein leeres required, du darfst also auch Pydantic nutzen (create_model), wenn du magst.
Gehe die Parameter der Signatur der Reihe nach durch und sammle zwei Dinge: den JSON-Typ und ob ein Default fehlt. Teste deine Funktion früh mit einer Funktion, die einen bool-Parameter hat, und vergleiche mit der Tabelle oben.
import inspect
from typing import get_origin, get_args, get_type_hints
JSON_TYPEN = {str: "string", int: "integer", float: "number", bool: "boolean"}
def _json_typ(hint):
if hint in JSON_TYPEN:
return {"type": JSON_TYPEN[hint]}
if get_origin(hint) is list and len(get_args(hint)) == 1 and get_args(hint)[0] in JSON_TYPEN:
return {"type": "array", "items": {"type": JSON_TYPEN[get_args(hint)[0]]}}
raise ValueError(f"Typ nicht unterstützt: {hint!r}")
def schema_aus_funktion(f):
doc = inspect.getdoc(f)
if not doc:
raise ValueError(f"{f.__name__} hat keinen Docstring")
hints = get_type_hints(f)
eigenschaften, pflicht = {}, []
for name, p in inspect.signature(f).parameters.items():
if name not in hints:
raise ValueError(f"Parameter {name} hat keinen Type Hint")
eigenschaften[name] = _json_typ(hints[name])
if p.default is inspect.Parameter.empty:
pflicht.append(name)
return {
"name": f.__name__,
"description": doc.splitlines()[0].strip(),
"input_schema": {"type": "object", "properties": eigenschaften, "required": pflicht},
}
schema_aus_funktionZusatzübung (lokal): echter Aufruf mit erzwungenem Tool (optional, 15 Min.)
Die Quellenübung zu Baustein 03 verlangt extract_rechnung(text) -> Rechnung mit einem Testfall ohne Betrag. Die Datei lernlabor/uebung/ki/ki_06_echter_aufruf.py baut genau das nach dem Muster aus Schritt 4. Sie hat zwei Betriebsarten:
- Trockenlauf (Standard): Ohne gesetzte Umgebungsvariable
ANTHROPIC_API_KEYliefert ein Simulator die Antworten im Format der echten API. Das läuft sofort und kostet nichts. - Echter Aufruf: Mit gesetztem Schlüssel und installiertem Paket
anthropic. Das Paket steht noch nicht in derpyproject.tomldes Lernlabors, du installierst es mituv add anthropicim Ordnerlernlabor. Fehlt es, meldet das Skript es und läuft im Trockenlauf weiter. Der Schlüssel gehört nur in deine Shell, nie in eine Datei. Echte Aufrufe kosten Geld, aktuelle Preise bitte beim Anbieter prüfen.
cd lernlabor && uv run python uebung/ki/ki_06_echter_aufruf.pyAusgabe des Trockenlaufs (ausgeführt mit Python 3.13.9, ohne Schlüssel):
Kein ANTHROPIC_API_KEY gesetzt: Trockenlauf mit Simulator.
Modus: Trockenlauf
Mail 'mit_betrag': Hallo, anbei die Rechnung für März: 49,90 EUR, zahlbar in 14 Tagen.
-> betrag=49.9 waehrung='EUR'
Mail 'ohne_betrag': Hallo, die Rechnung für März kommt nächste Woche, ich melde mich.
-> (ausgelassen, das ist deine Aufgabe)
TODO: trage in aufgabe den Namen der Exception ein.
Deine Aufgabe: Sage voraus, was bei der Mail ohne Betrag passiert, und trage in aufgabe den Namen der Exception ein. Mit Schlüssel (optional) siehst du, was ein echtes Modell bei der Mail ohne Betrag trotz tool_choice tut: Meldet es den Betrag als fehlend, oder erfindet es einen? Das Schema erzwingt Struktur, keine Korrektheit. Überlege, wie du das Modell Rechnung ändern würdest, damit “kein Betrag im Text” ein erlaubter, erkennbarer Fall wird (zum Beispiel ein Feld, das auch fehlen darf, und danach eine Prüfung).
Welche Exception hast du in Schritt 2 und 4 gesehen, wenn ein Pflichtfeld fehlte?
aufgabe = "ValidationError"Pydantic sammelt alle Fehler in einer ValidationError. Hier mit loc ('betrag',) und Typ missing.
Merksatz
Ein Tool-Schema erzwingt Struktur, keine Korrektheit: Jede Antwort des Modells geht durch Pydantic, ein Codezaun wird mit Code entfernt, ein Retry bekommt den Fehlertext und eine Obergrenze.
Prüfstein
Ein Modell liefert dir einmal JSON im Codezaun, einmal abgebrochenes JSON und einmal gültiges JSON mit einem falschen Betrag. Wie reagierst du jeweils, und welche der drei Antworten kostet dich einen zweiten, bezahlten Modellaufruf?
Weiter mit Teil 2: Tool Calling.
Quelle: quellen/kursbuch-lerninhalte.md, Modul M1, Baustein “03 Structured Outputs” (Merksatz, Stolperfalle, Übung; der Modellname claude-sonnet-4-5 steht dort, bitte prüfen, ob er aktuell ist). Über die Quelle hinaus (allgemeines Fachwissen, bitte gegen die aktuelle API-Doku prüfen): die Reparatur-Schleife mit Fehlertext, model_validate_json, JSON Schema aus Funktionen mit inspect, die Einordnung von Fehlertexten. Das Fake-Modell und alle Beispielantworten sind von Hand geschrieben.