Bitten Sie einen LLM, ein JSON-Objekt zu generieren, und er wird irgendwann ein abschließendes Komma ausgeben, einen nicht escapten Zeilenumbruch innerhalb eines Strings, oder ein bare word, wo ein quoted key stehen sollte. Der Fehler ist kein Bug im Modell. Er ist eine Konsequenz dessen, wie autoregressive sampling funktioniert: in jedem Schritt sieht das Modell jedes Token in seinem Vokabular als Kandidaten an, einschließlich Tokens, die die partielle Ausgabe syntaktisch invalid machen würden.
Sie können das beheben, indem Sie das Token-Vokabular selbst einschränken. Anstatt dem Modell die Wahl aus dreißigtausend Tokens zu geben, geben Sie ihm nur die Teilmenge, die den partiellen Parse in einem validen Zustand hält. Die Technik heißt grammar-constrained decoding, und sie verwandelt einen LLM von einem probabilistischen Textgenerator in einen syntaxbewussten Code-Synthesizer.
Warum Prompt-Engineering Syntaxvalidität nicht lösen kann
Die meisten Entwickler beginnen mit dem naheliegenden Ansatz: fügen Sie “Output valid JSON only” zum System-Prompt hinzu, wiederholen das Schema in der User-Message und hoffen. Das hilft, aber es ist keine Garantie.
Das Modell parsed seine eigene Ausgabe nicht während der Generierung. Es predicted das nächste Token basierend auf der Wahrscheinlichkeitsverteilung über das Vokabular, konditioniert auf den Prompt und alles Bisherige. Ein Token wie , könnte nach einer schließenden Klammer hohe Wahrscheinlichkeit haben, weil Kommas in der Trainingsdaten oft an dieser Position auftreten. Ob das Komma an diesem exakten Punkt in diesem spezifischen JSON-Objekt syntaktisch legal ist, ist nicht Teil des Entscheidungsprozesses des Modells.
Selbst fine-getunte Modelle mit RLHF verschieben nur die Probability-Masse in Richtung generell korrekter Muster. Sie eliminieren keine invaliden Pfade. Wenn Sie eine Garantie brauchen, keine hohe Wahrscheinlichkeit, müssen Sie den Sampling-Mechanismus selbst ändern.
Wie grammar-constrained decoding funktioniert
Die Kernidee ist einfach: halten Sie einen parser state neben der Generierung des LLMs, und maskieren Sie an jeder Token-Position jedes Token aus, das den Parser in einen error state überführen würde.
An Schritt t produziert das Modell einen logits-Vektor über das gesamte Vokabular. Normalerweise wenden Sie temperature scaling an und sampeln. Mit constrained decoding fragen Sie zuerst eine grammar engine: gegeben die bisher generierten Tokens, welche Tokens sind als Nächstes legal? Die Engine gibt eine bitmask über das Vokabular zurück. Sie setzen die logits illegaler Tokens auf negative Unendlichkeit, führen softmax über den Rest aus und sampeln aus der gefilterten Verteilung.
Die grammar engine muss die vollständige Ausgabe nicht nachträglich parsen. Sie parsed inkrementell. Nachdem jedes Token akzeptiert wurde, aktualisiert sie ihre interne state machine. Wenn der Parser einen accepting state erreicht, kann die Generierung stoppen. Wenn er in einem non-accepting state ist, geht die Generierung weiter.
Das bedeutet, das Modell kann immer noch frei unter allen syntaktisch validen Fortsetzungen wählen. Es behält Kreativität innerhalb der grammar. Es kann nur nicht aus ihr heraustreten.
Wie die Implementierung aussieht
Hier ist ein vereinfachter Python-Sketch mit lark, einer modernen Parsing-Library, um eine grammar mask zu bauen. In Produktion würden Sie eine schnellere Engine wie outlines, guidance oder die eingebaute GBNF-Unterstützung von llama.cpp verwenden, aber das Prinzip ist identisch.
from lark import Lark, Token
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
# Define a tiny grammar for a simple config DSL.
grammar = r"""
start: pair+
pair: KEY "=" VALUE
KEY: /[a-z_]+/
VALUE: /"[^"]*"/
"""
parser = Lark(grammar, parser="lalr")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
def legal_next_tokens(partial_text: str) -> set[int]:
"""Return token IDs that do not cause a parse error."""
legal = set()
for token_id in range(tokenizer.vocab_size):
candidate = tokenizer.decode([token_id], skip_special_tokens=True)
try:
# Try parsing partial_text + candidate as a prefix.
parser.parse(partial_text + candidate)
legal.add(token_id)
except Exception:
# Some exceptions are expected mid-parse.
# A production engine tracks parser state, not exceptions.
pass
return legal
# Generate one token at a time with grammar masking.
prompt = "Generate a config: "
input_ids = tokenizer.encode(prompt, return_tensors="pt")
generated = input_ids.clone()
for _ in range(50):
outputs = model(generated)
logits = outputs.logits[:, -1, :]
partial = tokenizer.decode(generated[0], skip_special_tokens=True)
legal_ids = legal_next_tokens(partial)
mask = torch.full_like(logits, float("-inf"))
for tid in legal_ids:
mask[0, tid] = logits[0, tid]
next_token = torch.argmax(mask, dim=-1).unsqueeze(0)
generated = torch.cat([generated, next_token], dim=-1)
# Stop if the parser is in an accepting state.
if parser.parse(partial):
break
print(tokenizer.decode(generated[0], skip_special_tokens=True))
Dies ist eine naive Implementierung. Ein echtes System iteriert nicht über das gesamte Vokabular in jedem Schritt. Es baut einen finite-state automaton aus der grammar vorab und berechnet im Voraus, welche Vokabular-Tokens aus jedem automaton state valid sind. Zur Generierungszeit ist die mask ein einzelner Table-Lookup.
Wo constrained decoding in einen bounded context passt
Bounded contexts in Domain-Driven Design sind durch ihre ubiquitous language definiert. Diese Sprache wird oft als kleine DSL formalisiert: eine Query-Syntax, eine rule grammar, ein Config-Format, oder eine expression language. Wenn Sie einen LLM bitten, Artefakte innerhalb dieses Kontexts zu generieren oder zu editieren, wollen Sie, dass er die Sprache korrekt spricht.
Grammar-Constraints erzwingen die Boundary. Der LLM kann keine Felder erfinden, die in Ihrer DSL nicht existieren, kann keine mismatched brackets emitieren und kann keine Werte außerhalb des erlaubten enum-Sets produzieren. Die Syntax wird zu einer compile-time-Garantie, nicht zu einem runtime-Gebet.
Das ist besonders wertvoll, wenn die LLM-Ausgabe direkt in einen Parser oder Interpreter gespeist wird. Wenn Ihre DSL von einem strikten recursive descent parser geparsed wird, bricht ein einzelnes unexpected token die gesamte Pipeline ab. Constrained decoding entfernt diesen failure mode.
Die Trade-offs, die Sie kennen sollten
Constrained decoding ist nicht kostenlos. Der overhead hängt davon ab, wie Sie die grammar engine implementieren.
Throughput. Die mask in jedem forward pass zu bauen, fügt Latenz hinzu. Schnelle Engines wie outlines oder llama.cpp mit GBNF precomputen Token-to-State-Mappings und reduzieren die Kosten pro Schritt auf etwa 5-10% overhead. Naive Implementierungen, die auf jedem Kandidaten-Token reparsten, können die Generierung um Größenordnungen verlangsamen.
Grammar expressiveness. Nicht jede Syntax lässt sich leicht in einer grammar ausdrücken, die sauber auf Token-Masks abbildet. Kontextsensitive Regeln wie “dieser Identifier muss früher im Scope deklariert worden sein” werden von standard context-free grammars nicht erfasst. Sie können Syntax mit einer grammar einschränken. Sie können Semantik nicht ohne zusätzliche Maschinerie einschränken.
Model compatibility. Einige inference APIs, besonders gehostete Cloud-APIs, exposen keine logits oder erlauben kein custom masking. OpenAIs API bietet JSON mode, was eine hardcoded grammar constraint speziell für JSON ist, aber Sie können keine eigene grammar mitbringen. Für custom DSLs brauchen Sie typischerweise lokale inference oder ein Framework, das die logits exposiert.
Partial token problems. Eine token boundary aligniert nicht immer mit einer grammar boundary. Eine grammar könnte einen quoted string erwarten, aber das nächste Token könnte "hel gefolgt von lo" sein. Die grammar engine muss über partial token matches reasonen, nicht nur über ganze Tokens. Produktions-Libraries handhaben das, indem sie Tokens auf character prefixes mappen und prefix validity gegen die grammar prüfen.
Wie man das heute tatsächlich implementiert
Sie müssen keine grammar engine von Grund auf schreiben. Mehrere Libraries handhaben die schwierigen Teile.
outlines ist der zugänglichste für Python-User. Sie definieren ein Pydantic-Modell oder einen regulären Ausdruck, und es compiliert die constraint in einen effizienten finite-state automaton, der sich in Hugging Face transformers und vLLM integriert.
from outlines import models, generate
model = models.transformers("microsoft/Phi-3-mini-4k-instruct")
generator = generate.regex(model, r'\{[a-z_]+\}')
result = generator("Extract the key: ")
guidance von Microsoft bietet ein reicheres Templating-System. Sie verweben grammar constraints mit Prompt-Templates, und es handhabt das masking intern.
llama.cpp unterstützt GBNF (GGML BNF), ein BNF-ähnliches Grammar-Format. Sie übergeben eine .gbnf-Datei zur inference-Zeit, und die Engine erzwingt sie auf C++-Ebene. Das ist die schnellste Option für lokale inference.
jsonformer und instructor sind leichtere Alternativen speziell für JSON. Sie verwenden grammar-ähnliche constraints, sind aber auf JSON-Schema beschränkt. Wenn Ihre bounded-context-DSL zufällig JSON-shaped ist, sind sie der einfachste Einstiegspunkt.
Was constrained decoding nicht behebt
Ein grammar-constrained LLM kann immer noch semantisch falsche Ausgabe generieren. Er kann einen validen SQL-Query emitieren, der eine nicht existente Tabelle referenziert, oder eine valide Config, die eine invalide Port-Nummer setzt. Syntax ist ein notwendiges guardrail, kein ausreichendes.
Sie brauchen immer noch validation layers unterhalb des LLMs. Parsen Sie die Ausgabe, type-checken Sie sie gegen Ihr Domain-Modell, und rejecten oder retryen Sie, wenn die Semantik falsch ist. Constrained decoding reduziert die failure modes von “Syntaxfehler” auf “Logikfehler.” Das ist eine große Verbesserung, aber kein free pass.
Außerdem machen grammar constraints das Modell nicht schlauer. Wenn die grammar zu permissive ist, kann das Modell immer noch in unsinniges, aber syntaktisch valides Territorium wandern. Wenn die grammar zu restriktiv ist, hat das Modell keinen validen Pfad, eine korrekte Antwort auszudrücken, und Sie bekommen low-probability garbage oder repetitive loops. Das Design der grammar ist Teil der Engineering-Arbeit.
Beginnen Sie mit dem Output-Format, nicht dem Modell
Bevor Sie den Prompt tunen oder zu einem größeren Modell wechseln, fragen Sie, ob das Problem wirklich um Reasoning oder um Syntax geht. Wenn der LLM versteht, was Sie wollen, es aber gelegentlich falsch formatiert, sind grammar constraints der richtige Fix. Sie sind billiger als fine-tuning, zuverlässiger als prompt engineering, und sie geben Ihnen eine Garantie, die sampling allein nicht kann.
Definieren Sie die grammar für Ihre bounded-context-DSL, stecken Sie sie in einen constrained decoder, und lassen Sie das Modell innerhalb der Linien generieren. Die Ausgabe wird Sie gelegentlich immer noch überraschen, aber sie wird nie ein Syntaxfehler sein.
FAQ
Was ist grammar-constrained decoding?
Grammar-constrained decoding ist eine Technik, die das Vokabular eines LLMs in jedem Generierungsschritt mittels einer formalen grammar filtert. Nur Tokens, die die partielle Ausgabe syntaktisch valid halten, werden berücksichtigt, was garantiert, dass die finale Ausgabe der grammar entspricht.
Reduziert constrained decoding die Output-Qualität?
Nicht für syntax-gebundene Tasks. Das Modell wählt immer noch frei unter allen grammatikalisch validen Fortsetzungen. Für offenes kreatives Schreiben würden constraints die Qualität schädigen. Für Code, Config oder DSL-Generierung verbessern sie sowohl Korrektheit als auch Zuverlässigkeit.
Kann ich das mit OpenAI- oder Claude-APIs verwenden?
OpenAI bietet JSON mode, was eine built-in grammar constraint speziell für JSON ist. Sie können keine eigene grammar mitbringen. Anthropic exposet derzeit keine grammar constraints. Für custom DSLs brauchen Sie typischerweise lokale inference mit vLLM, llama.cpp oder einer ähnlichen Engine.
Welche Grammar-Formate werden unterstützt?
Gängige Formate umfassen EBNF, BNF, PEG und GBNF. Libraries wie outlines akzeptieren reguläre Ausdrücke und Pydantic-Modelle. llama.cpp verwendet GBNF. Wählen Sie das Format, das Ihre inference engine unterstützt.