Structured output: el formulario con desplegables que tacha respuestas inválidas antes de que el modelo elija — Outlines, XGrammar, LLGuidance y la matemática del bitmask

Este post complementa los de Continuous batching (donde el scheduler vive) y Speculative decoding (otra técnica que opera en el último kilómetro del sampler). Structured output es el contrato de salida del LLM hacia el código que lo consume; sin él, la integración entre LLM y aplicaciones es frágil por defecto.

Estás aquí: DEPLOY

Estás aquí: DEPLOY · constraint sobre los logits, capa última del sampler1 · Data2 · Tune3 · Eval4 · Deploy5 · Observe6 · Retrain

TL;DR

Un LLM produce texto libre, pero muchas aplicaciones —function calling, extracción de entidades, routing, text-to-SQL, generación de configs— necesitan parsearlo como JSON, una llamada a herramienta con args tipados, una sentencia SQL válida, o una opción de un enum. Las soluciones naïve fallan: prompt engineering (“responde en JSON”) deja 25 % de outputs no parseables en muchos modelos; validación post-hoc + retry cuesta latencia y no garantiza terminación; json-repair libraries son parches heurísticos. Constrained decoding garantiza la conformidad al 100 %, por construcción: a cada paso de generación, antes de samplear del softmax sobre los V tokens del vocabulario, se enmascara a -∞ los tokens que romperían la estructura objetivo. El output es válido por contrato matemático, no por suerte. Las cuatro familias dominantes en mayo de 2026 son Outlines (Willard & Louf, 2023; FSM + token trie precomputado a partir de regex/JSON Schema/CFG), XGrammar (Dong et al., 2024-25, CMU+MLC; pushdown automaton byte-level con cache adaptativo de tokens context-independent, default en vLLM v1, SGLang, TensorRT-LLM, NIM y MLC-LLM), LLGuidance (Microsoft Research; Earley parser + derivadas de regex, ~50 µs CPU por token, debajo de OpenAI Structured Outputs) y LM Format Enforcer (noamgat; orientado a JSON Schema, integrado en muchos engines como fallback). XGrammar-2 (mayo 2026) introduce Structural Tag y cross-grammar cache para tool calling agentic dinámico. El coste real bien integrado: <5 % en TPOT y ~40 µs CPU por token de cómputo de máscara, parcialmente solapable con el forward pass del modelo. La pregunta operacional abierta: ¿degrada el reasoning? El paper Let me Speak Freely? (Tam et al., EMNLP 2024) reportó degradación significativa en reasoning bajo format constraints; el rebuttal de dottxt mostró que el efecto se debía a prompts no equivalentes entre experimentos. El consenso emergente mayo 2026: usar two-pass (reasoning libre con CoT en texto + structured output en segundo call) para tareas que requieren razonamiento multi-step; usar single-pass constrained para extracción, classification y function calling donde estructura forzada mejora la exactness y reduce alucinaciones. Este post desmonta el mecanismo, las matemáticas (tamaño del bitmask = V/8 bytes, latencia por step), la tabla comparativa de backends, los pitfalls (compile time, tokenizer-specific FSM, streaming SSE) y el patrón de despliegue en producción.

La analogía: el formulario con desplegables en lugar de campos libres

Imagina dos formas de pedirle a alguien que rellene un formulario.

La primera forma es darle el papel con campos en blanco y decirle “rellénalo en este formato exacto: el nombre va aquí, después el DNI sin espacios, después la fecha como YYYY-MM-DD, después marca uno de estos cinco motivos posibles separados por punto y coma”. Le explicas el formato con todo el detalle del mundo, pero la persona sigue siendo libre de escribir lo que quiera en cada campo. Si tiene prisa puede saltarse un cero del DNI, poner la fecha en formato americano, marcar dos motivos cuando solo se pedía uno. Cuando recibes el formulario, mucha veces tienes que devolverlo: campo X mal formateado, motivo Y inválido, fecha Z imposible. Es exactamente lo que un LLM hace cuando le pides “responde en JSON con este schema”: funciona la mayoría de veces, falla un porcentaje no despreciable, y no tienes garantía formal de nada.

