[!NOTE] Traduction automatique Cet article a été traduit automatiquement depuis la version originale en anglais.

Raisonnement guidé par un schéma sur vLLM : Structured Outputs à l’aide de xgrammar et Pydantic

Tenter à nouveau une appel LLM ne garantit pas l’obtention de JSON valide. Le prochain échantillon peut échouer de la même manière, et les appels répétés augmentent à la fois le temps de réponse et les coûts.

Le raisonnement guidé par schéma (SGR) impose le respect d’un schéma au fur et à mesure que le modèle génère chaque token. Vous définissez les champs requis à l’aide de Pydantic, et le moteur d’inférence bloque les tokens qui contreviendraient à cette structure. Ainsi, le résultat est par construction syntaxiquement valide, sans nécessiter de tentatives supplémentaires.

En résumé. SGR utilise constrained decoding afin de lier la sortie d’un LLM à un schéma Pydantic que vous contrôlez. En l’associant au xgrammar backend de vLLM, vous obtenez systématiquement des JSON valides, avec des coûts en latence négligeables.


Qu’est-ce que le raisonnement guidé par schéma ?

Le raisonnement guidé par schéma est une technique qui Rinat Abdullin Rédigé en 2024. Au lieu de laisser le modèle compléter librement du texte (ce qui peut entraîner des résultats incohérents ou ambigus), on lui fournit un modèle strict qui définit :

Considérez-le comme une liste de contrôle cognitive que le modèle doit suivre.

SGR Aperçu

Ce que le schéma contrôle

Des champs tels que churn_analysis, margin_math, et max_discount_percent Il convient de rendre explicites les sorties intermédiaires attendues. Le modèle doit remplir la structure requise avant de pouvoir retourner la décision finale concernant la remise.

Cela vous donne :


SGR contre la chaîne de réflexion contre prompt engineering

Les trois approches diffèrent principalement par le degré de contrainte qu’elles imposent au modèle.

SGR Comparaison

FonctionnalitéPrompt EngineeringChaîne de réflexionRaisonnement guidé par schéma
Structure de sortieTexte variableProse libreRigide JSON/Pydantic
Mécanisme de contrôlePersuasion sémantique (“Veuillez afficher JSON”)Incitation heuristique (« Prenons le temps de réfléchir étape par étape »)Constrained decoding (basé sur la grammaire)
Flux de raisonnementLe modèle détermineLe modèle détermineLe développeur définit la topologie du schéma.
AuditableFaible (nécessite du parsing)Faible (nécessite la lecture du texte)Inspection de haut niveau (au niveau du champ)
IntégrationDifficile (format variable)Trivial (désérialisation d’objet natif)
Taux d’erreurÉlevée (variabilité de format)Modéré (hallucination de format)
Exigences relatives au modèleSuivi strict des instructionsCapacité de raisonnement puissantFonctionne également avec des modèles plus petits.

Prompt engineering : persuasion sémantique

Please analyze the customer data and output your response as valid JSON
with the following structure: {"discount": <number>, "reason": <string>}
Be careful with the formatting!

Vous espérez que la compréhension du modèle concernant « output JSON » l’emportera sur sa tendance à adopter un style conversationnel. Une mise à jour du modèle, une modification de la température, ou l’utilisation d’un exemple de type few-shot différent peut compromettre le bon fonctionnement de votre analyseur.

Chaîne de réflexion : raisonnement amélioré, même problème de structure

Let's think step by step:
1. First, I'll analyze the customer's churn risk...
2. Then I'll calculate the margin...
3. Therefore, I recommend a 15% discount.

CoT améliore la précision du raisonnement, mais détériore la structure. Le résultat est un texte imprévisible dont il est presque impossible d’effectuer une analyse fiable. Il est généralement nécessaire d’exécuter une seconde invocation de LLM afin d’extraire les données structurées.

SGR : chaîne structurée de réflexion

SGR conserve l’intuition de CoT selon laquelle le raisonnement intermédiaire améliore la précision. Il se contente de formaliser les étapes correspondantes :

