Demandez à un LLM de générer un objet JSON et il finira par émettre une virgule finale, un saut de ligne non échappé à l’intérieur d’une string, ou un bare word là où une clé entre guillemets devrait se trouver. L’erreur n’est pas un bug dans le modèle. C’est une conséquence du fonctionnement de l’autoregressive sampling : à chaque étape, le modèle voit chaque token de son vocabulaire comme un candidat, y compris des tokens qui rendraient la sortie partielle syntaxiquement invalide.

Vous pouvez corriger cela en contraignant le vocabulaire de tokens lui-même. Au lieu de laisser le modèle choisir parmi trente-deux mille tokens, donnez-lui seulement le sous-ensemble qui maintient le parse partiel dans un état valide. La technique s’appelle grammar-constrained decoding, et elle transforme un LLM d’un générateur de texte probabiliste en un synthétiseur de code conscient de la syntaxe.

Pourquoi le prompt engineering ne peut pas résoudre la validité syntaxique

La plupart des développeurs commencent par l’approche évidente : ajouter “Output valid JSON only” au system prompt, répéter le schema dans le user message, et espérer. Cela aide, mais ce n’est pas une garantie.

Le modèle ne parse pas sa propre sortie pendant qu’il la génère. Il prédit le token suivant sur la base de la distribution de probabilité sur le vocabulaire, conditionnée par le prompt et tout ce qui a été généré jusqu’à présent. Un token comme , pourrait avoir une probabilité élevée après une accolade fermante parce que les virgules apparaissent souvent à cette position dans les données d’entraînement. Que la virgule soit syntaxiquement légale à ce point exact de cet objet JSON spécifique ne fait pas partie du processus de décision du modèle.

Même les modèles fine-tunés avec RLHF ne font que déplacer la probability mass vers des patterns généralement corrects. Ils n’éliminent pas les chemins invalides. Si vous avez besoin d’une garantie, pas d’une haute probabilité, vous devez changer le sampling mechanism lui-même.

Comment fonctionne le grammar-constrained decoding

L’idée centrale est simple : maintenir un parser state aux côtés de la génération du LLM, et à chaque position de token, masquer tout token qui ferait passer le parser dans un error state.

À l’étape t, le modèle produit un vecteur de logits sur l’ensemble du vocabulaire. Normalement vous appliquez un temperature scaling et vous samplez. Avec le constrained decoding, vous demandez d’abord à une grammar engine : étant donné les tokens générés jusqu’à présent, quels tokens sont légaux ensuite ? Le moteur renvoie une bitmask sur le vocabulaire. Vous mettez les logits des tokens illégaux à moins l’infini, vous exécutez un softmax sur le reste, et vous samplez à partir de la distribution filtrée.

La grammar engine n’a pas besoin de parser la sortie complète après coup. Elle parse de manière incrémentale. Après que chaque token est accepté, elle met à jour sa state machine interne. Quand le parser atteint un accepting state, la génération peut s’arrêter. Quand il est dans un non-accepting state, la génération continue.

Cela signifie que le modèle peut toujours choisir librement parmi toutes les continuations syntaxiquement valides. Il conserve sa créativité à l’intérieur de la grammar. Il ne peut simplement pas en sortir.

À quoi ressemble l’implémentation

Voici un sketch Python simplifié utilisant lark, une library de parsing moderne, pour construire une grammar mask. En production vous utiliseriez un moteur plus rapide comme outlines, guidance, ou le support GBNF intégré de llama.cpp, mais le principe est identique.

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))

C’est une implémentation naïve. Un vrai système n’itère pas sur l’ensemble du vocabulaire à chaque étape. Il construit un finite-state automaton à partir de la grammar à l’avance et précalcule quels tokens du vocabulaire sont valides depuis chaque automaton state. Au moment de la génération, la mask est un simple lookup dans une table.

Où le constrained decoding s’inscrit dans un bounded context

Les bounded contexts en Domain-Driven Design sont définis par leur ubiquitous language. Ce langage est souvent formalisé comme une petite DSL : une syntaxe de requête, une rule grammar, un format de config, ou une expression language. Lorsque vous demandez à un LLM de générer ou d’éditer des artefacts à l’intérieur de ce contexte, vous voulez qu’il parle correctement le langage.

Les grammar constraints renforcent la boundary. Le LLM ne peut pas inventer des fields qui n’existent pas dans votre DSL, ne peut pas émettre de mismatched brackets, et ne peut pas produire de valeurs en dehors de l’enum set autorisé. La syntaxe devient une garantie de compile-time, pas une prière de runtime.

Cela est particulièrement précieux lorsque la sortie du LLM alimente directement un parser ou un interpréteur. Si votre DSL est parsé par un recursive descent parser strict, un seul unexpected token aborte tout le pipeline. Le constrained decoding élimine ce failure mode.

Les trade-offs que vous devriez connaître

Le constrained decoding n’est pas gratuit. L’overhead dépend de la façon dont vous implémentez la grammar engine.

Throughput. Construire la mask à chaque forward pass ajoute de la latence. Des moteurs rapides comme outlines ou llama.cpp avec GBNF précalculent les mappings token-à-état, réduisant le coût par étape à environ 5-10% d’overhead. Des implémentations naïves qui reparse à chaque token candidat peuvent ralentir la génération de plusieurs ordres de grandeur.

