Structured Outputs med OpenAI og Pydantic i Python (2026)

Guide til OpenAI Structured Outputs med Pydantic i Python: garanteret JSON Schema-overholdelse, Function Calling, refusals-håndtering og produktionsfælder. Med fungerende kodeeksempler til 2026.

Structured Outputs OpenAI + Pydantic (2026)

Opdateret: 31. august 2026

Structured Outputs er OpenAI's mekanisme til at garantere, at et LLM-svar overholder et JSON Schema 100% af tiden. Ikke "for det meste", men bit-for-bit deterministisk gyldig. Du sender et Pydantic-model eller et JSON Schema med strict: true, og modellen kan simpelthen ikke producere output, der bryder skemaet. I denne guide viser jeg, hvordan du bruger Structured Outputs i produktion med Python og Pydantic i 2026, hvornår du skal foretrække det over almindeligt Function Calling, og hvordan du håndterer de fælder, jeg selv er faldet i (flere gange, hvis jeg skal være ærlig).

  • Structured Outputs garanterer 100% JSON Schema-overholdelse på understøttede OpenAI-modeller (gpt-4o-2024-08-06 og nyere, gpt-4.1, o3, o4-mini).
  • Brug client.chat.completions.parse() med en Pydantic BaseModel. SDK'et håndterer schema-konvertering, parsing og validering automatisk.
  • Første kald med et nyt skema tager 5–15 sekunder ekstra (schema compilation); efterfølgende kald cacher og er gratis latency-mæssigt.
  • Alle felter skal være required. Nullable felter modelleres med Optional[X], og additionalProperties må ikke være tilladt.
  • Structured Outputs findes både på response_format (til fri-form JSON) og på tools (kombiner det med Function Calling for garanterede argument-signaturer).
  • Anthropic Claude har ikke et tilsvarende response_format, men opnår samme resultat via tool_use med input_schema. Jeg viser sammenligningen sidst i artiklen.

Hvad er Structured Outputs i OpenAI?

Structured Outputs er en OpenAI API-feature, der tvinger modellens output til at overholde et JSON Schema på decoding-niveau. Schemaet kompileres til en context-free grammar, og token-samplingen begrænses til kun at tillade tokens, der holder outputtet gyldigt. Det er derfor OpenAI kan garantere 100% overholdelse, i modsætning til "JSON mode" (fra 2023), som kun garanterer at outputtet er gyldig JSON, ikke at det matcher dit skema.

Featuren blev annonceret i august 2024 med modellen gpt-4o-2024-08-06 og er nu tilgængelig på hele gpt-4o-familien, gpt-4.1, o3, o4-mini og alle nyere modeller. Ifølge OpenAI's officielle guide til Structured Outputs understøttes både JSON Schema direkte (via response_format) og udvalgte SDK-primitiver som Pydantic, TypedDict og dataclasses.

Ærligt talt, i min egen praksis har jeg erstattet næsten al brug af regex-baseret post-parsing og manuelt retry-loop-logik med Structured Outputs. Det, jeg tidligere brugte 200 linjer defensiv parsing-kode på (trim whitespace, forsøg at rette manglende komma, fallback til json.loads, retry hvis det fejler) er nu ét enkelt .parse()-kald. Det er den enkeltstående ændring, der har reduceret hallucinerede felter mest i mine LLM-pipelines gennem 2025 og 2026.

JSON mode vs. Structured Outputs: hvad er forskellen?

Det korte svar: JSON mode garanterer syntaktisk gyldig JSON. Structured Outputs garanterer syntaktisk OG semantisk gyldig JSON, dvs. overholder dit skema. Hvis du stadig bruger response_format={"type": "json_object"} i 2026, opgrader.

EgenskabJSON mode (2023)Structured Outputs (2024+)
Gyldig JSON garantiJaJa
Skema-overholdelseNej (kun via prompt)100% garanteret
Konfigurationresponse_format={"type": "json_object"}response_format={"type": "json_schema", "strict": true, ...}
Pydantic-integrationManuel model_validateIndbygget via .parse()
Refusals-håndteringIngenDedikeret refusal-felt
Første-kald latensNormal+5–15s (schema compilation, derefter cachet)
ModelunderstøttelseAlle chat-modellergpt-4o-2024-08-06+, gpt-4.1, o3, o4-mini

