Gestructureerde Output van LLMs met Python: Pydantic, Instructor en PydanticAI

Leer hoe je betrouwbare, gevalideerde datastructuren uit LLMs haalt met Python. Praktische handleiding over Pydantic, Instructor en PydanticAI met werkende codevoorbeelden voor productie-AI.

Je bouwt een AI-applicatie die productrecensies analyseert. Het LLM moet per recensie een sentimentscore, een lijst met genoemde features en een samenvatting in JSON teruggeven. Je stuurt de prompt, het model antwoordt braaf met iets wat op JSON lijkt — maar dan mist er een sluitend haakje, staat er een extra komma en heeft het model "score" ineens "rating" genoemd. Je parser breekt, je pipeline stopt, en je vraagt je af waarom je niet gewoon een reguliere expressie hebt geschreven.

Klinkt bekend? Dan is dit artikel precies wat je nodig hebt.

Gestructureerde output is in 2026 geen leuke extraatje meer — het is een absolute must voor productie-AI. Elke grote LLM-provider (OpenAI, Anthropic, Google, Mistral) ondersteunt inmiddels native gestructureerde output met JSON Schema-validatie op decodingniveau. En aan de Python-kant? Daar is het ecosysteem rondom Pydantic behoorlijk geëxplodeerd, met libraries als Instructor (meer dan 3 miljoen maandelijkse downloads) en PydanticAI (het officiële agent-framework van het Pydantic-team zelf) die het hele proces een stuk makkelijker maken.

In deze handleiding neem ik je mee door alles wat je moet weten om betrouwbare, gevalideerde data uit LLMs te halen — van de basisprincipes tot productiepatronen. Met werkende codevoorbeelden die je direct kunt kopiëren en aanpassen.

Waarom Gestructureerde Output Onmisbaar Is

Large Language Models genereren tekst, geen datastructuren. Dat is een belangrijk onderscheid. Zelfs als je een LLM vraagt om JSON te retourneren, genereert het in feite tekst die er als JSON uitziet. Er is geen garantie dat de veldnamen kloppen, dat verplichte velden aanwezig zijn, dat datatypes correct zijn, of dat er geen extra tekst rondom de JSON staat.

In een chatbot-demo is dat niet zo erg.

Maar in een productiesysteem waar de output van het ene model de input van het volgende proces is? Dan is het een dealbreaker. Denk aan deze scenario's:

  • Data-extractie pipelines — Je haalt gestructureerde entiteiten (namen, bedragen, datums) uit ongestructureerde tekst. Eén ontbrekend veld en je downstream-database raakt inconsistent.
  • Agentic workflows — Je AI-agent moet een tool aanroepen met specifieke parameters. Verkeerde types of missende velden betekent dat de tool gewoon faalt.
  • RAG-systemen — Je genereert antwoorden met bronvermelding. Zonder gevalideerde structuur kun je niet betrouwbaar linken naar brondocumenten.
  • API-responses — Je bouwt een AI-endpoint dat andere systemen consumeren. Inconsistente output breekt elke client die je hebt.

Gestructureerde output lost dit op door het model te dwingen zich aan een schema te houden — niet als suggestie, maar als harde constraint op decodingniveau.

De Drie Niveaus van Gestructureerde Output

Niet alle benaderingen zijn gelijk. In de praktijk zie je drie niveaus, elk met toenemende betrouwbaarheid.

Niveau 1: Prompt Engineering (Onbetrouwbaar)

De meest voor de hand liggende aanpak — je vraagt het model gewoon om JSON te retourneren:

Geef je antwoord als JSON met de volgende velden:
- naam (string)
- leeftijd (integer)
- stad (string)

Dit werkt in zo'n 80–95% van de gevallen, maar faalt stilletjes op edge cases. Geen typegarantie, geen validatie, en het model kan op elk willekeurig moment besluiten om er extra tekst omheen te zetten. Niet geschikt voor productie.

Niveau 2: Function Calling / Tool Use (Beter)

Alle grote providers ondersteunen function calling, waarbij je een JSON Schema meegeeft dat beschrijft welke parameters de "functie" verwacht. Het model retourneert dan een gestructureerde functie-aanroep in plaats van vrije tekst. Dit werkt in 95–99% van de gevallen, maar eerlijk gezegd fungeert het schema meer als een hint dan als een absolute constraint.