Grammar expressiveness. Toute syntaxe n’est pas facile à exprimer dans une grammar qui se mappe proprement à des token masks. Des règles sensibles au contexte, comme “cet identifier doit avoir été déclaré plus tôt dans le scope”, ne sont pas capturées par les context-free grammars standards. Vous pouvez contraindre la syntaxe avec une grammar. Vous ne pouvez pas contraindre la sémantique sans une machinerie supplémentaire.

Model compatibility. Certaines inference APIs, notamment des APIs cloud hébergées, n’exposent pas de logits et ne permettent pas de masking personnalisé. L’API d’OpenAI propose JSON mode, qui est une grammar constraint hardcodée spécifiquement pour JSON, mais vous ne pouvez pas apporter votre propre grammar. Pour des DSLs personnalisées, vous avez typiquement besoin d’une inference locale ou d’un framework qui expose les logits.

Partial token problems. Une token boundary ne s’aligne pas toujours avec une grammar boundary. Une grammar pourrait attendre une string entre guillemets, mais le token suivant pourrait être "hel suivi de lo". La grammar engine doit raisonner sur les partial token matches, pas seulement sur des tokens entiers. Les libraries de production gèrent cela en mappant les tokens à des character prefixes et en vérifiant la prefix validity contre la grammar.

Comment implémenter cela concrètement aujourd’hui

Vous n’avez pas besoin d’écrire une grammar engine from scratch. Plusieurs libraries gèrent les parties difficiles.

outlines est le plus accessible pour les utilisateurs Python. Vous définissez un modèle Pydantic ou une expression régulière, et il compile la constraint en un finite-state automaton efficace qui s’intègre avec Hugging Face transformers et vLLM.

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 de Microsoft offre un système de templating plus riche. Vous entrelacez des grammar constraints avec des prompt templates, et il gère le masking en interne.

llama.cpp prend en charge GBNF (GGML BNF), un format de grammar de type BNF. Vous passez un fichier .gbnf au moment de l’inference, et le moteur l’impose au niveau C++. C’est l’option la plus rapide pour l’inference locale.

jsonformer et instructor sont des alternatives plus légères spécifiquement pour JSON. Elles utilisent des constraints de type grammar mais sont restreintes au JSON schema. Si votre DSL de bounded context se trouve être JSON-shaped, ce sont les points d’entrée les plus simples.

Ce que le constrained decoding ne corrige pas

Un LLM avec grammar constraint peut toujours générer une sortie sémantiquement incorrecte. Il peut émettre une requête SQL valide qui référence une table inexistante, ou une config valide qui définit un numéro de port invalide. La syntaxe est un guardrail nécessaire, pas suffisant.

Vous avez toujours besoin de validation layers en dessous du LLM. Parsez la sortie, faites un type-check contre votre modèle de domaine, et rejectez ou retryez si la sémantique est fausse. Le constrained decoding réduit les failure modes de “erreur de syntaxe” à “erreur de logique”. C’est une grande amélioration, mais ce n’est pas un free pass.

De plus, les grammar constraints ne rendent pas le modèle plus intelligent. Si la grammar est trop permissive, le modèle peut toujours errer dans un territoire absurde mais syntaxiquement valide. Si la grammar est trop restrictive, le modèle n’a aucun chemin valide pour exprimer une réponse correcte, et vous obtenez du garbage à low probability ou des loops répétitives. Concevoir la grammar fait partie du travail d’ingénierie.

Commencez par le format de sortie, pas par le modèle

Avant de tuner le prompt ou de passer à un modèle plus grand, demandez-vous si le problème est vraiment de reasoning ou de syntaxe. Si le LLM comprend ce que vous voulez mais le formate parfois mal, les grammar constraints sont le bon correctif. Elles sont moins chères que le fine-tuning, plus fiables que le prompt engineering, et elles vous donnent une garantie que le sampling seul ne peut pas.

Définissez la grammar pour votre DSL de bounded context, branchez-la dans un constrained decoder, et laissez le modèle générer dans les lignes. La sortie vous surprendra encore occasionnellement, mais ce ne sera jamais une erreur de syntaxe.

FAQ

Qu’est-ce que le grammar-constrained decoding ?

Le grammar-constrained decoding est une technique qui filtre le vocabulaire d’un LLM à chaque étape de génération en utilisant une grammar formelle. Seuls les tokens qui maintiennent la sortie partielle syntaxiquement valide sont considérés, garantissant que la sortie finale est conforme à la grammar.

Le constrained decoding réduit-il la qualité de la sortie ?

Pas pour les tâches liées à la syntaxe. Le modèle choisit toujours librement parmi toutes les continuations grammaticalement valides. Pour l’écriture créative ouverte, les constraints nuiraient à la qualité. Pour le code, la config ou la génération de DSLs, elles améliorent à la fois la correction et la fiabilité.

Puis-je utiliser cela avec les APIs d’OpenAI ou de Claude ?

OpenAI propose JSON mode, qui est une grammar constraint built-in spécifiquement pour JSON. Vous ne pouvez pas apporter votre propre grammar. Anthropic n’expose actuellement pas de grammar constraints. Pour des DSLs personnalisées, vous avez typiquement besoin d’une inference locale avec vLLM, llama.cpp, ou un moteur similaire.

Quels formats de grammar sont supportés ?

Les formats courants incluent EBNF, BNF, PEG et GBNF. Des libraries comme outlines acceptent des expressions régulières et des modèles Pydantic. llama.cpp utilise GBNF. Choisissez le format supporté par votre inference engine.