Naar de inhoud
NLEN
Illustratie: Structured Output via Regex en Grammatica-Restricties

Structured Output via Regex en Grammatica-Restricties

Door Ivo Donker — samengesteld met AI-ondersteuning (Claude & Gemini)

Het genereren van gestructureerde data zoals JSON, YAML of strikte enum-waarden via een Large Language Model (LLM) is in productie-omgevingen berucht om onvoorspelbare syntaxfouten. Wie uitsluitend vertrouwt op natuurlijke taalinstructies in de prompt merkt al snel dat een ontbrekende accolade, een ontsnapte aanhalingstekenreeks of onverwachte Markdown-annotaties downstream parsers direct laten crashen. Om die reden heeft het veld zich ontwikkeld van defensieve prompt-engineering naar formele restricties op decoderniveau. In dit artikel bekijken we hoe constrained decoding via reguliere expressies (regex), contextvrije grammatica's (CFG) en automatische deterministische eindige automaten (DFA) werkt.

Het centrale principe van deze aanpak is eenvoudig: in plaats van hopen dat het neurale netwerk zich aan de syntaxregels houdt, dwingen we tijdens elke decoderingsstap af dat het model alleen tokens kan selecteren die volgens een vooraf gedefinieerde grammatica geldig zijn. Wie zich eerst wil verdiepen in de bredere context van formaatsturing kan terecht in het overzichtsartikel over hoe je een LLM betrouwbaar in een vast outputformaat laat antwoorden waarin de verschillen tussen instructies, post-processing en inference-time restricties worden geschetst.

De anatomie van LLM-decodering en logit-maskering

Om te begrijpen hoe grammatica-restricties werken, moeten we kijken naar de laatste laag van een autoregressief taalmodel. Na de transformatielagen produceert het netwerk voor elke token-positie een vector van ongenormaliseerde log-kansen, de zogeheten logits. De lengte van deze vector is gelijk aan de grootte van het vocabulair van de tokenizer ($V$). Onder normale omstandigheden transformeert een softmax-operatie deze logits naar een kansverdeling, waarna sampling (via parameters zoals temperature of top-p) de volgende token aanwijst.

Bij constrained decoding grijpen we in op de logit-vector vóórdat de softmax-berekening plaatsvindt. Als we op positie $t$ weten dat uitsluitend cijfers zijn toegestaan, maskeren we alle tokens in het vocabulair die letters of leestekens bevatten door hun logit-waarde handmatig op negatief oneindig ($-\infty$) te zetten. Na de softmax-operatie hebben deze ongeldige tokens een kans van exact $0\%$, waardoor het wiskundig onmogelijk is dat de sampler ze selecteert.

Formele definitie van logit-masking:
Laat $z \in \mathbb{R}^{|V|}$ de logit-vector zijn en $M \subseteq \{1, \dots, |V|\}$ de verzameling geldige token-ID's op stap $t$. De gemaskeerde logit $\tilde{z}_i$ wordt berekend als:

$\tilde{z}_i = z_i \text{ als } i \in M, \text{ anders } -\infty$.

De werkelijke complexiteit zit niet in de wiskunde van de maskering, maar in het efficiënt bepalen van de geldige set $M$ op elke stap $t$ zonder dat de inferentielatentie explodeert. Dit vereist state machines die synchroon meelopen met de gegenereerde byte-stromen.

Reguliere expressies en deterministische automaten (DFA)

Wanneer de gewenste output beschreven kan worden met een reguliere expressie — zoals een datumformaat \d{4}-\d{2}-\d{2}, een IPv4-adres of een vaste set categorieën — converteren moderne inference engines deze expressie vooraf naar een Deterministic Finite Automaton (DFA). Een DFA bestaat uit een eindige set toestanden, een begintoestand, overgangsfuncties op basis van karakters (of bytes) en acceptatietoestanden.

Omdat LLM's niet werken op het niveau van losse karakters maar op subwoord-tokens (die meerdere bytes lang kunnen zijn), moet de DFA worden geprojecteerd op de token-woordenschat. Voor elk token in het vocabulair wordt geëvalueerd of de reeks bytes binnen dat specifieke token een geldig pad vormt van de huidige DFA-toestand naar een volgende toestand. Alleen tokens waarvan de volledige bytereeks een geldige overgang mogelijk maakt, worden opgenomen in masker $M$.

// Voorbeeld van een eenvoudige DFA transitie-evaluatie in pseudo-code
function getValidTokens(currentState, dfa, vocabulary):
  validTokens = []
  for token in vocabulary:
    state = currentState
    isValid = true
    for byte in token.bytes:
      if dfa.hasTransition(state, byte):
        state = dfa.nextState(state, byte)
      else:
        isValid = false
        break
    if isValid:
      validTokens.append(token.id)
  return validTokens