class PricingLogic(BaseModel):
    # 1. Data Analysis (must complete before decision)
    churn_analysis: str = Field(..., description="Analyze churn_probability")
    financial_analysis: str = Field(..., description="Analyze cart_value and margin")

    # 2. Math Enforcement (explicit calculation)
    margin_math: str = Field(..., description="Calculate: 'Cart $X * Y% = $Z'")

    # 3. Decision Constraint (bounded by prior analysis)
    max_discount_percent: float = Field(..., description="Max allowed discount")

    # 4. Final Output
    offer_code: str
    customer_message: str

Le modèle ne peut pas générer de sortie. max_discount_percent jusqu’à churn_analysis, financial_analysis, et margin_math Ils sont remplis. Le schéma impose l’ordre de raisonnement.


Patterns SGR

SGR comporte trois motifs fondamentaux qui s’assemblent pour former des flux de travail plus complexes.

SGR Patterns

1. Cascade : étapes successives de raisonnement

Cascade impose un ordre de raisonnement : chaque champ doit être rempli avant le suivant.

from pydantic import BaseModel
from typing import Literal, Annotated
from annotated_types import Ge, Le

class CandidateEvaluation(BaseModel):
    """Evaluate a job candidate with enforced reasoning order."""

    # Step 1: Summarize (forces context awareness)
    brief_candidate_summary: str

    # Step 2: Rate (bounded integer)
    rate_skill_match: Annotated[int, Ge(1), Le(10)]

    # Step 3: Decide (constrained choices)
    final_recommendation: Literal["hire", "reject", "hold"]

Cas d’usage pertinents : évaluation des candidats, classification de documents, analyse de conformité, diagnostic médical.

Le modèle doit écrire brief_candidate_summary Avant de pouvoir évaluer, il faut d’abord évaluer ; avant de pouvoir recommander, il faut d’abord évaluer. Il n’existe pas de raccourci.


2. Acheminement : une instruction conditionnelle sémantique

Le routage amène le modèle à choisir une voie parmi un ensemble d’options, ce qui est réalisé grâce à Union types.

from pydantic import BaseModel
from typing import Literal, Union

class FeatureLookup(BaseModel):
    """Route to database lookup."""
    rationale: str
    tool_name: Literal["fetch_user_features"] = "fetch_user_features"
    user_id: str

class GeneralResponse(BaseModel):
    """Standard response for non-pricing queries."""
    tool_name: Literal["respond"] = "respond"
    content: str

class RouterSchema(BaseModel):
    """The model must pick exactly ONE branch."""
    action: Union[FeatureLookup, GeneralResponse]

Cas d’usage pertinents : classification de l’intention, sélection d’outils, tri des demandes de support, et diffusion multi-agent.

Le Literal déterminanttool_name) amène le modèle à sélectionner une seule branche et à remplir uniquement les champs requis par cette branche.


3. Cycle : raisonnement itératif à l’aide de listes

Cycle force le modèle à générer plusieurs éléments, en imposant des limites sur leur nombre.

from pydantic import BaseModel
from typing import List, Literal, Annotated
from annotated_types import MinLen, MaxLen

class RiskFactor(BaseModel):
    explanation: str
    severity: Literal["low", "medium", "high"]

class RiskAssessment(BaseModel):
    """Generate 2-4 risk factors."""
    factors: Annotated[List[RiskFactor], MinLen(2), MaxLen(4)]

Cas d’usage pertinents : évaluation des risques, extraction des problèmes, traitement parallèle tool calls, planification en plusieurs étapes.

Le MinLen et MaxLen La contrainte de limite impose d’utiliser au moins 2 et au plus 4 éléments. En combinaison avec le routage, c’est ainsi que l’on envoie un lot à largeur fixe de tool calls.


Faire fonctionner SGR : constrained decoding

Les schémas présentés ci-dessus ne sont rien d’autre que des schémas Pydantic. Ce qui leur confère un caractère contraignant, c’est constrained decoding (également désigné sous le nom de Structured Output).

Constrained decoding modifie l’étape de génération des tokens. Au lieu de permettre au modèle de sélectionner librement dans son vocabulaire, le moteur applique un masque grammatical qui bloque les tokens susceptibles de violer le schéma. Ce processus a lieu au niveau du moteur d’inférence, et non dans le code de votre application.