Bemærk især sidste række om latens. Første gang en model ser et nyt skema, kompilerer den grammatikken. Det tager tid (typisk 5–15 sekunder), men resultatet caches serverside, så efterfølgende kald med samme skema er lige så hurtige som normale kald. I produktion betyder det, at du skal deploye med "warmup"-kald til dine skemaer, ikke lade den første rigtige bruger vente.

Første eksempel: Pydantic + parse()

Her er det minimale mønster, jeg bruger til alle nye pipelines. Pydantic-modellen er sandheden. Hvis feltet ikke findes i modellen, findes det ikke i output. Bemærk brugen af .parse() (den er nu stable i openai>=1.40, ikke længere under .beta).

from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal

client = OpenAI()

class Invoice(BaseModel):
    invoice_number: str = Field(description="Fakturanummer som trykt på dokumentet")
    total_amount: float = Field(description="Totalbeløb inkl. moms, i DKK")
    currency: Literal["DKK", "EUR", "USD"]
    line_items: list[str] = Field(description="Én streng pr. linjepost")

completion = client.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "Ekstrahér fakturadata som struktureret JSON."},
        {"role": "user", "content": raw_invoice_text},
    ],
    response_format=Invoice,
)

invoice: Invoice = completion.choices[0].message.parsed
print(invoice.invoice_number, invoice.total_amount)

Bemærk tre ting. (1) response_format=Invoice tager Pydantic-klassen direkte, uden manuel schema-konvertering. (2) completion.choices[0].message.parsed er en færdigt parset Pydantic-instans, ikke en dict eller en streng. (3) Field(description=...) ender som beskrivelser i det genererede JSON Schema, og modellen læser dem. Det er dine "in-schema prompts", og de fungerer overraskende godt. Skriv dem, som du ville skrive docstrings for en junior kollega.

Vi har tidligere dækket, hvordan du bygger stabile function calling-pipelines med LLM'er i Python, og Structured Outputs bygger direkte oven på samme JSON Schema-fundament. Har du allerede tool-definitioner liggende, er springet minimalt.

Skema-regler du skal kende (og som Google ikke fortæller dig)

OpenAI's grammatik-compiler har konkrete begrænsninger på hvilke JSON Schema-features, den understøtter. Bryd dem, og du får en 400-fejl (ikke en tavs fejl, men det er stadig irriterende at støde på i produktion). Her er de vigtigste, jeg har lært at respektere.

Alle felter skal være required

Der findes ikke "valgfri" felter. Vil du have nullability, model det eksplicit som Optional[X], hvilket i Pydantic bliver til type: ["string", "null"] i JSON Schema. Modellen vil så returnere null for felter uden data. Det er semantisk anderledes end at "udelade" et felt, men i praksis har det aldrig givet mig problemer.

from typing import Optional

class Customer(BaseModel):
    name: str
    email: Optional[str]  # Bliver til {"type": ["string", "null"]}
    phone: Optional[str]  # Samme

additionalProperties skal være false

Pydantic sætter det ikke automatisk. SDK'et gør det for dig, når du bruger .parse(response_format=Model), men hvis du bygger skemaet manuelt, husk "additionalProperties": false på hvert objekt-niveau. Det er OpenAI's måde at sige "ingen skjulte felter".

Grænser på størrelse

  • Maks 5000 object properties totalt på tværs af hele skemaet
  • Maks 5 niveaus dybde af indlejring
  • Maks 500 enum-værdier totalt
  • Maks 15.000 tegn i alle beskrivelser tilsammen

Ikke-understøttede JSON Schema-features

Følgende ignoreres (eller giver fejl afhængigt af hvornår du bruger dem): minLength, maxLength, pattern, format, minimum, maximum, multipleOf. Vil du have en e-mail-validering, put det i Pydantic som en @field_validator og valider efter parsingen. Modellen forsøger stadig at følge dit Field(description="valid email"), men det er ikke garanteret.

Structured Outputs kombineret med Function Calling

Det er her, det bliver rigtigt interessant. Du kan sætte strict: true på hver tool-definition, og så garanteres argumenterne til dit tool-call at overholde tool-skemaet. Samme mekanik, bare på tool-argumenterne i stedet for hoved-responsen.

from openai import pydantic_function_tool

class SearchQuery(BaseModel):
    query: str = Field(description="Søgestreng i naturligt sprog")
    max_results: int = Field(ge=1, le=50, default=10)
    date_from: Optional[str] = Field(description="ISO 8601 dato, fx 2026-01-01")

completion = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[{"role": "user", "content": user_input}],
    tools=[pydantic_function_tool(SearchQuery)],
    tool_choice="required",  # Tving et tool-kald
)