La segunda forma es darle un formulario electrónico donde los campos no son texto libre. El nombre acepta cualquier texto, pero el DNI tiene una máscara que solo permite teclear ocho dígitos seguidos de letra; la fecha es un selector que solo deja elegir fechas válidas; los motivos son un dropdown con cinco opciones cerradas. La persona puede teclear lo que quiera, pero el formulario no acepta caracteres inválidos en cada momento. El resultado es parseable por construcción: cuando recibes el formulario relleno, el DNI tiene exactamente el formato esperado, la fecha es una fecha real, el motivo es uno de los cinco. Esto es constrained decoding.

La analogía se sostiene en cuatro mapeos:

  • La persona rellenando = el LLM produciendo logits sobre el vocabulario.
  • Los caracteres que el formulario permite teclear en cada campo = el bitmask aplicado a los logits antes del sampling.
  • Cómo el formulario sabe qué caracteres permitir según en qué campo estás = el autómata (FSM o PDA) que mantiene el estado actual de la grammar.
  • Que el dropdown se pre-compute al cargar la página, no se calcule cada vez = la tabla precomputada de tokens válidos por estado del FSM, lo que hace que el coste por step sea O(1) amortizado.

El problema que structured output resuelve

El problema operacional es el contrato entre el LLM y el código que consume su output. Hay tres approaches naïve, todos con fallos documentados:

Prompt engineering puro. “Responde solo con un JSON válido, sin comentarios ni prosa”. Funciona la mayoría de las veces para modelos buenos; falla entre el 5 % y el 25 % de las veces dependiendo del modelo, la temperatura, la complejidad del schema y la longitud del output. El modelo mete una coma extra, escapa mal una cita, encierra el JSON en bloque markdown ```json, alucina un campo que no estaba en el schema, ignora un campo obligatorio. SqueezeBits mide ≤72 % correct rate sin constraining para algunos modelos en JSON Schema de complejidad moderada.

Validación post-hoc + retry. El servidor recibe el output, intenta parsearlo, si falla devuelve el error al modelo y le pide que reintente. Coste: latencia 2-3× en peor caso (típicamente 2-3 retries antes de rendirse), sin garantía de terminación, ruido en logs, dificil de testear deterministamente.

JSON repair libraries (json_repair, fast-json-repair Rust port). Parches heurísticos para errores comunes: comas extras, comillas faltantes, prosa intercalada con JSON. Útiles como fallback del approach 2; no son un contrato.

El coste operacional acumulado: latencia inflada por retries, ruido en producción, debugging penoso de parse errors intermitentes, contratos rotos con clientes downstream que asumían parseo limpio.

Constrained decoding: el principio

En cada paso de decode, el modelo produce un vector logits ∈ R^V donde V es el tamaño del vocabulario (Llama 3: 128 256, GPT-4o: ~200 000, Qwen 3: 152 064). El sampler convencional aplica softmax + estrategia de sampling (greedy, top-k, top-p, temperature) sobre los V tokens.

Constrained decoding intercala una operación antes del softmax: aplica un bitmask que pone a -∞ los logits de los tokens que violarían la grammar objetivo en este paso. Resultado: el softmax solo asigna masa de probabilidad a tokens admisibles; el sampling, cualquiera que sea su estrategia, solo puede elegir uno válido.

Las dos preguntas operacionales son siempre las mismas:

  1. ¿Cómo saber qué tokens son válidos en cada step? → mantener el estado actual de un autómata (FSM/PDA) construido a partir de la grammar; consultar la tabla (estado → set de tokens válidos).
  2. ¿Cuánto cuesta calcular y aplicar la máscara? → ahí compiten Outlines, XGrammar, LLGuidance y LM Format Enforcer.

1. El modelo produce logits sobre los V tokens del vocabulariologits ∈ R^V[2.1, 5.3, -1.2, 8.7, 0.4, …, 3.1]

2. El autómata sabe en qué estado estamos (en este JSON)FSM / PDA stateesperando: comilla apertura de string(después del campo “name”:)

3. Tabla precomputadacache: state → tokens válidos{ ‘"’, ’ ‘, ‘\t’, ‘\n’ }resto del vocab → -∞

4. Bitmask resultante (16 KB para Llama 3, V=128256)bitmask: 0..010..010..0..01 = permitido, 0 = a -∞

5. logits + bitmask → logits restringidos → softmax → samplingapply_token_bitmask_inplace(logits, bitmask)softmax → top-k / top-p / greedy → token muestreado

token = '"'(válido por construcción)6. update FSM/PDA state con el token elegido → siguiente step

Las cuatro familias de backends

BackendOrigenAlgoritmoGrammar formatsDefault enNotas
Outlinesdottxt (2023)FSM + token trie precomputadoregex, JSON Schema, Lark CFGHF TGIVersión Rust (outlines-core) cierra parte del gap con XGrammar
XGrammarCMU + MLC (2024-25)PDA byte-level + adaptive cache ctx-indep/depregex, JSON Schema, EBNF/CFGvLLM v1, SGLang, TensorRT-LLM, NIM, MLC-LLMSpeedup vs naive: hasta 100× CFG, 3× JSON; <40 µs/token JSON
LLGuidanceMicrosoft ResearchEarley parser + regex derivativesregex, JSON Schema, LarkOpenAI Structured Outputs (interno), Chromium~50 µs CPU/token; debajo de Guidance, llama.cpp, mistral.rs
LM Format Enforcernoamgatchar-level parser + tokenizer prefix treeJSON Schema, regex(deprecated default en muchos engines, fallback en NIM)Más lento que XGrammar (3.5× peor JSON, 10× peor CFG)

Tres observaciones operacionales:

  1. XGrammar es el default de facto en mayo 2026en el ecosistema open-source (vLLM v1, SGLang, TensorRT-LLM, NIM, MLC-LLM). Su PDA byte-level con cache adaptativo le da overhead casi cero cuando bien integrado.
  2. LLGuidance es la pieza menos conocida pero más usada del mercadoporque está debajo de OpenAI Structured Outputs (confirmado en el README del propio repo). 50 µs CPU por token para tokenizers de 128 k.
  3. Outlines fue el primero, sigue manteniendo el mindshare conceptual (papers, blog posts canónicos), pero perdió terreno operacional vs XGrammar. La versión Rust (outlines-core) cierra parte del gap.

XGrammar en detalle (lo que hay debajo de vLLM y SGLang)

El paper de Dong, Yin, Ruan y Chen (arXiv:2411.15100, noviembre 2024) introduce una técnica de particionado que es clave para entender por qué XGrammar es 3-100× más rápido que las alternativas.

Particionado del vocabulario en cada estado del PDA:

  • Tokenscontext-independent(~99 % del vocab en JSON Schema típico): su validez se decidesolopor la posición actual del PDA, sin necesitar inspeccionar la stack completa. Estos sonprecomputablesy se almacenan como bitmasks en cache.
  • Tokenscontext-dependent(~1 %): requieren inspeccionar la stack del PDA en runtime. Se manejan caso a caso con coste mayor.

Esta partición es la razón fundamental por la que XGrammar funciona en producción a TPOT bajo. El 99 % de los lookups van a una tabla precomputada en O(1); solo el 1 % residual paga el coste real.

Otras técnicas combinadas: pushdown automaton para CFG completos (no solo regex), persistent stack para branching/rollback rápido, JIT compilation + Earley parser en XGrammar-2.

Speedups reportados (paper):

  • Hasta100×sobre soluciones previas en CFG.
  • en JSON Schema vs Outlines.
  • Latencia <40 µs por token JSON Schema; <200 µs XML/Python DSL.
  • Cuando bien integrado en vLLM/SGLang/TRT-LLM:near-zero end-to-end overhead.

XGrammar-2(mayo 2026, arXiv:2601.04426) añade dos piezas relevantes para agentes:

  • Structural Tag: protocolo JSON componible que unifica OpenAI harmony, tool calling, reasoning channels.
  • Cross-Grammar Cachepara reuso a nivel sub-estructura → permite switching dinámico entre sub-grammars en agentic loops sin recompilar.
  • 6× faster compile timeque XGrammar-1.

La matemática del bitmask

Tres números mueven la decisión operacional.

Tamaño del bitmask por step.Si el vocabulario tieneVtokens, el bitmask packed en bits ocupaV/8bytes:

$$\text{tamaño bitmask} = \frac{V}{8} \text{ bytes}$$

  • Llama 3 (V=128 256):16 KBpor bitmask por request por step.
  • GPT-4o tokenizer o200k (V≈200 000):25 KB.
  • Llama 2 (V=32 000):4 KB.

Para un batch de 32 requests con structured output activo en H100, hablamos de ~512 KB de bitmasks por step — trivial vs los GBs que mueve el forward pass.

Coste de aplicar el bitmask a los logits.Un kernel CUDA simple, complejidadO(V), latencia ~5-10 µs en H100. Despreciable.

Coste de calcular el bitmask (CPU-side).Aquí está la diferencia entre backends:

BackendLatencia CPU por token (Llama 3, V=128k)
LLGuidance~50 µs
XGrammar~40 µs (JSON Schema), ~200 µs (XML/DSL)
outlines-corecomparable a XGrammar tras 2024
Outlines Python (legacy)200-1000 µs
LM Format Enforcerintermedio, degrada con vocab grande

Con un forward pass del modelo en orden de 10-50 ms por token en decode,una máscara de 40-50 µs es <0.5 % de overhead— invisible. La clave operacional es que el cómputo de la máscara CPU-side se puedesolapar con el forward pass GPU-side: mientras la GPU calcula los logits del tokent, la CPU pre-computa la máscara para el tokent+1basándose en el estado del FSM tras el tokent-1. SGLang lo hace explícitamente; vLLM v1 mejoró notablemente sobre v0.

Coste de compile-time.Pre-cómputo del FSM/PDA y de la cache:

  • Schemas simples (1-5 campos): <100 ms.
  • JSON Schemas profundos con muchos$defs: segundos.
  • OpenAI structured outputs: “10s típico, hasta 1 minuto para schemas complejos” — cacheado tras el primer call.
  • XGrammar-2: 6× faster que XGrammar-1.
  • LLGuidance: ~2 ms startup.

Best practice operacional: pre-cachear los schemas conocidos al arrancar el servidor para evitar latency spikes en la primera request de cada schema nuevo.

¿Degrada el reasoning del modelo?

Es la pregunta más interesante del campo y la respuesta no es obvia.

El argumento teórico de la degradación: forzar la estructura cambia la distribución de probabilidad del modelo; si el camino estructurado empuja a paths bajos en probabilidad, el modelo se queda “atascado” en una rama subóptima sin poder explorar.

PaperLet me Speak Freely?(Tam et al., arXiv:2408.02442, EMNLP 2024 Industry Track): reportódegradación significativa en reasoning tasksbajo format constraints (JSON/XML/YAML). Cuanto más estricto el formato, mayor la degradación. Sin embargo, el propio paper reconocíamejora en classification taskscon estructura forzada.

Rebuttal dottxt —Say What You Mean(blog post oficial): crítica metodológica. Los prompts del paper original erandiferentesentre estructurado y no-estructurado (no apples-to-apples). Los prompts JSON del experimento original daban menos información que los unstructured. Re-corriendo con prompts equivalentes (Llama-3-8B-Instruct), dottxt no reproduce la degradación. La conclusión: el paper confundióformat constraintconprompt engineeringdel mismo.

Consenso emergente mayo 2026(recogido por blogs técnicos y empirismo de la comunidad):

  • Para extracción, classification, function calling, routing: constrained decodingmejora exactness y reduce alucinaciones. Es la herramienta correcta.
  • Para reasoning multi-step (matemáticas, lógica, code review): usartwo-pass:
    1. Primera llamada: reasoning libre con Chain-of-Thought en texto natural (max_tokensgeneroso, sin constraint).
    2. Segunda llamada: pasarle el reasoning + el schema, pedirle que produzca structured output con constrained decoding.

El patrón “reason then structure” da el mejor de los dos mundos: razonamiento sin coartar + output garantizado parseable.

Implementaciones reales en mayo 2026

EngineDefault backendOtros disponiblesAPI
vLLM v1XGrammar (auto)Outlines, Guidance (llguidance), LM Format Enforcerguided_json,guided_regex,guided_choice,guided_grammaren request
SGLangXGrammarOutlines, LLGuidanceresponse_format.json_schema,extra_body.regex,extra_body.ebnf
TensorRT-LLMXGrammarLLGTRT (Rust llguidance)Integración oficial desde ene 2025
NVIDIA NIMXGrammar (cambió de Outlines en 2025)LM Format Enforcer (requiereNIM_ENABLE_KV_CACHE_REUSE=0)Multi-backend pluggable
llama.cppGBNF nativoLLGuidanceCada candidate token se testea contra parse state
HF TGIOutlinesXGrammar (experimental)Feature “Guidance” con/generatey/chat/completioncontools
MLC-LLMXGrammar (nativo, mismo equipo)API propia

Ejemplo vLLM:

fromopenaiimportOpenAIclient=OpenAI(base_url="http://localhost:8000/v1",api_key="x")response=client.chat.completions.create(model="meta-llama/Llama-3.1-70B-Instruct",messages=[{"role":"user","content":"Extrae nombre y edad de: María tiene 34 años"}],extra_body={"guided_json":{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}})

Equivalente SGLang:

response=client.chat.completions.create(model="meta-llama/Llama-3.1-70B-Instruct",messages=[...],response_format={"type":"json_schema","json_schema":{"name":"person","strict":True,"schema":{...}}})

Patrones de uso en producción

Function calling / tool use.El LLM produce{tool_name: enum, arguments: object}según schema. Caso dominante hoy (OpenAI, Anthropic, modelos open). El schema garantiza quetool_nameestá en el set de tools disponibles y queargumentstiene los tipos correctos.

Extracción de entidades.Schema con{name, address, phone, ...}desde texto no estructurado. Reduce 76% → 98% schema adherence en benchmarks vendor.

Routing / classification.El LLM elige entre N opciones (enumen schema). El bitmask se reduce a unos pocos tokens válidos → overhead casi nulo, máxima fiabilidad.

SQL constrained.Grammar SQL como GBNF o Lark → evita inyección y errores sintácticos. Útil en text-to-SQL agents.

Code generation con AST válido.Grammar del lenguaje target (Python, Rust, DSL propio). Garantiza que el output es código compilable / parseable.

Agentic loops.XGrammar-2 Structural Tag para switching dinámico entre formatos (tool call → reasoning channel → tool result) sin recompilar la grammar.

Pitfalls operacionales

Compile time de schemas grandes.JSON Schemas con muchos$defspueden tardar segundos en compilar. Causastartup lentosi se cargan al arranque, o latency spikes en la primera request si se cargan on-demand. Mitigación: warm cache al boot con los schemas conocidos del fleet.

Tokenizer-specific FSM/PDA.Un schema pre-compilado para Llama 3 (tokenizer de 128 k)no sirve para Qwen(152 k). La cache de schemas debe estar tipada por(tokenizer_hash, schema_hash). Cambio de modelo = invalidar cache.

Cambios de schema.Versionar schemas explícitamente. Cambios breaking → rebuild + warm cache. No silenciar errors de compile en producción.

Streaming SSE.Structured output con streaming requiere parsers tolerantes a output parcial (partial-json-parser,json-stream). Pydantic v1 estrictofalla; v2 conpartial validationfunciona. Los clients antiguos pueden no manejar la validación incremental — testear con el client real.

Token healing.Tokens que cruzan boundaries (://como single token vs:+//) pueden romper constraint puro. Outlines y XGrammar lo mitigan internamente; llama.cpp y otros requieren token healing explícito. Si el modelo cuela un caracter “extraño” después de la estructura, probablemente sea esto.

Bugs reales conocidos en vLLM 2025-26:

  • vLLM 0.8.4: XGrammar rechazaminItemsen JSON Schema (issue #16880).
  • vLLM con Qwen 2.5 VL: Outlines/XGrammar no respeta schema en algunos casos (issue #13038).
  • vLLM bitmask backendapply_token_bitmask_inplacesiempreauto, no expuesto al usuario.

OpenAI subset limitations: si vas a portar un schema validado en OpenAI structured outputs a otro backend, comprobar que no usas constructs que OpenAI rechaza pero otros permiten ($refprofundos,pattern,default, profundidad >5,anyOfcomo root).

Implicaciones en hardware on-premise

En una RTX 4090 (24 GB).Cualquier modelo que sirvas con vLLM o llama.cpp puede llevar structured output con coste prácticamente despreciable. La latencia de máscara CPU-side (~40 µs) es trivial comparada con el TPOT típico (30-100 ms en consumer hardware). El caso interesante: function calling sobre Llama 3 8B o Qwen 3 14B INT4 → tool use fiable sin retries, sin necesidad de API hosted.

En un cluster genérico 4×H100 SXM (320 GB, NVLink, FP8 nativo).Aquí XGrammar como default de vLLM v1 / SGLang es el camino:

  • Llama 3 70B FP8 + XGrammar JSON Schema: TPOT P95 estable bajo 60 ms incluso con structured output activo en todo el batch. Soporta cientos de schemas distintos cacheados.
  • DeepSeek-V3 + XGrammar Structural Tag: agentic tool calling con MTP nativo y constrained decoding combinados; el coste de la máscara se solapa con el forward del MoE.
  • Multi-tenant function calling: cada cliente puede tener su set de tools (= sus schemas); compile-time se amortiza con el caching, runtime es invariante.

La regla de pulgar mayo 2026:XGrammar por defecto, pre-cachear schemas del fleet al boot, two-pass para reasoning tasks.

Lo que no hemos cubierto

  • Tool routing dinámicocon XGrammar-2 Structural Tag: el detalle de cómo TagDispatch elige sub-grammars en runtime.
  • Constrained beam searchy su interacción con grammar: degradación de calidad teórica vs greedy.
  • Grammar para code generation con AST completo: Python/Rust grammars production-grade, performance.
  • JSON Schema → Pydantic → grammar pipelines: tooling para reducir error humano.
  • Inhibition decoding(paper Inhibition Decoding, 2025): variante que penaliza pero no prohíbe ciertos tokens, útil para safety constraints suaves.

Ver también

  • Continuous batching: la peluquería con 8 sillones— el scheduler donde structured output se aplica request a request; el cómputo de máscara CPU-side puede solaparse con el forward GPU-side.
  • Speculative decoding— otra técnica que opera sobre el sampler; speculative + structured se combina pero requiere cuidado (la regla de aceptación tiene que respetar el bitmask).
  • Multi-LoRA serving— un adapter puede estar entrenado específicamente para function calling, complementa la garantía de structured output con afinidad del modelo a la tarea.
  • LLM-as-judge: el corrector de oposiciones— el judge produce un veredicto estructurado ({score, reasoning, decision}) que puede asegurarse con structured output para evitar parseo manual.
  • Evals para LLMs— los evals con LLM-as-judge se benefician enormemente de structured output garantizado.
  • El pipeline LLMOps de seis etapas— el mapa maestro donde Deploy es la etapa 4.

Referencias