[!TIP] SGR n’a pas besoin de « modèles de raisonnement » tels que o1 ou DeepSeek-R1. Il fonctionne parfaitement avec des modèles affinés par des instructions, et encore mieux avec des modèles distillés à partir de modèles de raisonnement.

Fournisseurs de cloud qui le prennent en charge

La plupart des fournisseurs modernes de LLM proposent des structured outputs via constrained decoding :

FournisseurSupport technique
OpenAIStructured Outputs (y compris Azure). GPT-5 utilise JSON Schema grâce à llguidance
Google/GeminiJSON Schema Support depuis novembre 2025 (Pydantic et Zod)
MistralPersonnalisation Structured Output
GrokStructured Outputs pour plusieurs modèles
Feux d’artifice AIJSON Schema
CerebrasStructured Outputs
OpenRouterCela dépend du fournisseur intermédiaire, ce qui correspond à JSON Schema

Moteurs d’inférence qui le prennent en charge

Pour les modèles auto-hébergés, tous les principaux moteurs disposent d’un constrained decoding backend :

MoteurBackend
vLLMxgrammar ou directives
SGLangAperçus, XGrammar, ou guidance LL
TensorRT-LLMGuidéDécodage
OllamaStructured Outputs

Pourquoi cet article se concentre sur vLLM et xgrammar

Quelques raisons :

La section suivante explique en détail comment xgrammar applique réellement un schéma au niveau des tokens.


Comment xgrammar impose les schémas

Il est essentiel de bien comprendre cette partie, car elle modifie la manière dont vous déboguez et ajustez les workflows SGR.

Application des règles XGrammar

Où a lieu le masquage

xgrammar modifie les logits de sortie après le passage en avant du modèle et avant l’échantillonnage. Il ne modifie pas le modèle lui-même ; il filtre les tokens qui peuvent être sélectionnés.

Un cycle d’inférence standard se présente comme suit :

1. Input tokens → GPU Forward Pass → Logits (probability scores for all ~128K tokens)
2. Logits → Sampling (temperature, top-p, etc.) → Next Token
3. Repeat until done

xgrammar glisse entre les étapes 1 et 2 :

1. Input tokens → GPU Forward Pass → Raw Logits
2. Raw Logits → xgrammar Logits Processor → Masked Logits
3. Masked Logits → Sampling → Next Token (guaranteed valid)
4. Repeat until done

Le modèle calcule toujours sa distribution de probabilité complète sur le GPU. xgrammar s’exécute ensuite sur le CPU et applique un masque binaire à ces logits avant l’échantillonnage. Les tokens invalides voient leurs logits être mis à -∞ce qui fait que leur probabilité devient exactement 0 après application de la fonction softmax.

Deux phases

xgrammar divise le travail en phase de compilation et en runtime, ce qui est la raison de sa grande rapidité.

Phase 1 : compilation de la grammaire, une fois par schéma

# This happens once per schema
tokenizer_info = xgr.TokenizerInfo.from_huggingface(tokenizer)
grammar_compiler = xgr.GrammarCompiler(tokenizer_info)
compiled_grammar = grammar_compiler.compile_json_schema(schema_json)

Lors de la compilation, xgrammar :

  1. Convertit le JSON Schema en une grammaire libre de contexte.
  2. Construit un automate à pile (PDA), qui est un automate à états doté d’une pile afin de pouvoir gérer des structures imbriquées telles que {"a": {"b": {"c": ...}}}.
  3. Il calcule à l’avance quels tokens sont valables à chaque position grammaticale. Le résultat correspond à la « cache de masques de tokens adaptatifs ».
  4. Les tokens sont classés comme « indépendants du contexte » (pouvant être mémorisés en cache) ou « dépendants du contexte » (doivent être vérifiés à runtime par rapport à l’état de la pile).

[!NOTE] Environ 99 % des tokens s’avèrent indépendants du contexte et sont finalement mis en cache (Article sur XGrammar). La plupart des vérifications de validité effectuées à runtime ne sont en réalité que des recherches dans le cache, d’où la grande rapidité de xgrammar.

Phase 2 : génération de la masque runtime, pour chaque token

À chaque étape de génération :

  1. Le GrammarMatcher Il suit la position actuelle dans la grammaire.
  2. Il consulte le masque précalculé destiné aux tokens indépendants du contexte.
  3. Il exécute le PDA afin de vérifier les tokens restants dépendants du contexte.
  4. Il les combine en un masque binaire final et l’applique aux logits.