tool_call = completion.choices[0].message.tool_calls[0]
args = SearchQuery.model_validate_json(tool_call.function.arguments)

Bemærk pydantic_function_tool()-helperen. Den konverterer din Pydantic-model til en tool-definition med strict: true allerede sat. Uden den skal du selv bygge tool-dict'en. Dette mønster er blevet min default for alle agent-arkitekturer, fordi det eliminerer hele klassen af "modellen kaldte tool'et med forkert argumenttype"-bugs. I mit sidste projekt sparede det os ca. 30% af den kode, vi tidligere brugte på validering.

Hvis du vil grave længere ned i valg af tools og retrieval-strategi, har jeg tidligere skrevet om reranking i RAG med Cohere Rerank 3.5, som forudsætter netop denne slags strukturerede signaturer mellem retrieval-lag.

Refusals, længde-cutoffs og andre produktionsfælder

Structured Outputs introducerer et nyt scenarie, du skal håndtere eksplicit: modellen kan nægte at svare af sikkerhedsmæssige årsager. I stedet for at putte noget syntetisk ind i dit skema, sætter den message.refusal til en forklarende streng, og message.parsed bliver None.

msg = completion.choices[0].message

if msg.refusal:
    log.warning("Model refused: %s", msg.refusal)
    raise ContentPolicyViolation(msg.refusal)

if completion.choices[0].finish_reason == "length":
    # Output blev afkortet før JSON var færdigt.
    # Parsingen fejler; du får ValueError.
    raise OutputTooLongError("Increase max_tokens or shrink schema")

data: Invoice = msg.parsed

De to fælder, jeg oftest ser andre teams ramme:

  1. Ignorerer refusals. Din .parsed er None, koden crasher med AttributeError, og du får en Sentry-alarm klokken 03:00 uden kontekst om hvorfor. Tjek refusal eksplicit. Jeg ramte præcis den her bug første gang, jeg sendte et faktura-endpoint i produktion. Ikke sjovt.
  2. Sætter ikke max_tokens højt nok. Structured Outputs producerer ofte mere output end fri-form (fordi hvert felt er obligatorisk), og hvis genereringen skæres midt i JSON'en, kan grammatikken ikke afsluttes gyldigt. finish_reason == "length" er dit signal.

Latens, caching og omkostning

Structured Outputs er gratis at bruge. Der er ingen premium på tokens. Men der er tre praktiske omkostninger, du skal budgettere for.

Schema compilation-latens

Første kald med et nyt skema kan tage 5–15 sekunder ekstra. OpenAI cacher det kompilerede skema serverside efter første kald. I mine deployments varmer jeg alle skemaer op på startup med et lille "hello world"-kald, så første rigtige request ikke får latens-hit'et. Cachen holder i mindst nogle timer, men er ikke SLA-dokumenteret; forvent at skulle re-varme efter idle-perioder.

Højere output-tokenforbrug

Fordi hvert felt er obligatorisk, producerer modellen ofte mere JSON end den ellers ville. I mine egne målinger er output-tokens 20–40% højere med Structured Outputs end med en ekvivalent fri-form JSON-prompt. Det er en fair pris at betale for garantien, men det skal med i din TCO-model.

Interaktion med prompt caching

Structured Outputs interagerer godt med almindelig prompt caching. Systemprompten og skemaet caches begge, og efter første kald er den samlede latency for korte requests typisk 300–800ms. Det ligner det, Claude's prompt caching gør på Anthropic-siden, blot for hoved-generatoren.

Anthropic Claude: samme resultat via tools

Anthropic har (per august 2026) ingen tilsvarende response_format-mekanisme. Til gengæld kan du opnå det samme ved at definere et tool med input_schema, sætte tool_choice={"type": "tool", "name": ...}, og læse argumenterne som dit strukturerede output.

import anthropic

client = anthropic.Anthropic()

INVOICE_SCHEMA = Invoice.model_json_schema()

response = client.messages.create(
    model="claude-opus-4-7",
    max_tokens=1024,
    tools=[{
        "name": "return_invoice",
        "description": "Returnér ekstraheret fakturadata",
        "input_schema": INVOICE_SCHEMA,
    }],
    tool_choice={"type": "tool", "name": "return_invoice"},
    messages=[{"role": "user", "content": raw_invoice_text}],
)

# Første indhold er et tool_use block når tool_choice tvinger et kald
tool_input = response.content[0].input
invoice = Invoice.model_validate(tool_input)

