[!NOTE] 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: Structured Outputs mediante xgrammar y Pydantic

Volver a intentar una llamada a LLM no garantiza la obtención de un JSON válido. El siguiente ejemplo de datos puede fallar de la misma manera, y las llamadas repetidas incrementan la latencia y los costes.

El razonamiento guiado por esquema (SGR)) impone la estructura definida mientras el modelo genera cada token. Se definen los campos obligatorios mediante Pydantic, y el motor de inferencia bloquea aquellos tokens que violarían dicha estructura. Como resultado, los outputs obtenidos son sintácticamente válidos desde su creación, sin necesidad de intentos repetidos.

Resumen. SGR utiliza constrained decoding para fijar la salida de un LLM a un esquema Pydantic que se encuentra bajo nuestro control. Al combinarlo con el xgrammar backend de vLLM, se obtienen siempre JSON válidos, con una sobrecarga de latencia prácticamente nula.


¿Qué es el razonamiento guiado por esquema?

El razonamiento guiado por esquemas es una técnica que Rinat Abdullin Redactado en 2024. En lugar de permitir que el modelo complete el texto de forma libre (lo cual puede resultar inconsistente o ambiguo), se le proporciona una plantilla estricta que define:

Considérenlo como una lista de verificación cognitiva que el modelo debe seguir.

SGR Visión general

Qué controla el esquema

Campos como churn_analysis, margin_math, y max_discount_percent Debe especificarse claramente cuáles son los resultados intermedios esperados. El modelo tiene que rellenar la estructura obligatoria antes de poder devolver la decisión final sobre el descuento.

Esto le proporciona:


SGR frente a cadena de razonamiento frente a prompt engineering

Los tres enfoques difieren principalmente en la intensidad con la que imponen restricciones al modelo.

SGR Comparación

CaracterísticaPrompt EngineeringCadena de razonamientoRazonamiento guiado por esquema
Estructura de salidaTexto variableProsa de formato libreRígido JSON/Pydantic
Mecanismo de controlPersuasión semántica (“Por favor, genere JSON”)Inducción heurística (“Pensemos paso a paso”)Constrained decoding (basado en gramática)
Flujo de razonamientoEl modelo determinaEl modelo determinaEl desarrollador determina la topología del esquema.
AuditablezBajo (requiere análisis sintáctico)Bajo (requiere leer el texto)Inspección de alto nivel (a nivel de campo)
Integración
Tasa de errorAlto (variabilidad de formato)Moderado (alucinación de formato)
Requisito del modeloFuerte capacidad de seguimiento de instruccionesCapacidad de razonamiento sólidoFunciona igualmente 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!

Esperas que la comprensión del modelo sobre “output JSON” sea más fuerte que su tendencia a adoptar un tono conversacional. Una actualización del modelo, un cambio en la temperatura de sampling o un ejemplo de tipo few-shot diferente pueden invalidar tu analizador de texto.

Cadena de razonamiento: mejor capacidad de inferencia, 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 del resultado. La salida que se genera es un texto impredecible que resulta prácticamente imposible de analizar de forma fiable. Por lo general, es necesario realizar una segunda llamada a LLM solo para poder extraer los datos estructurados.

SGR: cadena estructurada de razonamiento

SGR mantiene la intuición de CoT de que el razonamiento intermedio mejora la precisión. Simplemente formaliza los pasos correspondientes:

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 generar salida. max_discount_percent hasta que churn_analysis, financial_analysis, y margin_math Se llenan con los valores correspondientes. El esquema impone el orden de razonamiento.


Patrones SGR

SGR cuenta con tres patrones fundamentales que se combinan para formar flujos de trabajo más complejos.

SGR Patrones

1. Cascada: pasos secuenciales de razonamiento

El método Cascade impone un orden específico para el razonamiento: es necesario completar cada campo antes de pasar al 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"]

Casos de uso adecuados: evaluación de candidatos, clasificación de documentos, análisis de cumplimiento, diagnóstico médico.

El modelo debe generar texto. brief_candidate_summary Antes de que pueda realizar una evaluación, es necesario primero evaluar; y antes de que pueda hacer una recomendación, debe haber realizado la evaluación previa. No existe atajo.


2. Enrutamiento: una instrucción de conmutación semántica

El enrutamiento hace que el modelo elija una única ruta entre un conjunto de opciones, implementado mediante Union Tipos.

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]

Casos de uso adecuados: clasificación de intenciones, selección de herramientas, triaje de soporte y envío de multi-agent.

El Literal discriminadortool_name) hace que el modelo elija una única rama y rellene únicamente los campos que esa rama requiere.


3. Ciclo: razonamiento repetido con listas

El ciclo obliga al modelo a generar varios elementos, estableciendo límites sobre cuántos pueden ser.

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

