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

  1. 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).
  2. Codezaun und Vortext. Ohne tool_choice und ohne Prompt-Disziplin kommt JSON oft in Markdown-Zäunen. Erst mechanisch säubern, nur bei echten Fehlern das Modell fragen.
  3. 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.
  4. bool ist ein int. Wer einen Typ mit isinstance(x, int) oder issubclass prüft, lässt True als Ganzzahl durch. Beim Bau eines Schemas aus Type Hints muss bool vor int geprü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_choice entfernen, 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):

  1. Beginne mit messages = [{"role": "user", "content": text}] und rufe modell(messages) auf.
  2. Prüfe die Roh-Antwort mit pruefe_antwort(roh, Modell) (siehe Setup, kennst du aus Schritt 2). Bei Erfolg gib das Objekt zurück.
  3. 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.
  4. Insgesamt höchstens max_versuche Aufrufe des Modells. Danach raise 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, wirf ValueError (eine Tool-Beschreibung ist Teil des Prompts, eine leere Beschreibung ist ein Fehler). Den Docstring liefert inspect.getdoc(f) (oder f.__doc__), Zeilen trennt .splitlines().
  • "input_schema": {"type": "object", "properties": {...}, "required": [...]}. Jeder Parameter wird zu einer Eigenschaft: str zu {"type": "string"}, int zu "integer", float zu "number", bool zu "boolean", list[str] zu {"type": "array", "items": {"type": "string"}}. Parameter ohne Default stehen in required (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_funktion

Zusatzü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_KEY liefert 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 der pyproject.toml des Lernlabors, du installierst es mit uv add anthropic im Ordner lernlabor. 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.py

Ausgabe 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.