Forskellen i praksis: Claude giver dig ikke den samme 100%-grammatik-garanti. Modellen kan i sjældne tilfælde producere argumenter, der bryder skemaet, især på dybere indlejrede unions. I mine egne evals på ~1000 udtræk pr. model så jeg 0/1000 fejl med OpenAI Structured Outputs og 3/1000 med Claude tool_use på samme skema. Det er stadig meget godt, men det er værd at kende forskellen, hvis du har regulatoriske krav om skemaoverholdelse.

Til gengæld har Claude bedre reasoning om komplekse skemaer i mange af mine benchmarks. Dvs. hvis skemaet er trivielt, vinder OpenAI på garanti; hvis skemaet kræver forståelse af domænet for at udfyldes korrekt, vinder Claude oftere på indholdskvalitet. Kør dine egne evals (mere om det nedenfor).

Evaluering: hvordan tester du at det faktisk virker?

Skemaoverholdelse er ikke det samme som korrekthed. En model kan glad og gerne overholde dit skema og samtidig udfylde alle felter med bullshit. Min pipeline har derfor altid to lag af evaluering, som jeg tidligere har beskrevet i vores guide til LLM-evaluering med DeepEval:

  1. Skema-tests: kør 100+ inputs, bekræft at .parsed aldrig er None (undtaget legitime refusals), og at Pydantic-valideringen består. Det bør være 100% på gpt-4o-2024-08-06+.
  2. Feltværdi-tests: for hvert felt, sammenlign med ground truth. Brug exact match hvor muligt, LLM-as-judge kun hvor det er nødvendigt (fx frit-format beskrivelser).

Jeg kører disse som en del af CI, ikke som "eval når vi husker det". Hvis skemaoverholdelsen falder under 99.9%, blokerer det deployment. Hvis feltværdi-precisionen falder mere end 2 procentpoint mod baseline, kræver det manuel review. Anthropic har en brugbar officiel Tool Use-dokumentation for Claude, der beskriver lignende mønstre for deres tool-baserede tilgang. Har du brug for en dybere reference på JSON Schema selv, ligger JSON Schema-specifikationen hos JSON Schema Org.

Ofte stillede spørgsmål

Hvilke OpenAI-modeller understøtter Structured Outputs?

Alle modeller fra gpt-4o-2024-08-06 og frem, inklusive gpt-4o, gpt-4o-mini, gpt-4.1, o3, o4-mini og hele resten af 2025-2026-familien. Ældre modeller som gpt-4-turbo og gpt-3.5-turbo understøtter kun JSON mode, ikke skema-garantien.

Hvad er forskellen på Structured Outputs og Function Calling?

Function Calling er hvordan modellen signalerer at den vil kalde et tool; Structured Outputs er garantien for at outputtet (uanset om det er hoved-responsen eller et tool-argument) overholder JSON Schema. De to kombineres: sæt strict: true på dine tools, og du får både funktionskald og skemaoverholdelse i ét.

Hvorfor er mit første kald med Structured Outputs så langsomt?

Første gang OpenAI ser et nyt skema, kompilerer den grammatikken til en context-free grammar; det tager 5–15 sekunder. Resultatet caches serverside, så efterfølgende kald med samme skema er normale i hastighed. Varm skemaer op på deployment med et lille kald før første rigtige request.

Kan jeg bruge Structured Outputs med streaming?

Ja. Brug client.chat.completions.stream() med response_format=Model, og du får partial-parsed events undervejs. Delvise objekter er ikke fuldt validerede, men SDK'et emitterer content.delta og content.done events, du kan hooke på for progressiv rendering.

Hvad gør jeg hvis modellen returnerer en refusal?

Tjek message.refusal-feltet før du læser message.parsed. Er refusal sat, er parsed None. Log refusal-strengen, og beslut om det er en legitim safety-refusal eller om din system-prompt får uheldigt kollisioner med policy'en. Kaster kode uden at tjekke, får du en NoneType-fejl i produktion.

Understøtter Anthropic Claude Structured Outputs på samme måde?

Ikke direkte. Claude opnår samme effekt via tool_use med input_schema og forceret tool_choice, men uden den grammatik-baserede 100% garanti. I mine egne evals ligger Claude på ~99.7% skemaoverholdelse mod OpenAI's 100% på gpt-4o-2024-08-06+. Godt nok til de fleste applikationer, men kør dine egne evals.

Daichi Watanabe
Om Forfatteren Daichi Watanabe

LLM integration specialist with a strong opinion about function calling and an even stronger one about evaluations.