Pourquoi les automates à pile et non les expressions régulières ?

En raison du chevauchement des niveaux. Une expression régulière (une machine à états finis) ne peut pas correspondre de manière fiable à des structures telles que :

{ "user": { "profile": { "settings": { "theme": "dark" } } } }

La partie difficile réside dans la fermeture des crochets. }}}: il vous faut garder en mémoire le nombre d’éléments que vous avez ouverts. Un automate à pile permet de suivre ce compteur grâce à sa pile interne, ce qui lui permet de gérer des profondeurs de nesting arbitraires. C’est également la raison pour laquelle xgrammar peut imposer des types Union, des objets imbriqués ainsi que des schémas récursifs, là où les approches basées sur les regex se révèlent insuffisantes.

Exemple concret : génération d’un champ à virgule flottante

Lorsque le modèle génère "max_discount_percent":, xgrammar sait, à partir du schéma, qu’un float Voici ce qui suit. La masque :

La passe avant a pu attribuer une haute probabilité au mot "fifteen". Après le masquage effectué par xgrammar, cette token possède une probabilité nulle. Le modèle doit donc générer des chiffres.

Pourquoi un surcoût quasi nul

Trois raisons :

  1. Exécution parallèle. Le calcul de la masque sur le CPU coïncide avec le prochain passage en avant sur le GPU. Tandis que le GPU calcule les logits du token N+1, le CPU génère la masque correspondant au token N.
  2. Mémorisation. La majeure partie du travail de validation s’effectue à l’étape de compilation. Les Runtime consistent principalement en des recherches dans la mémoire cache.
  3. Implémentation en C++. Le chemin le plus fréquemment exécuté est écrit en C++, et non en Python ; la masque est appliquée directement aux logits.

Dans benchmarks, le xgrammar présente des coûts d’overhead négligeables, et la génération structurée peut parfois être plus rapide que la génération non contrainte, car un vocabulaire restreint rend l’échantillonnage moins coûteux.


Implémentation pratique avec vLLM

Le référent est le sgr – gestionnaire de remises projet : une petite démonstration qui utilise SGR pour la tarification dynamique.

Flux de travail d’agent

Structure du projet

sgr/
├── agent.py            # Main orchestration
├── models/
│   └── schemas.py      # Pydantic SGR schemas
├── prompts/
│   ├── routing.py      # Phase 1 prompts
│   └── pricing.py      # Phase 3 prompts
├── store/
│   └── hybrid_store.py # Hot/Cold data retrieval
└── utils/
    └── llm_client.py   # LLM client wrapper with xgrammar

Étape 1 : définir les schémas

# sgr/models/schemas.py
from pydantic import BaseModel, Field
from typing import Literal, Union


# --- Phase 1: Routing (Union for branching) ---
class FeatureLookup(BaseModel):
    """Route to DB lookup if pricing context is needed."""
    rationale: str
    tool_name: Literal["fetch_user_features"] = "fetch_user_features"
    user_id: str


class GeneralResponse(BaseModel):
    """Standard response for non-pricing queries."""
    tool_name: Literal["respond"] = "respond"
    content: str


class RouterSchema(BaseModel):
    action: Union[FeatureLookup, GeneralResponse]


# --- Phase 2: Pricing Logic (Cascade for sequential reasoning) ---
class PricingLogic(BaseModel):
    """
    Strict reasoning topology for dynamic pricing.
    Fields are ordered to enforce the analysis→decision flow.
    """
    # 1. Data Analysis (Reflection)
    churn_analysis: str = Field(...,
        description="Analyze churn_probability (High > 0.7).")
    financial_analysis: str = Field(...,
        description="Analyze cart_value and profit_margin.")

    # 2. Hard Math Enforcement
    margin_math: str = Field(...,
        description="Calculate absolute profit: 'Cart $200 * 0.20 Margin = $40'.")

    # 3. The Decision Constraint
    max_discount_percent: float = Field(...,
        description="Max allowed discount %. NEVER exceed margin.")

    # 4. Final Output
    offer_code: str = Field(..., description="Generated code (e.g. SAVE20).")
    customer_message: str = Field(..., description="The final polite offer text.")