Casos de uso adecuados: evaluación de riesgos, extracción de problemas, tool calls en paralelo, planificación multietapa.

El MinLen y MaxLen El límite impone que haya al menos 2 y como máximo 4 elementos. Combinado con el enrutamiento, así es como se envía un lote de ancho fijo de tool calls.


Haciendo que SGR funcione: constrained decoding

Los patrones anteriores no son más que esquemas Pydantic. Lo que les confiere carácter vinculante es constrained decoding (también conocido como Structured Output).

Constrained decoding modifica la fase de generación de tokens. En lugar de permitir que el modelo seleccione libremente desde su vocabulario, el motor aplica una máscara gramatical que bloquea aquellos tokens que podrían violar el esquema. Este proceso tiene lugar en el motor de inferencia, y no en el código de la aplicación.

[!TIP] SGR no necesita “modelos de razonamiento” como o1 o DeepSeek-R1. Funciona perfectamente con modelos ajustados mediante instrucciones, y de forma especialmente eficaz con modelos derivados de aquellos basados en razonamiento.

Proveedores cloud que lo soportan

La mayoría de los proveedores modernos de LLM ofrecen structured outputs a través de constrained decoding:

ProveedorSoporte técnico
OpenAIStructured Outputs (incluyendo Azure). GPT-5 utiliza JSON Schema a través de llguidance
Google/GeminiJSON Schema Soporte disponible a partir de noviembre de 2025 (Pydantic y Zod).
MistralPersonalizado Structured Output
Structured Outputs para múltiples modelos
Fuegos artificiales AIJSON Schema
CerebrasStructured Outputs
OpenRouterDepende del proveedor posterior en la cadena de procesamiento; se corresponde con JSON Schema.

Motores de inferencia que lo soportan

En el caso de los modelos autohospedados, todos los motores principales disponen de un constrained decoding backend:

MotorBackend
vLLMxgrammar o orientación
Esquemas, XGrammar, o guía de orientación
TensorRT-LLMDecodificación guiada
OllamaStructured Outputs

Por qué este artículo se centra en vLLM y xgrammar

Algunas razones:

La sección siguiente explica detalladamente cómo xgrammar aplica efectivamente un esquema a nivel de token.


Cómo aplica xgrammar los esquemas

Esta sección es fundamental comprenderla con precisión, ya que modifica la forma en la que se depuran y se ajustan los flujos de trabajo de SGR.

Aplicación de xgrammar

Dónde tiene lugar el enmascaramiento

xgrammar modifica los logits de salida después del paso forward del modelo y antes de la muestreo. No altera al propio modelo, sino que filtra qué tokens pueden ser seleccionados.

Un bucle de inferencia estándar se presenta de la siguiente manera:

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 desliza 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 sobre el GPU. A continuación, xgrammar se ejecuta sobre el CPU y aplica una máscara de bits a dichos logits antes de realizar la muestreo. Los tokens inválidos tienen sus logits establecidos en -∞, lo que hace que su probabilidad sea exactamente 0 tras aplicar la función softmax.

Dos fases

xgrammar divide el proceso en fases de compilación y runtime, y es precisamente esto lo que garantiza su alta velocidad.

Fase 1: compilación de 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:

  1. Convierte el JSON Schema en una gramática libre de contexto.
  2. Construye un autómata de desplazamiento hacia abajo (PDA), que es un autómato de estados dotado de una pila para poder manejar estructuras anidadas como {"a": {"b": {"c": ...}}}.
  3. Precomputa qué tokens son válidos en cada posición gramatical. El resultado que se obtiene es la “caché de máscaras de tokens adaptativas”.
  4. Clasifica los tokens como “independientes del contexto” (que pueden almacenarse en caché) o “dependientes del contexto” (que deben verificarse en runtime en función del estado de la pila).

[!NOTA] Aproximadamente el 99 % de los tokens resultan ser independientes del contexto y terminan almacenándose en caché.El artículo sobre XGrammar). La mayoría de las comprobaciones de validez en runtime no son más que búsquedas en caché, y por eso xgrammar es tan rápido.

Fase 2: generación de la máscara runtime, para cada token

En cada paso de generación:

  1. El GrammarMatcher Registra la posición actual dentro de la gramática.
  2. Consulta la máscara precalculada correspondiente a los tokens independientes del contexto.
  3. Ejecuta el PDA para analizar los tokens restantes que sí dependen del contexto.
  4. Combina todos ellos en una máscara binaria final y la aplica a los logits.

¿Por qué los autómatas de desplazamiento hacia abajo y no las expresiones regulares?

Debido al anidamiento, una expresión regular (una máquina de estados finitos) no puede coincidir de forma fiable con estructuras como:

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