Niveau 3: Native Structured Output (Het Beste)

Bij native structured output wordt het JSON Schema op decodingniveau afgedwongen via constrained decoding. Het model kan simpelweg geen tokens genereren die het schema schenden. Dit werkt in 100% van de gevallen — types en waarden worden gegarandeerd door het genereringsproces zelf.

OpenAI biedt dit via response_format met type: "json_schema", Anthropic via tool use met input_schema, en Google via response_mime_type met een schema. Als je in 2026 iets in productie draait, zou je altijd op Niveau 3 moeten mikken.

Pydantic: De Ruggengraat van Gestructureerde Output

Pydantic is een data-validatie en parsing library voor Python die je schema definieert via standaard Python type hints. Het is de facto de standaard geworden voor gestructureerde output in AI-applicaties — en eerlijk gezegd is dat niet zo vreemd als je ziet hoe breed het wordt ingezet. De validatielaag van Pydantic wordt intern gebruikt door de OpenAI SDK, Anthropic SDK, Google ADK, LangChain, LlamaIndex, CrewAI en tientallen andere frameworks.

De kerngedachte is verrassend simpel: je definieert een Python-klasse die je gewenste datastructuur beschrijft, en Pydantic doet de rest. Het genereert automatisch een JSON Schema, valideert inkomende data, converteert types waar mogelijk, en geeft heldere foutmeldingen bij ongeldige data.

from pydantic import BaseModel, Field
from typing import Optional
from enum import Enum

class Sentiment(str, Enum):
    positief = "positief"
    neutraal = "neutraal"
    negatief = "negatief"

class ProductRecensie(BaseModel):
    product_naam: str = Field(description="De naam van het beoordeelde product")
    sentiment: Sentiment = Field(description="Het algehele sentiment van de recensie")
    score: float = Field(ge=0, le=10, description="Score van 0 tot 10")
    samenvatting: str = Field(max_length=200, description="Korte samenvatting")
    genoemde_features: list[str] = Field(
        default_factory=list,
        description="Lijst van specifiek genoemde productkenmerken"
    )
    aanbeveling: Optional[bool] = Field(
        default=None,
        description="Of de reviewer het product aanbeveelt"
    )

Dit model doet best veel tegelijk: Field(ge=0, le=10) garandeert dat de score binnen bereik valt, de Sentiment enum beperkt de waarden tot drie opties, en max_length=200 voorkomt dat het model een heel essay als samenvatting retourneert. Het mooie is dat al deze constraints zowel in het JSON Schema (dat naar het model gaat) als in de runtime-validatie (die de output controleert) worden meegenomen.

Instructor: Gestructureerde Extractie in Vijf Regels

Instructor is de populairste Python-library voor het extraheren van gestructureerde data uit LLMs. Met meer dan 3 miljoen maandelijkse downloads en 11.000+ GitHub-sterren is het simpelweg de go-to oplossing voor schema-first extractie. En wat het echt aantrekkelijk maakt, is de eenvoud — je definieert een Pydantic-model, wijst naar een provider, en je krijgt gevalideerde data terug.

Basisgebruik

import instructor
from pydantic import BaseModel

class Contactgegevens(BaseModel):
    naam: str
    email: str
    telefoon: str | None = None
    bedrijf: str | None = None

# Eén regel om je client aan te maken
client = instructor.from_provider("openai/gpt-4o")

# Extract gestructureerde data
contact = client.create(
    response_model=Contactgegevens,
    messages=[
        {
            "role": "user",
            "content": "Haal de contactgegevens uit deze tekst: Jan de Vries van "
                       "TechBedrijf BV is bereikbaar op [email protected] "
                       "of 06-12345678."
        }
    ],
)

print(contact)
# Contactgegevens(naam='Jan de Vries', email='[email protected]',
#                  telefoon='06-12345678', bedrijf='TechBedrijf BV')

Dat is het. Geen JSON-parsing, geen try/except rondom json.loads(), geen hoop dat het model de veldnamen goed spelt. Instructor vertaalt je Pydantic-model naar het juiste API-formaat voor je provider, stuurt het verzoek, parseert de response, valideert het tegen je model, en retourneert een getypeerd Python-object. Zo simpel kan het zijn.

Meerdere Providers, Dezelfde Interface

