Skiftet fra “LLM-chatbot” til “LLM-drevet system, der udfører arbejde” sker i laget for strukturerede output og funktionskald. Her holder LLM’en op med blot at producere tekst og begynder at producere data, vælge handlinger og indgå i resten af infrastrukturen.
I produktion betyder “struktureret output” ikke, at “JSON-tilstand virkede én gang i min test”. Det betyder en robust pipeline, der håndterer modelvariation, forkert formaterede output, delvise fejl, skemaudvikling og den kaotiske virkelighed, at LLM’er ikke altid følger instruktioner.
Denne artikel gennemgår de mønstre, der faktisk holder i produktion. Vi antager, at I kender grundprincipperne og har brugt OpenAI’s tool_choice, Anthropics værktøjsbrug og begrænsninger med JSON-skemaer. Her går vi dybere ned i det, der gør systemerne pålidelige.
De to tilstande
To relaterede men forskellige evner:
Struktureret output: LLM’en producerer output, der overholder et skema, typisk JSON. Bruges, når I har brug for LLM’ens svar i et format, der kan behandles programmatisk.
Funktionskald/værktøjskald: LLM’en får et sæt funktioner, den kan kalde, vælger hvilken der skal kaldes, hvis nogen, og producerer parametrene. Værtssystemet udfører funktionen og returnerer resultatet. LLM’en kan derefter kalde flere funktioner eller producere et endeligt svar.
Model-API’erne udtrykker typisk disse via:
- En parameter som
response_format, der modtager et JSON-skema til almindelige strukturerede output — OpenAI’s “Structured Outputs”, Google GeminisresponseSchemaosv. - Et
tools-array, der beskriver tilgængelige funktioner, samt et svar med værktøjskaldet. Anthropics API til værktøjsbrug kan også begrænse output til et skema: Definér ét værktøj med det ønskede skema, og tving modellen til at kalde det.
Begge tilgange virker og er beslægtede. Et funktionskald er i praksis et struktureret output, hvor skemaet er funktionens signatur.
Mønster 1: Stramme, eksplicitte skemaer
Den største enkeltstående forbedring af pålideligheden ligger i jeres skemaer.
Et løst skema:
{
"type": "object",
"properties": {
"category": { "type": "string" },
"priority": { "type": "string" }
}
}
Et stramt skema:
{
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["billing", "technical", "account", "feature_request", "complaint"],
"description": "The ticket category. Use 'technical' for product bugs and 'account' for login/password issues."
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "urgent"],
"description": "Use 'urgent' only for outages or business-critical impact. 'high' for blocking issues on important customers. 'medium' for standard impact. 'low' for nice-to-haves."
}
},
"required": ["category", "priority"],
"additionalProperties": false
}
Den stramme version:
- Begrænser værdierne til kendte enums uden drift i fritekst.
- Indeholder beskrivelser, der fungerer som inline prompts (modellen bruger dem).
- Kræver felter, så I ikke får delvise svar.
- Forbyder ekstra felter (ingen tilfældige hallucinerede nøgler).
I produktion bør alle skemafelter have en beskrivelse, alle enums være eksplicitte, og alle obligatoriske felter være markeret. Det er “skemaet som prompt”: Skemaet udfører en del af promptdesignet.
Mønster 2: Begrænset generering
De vigtigste leverandører understøtter nu begrænset generering — modellen begrænses på dekodningsniveauet til kun at producere gyldige output.
- OpenAI:
response_format: { type: "json_schema", json_schema: { ..., strict: true } } - Anthropic: værktøjer med strenge skemaer.
- Open-source:
outlines,lm-format-enforcer,jsonformer, vLLM’s grammar-based decoding.
Brug dem altid. De fjerner en hel klasse af fejl: forkert formateret JSON, hallucinerede felter og manglende obligatoriske felter. Ydelsesomkostningen er ubetydelig.
Når begrænset generering ikke er tilgængelig, f.eks. med visse åbne modeller eller konfigurationer, er validering og et nyt forsøg reserveløsningen (se Mønster 4).
Mønster 3: Skemaversionering
Skemaer udvikler sig. I tilføjer felter, udfaser felter og ændrer enums.
En ændring af skemaversionen er en kodeændring. Den bør være:
- Versioneret. Giv hvert skema et versionsnummer.
- Testet. Kør evalueringssuiten mod det nye skema før udrulning.
- Kommunikeret. Efterfølgende forbrugere af outputtet skal kende ændringen.
- Bagudkompatibel, når det er muligt. Tilføj nye valgfrie felter; fjern ikke obligatoriske felter.
Et velfungerende mønster er at gemme skemaer som TypeScript-typer eller Pydantic-modeller, versionere dem i versionsstyringen og generere JSON Schema ud fra dem. Typerne betjener både model-API’et og applikationskoden.
class TicketClassificationV2(BaseModel):
category: Literal["billing", "technical", "account", "feature_request", "complaint"]
priority: Literal["low", "medium", "high", "urgent"]
confidence: float = Field(ge=0, le=1, description="Confidence in this classification, 0-1")
needs_human_review: bool = Field(description="True if any field has low confidence or unusual signal")
reasoning: str = Field(description="Brief reasoning for the classification, especially for non-obvious cases")
En Pydantic-model definerer skemaet, validerer outputtet og fungerer som type i Python-koden. Én autoritativ kilde.
Mønster 4: Validering og nyt forsøg
Selv med begrænset generering skal outputtet valideres, før det bruges:
from pydantic import ValidationError
def call_with_validation(prompt, schema, max_retries=2):
for attempt in range(max_retries + 1):
response = llm_call(prompt, response_format=schema)
try:
parsed = schema.model_validate_json(response.content)
return parsed
except ValidationError as e:
if attempt < max_retries:
prompt = build_retry_prompt(prompt, response.content, e)
continue
raise
Prompten til det nye forsøg bør indeholde de oprindelige instruktioner, modellens tidligere output og en konkret beskrivelse af fejlen:
Your previous response had a validation error:
{error message}
Your previous output:
{previous output}
Please correct the issue and produce a valid response.
Nye forsøg virker overraskende godt — typisk retter ét nyt forsøg en modelfejl.
Grænser: Forsøg ikke uendeligt (maks. 2-3). Gentag ikke ved fejl, der ikke skyldes validering, f.eks. hastighedsbegrænsning eller indholdsfiltrering. Log nye forsøg, så frekvensen kan overvåges; en stigende frekvens signalerer modeldrift eller promptproblemer.
Mønster 5: Resultatrefleksion
Ved funktionskald med høj risiko skal modellen vurdere værktøjsresultaterne, før de bruges.
En ren løkke:
1. Call LLM with tools available.
2. Model decides to call tool X.
3. Execute X.
4. Pass result back to model.
5. Model produces final response.
En refleksionsløkke:
1. Call LLM with tools available.
2. Model decides to call tool X.
3. Execute X.
4. Pass result back to model.
5. Model evaluates: does this result match what I expected? Should I act on it?
6. If yes, model produces final response. If no, model calls another tool or asks for clarification.
Dette opdager tilfælde som:
- Værktøjet returnerede 0 resultater, når det skulle have returneret data → modellen genkender tomme tilfælde.
- Værktøjet returnerede en fejl → modellen håndterer den eksplicit i stedet for at ignorere den.
- Værktøjet returnerede uventede data → modellen bemærker det og tilpasser.
Implementering: Bed modellen om eksplicit at vurdere værktøjsresultaterne, eventuelt via et struktureret “vurdér og handl”-mønster.
Det øger svartid og tokenforbrug. Ved handlinger med høj risiko som at sende mail, behandle betalinger eller ændre poster er det prisen værd. Spring det over ved informationsindsamling med lav risiko.
Mønster 6: Idempotens
LLM’er kan kalde det samme værktøj to gange eller gentage kald, der allerede er lykkedes. Uden idempotens opstår dubletter: to refunderinger, to afsendte mails eller to oprettede poster.
Mønstre for idempotens:
Idempotensnøgler. Hvert værktøjskald får en unik nøgle, genereret på klienten og medtaget i kaldet. Det efterfølgende API eller wrapperen omkring værktøjet bruger nøglen til at opdage dubletter og returnere det eksisterende resultat.
Get-or-create-semantik. Værktøjer, der opretter poster, søger først. “Opret kunde med mail X” kontrollerer, om kunden allerede findes, og returnerer i så fald den eksisterende post.
Driftslogfiler. Værktøjer logger alle operationer. Wrapperen kontrollerer loggen før udførelse og returnerer det cachelagrede resultat, hvis operationen allerede er udført.
Konservativt værktøjsdesign. Værktøjer med væsentlige konsekvenser kræver eksplicit bekræftelse eller menneskelig godkendelse. LLM’en må ikke kunne udløse dem gentagne gange i en tæt løkke.
For ethvert værktøj, der har sideeffekter, design for idempotens. At overse dette er en stor kilde til produktionssystemfejl.
Mønster 7: Observerbarhed for værktøjskald
Du skal vide, hvad der sker med værktøjskald. For hvert kald, log:
- Tidspunkt.
- Værktøjets navn og argumenter.
- Resultat (eller fejl).
- Varighed.
- Brugeren/sessionen det tilhører.
- Kæden af kald i denne tur (var dette værktøjskald en del af en længere kæde?).
Byg dashboards på disse data. Almindelige visninger:
- Antal værktøjskald pr. værktøj.
- Fejlrate pr. værktøj.
- Gennemsnitlig varighed pr. værktøj.
- Mønstre i værktøjssekvenser (“hvilke værktøjer kaldes typisk sammen?”).
- Hallucinerede værktøjskald (LLM’et forsøgte at kalde et værktøj, der ikke eksisterer).
De viser, hvor systemet fejler, og hvor omkostningerne opstår.
Mønster 8: Hallucinerede argumenter
LLM’er kan opfinde værdier til værktøjsparametre. De kan kalde search_customers(email="...") med en mailadresse, der ikke svarer til brugerens spørgsmål, eller book_meeting(date="...") med en dato, som aldrig blev nævnt.
Måder at reducere:
Stramt skema med beskrivelser. “user_id skal være et ID, der tidligere er nævnt i samtalen. Opfind ikke ID’er.”
Validering i værktøjswrapperen. Hvis værdien ikke er troværdig, f.eks. fordi user_id ikke findes, eller datoen ligger i fortiden, returnerer værktøjet en struktureret fejl, og modellen vurderer igen.
Refleksion. “Før du kalder dette værktøj, skal du bekræfte, at de anvendte værdier har grundlag i samtalen.”
Begrænsede værktøjsbeskrivelser. Værktøjer der opererer på specifikke entiteter viser kun entitets-ID’er, der blev hentet tidligere i samtalen. Undgå rå søgning.
Revisionslogfiler. Find mønstre i hallucinerede argumenter, og justér prompts eller skemaer.
Mønster 9: Kontrolleret forringelse
Værktøjer fejler. API’er går ned. Hastighedsbegrænsninger rammer. Den rigtige reaktion er sjældent “fortæl brugeren, at intet fungerer.”
Mønstre:
Cachelagrede eller forældede data. Hvis den levende datakilde ikke er tilgængelig, returnér cachelagrede data med en note om, at de er forældede.
Delvis gennemførelse. Hvis 3 af 5 underopgaver lykkes, skal I oplyse, hvad der blev udført, og hvad der ikke blev udført.
Reserveløsninger. Hvis det primære værktøj fejler, skal modellen kende en reserveløsning. Hvis “search_documents” f.eks. fejler, kan den bruge “search_web” med passende advarsler.
Fejltilstande, der er synlige for brugeren. Hvis et værktøj reelt ikke kan udføre opgaven, skal modellen give brugeren en tydelig fejlmeddelelse — ikke hallucinere en succes.
Modellen skal vide om disse mønstre. Dokumentér dem i systemprompten:
If a tool returns an error:
- Try the alternate tool if one exists.
- Report partial results clearly if the user has already provided information.
- Never claim success when a tool returned an error.
Mønster 10: Streaming strukturerede output
For UX er streaming af delvise strukturerede output godt – brugeren ser resultaterne blive dannet i realtid snarere end at vente.
Implementering:
- De fleste moderne model-API’er streamer JSON-output token for token.
- Parse den delvise JSON løbende med biblioteker som
partial-json-parsereller en lille streamingparser. - Opdater UI’en, når felter ankommer.
Det fungerer især godt for output med flere sektioner. En lang produktbeskrivelse, en analyse med flere indsigter eller en kodegennemgang med flere fund føles langt mere responsiv, når den streames.
Advarsel: Træf ikke beslutninger på baggrund af delvise output. Stream til visning, men vent på det færdige strukturerede resultat, før systemet handler.
Mønster 11: Funktionskald kontra et eksplicit “beslut”-kald
Indbyggede funktionskald er praktiske — modellen “beslutter”, hvornår den vil kalde et værktøj. I nogle arbejdsgange er et eksplicit “beslut”-kald dog mere pålideligt.
Eksempel: en kundeservicearbejdsgang, hvor modellen skal beslutte mellem flere handlinger.
Tilgang med indbygget funktionskald: Giv modellen 5 værktøjer (refundering, send_artikel, eskalér_til_menneske, spørg_oplysning, luk_kort). Lad den beslutte.
Tilgang med eksplicit beslutningskald: Først kaldes modellen med ét værktøj, decide_action, som modtager én parameter: den valgte handling. Derefter kaldes modellen igen med kun det relevante værktøj ud fra beslutningen.
Den eksplicitte tilgang er langsommere og mere omstændelig, men mere pålidelig. Modellen er mere fokuseret i hvert trin, og værtssystemet har større kontrol over arbejdsgangen.
Ved arbejdsgange med høj risiko er den eksplicitte tilgang ofte bedst. Til undersøgende eller enkle arbejdsgange er indbyggede funktionskald tilstrækkelige.
Mønster 12: Værktøjsresultatformatering
Måden, værktøjsresultater returneres på, betyder noget. Modellen læser resultatet, og formatet påvirker dens brug af det.
Dårligt:
{"id": "cus_123", "n": "John", "p": "12345"}
Bedre:
{
"customer_id": "cus_123",
"name": "John Doe",
"phone": "+1-555-0123",
"tier": "premium",
"open_tickets": 0
}
Best (for nogle tilfælde):
Customer found:
- ID: cus_123
- Name: John Doe
- Tier: Premium
- Phone: +1-555-0123
- Open tickets: 0
This customer is in the premium tier and has no open tickets.
Den “bedste” form er menneskevenlig, indeholder kontekst og er lettere for modellen at bruge i efterfølgende generering. Den “bedre” form er mere struktureret og maskinlæsbar. Brug det format, modellen håndterer bedst til de efterfølgende opgaver, og test det.
For nogle værktøjer fungerer det at returnere både struktureret og narrativt (“Her er resultatet: [narrativ]. Rå data: [JSON]”) godt.
Mønster 13: Skemabevidste nye forsøg
Nogle valideringsfejl kan ikke rettes med et nyt forsøg, fordi modellen grundlæggende misforstod opgaven. Andre er lette at rette.
Et nyttigt mønster: klassificér fejlen og reager tilsvarende.
def handle_validation_error(error):
if "missing required field" in str(error):
return retry_with_message("You omitted required field X. Please include it.")
elif "value not in enum" in str(error):
return retry_with_message("Value X is not in the allowed set. Choose from: ...")
elif "type mismatch" in str(error):
return retry_with_message("Field X must be a number, not a string.")
else:
# Unknown error — single generic retry
return retry_with_message("There was an error in your response. Please try again.")
Målrettede nye forsøg lykkes oftere end generiske.
Mønster 14: Komposition
Værktøjer bør kunne kombineres. Små, fokuserede værktøjer, der gør én ting, kan sættes sammen af modellen til komplekse arbejdsgange.
Et monolitisk værktøj som process_customer_request(query), der gør alt, er en sort boks. Modellen kan ikke observere eller styre den interne logik.
Et sæt fokuserede værktøjer — search_customer(email), get_recent_orders(customer_id), check_subscription_status(customer_id), escalate_to_human(reason) — kan kombineres af modellen til det rigtige forløb i hvert tilfælde.
Design værktøjer med den rigtige granularitet. Hvert værktøj gør én ting. Værktøjerne kombineres til arbejdsgange.
Mønster 15: Skema for “Jeg ved ikke”
Et subtilt mønster er at modellere usikkerhed eksplicit i skemaet.
class CustomerInfo(BaseModel):
name: str
name_confidence: Literal["high", "medium", "low"]
needs_clarification: bool
clarification_question: Optional[str] = None
Modellen kan returnere “lav konfidens” sammen med et afklarende spørgsmål i stedet for at opfinde data.
Det er langt bedre end en model, der altid udfylder felterne med stor sikkerhed og undertiden hallucinerede data.
Et eksempel: fakturabehandling
Som samlet eksempel ser vi på et produktionsklart fakturabehandlingssystem.
Input: En PDF-faktura vedhæftet en mail. Mål: Udtræk strukturerede data, og send dem til regnskabssystemet.
Skema:
class LineItem(BaseModel):
description: str
quantity: float
unit_price: float
total: float
confidence: Literal["high", "medium", "low"]
class Invoice(BaseModel):
vendor_name: str
vendor_id: Optional[str] = None # null if not found in our records
invoice_number: str
invoice_date: date
due_date: Optional[date] = None
line_items: List[LineItem]
subtotal: float
tax: float
total: float
currency: str # ISO 4217
confidence: Literal["high", "medium", "low"]
needs_review: bool
review_reasons: List[str] # Specific reasons review is needed
Arbejdsgang:
- OCR-trin: En billedmodel udtrækker tekst fra PDF’en.
- Ekstraktionstrin: LLM-kald med skemaet ovenfor, begrænset generering aktiveret.
- Valideringstrin: Pydantic validerer. Valideringsfejl udløser ét nyt forsøg med oplysninger om fejlen.
- Krydskontrol: Et værktøjskald søger efter leverandøren i vores registre. Hvis
vendor_namematcher en kendt leverandør, tilføjesvendor_id; ellers sættesneeds_review=true. - Matematikkontrol: Bekræft
sum(line_items.total) ≈ subtotalogsubtotal + tax ≈ total. Hvis kontrollen fejler, sættesneeds_review=true. - Konfidenskontrol: Hvis
confidenceer lav, eller en linjepost har lav konfidens, sættesneeds_review=true. - Routing: Hvis
needs_review=true, sendes fakturaen til køen for menneskelig gennemgang. Ellers sendes den til regnskabssystemet. - Logning: Hvert trin logges med input, output, varighed og fejl.
Fejlmønstre håndteres:
- Forkert formateret JSON: Begrænset generering forebygger det; et nyt forsøg håndterer grænsetilfælde.
- Hallucinerede felter: Skemaet er stramt.
- Matematikfejl: valideret.
- Ukendte leverandører: markeret.
- Lav konfidens: markeret.
- Værktøjsfejl: eksplicit håndtering.
Ydelse i produktion: ~95% af fakturaerne behandles direkte; 5% markeres til gennemgang. Blandt de automatisk behandlede er fejlprocenten <0.5% (klart inden for det acceptable). I gennemgangskøen bekræftes ~80% som korrekte, mens 20% kræver rettelser.
Sådan ser produktionsklare strukturerede output ud. Ikke blot “JSON-tilstand virkede én gang”, men en pipeline, der håndterer virkelige fejlmønstre.
Almindelige fejl
Nogle mønstre, vi ser gentagne gange:
Fejl 1: Ingen validering. Brug Pydantic, Zod eller noget andet — men validér. Stol ikke på modellen.
Fejl 2: Vage beskrivelser. “kategori: string” hjælper ikke modellen. “kategori: én af fakturering, teknisk, kontoadgang, hvor fakturering dækker …” gør.
Fejl 3: For mange værktøjer. Med 30 tilgængelige værktøjer vælger modellen forkert. Begræns udvalget til <10 relevante værktøjer pr. kald.
Fejl 4: Intet nyt forsøg ved valideringsfejl. Et enkelt forkert formateret output stopper hele forløbet. Prøv én gang til med feedback.
Fejl 5: Ingen observerbarhed. Når værktøjskald fejler i produktion, kan fejlen ikke diagnosticeres uden sporingsdata.
Fejl 6: Ingen idempotens i værktøjer med sideeffekter. Dobbeltrefunderinger og dobbeltmails er forudsigelige fejl.
Fejl 7: Tillid til LLM-valgte argumenter uden validering. Hallucinerede bruger-ID’er og datoer skal fanges. Validér værktøjsargumenter før udførelse.
Fejl 8: Skemaversionering springes over. Ændringer i skemaet bryder efterfølgende forbrugere. Versionér det.
Fra demo til produktionssystem
Strukturerede output og funktionskald er broen fra “LLM, der taler” til “LLM, der udfører arbejde”. Udført godt gør de produktions-AI mulig. Udført dårligt fejler de på interessante og dyre måder.
De vigtige mønstre er stramme skemaer, begrænset generering, validering med nye forsøg, refleksion over værktøjsresultater, idempotens, kontrolleret forringelse, skemabevidst fejlhåndtering og observerbarhed fra ende til anden.
Hvert af disse mønstre adskiller en demo fra et produktionssystem. Byg dem ind fra begyndelsen.