La parte más complicada son los corchetes de cierre. }}}: es necesario recordar cuántos se han abierto. Un autómata de pila cuenta con una pila que permite hacer un seguimiento de esto, lo que le permite gestionar profundidades de anidamiento arbitrarias. Por esa misma razón, xgrammar puede aplicar tipos de unión, objetos anidados y esquemas recursivos, algo que los enfoques basados en expresiones regulares no logran hacer.

Un ejemplo concreto: generación de un campo de tipo float

Cuando el modelo está generando "max_discount_percent":, xgrammar sabe, a partir del esquema, que un float A continuación se presenta la máscara:

La pasada hacia adelante podría haber asignado una alta probabilidad a la palabra. "fifteen". Tras aplicar la máscara de xgrammar, dicho token tiene una probabilidad de 0. El modelo debe generar dígitos.

¿Por qué un sobrecoste casi nulo?

Tres razones:

  1. Ejecución paralela. El cálculo de la máscara en el CPU se superpone con el siguiente paso de propagación hacia adelante en el GPU. Mientras que el GPU está calculando los logit para el token N+1, el CPU está generando la máscara correspondiente al token N.
  2. Caché. La mayor parte del trabajo relacionado con la validez se realiza en tiempo de compilación. Las operaciones de Runtime consisten principalmente en búsquedas en el caché.
  3. Implementación en C++. La ruta crítica del algoritmo está escrita en C++, no en Python, y la máscara se aplica directamente sobre los logit.

En benchmarks, xgrammar presenta una sobrecarga prácticamente nula, y la generación estructurada puede ser ocasionalmente más rápida que la generación sin restricciones, ya que el vocabulario limitado reduce el costo de muestreo.


Implementación práctica con vLLM

La referencia es el sgr - gestor de descuentos proyecto: una pequeña demostración que utiliza SGR para la fijación dinámica de precios.

Flujo de trabajo del agente

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)

[!NOTA] > guided_json Acepta un diccionario JSON Schema. Con guided_decoding_backend: "xgrammar"El LLM solo puede generar tokens que conformen JSON válidos acordes con su 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 registro de auditoría muestra el funcionamiento real del modelo: calculó el margen (40 dólares en un carrito de 200 dólares con un 20% de comisión) y estableció un límite para el descuento, de modo que la oferta se mantuviera dentro de los parámetros de rentabilidad establecidos.


Mejores prácticas

Diseño de esquema

  1. Ordenar los campos según el flujo de razonamiento. Los campos de análisis deben ir antes que los campos de decisión.
  2. Escribir descripciones descriptivas. Field Descripciones. Estas guían la atención del modelo de forma similar a como lo hace el nombre del campo.
  3. Restringir con Literal y Annotated. Utilizar Literal["a", "b"] para enums y Annotated[int, Ge(1), Le(10)] Para los límites de validez.
  4. Mantenga los esquemas enfocados: un esquema por fase de razonamiento, y posteriormente combínelos mediante múltiples llamadas.

Configuración de vLLM

  1. Utilice una temperatura baja (0.1-0.3) para lograr un razonamiento determinista.
  2. Deje que xgrammar se encargue de la estructura; no intente contrarrestarlo con instrucciones de formato en prompt.
  3. Vigile el consumo de tokens. SGR suele utilizar menos tokens que CoT, ya que no contiene prosa excesivamente extensa.

Consideraciones de producción

  1. Versione sus esquemas de la misma manera que versiona APIs.
  2. Incluso con SGR, los errores de red y del servidor siguen requiriendo un manejo adecuado.
  3. Registre las salidas brutas de SGR para fines de cumplimiento normativo y depuración.
  4. Pruebe con casos límite para asegurarse de que el esquema funcione correctamente en los bordes.

Conclusión

SGR es lo que permite pasar de un sistema que “funciona en entornos de demostración” a uno que “funciona en producción”. Se define la topología de razonamiento en Pydantic, se utiliza xgrammar para hacerla cumplir en el momento de la decodificación, y el resultado es:

El sgr - gestor de descuentos Los cables de demostración prueban cada ejemplo de código de esta publicación contra un servidor vLLM real. Clona dicho servidor y comienza a adaptar los esquemas a tu propio flujo de trabajo.


Conclusiones clave

  1. El razonamiento guiado por esquema hace explícita la topología de razonamiento en lugar de confiar en que el modelo siga instrucciones redactadas en prosa.
  2. Constrained decoding evita la generación de JSON inválidos en el momento de la creación, lo cual resulta más eficiente que validar y volver a intentarlo posteriormente.
  3. Coloque los campos de análisis antes que los campos de decisión cuando el esquema debe obligar al razonamiento antes de producir la salida.
  4. Utilice SGR cuando el código posterior dependa de una estructura definida, y no cuando el resultado sea texto en prosa sin formato fijo.

Referencias

SGR Framework

xgrammar

vLLM

Proyecto de demostración