Étape 2 : un client LLM qui active xgrammar

# sgr/utils/llm_client.py
from openai import OpenAI
from pydantic import BaseModel
from typing import TypeVar
import json

T = TypeVar("T", bound=BaseModel)


class LLMClient:
    """Wrapper for vLLM with xgrammar-enforced structured generation."""

    def __init__(self, base_url: str = "http://localhost:8000/v1"):
        self.client = OpenAI(base_url=base_url, api_key="EMPTY")
        self.model = self._get_available_model()

    def _get_available_model(self) -> str:
        """Auto-detect the model running on vLLM server."""
        try:
            models = self.client.models.list()
            if models.data:
                return models.data[0].id
        except Exception:
            pass
        return "Qwen/Qwen2.5-7B-Instruct"

    def run_sgr(self, messages: list[dict], schema_class: type[T]) -> T:
        """Run inference with Schema-Guided Response constraints.

        Uses vLLM's guided_json with xgrammar backend to enforce
        strict schema constraints at the token generation level.
        """
        schema_dict = schema_class.model_json_schema()

        # Enhance system message with schema for model guidance
        enhanced_messages = messages.copy()
        if enhanced_messages and enhanced_messages[0]["role"] == "system":
            schema_json = json.dumps(schema_dict, indent=2)
            enhanced_messages[0] = {
                "role": "system",
                "content": (
                    enhanced_messages[0]["content"]
                    + f"\n\nRespond with JSON matching this schema:\n{schema_json}"
                ),
            }

        # vLLM's guided_json with the xgrammar backend
        completion = self.client.chat.completions.create(
            model=self.model,
            messages=enhanced_messages,
            temperature=0.1,  # Low temp for deterministic reasoning
            extra_body={
                "guided_json": schema_dict,  # Pydantic schema as dict
                "guided_decoding_backend": "xgrammar",  # Hardware-enforced
            },
        )

        raw_response = completion.choices[0].message.content
        return schema_class.model_validate_json(raw_response)

[!NOTE] > guided_json accepte un dictionnaire JSON Schema. Avec guided_decoding_backend: "xgrammar"Le LLM ne peut générer que des tokens qui forment des JSON valides conformes à votre schéma.

Étape 3 : orchestrer l’agent

# sgr/agent.py
from .models.schemas import PricingLogic, RouterSchema
from .prompts.routing import build_routing_prompt
from .prompts.pricing import build_pricing_context_prompt, ASSISTANT_FETCH_MESSAGE
from .store.hybrid_store import HybridFeatureStore
from .utils.llm_client import LLMClient


def pricing_agent(user_query: str, user_id: str) -> str:
    """Process a pricing query with three-phase SGR workflow."""

    llm = LLMClient()
    feature_store = HybridFeatureStore()

    # Build conversation history
    history = [
        {"role": "system", "content": build_routing_prompt(user_id)},
        {"role": "user", "content": user_query},
    ]

    # --- Phase 1: Routing (Uses RouterSchema) ---
    print(f"🤖 Processing: '{user_query}' for {user_id}")
    decision = llm.run_sgr(history, RouterSchema)
    print(f"📍 Routing decision: {decision.action.tool_name}")

    if decision.action.tool_name == "respond":
        return decision.action.content

    # --- Phase 2: Context Retrieval ---
    if decision.action.tool_name == "fetch_user_features":
        print(f"🔍 Fetching features for {user_id}...")
        context = feature_store.get_user_context(user_id)

        if not context:
            return "Error: User profile not found."

        print(f"   [Data] LTV: ${context.get('user_ltv')} | "
              f"Margin: {context.get('cart_profit_margin', 0) * 100}%")

        # Inject context into conversation
        history.append({"role": "assistant", "content": ASSISTANT_FETCH_MESSAGE})
        history.append({
            "role": "user",
            "content": build_pricing_context_prompt(
                churn_prob=context.get("churn_probability", 0.5),
                cart_val=context.get("current_cart_value", 100),
                margin=context.get("cart_profit_margin", 0.2),
                user_ltv=context.get("user_ltv", 0),
            ),
        })

        # --- Phase 3: SGR Logic Execution (Uses PricingLogic) ---
        print("🧠 Calculating Offer (Schema Enforced)...")
        offer = llm.run_sgr(history, PricingLogic)

        # Audit log — the SGR benefit: explicit reasoning traces
        print(f"   [Audit] Math: {offer.margin_math}")
        print(f"   [Audit] Max Allowed: {offer.max_discount_percent}%")

        return offer.customer_message

    return "I'm sorry, I couldn't process your request."