Een van de sterkste punten van Instructor is de provider-agnostische from_provider functie. Je wisselt van model door letterlijk één string te wijzigen:

# OpenAI
client = instructor.from_provider("openai/gpt-4o")

# Anthropic Claude
client = instructor.from_provider("anthropic/claude-sonnet-4-6")

# Google Gemini
client = instructor.from_provider("google/gemini-2.5-flash")

# Lokaal model via Ollama
client = instructor.from_provider("ollama/llama3")

# Alle gebruiken exact dezelfde .create() interface
resultaat = client.create(
    response_model=MijnModel,
    messages=[{"role": "user", "content": "..."}],
)

Dat maakt het testen met verschillende modellen echt een fluitje van een cent.

Automatische Retries bij Validatiefouten

Wat gebeurt er als het model output genereert die niet aan je schema voldoet? Instructor pakt dit slim aan: het stuurt de validatiefout automatisch terug naar het model met het verzoek om het opnieuw te proberen.

from pydantic import BaseModel, field_validator

class LeeftijdsVerificatie(BaseModel):
    naam: str
    leeftijd: int

    @field_validator("leeftijd")
    @classmethod
    def leeftijd_moet_realistisch_zijn(cls, v: int) -> int:
        if v < 0 or v > 150:
            raise ValueError(f"Leeftijd {v} is niet realistisch (0-150 verwacht)")
        return v

client = instructor.from_provider("openai/gpt-4o")

# max_retries=3 → bij validatiefout probeert Instructor het tot 3x opnieuw
resultaat = client.create(
    response_model=LeeftijdsVerificatie,
    messages=[{"role": "user", "content": "Extract: Marie is geboren in 1985."}],
    max_retries=3,
)

Stel dat het model leeftijd: -5 retourneert — dan stuurt Instructor de foutmelding terug en vraagt het model om het te corrigeren. Dit self-healing mechanisme is in de praktijk ontzettend krachtig, omdat je simpelweg niet elke edge case van tevoren kunt voorspellen.

PydanticAI: Van Extractie naar Agents

Waar Instructor zich focust op gestructureerde extractie, gaat PydanticAI een flinke stap verder. Het is een volledig agent-framework — gebouwd door het Pydantic-team zelf — dat gestructureerde output combineert met tools, dependency injection, streaming en grafische workflows. Versie 0.67 (maart 2026) ondersteunt inmiddels meer dan 30 LLM-providers.

Basisgebruik met output_type

from pydantic import BaseModel
from pydantic_ai import Agent

class StadLocatie(BaseModel):
    stad: str
    land: str
    populatie: int | None = None

agent = Agent(
    "openai:gpt-4o",
    output_type=StadLocatie,
    instructions="Je bent een geografische expert. Geef altijd accurate gegevens."
)

resultaat = agent.run_sync("Waar werden de Olympische Spelen gehouden in 2024?")
print(resultaat.output)
# StadLocatie(stad='Parijs', land='Frankrijk', populatie=2102650)

Het grote verschil met Instructor zit 'm in de Agent-abstractie. Een Agent heeft state, kan tools aanroepen, heeft system instructions, en kan meerdere conversatie-turns afhandelen. De output_type parameter definieert wat het eindresultaat van de agent-run moet zijn — en dat wordt net als bij Instructor strikt gevalideerd.

Meerdere Output Types

PydanticAI ondersteunt union types en lijsten van output types. Elk type wordt intern geregistreerd als een apart tool, waardoor het model zelf kan kiezen welk formaat het beste past:

from pydantic import BaseModel
from pydantic_ai import Agent

class SuccesResponse(BaseModel):
    antwoord: str
    bronnen: list[str]
    betrouwbaarheid: float

class FoutResponse(BaseModel):
    foutmelding: str
    suggestie: str

agent = Agent(
    "openai:gpt-4o",
    output_type=[SuccesResponse, FoutResponse],
    instructions="Beantwoord vragen als je het antwoord weet. "
                 "Geef anders een foutmelding met suggestie."
)

resultaat = agent.run_sync("Wat is de hoofdstad van Flevoland?")

match resultaat.output:
    case SuccesResponse() as succes:
        print(f"Antwoord: {succes.antwoord}")
    case FoutResponse() as fout:
        print(f"Fout: {fout.foutmelding} — {fout.suggestie}")

