Traducción automática
Este artículo se tradujo automáticamente a partir de la versión original en inglés.
Razonamiento Guiado por Esquemas en vLLM: salidas estructuradas con xgrammar y Pydantic
Un bucle de reintentos es una confesión. Dice: esperaba que el modelo devolviera JSON válido, no lo hizo, así que volveré a tirar los dados. La mayor parte del código de agentes con LLM con el que he trabajado se apoya muchísimo en los reintentos, y aun así el JSON sigue rompiéndose con la suficiente frecuencia como para que alguien termine configurando una alerta.
El Razonamiento Guiado por Esquemas (SGR) se salta el juego de los reintentos imponiendo el esquema a nivel de token. Defines la topología de razonamiento como un esquema de Pydantic, y el motor de inferencia enmascara cualquier token que lo incumpliría antes del muestreo. La salida es válida por construcción, no por reintento.
TL;DR. SGR usa decodificación restringida para fijar la salida de un LLM a un esquema de Pydantic que controlas tú. Si lo combinas con el backend xgrammar de vLLM, obtienes JSON válido siempre, con una sobrecarga de latencia despreciable.
¿Qué es Schema-Guided Reasoning?
Schema-Guided Reasoning es una técnica que Rinat Abdullin describió en 2024. En lugar de dejar que el modelo complete texto libremente (algo que puede ser inconsistente o ambiguo), le das una plantilla estricta que define:
- qué pasos debe seguir, para que no pueda saltarse el análisis
- el orden de esos pasos, para que el razonamiento vaya de los datos a la decisión
- dónde debe centrar la atención, para que la profundidad se aplique donde importa
Piensa en ello como una lista de comprobación cognitiva que el modelo tiene que seguir.
Por qué importa SGR
Cuando defines un esquema con campos como churn_analysis, margin_math y max_discount_percent, el modelo tiene que rellenarlos en orden. No puede saltar directamente a la decisión sobre el descuento sin antes escribir el análisis.
Eso te da:
- razonamientos reproducibles en ejecuciones repetidas
- salidas auditables donde cada paso se puede inspeccionar
- campos intermedios que puedes evaluar contra un dataset de prueba
- modelos más pequeños que pasan a ser viables, ya que el esquema impone lo que de otro modo el modelo tendría que aprender
- el aumento del 5-10% de precisión habitualmente reportado en cargas de trabajo de producción
SGR frente a Chain of Thought frente a prompt engineering
Los tres enfoques se diferencian sobre todo en el grado en que restringen el modelo.
| Característica | Prompt Engineering | Chain of Thought | Schema-Guided Reasoning |
|---|---|---|---|
| Estructura de salida | Texto variable | Prosa de formato libre | JSON/Pydantic rígido |
| Mecanismo de control | Persuasión semántica ("Please output JSON") | Prompting heurístico ("Let's think step by step") | Decodificación restringida (basada en gramática) |
| Flujo de razonamiento | Lo determina el modelo | Lo determina el modelo | Lo determina el desarrollador (topología del esquema) |
| Auditabilidad | Baja (requiere parsing) | Baja (requiere leer prosa) | Alta (inspección a nivel de campo) |
| Integración | Difícil (parsing con regex) | Difícil (formato variable) | Trivial (deserialización nativa a objetos) |
| Tasa de error | Alta (variabilidad de formato) | Moderada (alucinación del formato) | Casi nula (la sintaxis la impone el motor) |
| Requisito del modelo | Buen seguimiento de instrucciones | Buena capacidad de razonamiento | Funciona también con modelos más pequeños |
Prompt engineering: persuasión semántica
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!
Estás esperando que la comprensión del modelo de "output JSON" pese más que su tendencia a ser conversacional. Una actualización del modelo, un cambio de temperatura o un ejemplo few-shot distinto pueden romper tu parser.
Chain of Thought: mejor razonamiento, mismo problema de estructura
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 mejora la precisión del razonamiento, pero empeora la estructura. La salida es prosa impredecible y es casi imposible parsearla de forma fiable. Normalmente acabas haciendo una segunda llamada al LLM solo para extraer los datos estructurados.
SGR: chain of thought estructurado
SGR mantiene la intuición de CoT de que el razonamiento intermedio mejora la precisión. Simplemente formaliza los pasos:
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
El modelo no puede emitir max_discount_percent hasta que churn_analysis, financial_analysis y margin_math estén rellenados. El esquema impone el orden del razonamiento.
Patrones de SGR
SGR tiene tres patrones básicos que se componen en flujos de trabajo mayores.
1. Cascade: pasos de razonamiento secuenciales
Cascade impone un orden de razonamiento. Cada campo tiene que completarse antes que el siguiente.
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"]
Buenos encajes: evaluación de candidatos, clasificación de documentos, análisis de cumplimiento, diagnóstico médico.
El modelo tiene que escribir brief_candidate_summary antes de poder puntuar, y puntuar antes de poder recomendar. No hay atajo posible.
2. Routing: un switch semántico
Routing hace que el modelo se comprometa con una ruta de entre varias opciones, implementada con tipos Union.
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]
Buenos encajes: clasificación de intenciones, selección de herramientas, triaje de soporte, despacho multiagente.
El discriminador Literal (tool_name) hace que el modelo elija una sola rama y rellene únicamente los campos que necesita esa rama.
3. Cycle: razonamiento repetido con listas
Cycle obliga al modelo a producir varios elementos, con límites sobre cuántos.
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)]
Buenos encajes: evaluación de riesgos, extracción de incidencias, llamadas paralelas a herramientas, planificación multi-paso.
Los límites MinLen y MaxLen fuerzan al menos 2 elementos y como máximo 4. Combinado con Routing, así es como despachas un lote de llamadas a herramientas de anchura fija.
Hacer que SGR funcione: decodificación restringida
Los patrones anteriores son solo esquemas de Pydantic. Lo que hace que sean vinculantes es la decodificación restringida (también llamada Structured Output).
La decodificación restringida modifica el paso de generación de tokens. En lugar de dejar que el modelo muestree libremente de su vocabulario, el motor aplica una máscara gramatical que bloquea los tokens que violarían el esquema. Ocurre en el motor de inferencia, no en el código de tu aplicación.
[!TIP] SGR no requiere "reasoning models" como o1 o DeepSeek-R1. Funciona bien con modelos ajustados por instrucciones, y especialmente bien con modelos destilados a partir de modelos de razonamiento.
Proveedores cloud que lo soportan
La mayoría de los proveedores modernos de LLM ofrecen salidas estructuradas mediante decodificación restringida:
| Proveedor | Soporte |
|---|---|
| OpenAI | Structured Outputs (incluyendo Azure). GPT-5 usa JSON Schema mediante llguidance |
| Google/Gemini | Soporte de JSON Schema desde nov 2025 (Pydantic y Zod) |
| Mistral | Custom Structured Output |
| Grok | Structured Outputs para varios modelos |
| Fireworks AI | JSON Schema |
| Cerebras | Structured Outputs |
| OpenRouter | Depende del proveedor subyacente, se mapea a JSON Schema |
Motores de inferencia que lo soportan
Para modelos self-hosted, todos los motores principales tienen un backend de decodificación restringida:
| Motor | Backend |
|---|---|
| vLLM | xgrammar o guidance |
| SGLang | Outlines, XGrammar o llguidance |
| TensorRT-LLM | GuidedDecoding |
| Ollama | Structured Outputs |
Por qué este artículo se centra en vLLM y xgrammar
Hay varios motivos:
- vLLM es el motor open source de inferencia de LLM más desplegado, así que lo que construyas aquí se porta fácilmente.
- xgrammar está implementado en C++ y añade una latencia despreciable.
- La API de vLLM es compatible con OpenAI, lo que hace barata la migración desde proveedores cloud.
- xgrammar maneja esquemas anidados complejos, uniones y estructuras recursivas.
La siguiente sección recorre cómo xgrammar impone realmente un esquema a nivel de token.
Cómo xgrammar impone esquemas
Vale la pena entender esta parte con precisión, porque cambia cómo depuras y ajustas los flujos SGR.
Dónde ocurre el enmascarado
xgrammar modifica los logits de salida después del forward pass del modelo y antes del muestreo. No cambia el modelo en sí; filtra qué tokens pueden seleccionarse.
Un bucle de inferencia estándar tiene este aspecto:
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 se cuela entre los pasos 1 y 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
El modelo sigue calculando su distribución de probabilidad completa en la GPU. Después xgrammar se ejecuta en la CPU y aplica una máscara de bits a esos logits antes del muestreo. A los tokens inválidos se les pone el logit a -∞, lo que hace que su probabilidad sea exactamente 0 tras el softmax.
Dos fases
xgrammar divide el trabajo entre tiempo de compilación y tiempo de ejecución, que es lo que lo hace rápido.
Fase 1: compilación de la gramática, una vez por esquema
# 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)
Durante la compilación, xgrammar:
- Convierte el JSON Schema en una Context-Free Grammar.
- Construye un Pushdown Automaton (PDA), que es una máquina de estados con pila para poder manejar estructuras anidadas como
{"a": {"b": {"c": ...}}}. - Precalcula qué tokens son válidos en cada posición de la gramática. El resultado es la "adaptive token mask cache".
- Clasifica los tokens como "context-independent" (cacheables) o "context-dependent" (deben comprobarse en tiempo de ejecución contra el estado de la pila).
[!NOTE] Aproximadamente el 99% de los tokens resultan ser context-independent y acaban en caché (artículo de XGrammar). La mayoría de las comprobaciones de validez en tiempo de ejecución son solo accesos a caché, y por eso xgrammar es rápido.
Fase 2: generación de máscara en tiempo de ejecución, en cada token
En cada paso de generación:
- El
GrammarMatcherrastrea la posición actual en la gramática. - Busca la máscara precalculada para los tokens context-independent.
- Ejecuta el PDA para comprobar los tokens context-dependent restantes.
- Los combina en una máscara de bits final y la aplica a los logits.
¿Por qué pushdown automata y no regex?
Por el anidamiento. Una expresión regular (una máquina de estados finitos) no puede hacer matching de forma fiable con estructuras como:
{ "user": { "profile": { "settings": { "theme": "dark" } } } }
La parte difícil son las llaves de cierre }}}: necesitas recordar cuántas has abierto. Un Pushdown Automaton tiene una pila que lleva esa cuenta, así que puede manejar una profundidad de anidamiento arbitraria. Esa es también la razón por la que xgrammar puede imponer tipos Union, objetos anidados y esquemas recursivos, donde los enfoques basados en regex se quedan cortos.
Un ejemplo concreto: generar un campo float
Cuando el modelo está generando "max_discount_percent":, xgrammar sabe por el esquema que a continuación va un float. La máscara:
- permite (probabilidad sin cambios):
0,1,2, ...,9,.,- - bloquea (probabilidad puesta a 0):
",{,[,true,false,nully el resto del vocabulario de más de 128K
El forward pass podría haber asignado una probabilidad alta a la palabra "fifteen". Tras la máscara de xgrammar, ese token tiene probabilidad 0. El modelo tiene que emitir dígitos.
Por qué la sobrecarga es "casi nula"
Por tres motivos:
- Ejecución en paralelo. El cálculo de la máscara en CPU se solapa con el siguiente forward pass en GPU. Mientras la GPU calcula los logits del token N+1, la CPU calcula la máscara del token N.
- Caché. La mayor parte del trabajo de validación se hace en tiempo de compilación. En ejecución casi todo son accesos a caché.
- Implementación en C++. El hot path está en C++, no en Python, y la máscara se aplica a los logits in place.
En benchmarks, xgrammar muestra una sobrecarga despreciable, y la generación estructurada puede ocasionalmente ser más rápida que la generación no restringida porque el vocabulario restringido abarata el muestreo.
Implementación práctica con vLLM
La referencia es el proyecto sgr-discount-manager, una pequeña demo que usa SGR para pricing dinámico.
Estructura del proyecto
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
Paso 1: definir los esquemas
# 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.")
Paso 2: un cliente LLM que activa 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_jsonacepta un dict de JSON Schema. Conguided_decoding_backend: "xgrammar", el LLM solo puede generar tokens que formen JSON válido y que encaje con tu esquema.
Paso 3: orquestar el agente
# 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}")
Paso 4: ejecutar vLLM con 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
Ejemplo de salida
🤖 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.
El log de auditoría muestra el trabajo real del modelo: calculó el margen ($40 en un carrito de $200 al 20%) y acotó el descuento para que la oferta se mantenga dentro de la restricción de beneficio.
Buenas prácticas
Diseño de esquemas
- Ordena los campos según el flujo de razonamiento. Los campos de análisis van antes que los de decisión.
- Escribe descripciones
Fielddescriptivas. Guían la atención del modelo tanto como el propio nombre del campo. - Restringe con
LiteralyAnnotated. UsaLiteral["a", "b"]para enums yAnnotated[int, Ge(1), Le(10)]para límites. - Mantén los esquemas enfocados. Un esquema por fase de razonamiento, y luego compón con varias llamadas.
Configuración de vLLM
- Usa una temperatura baja (0.1-0.3) para un razonamiento determinista.
- Deja que xgrammar maneje la estructura. No luches contra ello con instrucciones de formato en el prompt.
- Vigila el uso de tokens. SGR suele usar menos tokens que CoT porque no hay prosa verbosa.
Consideraciones de producción
- Versiona tus esquemas igual que versionas APIs.
- Incluso con SGR, los errores de red y de servidor siguen necesitando un manejo elegante.
- Registra las salidas SGR en bruto para cumplimiento y depuración.
- Prueba con casos límite para que el esquema se mantenga en los bordes.
Conclusión
SGR es lo que te lleva de "funciona en una demo" a "funciona en producción". Defines la topología de razonamiento en Pydantic, dejas que xgrammar la imponga en tiempo de decodificación, y la salida es:
- válida siempre, sin bucles de reintentos ni fallos de parsing
- auditable a nivel de campo
- utilizable con modelos más pequeños, porque ya no tienen que clavar el formato por sí solos
- más barata de ejecutar, ya que usas menos tokens, menos reintentos y modelos más pequeños
La demo sgr-discount-manager conecta cada ejemplo de código de este post contra un servidor vLLM real. Clónala y empieza a adaptar los esquemas a tu propio flujo de trabajo.
Referencias
Framework SGR
- Schema-Guided Reasoning (SGR) — framework original de Rinat Abdullin
- SGR Patterns — patrones Cascade, Routing y Cycle
xgrammar
- XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models — Yixin Dong et al., arXiv:2411.15100 (artículo técnico con benchmarks)
- xgrammar GitHub — librería rápida y flexible de generación estructurada
- xgrammar Documentation — documentación oficial con guía de inicio rápido
- xgrammar Quick Start — primeros pasos con xgrammar
- Achieving Efficient Structured Generation with XGrammar — post del blog de MLC sobre los internals de xgrammar
vLLM
- vLLM Structured Outputs — documentación oficial
Proyecto demo
- sgr-discount-manager — demo funcional con todos los ejemplos de código de este post