Bibliotheken zoals Outlines optimaliseren dit proces drastisch door de volledige token-transitietabel offline (vóór de inferentie) voor te berekenen en te cachen. Hierdoor kost het opzoeken van geldige tokens per decoderingstap slechts een constante tijd lookup ($O(1)$), wat leidt tot verwaarloosbare latency-overhead tijdens het streamen.

Contextvrije grammatica's (CFG) en GBNF

Voor hiërarchische structuren met geneste elementen — zoals willekeurige JSON-objecten, arrays binnen arrays of programmeertalen — schieten reguliere expressies tekort vanwege het ontbreken van een geheugenstapel. Hier komen Context-Free Grammars (CFG) in beeld. Een gangbare standaard in open-source engines zoals llama.cpp is GBNF (GGML BNF), een afgeleide van de Backus-Naur Form.

Een GBNF-grammatica definieert productieregels die specificeren welke reeksen niet-terminale en terminale symbolen zijn toegestaan. Tijdens de decodering houdt een pushdown-automaat (een state machine met een stack) bij in welke parseringscontext de generator zich bevindt. Hieronder staat een voorbeeld van een GBNF-grammatica die een strikt JSON-object afdwingt met een statusveld en een lijst van numerieke ID's.

# GBNF Grammatica voor een strict taak-status JSON object
root   ::= "{" ws "\"taak_id\":" ws id "," ws "\"status\":" ws status "," ws "\"logs\":" ws log_array "}"
id     ::= [0-9]+
status ::= "\"in_behandeling\"" | "\"voltooid\"" | "\"gefaald\""
log_array ::= "[" ws (log_item ("," ws log_item)*)? ws "]"
log_item  ::= "\"" [^"\\]* "\""
ws     ::= [ \t\n\r]*

Tijdens elke iteratie evalueert de parser welke bytes grammaticaal toegestaan zijn als volgende invoer. Tokens die niet overeenkomen met een geldige reductie of verschuiving binnen de grammatica worden direct gemaskeerd. Dit garandeert dat de uiteindelijke string gegarandeerd parseert via standaard parsers.

JSON Schema mapping naar grammatica's

In moderne software-architecturen schrijven ontwikkelaars zelden handmatig ruwe GBNF- of EBNF-grammatica's. In plaats daarvan vormt een JSON Schema (vaak gegenereerd via datamodellen zoals Pydantic of Zod) het bronartefact. Gespecialiseerde compilers vertalen dit schema automatisch naar een onderliggende state machine of grammatica.

JSON Schema Constructie Grammatica Equivalent DFA / Parser Gedrag
"type": "string", "enum": ["a", "b"] Exacte alternatieven: ("\"a\"" | "\"b\"") Directe letterlijke transities; maskeert alle andere tekens.
"type": "integer", "minimum": 10 Regex subset: [1-9][0-9]+ Staat alleen cijferreeksen toe die niet met 0 beginnen.
"type": "array", "items": {...} Recursieve regel met scheidingsteken Beheert stack met komma's tussen elementen en sluit met ].
"additionalProperties": false Strikte veldsequentie of permutaties Blokkeert het openen van ongeldige JSON-sleutels.

Wie de praktische implementatie hiervan via commerciële API's wil vergelijken met open-source engines kan de documentatie raadplegen over betrouwbare JSON en Structured Output uit LLM's halen, waarin specifieke API-parameters zoals response_format worden uitgediept.

De wisselwerking tussen constraints en modelintelligentie

Hoewel grammatica-restricties 100% syntactische correctheid garanderen, introduceren ze een subtiel maar cruciaal risico voor de semantische kwaliteit en redeneerkracht van het model. Dit fenomeen staat bekend als het token prefix sampling dilemma.

Een neuraal netwerk genereert zijn antwoord op basis van eerdere autoregressieve correlaties. Wanneer een grammatica het model dwingt om een specifiek token te kiezen dat zeer laag scoort in de ongefilterde kansverdeling, raakt de interne aandachtsrepresentatie (attention state) verstoord. Als we een model bijvoorbeeld dwingen direct te openen met {"analyse": "..."} zonder dat het eerst tussenstappen mag genereren, wordt het vermogen tot logisch redeneren ingekort.

Visualisatie van logit masking waarbij ongeldige tokens worden gefilterd ten opzichte van een grammatica boom
Logit-maskering: tokens die buiten de grammaticale paden vallen worden geëlimineerd uit de samplingruimte.

Om deze kwaliteitsdegradatie tegen te gaan, combineren geavanceerde implementaties redeneerruimte (zoals een "thought" of "reasoning" veld) vóór het formele antwoord. Hierdoor kan het model vrij redeneren binnen een string-veld voordat de grammatica overgaat op strikte enums of getalstructuren.

Overzicht van frameworks en tools

Het ecosysteem rondom gestructureerde decodering is snel volwassen geworden. Verschillende open-source tools en serving-engines bieden ingebouwde mechanismen voor logit-maskering:

Bij het kiezen van een architectuur moeten ontwikkelaars afwegen of de logit-restricties lokaal in de inference loop worden afgehandeld, of dat een upstream gateway het formaat moet bewaken. Een diepere vergelijking tussen promptregels en API-gedreven schema's is te vinden in de gids over output afdwingen via promptregels of API-schema, die helpt bepalen wanneer lichte instructies volstaan en wanneer harde grammaticamaskers noodzakelijk zijn.

Praktijkvoorbeeld: JSON-schema via Outlines in Python

Hieronder staat een concreet, reproduceerbaar voorbeeld waarin een Pydantic-model wordt gebruikt om via Outlines een strikte JSON-output te garanderen bij een open-source model. De generator maskeert op de achtergrond automatisch alle tokens die afwijken van het schema.

from enum import Enum
from pydantic import BaseModel, Field
import outlines

# 1. Definieer het gewenste datamodel
class RisicoNiveau(str, Enum):
    LAAG = "laag"
    GEMIDDELD = "gemiddeld"
    HOOG = "hoog"

class KlantEvaluatie(BaseModel):
    klant_id: int = Field(description="Numeriek ID van de klant")
    risico: RisicoNiveau
    score: float = Field(ge=0.0, le=100.0, description="Score tussen 0 en 100")
    samenvatting: str = Field(max_length=150)

# 2. Laad het model en compileer de gestructureerde generator
model = outlines.models.transformers("meta-llama/Llama-3.1-8B-Instruct")
generator = outlines.generate.json(model, KlantEvaluatie)

# 3. Voer de inferentie uit; de output is gegarandeerd geldig volgens het schema
prompt = "Evalueer het dossier van klant 48192 met 3 te late betalingen."
resultaat: KlantEvaluatie = generator(prompt)

print(resultaat.model_dump_json(indent=2))

In dit script kan het model onder geen beding een waarde produceren voor risico die afwijkt van de gedefinieerde enum. Zodra de quote na "risico": " wordt gegenereerd, beperkt de logit-masker direct de keuzes tot uitsluitend de tokens voor laag, gemiddeld of hoog.

Prestaties, overhead en faalmodi

Hoewel grammatica-restricties syntaxfouten elimineren, brengen ze specifieke computationele en logische afwegingen met zich mee die gemonitord moeten worden:

Aspect Impact van Grammatica-decodering Mitigatiestrategie
Initialisatiefase (Cold Start) Het compileren van complexe regex/CFG naar DFA kan 100ms tot enkele seconden duren. Pre-compileer schema's tijdens server-opstart en hergebruik de index.
Inter-token Latency Lichte toename per token indien dynamische parsering nodig is bij diepe geneste bomen. Gebruik geïndexeerde bitmaskers (zoals in xGrammar) op GPU-geheugen.
Oneindige lussen (Loop traps) Model raakt gevangen in een grammaticaal geldige maar oneindige herhaling van velden. Stel strikte max_tokens limieten en array-lengterestricties in.
Logische hallucinatie Gedwongen syntax maskeert onzinantwoorden (het model geeft syntactisch valide nonsens). Combineer schema-validatie met feitelijke evaluatie en brontoetsing.

Mocht een engine door extreme contextlengtes of ontbrekende schema-ondersteuning toch ontsporen, dan blijft een vangnet op applicatieniveau essentieel. Zie voor concrete herstelpatronen bij runtime-crashes het artikel over herstelprompts bij gefaalde validatie van LLM-output, waarin wordt uitgelegd hoe parsingsfouten automatisch worden teruggekoppeld naar het model.

Conclusie en best practices

Constrained decoding via regex en grammatica's transformeert LLM's van onvoorspelbare tekstgeneratoren naar betrouwbare softwarecomponenten. Door de geldige tokenruimte per stap wiskundig af te bakenen, verdwijnen syntaxfouten volledig uit de applicatiepijplijn. De techniek vereist echter wel een doordacht ontwerp: geef het model voldoende semantische ademruimte om eerst te redeneren, voorkom nodeloos complexe schema's die de compileertijd opdrijven, en cache state machines waar mogelijk. Met frameworks zoals Outlines en native ondersteuning in vLLM en llama.cpp is gestructureerde decodering inmiddels een standaard bouwsteen in moderne AI-architecturen.