Dit patroon is echt nuttig voor het afhandelen van onzekere scenario's. Het model kan expliciet aangeven wanneer het geen betrouwbaar antwoord kan geven, in plaats van maar wat te hallucineren. Persoonlijk vind ik dit een van de meest onderschatte features van PydanticAI.

Output Validators met ModelRetry

PydanticAI biedt @agent.output_validator decorators waarmee je custom validatielogica kunt toevoegen die verder gaat dan puur schema-validatie:

from pydantic import BaseModel
from pydantic_ai import Agent, RunContext, ModelRetry

class SQLQuery(BaseModel):
    query: str
    uitleg: str

agent = Agent("openai:gpt-4o", output_type=SQLQuery)

@agent.output_validator
async def valideer_sql(ctx: RunContext, output: SQLQuery) -> SQLQuery:
    verboden_keywords = ["DROP", "DELETE", "TRUNCATE", "ALTER"]
    for keyword in verboden_keywords:
        if keyword in output.query.upper():
            raise ModelRetry(
                f"Query bevat verboden keyword '{keyword}'. "
                f"Genereer een veilige SELECT-query."
            )
    return output

resultaat = agent.run_sync("Geef me alle gebruikers ouder dan 30")

Wanneer de validator een ModelRetry exceptie gooit, stuurt PydanticAI de foutmelding terug naar het model en vraagt het om de output te corrigeren. Het resultaat is een self-correcting loop die de betrouwbaarheid flink verhoogt.

Streaming van Gestructureerde Output

Voor real-time toepassingen ondersteunt PydanticAI het streamen van gestructureerde output. Het model bouwt de datastructuur incrementeel op en je krijgt tussentijdse updates:

import asyncio
from pydantic import BaseModel
from pydantic_ai import Agent

class Analyse(BaseModel):
    onderwerp: str
    samenvatting: str
    kernpunten: list[str]
    sentiment: str

agent = Agent("openai:gpt-4o", output_type=Analyse)

async def stream_analyse():
    async with agent.run_stream("Analyseer de AI-markt in 2026") as resultaat:
        async for deelresultaat in resultaat.stream_output():
            print(f"Voortgang: {deelresultaat}")

asyncio.run(stream_analyse())

Instructor vs. PydanticAI: Wanneer Gebruik Je Wat?

Goed, beide libraries zijn gebouwd op Pydantic en beide leveren gevalideerde, getypeerde output. Maar ze bedienen echt verschillende use cases:

Criterium Instructor PydanticAI
Focus Schema-first extractie Agent-framework met tools
Complexiteit Minimaal — 5 regels code Meer setup, meer mogelijkheden
Tools/Functies Niet ingebouwd Eerste-klas ondersteuning
Dependency Injection Nee Ja, type-safe
Streaming Partiële objecten en iterables Streaming met validatie
Graaf-workflows Nee Ja, ingebouwd
Ideaal voor Extractie, parsing, classificatie Agents, complexe workflows, multi-turn

Mijn vuistregel: begin met Instructor als je puur gestructureerde data uit tekst wilt halen. Stap over op PydanticAI zodra je tools, multi-turn conversaties of complexe orkestratie nodig hebt. Het fijne is dat beide dezelfde Pydantic-modellen gebruiken, dus migreren is vrij eenvoudig.

Productiepatronen en Best Practices

Gestructureerde output in een productieomgeving vereist meer dan alleen een Pydantic-model definiëren. Hier zijn de patronen die in mijn ervaring het verschil maken.

1. Valideer Altijd, Zelfs met Native Structured Output

Native structured output garandeert syntactisch valide JSON en correcte types, maar niet semantische correctheid. Een score van 5.0 is syntactisch geldig maar semantisch fout als het een percentage had moeten zijn. Voeg dus altijd Pydantic-validators toe voor je business rules:

from pydantic import BaseModel, field_validator

class Factuur(BaseModel):
    bedrag: float
    btw_percentage: float
    totaal: float

    @field_validator("btw_percentage")
    @classmethod
    def btw_moet_geldig_zijn(cls, v: float) -> float:
        geldige_tarieven = [0.0, 9.0, 21.0]
        if v not in geldige_tarieven:
            raise ValueError(f"BTW-tarief {v}% is ongeldig. Geldige tarieven: {geldige_tarieven}")
        return v