if __name__ == "__main__":
    response = pricing_agent("I want a discount or I'm leaving!", "user_102")
    print(f"\n💬 Final Reply: {response}")

Étape 4 : exécuter vLLM à l’aide de xgrammar

# Start vLLM server with xgrammar backend (default in recent versions)
python -m vllm.entrypoints.openai.api_server \
    --model Qwen/Qwen2.5-7B-Instruct \
    --port 8000

# Run the agent
uv run python -m sgr.agent

Exemple de sortie

🤖 Processing: 'I want a discount or I'm leaving!' for user_102
📍 Routing decision: fetch_user_features
🔍 Fetching features for user_102...
   [Data] LTV: $1,500 | Margin: 20%
🧠 Calculating Offer (Schema Enforced)...
   [Audit] Math: Cart $200 * 0.20 Margin = $40
   [Audit] Max Allowed: 15.0%

💬 Final Reply: We value your loyalty! Here's a special 15% discount
   with code SAVE15. This reflects our appreciation for your continued
   business with us.

Le journal d’audit montre le fonctionnement réel du modèle : il a calculé la marge (40 pourunpanierde200pour un panier de 200 à 20 %) et a défini une limite pour le rabais, afin que l’offre reste conforme à la contrainte de profit.


Bonnes pratiques

Conception de schéma

  1. Classer les champs selon le flux de raisonnement : les champs d’analyse doivent précéder les champs de décision.
  2. Rédiger des descriptions explicites. Field Descriptions. Elles orientent l’attention du modèle tout autant que le nom de champ.
  3. Restreindre par des contraintes Literal et Annotated. Utiliser Literal["a", "b"] pour les enums et Annotated[int, Ge(1), Le(10)] pour les bornes.
  4. Gardez les schémas ciblés : un schéma par phase de raisonnement, puis assemblez-les à l’aide de plusieurs appels.

Configuration vLLM

  1. Utilisez une température basse (0,1–0,3) pour un raisonnement déterministe.
  2. Laissez xgrammar gérer la structure ; évitez de contredire ses décisions en utilisant des instructions de formatage dans prompt.
  3. Faites attention à l’utilisation des tokens. SGR nécessite généralement moins de tokens que CoT, car il ne comporte pas de prose verbose.

Considérations de production

  1. Versionnez vos schémas de la même manière que vous versionnez APIs.
  2. Même avec SGR, les erreurs réseau et serveur nécessitent toujours une gestion souple.
  3. Enregistrez les sorties brutes SGR aux fins du respect des réglementations et du débogage.
  4. Testez avec des cas limites afin que le schéma reste valide en dehors des conditions normales.

Conclusion

SGR est ce qui permet de passer d’un fonctionnement en environnement de démonstration à un fonctionnement en production. Vous définez la topologie de raisonnement au sein de Pydantic, vous laissez xgrammar la faire respecter au moment du décodage, et le résultat obtenu est :

Le sgr – gestionnaire de remises Exécutez les exemples de code présentés dans cette publication en les connectant à un serveur vLLM réel. Clonez-le puis commencez à adapter les schémas à votre propre flux de travail.


Points clés

  1. Le raisonnement guidé par schéma rend explicite la topologie du raisonnement, au lieu de compter sur le fait que le modèle suive des instructions écrites.
  2. Constrained decoding empêche l’apparition de JSON non valides au moment de la génération, ce qui est plus efficace que de valider et de réessayer ultérieurement.
  3. Placer les champs d’analyse avant les champs de décision lorsque le schéma exige un raisonnement préalable à la production du résultat.
  4. Utiliser SGR lorsque du code intermédiaire dépend de la structure, et non lorsque le produit final est constitué de texte libre.

Références

SGR Framework

xgrammar

vLLM

Projet de démonstration