Strukturierte Outputs mit OpenAI und Pydantic
Marcin Tabisz
Large Language Models (LLMs) sind bemerkenswert gut darin, Dokumente zu lesen und zu verstehen. Fotografieren Sie einen Beleg oder scannen Sie eine Rechnung, und das Modell wird Ihnen alles darauf nennen - den Namen des Händlers, die einzelnen Posten, die Gesamtsumme, die Steuer. Das Problem ist jedoch, dass es Ihnen diese Informationen in Prosa mitteilt. Und Prosa, so präzise sie auch sein mag, lässt sich nicht ohne Weiteres in eine Datenbank laden, mit einem Ground-Truth-Label vergleichen oder zur Berechnung eines F1-Scores heranziehen.
Genau vor dieser Herausforderung standen wir im Forschungsprojekt FewTuRe, das sich mit Few-Shot Fine-tuning für die Informationsextraktion aus Quittungen und Rechnungen befasste. Wir benötigten das Modell, um strukturierte Informationen aus Dokumenten zu extrahieren, aber wir mussten auch sicherstellen, dass diese Informationen zuverlässig in einem maschinenlesbaren Format ankommen, das exakt unserem Ground-Truth-Datenschema entspricht. Eine Antwort, die die Gesamtsumme einmal unter „total_amount“ und beim nächsten Mal unter „total“ aufführte, war für unsere Evaluation-Pipeline ebenso nutzlos wie eine falsche Antwort. Konsistenz war kein „Nice-to-have“, sondern eine strikte Anforderung. Diese Erfahrung hat uns eines verdeutlicht: Die richtige Antwort zu erhalten, ist nur die halbe Miete. Sie in der richtigen Form zu erhalten, ist die andere Hälfte.
In diesem Beitrag geht es um die Lösung dieser zweiten Hälfte. Wir zeigen auf, wie das Feature für Structured Outputs von OpenAI in Kombination mit Pydantic und dem OpenAI Python SDK einen zuverlässigen und eleganten Weg bietet, genau das Ausgabeformat zu erzwingen, das Ihre Anwendung benötigt.
Um zu verstehen, warum Structured Outputs wichtig sind, hilft ein Blick darauf, was LLMs standardmäßig produzieren: einen Stream von Tokens, die natürlichen Sprachtext bilden. Selbst wenn Sie ein Modell anweisen, „ein JSON-Objekt zurückzugeben“, bitten Sie es im Grunde nur freundlich darum. Es gibt keine Garantie.
In der Praxis führt dies zu Antworten wie:
Valides JSON – großartig, aber verlassen Sie sich nicht jedes Mal darauf.
JSON, das in einen Markdown-Codeblock eingebettet ist, den Sie zuerst entfernen müssen.
JSON mit halluzinierten oder inkonsistent benannten Schlüsseln.
Ein hilfreicher Satz vor dem JSON, der Ihren Parser zum Absturz bringt.
Subtil falsche Typen, wie z. B. eine Zahl, die als String zurückgegeben wird, oder eine Liste als kommagetrennter Wert.
Für einen Prototyp oder eine Demo ist dies handhabbar. Man schreibt ein wenig Post-Processing-Logik, fängt die Edge-Cases ab und macht weiter. Aber für eine Production-Pipeline, insbesondere wenn der Output direkt in einen Training-Loop oder ein Evaluation-Framework fließt, ist diese Art von Fragilität ein ernsthaftes Problem.
In unserem Projekt zur Informationsextraktion verfügten unsere Ground-Truth-Daten über ein wohldefiniertes Schema. Jeder Beleg und jede Rechnung war mit demselben Satz von Feldern, denselben Typen und derselben Struktur gelabelt. Damit die Ausgabe des Modells mit dieser Ground Truth vergleichbar war, musste sie exakt diesem Schema entsprechen. Wir konnten uns keinen Parser leisten, der in 95 % der Fälle funktioniert. Wir brauchten etwas, auf das wir uns wirklich verlassen konnten. Hier kommen strukturierte Outputs ins Spiel.
Was sind strukturierte Outputs?
Strukturierte Outputs bezeichnen die Fähigkeit, die Antwort eines LLM so zu beschränken, dass sie einem vordefinierten Schema entspricht – und zwar nicht durch eine bloße Aufforderung, sondern durch Erzwingung auf Ebene der Generierung. Anstatt zu hoffen, dass das Modell valides JSON zurückgibt, garantiert die API dies, indem sie das von Ihnen bereitgestellte Schema verwendet, um den Prozess der Token-Generierung selbst zu steuern. Dies ist ein bedeutender Wandel: Die Schema-Compliance wird von einem Problem des Prompt Engineering zu einer Infrastruktur-Garantie.
Das von Ihnen definierte Schema beschreibt exakt, wie die Ausgabe aussehen soll: welche Felder vorhanden sind, welche Typen sie haben, ob sie erforderlich oder optional sind und wie verschachtelte Objekte strukturiert sein sollen. Das Modell füllt dieses Schema mit den extrahierten Informationen aus. Was zurückkommt, ist ein valides, konsistentes Objekt, das bereit ist, in ein typisiertes Python-Objekt geparst zu werden.
Dies führt uns direkt zu den Werkzeugen, die dies in Python ermöglichen: Pydantic für die Definition und Validierung des Schemas und das OpenAI Python SDK für die Durchsetzung während des Deployment bzw. der Inference. Betrachten wir also beides.
Pydantic
Pydantic ist eine Python-Bibliothek für Datenvalidierung und Einstellungsmanagement unter Verwendung von Typ-Annotationen. Im Kern ermöglicht sie es Ihnen, die Form und die Typen Ihrer Daten als Python-Klasse zu beschreiben und anschließend zu validieren, ob die übergebenen Daten tatsächlich dieser Beschreibung entsprechen. Ist dies nicht der Fall, gibt Pydantic einen klaren, deskriptiven Fehler aus, anstatt zuzulassen, dass sich fehlerhafte Daten unbemerkt durch Ihr System verbreiten.
Es hat sich zu einem Standard-Baustein in modernen Python-Anwendungen entwickelt. Sie finden es in der Request- und Response-Validierung von FastAPI, im Konfigurationsmanagement von ML-Frameworks und zunehmend bei Schema-Definitionen für LLM-Outputs. Die Gründe für die breite Akzeptanz sind vielfältig:
Sie definieren Ihre Datenmodelle als Klassen mit Standard-Python-Type-Hints, was sie zugänglich und einfach in der Anwendung macht.
Es ist strikt, wo es sein muss. Typen werden erzwungen und wo möglich konvertiert; Verletzungen treten sofort mit hilfreichen Fehlermeldungen zutage.
Es ist modular aufgebaut. Modelle können in andere Modelle verschachtelt werden, was die Darstellung komplexer, hierarchischer Datenstrukturen vereinfacht.
Es generiert automatisch ein JSON-Schema. Dieser letzte Punkt ist, wie wir sehen werden, genau das, was es in Kombination mit dem OpenAI SDK so leistungsfähig macht.
Definition eines Modells
Das Fundament von Pydantic ist die Klasse BaseModel. Sie definieren Ihre Datenstruktur, indem Sie eine Unterklasse davon erstellen und Felder als Klassenattribute mit Typ-Annotationen deklarieren:
from pydantic import BaseModel
from typing import Optional
class Vendor(BaseModel):
name: str
address: str | None = None
class LineItem(BaseModel):
description: str
quantity: int
unit_price: float
total: float
class Receipt(BaseModel):
vendor: Vendor
date: Optional[str] = None
line_items: list[LineItem] = []
subtotal: Optional[float] = None
tax: Optional[float] = None
total: float
Beachten Sie, wie wir einfach eine Klasse in die andere verschachteln können, um ein hierarchisches Datensatzfeld für den Beleg zu erstellen. Auf den ersten Blick mag dies Ähnlichkeit mit Dataclasses haben, aber Pydantic geht einen Schritt weiter. Sobald das Modell instanziiert wird, wird jedes Feld gegen seinen deklarierten Typ validiert. Wenn Sie versuchen, ein Receipt-Objekt zu erstellen, bei dem die Gesamtsumme ein String ist, der den Preis enthält, würde der folgende Fehler erscheinen:
ValidationError: 1 validation error for Receipt
total
Input should be a valid number, unable to parse string as a number
In der Definition von Receipt haben Sie vielleicht auch die Verwendung von Optional und Defaults bemerkt. Diese sind besonders nützlich, wenn Felder existieren, die nicht immer in einer Extraktion auftauchen müssen. In diesem Fall wird das Modell, falls es keine Daten oder Steuerinformationen auf dem Beleg findet, einfach einen None-Wert hinterlassen. Wenn keine einzelnen Posten erkannt werden, entspricht das entsprechende Feld einfach einer leeren Liste. All dies hält die Extraktion sauber und für die Evaluation handhabbar.
OpenAIs strukturierte Outputs
OpenAI hat Structured Outputs im August 2024 eingeführt, verfügbar für gpt-4o-mini und gpt-4o ab dem Snapshot 2024-08-06. Das Feature wurde seither auf neuere Modelle der GPT-4o- und o-Serie ausgeweitet.
Wenn Sie der API zusammen mit Ihrer Anfrage ein JSON-Schema bereitstellen, verwendet OpenAI eine Technik namens Constrained Decoding. Bei jedem Schritt der Token-Generierung wird die Output-Distribution des Modells so gefiltert, dass nur Tokens berücksichtigt werden, die valide Fortsetzungen des Schemas darstellen. Das Modell entscheidet immer noch über den Inhalt, aber die Form des Gesagten wird durch das Schema bei jedem einzelnen Token erzwungen. Das Schlüsselwort hier ist erzwungen – dies ist kein Prompt Engineering, sondern eine Einschränkung auf Ebene der Generierung.
Das OpenAI Python SDK bietet zwei Wege, um mit Structured Outputs zu arbeiten:
1. response_format mit type: json_schema
Dies ist der Low-Level-Ansatz. Sie übergeben ein JSON-Schema direkt als Teil der Anfrage, und die API gibt einen JSON-String zurück, den Sie anschließend selbst parsen:
from openai import OpenAI
import json
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "user", "content": "Extract the receipt information from the following text: ..."}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "Receipt",
"schema": Receipt.model_json_schema(),
"strict": True
}
}
)
data = json.loads(response.choices[0].message.content)
receipt = Receipt(**data)
Wie Sie sehen, erfordert dies, dass Sie das JSON-Schema mühsam selbst schreiben, was nicht nur zeitaufwendig ist, sondern auch die von Pydantic bereitgestellte Validierungseigenschaft nicht direkt einschließt. Genau hier setzt der zweite Ansatz an.
2. client.beta.chat.completions.parse()
Dies ist der High-Level-Ansatz und die Empfehlung für die Arbeit mit Pydantic. Sie übergeben Ihre Pydantic-Modellklasse direkt an das SDK, welches die Schema-Generierung, das JSON-Parsing und die Modell-Instanziierung für Sie übernimmt. Sie erhalten ein vollständig validiertes Pydantic-Objekt zurück:
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "user", "content": "Extract the receipt information from the following text: ..."}
],
response_format=Receipt
)
receipt = response.choices[0].message.parsed
Beachten Sie, dass response.choices[0].message.parsed Ihnen direkt eine Receipt-Instanz liefert. Kein JSON-Parsing, keine manuelle Instanziierung, kein Validierungsschritt – das SDK kümmert sich um alles.
Fazit
Der Aufbau einer Pipeline, die strukturierte Informationen aus realen Dokumenten extrahiert, ist nicht nur ein Modellierungsproblem – es ist ein Data-Engineering-Problem. Ein LLM dazu zu bringen, den Namen eines Händlers oder die Summe eines Postens korrekt zu identifizieren, ist eine Herausforderung. Es dazu zu bringen, diese Informationen in einem konsistenten, maschinenlesbaren Format zurückzugeben, das Ihr Evaluation-Framework, Ihr Training-Loop und Ihre Datenbank zuverlässig verarbeiten können, ist eine ganz andere Herausforderung – eine, die man leicht unterschätzt, bis sie in der Produktion Probleme verursacht.
Dies war eine wichtige Lerneffekt aus unserem Few-Shot Fine-Tuning-Projekt. Das Modell konnte die Dokumente lesen. Was wir brauchten, war ein Weg, um sicherzustellen, dass das Ergebnis immer gleich aussah: dieselben Felder, dieselben Typen, dieselbe Struktur, unabhängig davon, wie vielfältig oder unordentlich der Input war. Structured Outputs, kombiniert mit Pydantic und dem OpenAI Python SDK, haben uns genau das ermöglicht.
In diesem Beitrag haben wir das Gesamtbild der praktischen Umsetzung beleuchtet: Von Pydantic als Schema-Definitionsschicht, über die einmalige Definition Ihrer Datenstruktur als Python-Klasse inklusive kostenloser Validierung und automatischer JSON-Schema-Erstellung, bis hin zum Structured Outputs-Feature von OpenAI und der Funktionsweise des Constrained Decoding.
Die Kombination dieser Tools ist derzeit eines der saubersten Patterns für den Aufbau zuverlässiger, LLM-gestützter Daten-Pipelines. Sie bietet eine „Single Source of Truth“ für Ihre Datenstruktur, eliminiert eine ganze Klasse von Fehlern beim Parsing und macht die Evaluation sowie die nachgelagerte Verarbeitung drastisch einfacher.