2. Gebruik Enums voor Gesloten Sets

Wanneer een veld slechts een beperkt aantal waarden kan aannemen, gebruik dan altijd een Enum. Dit communiceert de exacte opties naar het model én valideert de output:

from enum import Enum

class Prioriteit(str, Enum):
    laag = "laag"
    medium = "medium"
    hoog = "hoog"
    kritiek = "kritiek"

class Ticket(BaseModel):
    titel: str
    prioriteit: Prioriteit
    categorie: str

3. Gebruik Field Descriptions als Mini-Prompts

Dit is een tip die veel ontwikkelaars over het hoofd zien: de description parameter in Field() wordt opgenomen in het JSON Schema dat naar het model gaat. Je kunt het dus gebruiken als een mini-prompt die het model in de juiste richting stuurt:

class Meeting(BaseModel):
    titel: str = Field(description="Korte, beschrijvende titel van de vergadering")
    datum: str = Field(description="Datum in ISO 8601 formaat (YYYY-MM-DD)")
    deelnemers: list[str] = Field(
        description="Volledige namen van alle deelnemers, gesorteerd op achternaam"
    )
    actiepunten: list[str] = Field(
        default_factory=list,
        description="Concrete actiepunten, elk beginnend met een werkwoord"
    )

4. Geneste Modellen voor Complexe Data

Echte data is zelden plat — dat weet iedereen die ooit met productiedata heeft gewerkt. Pydantic ondersteunt willekeurig geneste structuren die het model betrouwbaar kan vullen:

class Adres(BaseModel):
    straat: str
    huisnummer: str
    postcode: str = Field(pattern=r"^\d{4}\s?[A-Z]{2}$")
    plaats: str

class Werknemer(BaseModel):
    naam: str
    functie: str
    afdeling: str
    adres: Adres
    vaardigheden: list[str]

class Bedrijf(BaseModel):
    naam: str
    kvk_nummer: str | None = None
    werknemers: list[Werknemer]

Het postcode-patroon (^\d{4}\s?[A-Z]{2}$) valideert dat de postcode het Nederlandse formaat volgt. Dit soort domeinspecifieke validatie voorkomt subtiele fouten die anders pas veel later in je pipeline opduiken.

5. Graceful Degradation met Optional Fields

Niet alle informatie is altijd beschikbaar in de brontekst. Gebruik Optional velden met defaults zodat het model niet hoeft te hallucineren wanneer informatie gewoon ontbreekt:

class PersonenExtractie(BaseModel):
    naam: str
    geboortedatum: str | None = Field(
        default=None,
        description="Geboortedatum als genoemd, anders None"
    )
    nationaliteit: str | None = Field(
        default=None,
        description="Nationaliteit als genoemd, anders None"
    )

Dit klinkt misschien als een klein detail, maar het maakt een wereld van verschil. Zonder Optional velden gaat een LLM vrijwel altijd iets verzinnen om het veld te vullen.

Praktijkvoorbeeld: Factuurverwerking Pipeline

Goed, laten we alles samenbrengen in een realistisch voorbeeld. We bouwen een pipeline die facturen uit e-mails extraheert en valideert — iets wat je in de praktijk regelmatig tegenkomt.

import instructor
from pydantic import BaseModel, Field, field_validator, model_validator
from enum import Enum
from datetime import date

class BTWTarief(str, Enum):
    nul = "0"
    laag = "9"
    hoog = "21"

class FactuurRegel(BaseModel):
    omschrijving: str
    aantal: int = Field(ge=1)
    prijs_per_stuk: float = Field(ge=0)
    btw_tarief: BTWTarief
    totaal: float = Field(ge=0)

    @model_validator(mode="after")
    def controleer_totaal(self):
        verwacht = round(self.aantal * self.prijs_per_stuk, 2)
        if abs(self.totaal - verwacht) > 0.01:
            raise ValueError(
                f"Totaal {self.totaal} klopt niet. "
                f"Verwacht: {self.aantal} x {self.prijs_per_stuk} = {verwacht}"
            )
        return self

class Factuur(BaseModel):
    factuurnummer: str
    datum: str = Field(description="Factuurdatum in YYYY-MM-DD formaat")
    leverancier: str
    regels: list[FactuurRegel] = Field(min_length=1)
    subtotaal: float
    btw_bedrag: float
    totaal_bedrag: float

    @model_validator(mode="after")
    def controleer_totalen(self):
        berekend_subtotaal = sum(r.totaal for r in self.regels)
        if abs(self.subtotaal - berekend_subtotaal) > 0.01:
            raise ValueError(
                f"Subtotaal {self.subtotaal} komt niet overeen "
                f"met som van regels: {berekend_subtotaal}"
            )
        return self

# Pipeline
client = instructor.from_provider("openai/gpt-4o")

email_tekst = """
Beste klant,

Bijgaand onze factuur F-2026-0847 van 10 maart 2026.

Van: DataSolutions BV

- 5x API-calls pakket @ €49,00 = €245,00 (21% BTW)
- 1x Support abonnement @ €99,00 = €99,00 (21% BTW)

Subtotaal: €344,00
BTW (21%): €72,24
Totaal: €416,24
"""

factuur = client.create(
    response_model=Factuur,
    messages=[
        {
            "role": "system",
            "content": "Je bent een factuurverwerkingssysteem. Extraheer "
                       "factuurgegevens nauwkeurig uit de gegeven tekst."
        },
        {"role": "user", "content": email_tekst}
    ],
    max_retries=3,
)

print(f"Factuur {factuur.factuurnummer} van {factuur.leverancier}")
print(f"Totaal: €{factuur.totaal_bedrag:.2f}")
for regel in factuur.regels:
    print(f"  - {regel.aantal}x {regel.omschrijving}: €{regel.totaal:.2f}")

Dit voorbeeld laat goed zien wat gestructureerde output in de praktijk kan doen. Het model extraheert de data, en de Pydantic-validators controleren of de bedragen kloppen. Als het model een rekenfout maakt (en geloof me, LLMs maken regelmatig rekenfouten), vangt de model_validator dit netjes op en triggert Instructor een retry.

Veelgestelde Vragen

Wat is het verschil tussen JSON Mode en Structured Output?

JSON Mode garandeert dat de output geldig JSON is, maar niet dat het je schema volgt. Structured Output gaat een stap verder en dwingt conformiteit met een specifiek JSON Schema af op decodingniveau. OpenAI raadt in 2026 expliciet aan om altijd Structured Output te gebruiken in plaats van JSON Mode wanneer dat mogelijk is.

Werkt gestructureerde output ook met lokale modellen via Ollama?

Ja, zowel Instructor als PydanticAI ondersteunen lokale modellen via Ollama. PydanticAI heeft native structured output support voor Ollama toegevoegd. De betrouwbaarheid hangt wel af van het specifieke model — grotere modellen als Llama 3.1 70B presteren significant beter dan kleinere varianten. Mijn advies: test altijd grondig met je specifieke use case voordat je het in productie neemt.

Hoe ga ik om met LLMs die rekenen met bedragen?

Kort antwoord: vertrouw ze niet met berekeningen. LLMs zijn notoir onbetrouwbaar als het op rekenen aankomt. Gebruik Pydantic model_validator decorators om berekende velden te verifiëren (zoals in het factuurvoorbeeld hierboven). Laat het model de ruwe waarden extraheren en voer berekeningen uit in je eigen Python-code. Combineer dit met max_retries zodat het model een correctie kan maken als de validatie faalt.

Kan ik gestructureerde output combineren met streaming?

Ja, dat kan. PydanticAI ondersteunt streaming van gestructureerde output via run_stream() en stream_output(). Instructor biedt create_partial() voor het streamen van een enkel object en create_iterable() voor het streamen van meerdere objecten. Houd er wel rekening mee dat validatie bij streaming pas op het eindresultaat wordt uitgevoerd, niet op tussentijdse partiële objecten.

Welke Pydantic-versie heb ik nodig?

Zowel Instructor als PydanticAI vereisen Pydantic v2. Als je nog op Pydantic v1 zit, moet je eerst migreren. Maar het goede nieuws: Pydantic v2 is volledig herschreven in Rust en is 5–50x sneller dan v1, dus de migratie is sowieso de moeite waard — niet alleen voor gestructureerde output, maar voor elk project dat Pydantic gebruikt.

Over de Auteur Editorial Team

Our team of expert writers and editors.