Pedirle a un LLM que devuelva JSON válido puede fallar: el modelo inventa llaves cerradas, omite comas, añade comentarios. Constrained decoding (decodificación restringida) resuelve este problema cambiando cómo se generan tokens: en cada paso, solo se permiten tokens compatibles con la gramática deseada. El resultado: garantía matemática de que la salida cumple el formato.
Outlines, Guidance y jsonformer son las librerías principales. Este artículo cubre cómo funcionan, cuándo superan al json_mode de OpenAI, y cómo integrarlas.
El problema
Prompt: “Responde con JSON: {name, age}”.
El modelo puede:
- Olvidar una coma.
- Añadir
```jsonal inicio. - Añadir un “aquí tienes:” antes.
- Omitir llave final.
Tras muchos prompts cuidadosos y pocos éxitos 100% fiables, buscas robustez real.
Cómo funciona constrained decoding
En cada paso de generación:
- El LLM produce distribución probabilística sobre vocabulario (~50k tokens).
- Máscara basada en gramática: tokens no-válidos obtienen probabilidad 0.
- Sample o argmax solo sobre tokens válidos.
Resultado: la salida respeta gramática perfectamente.
Se aplica con JSON Schema, regex, o gramáticas context-free (CFG).
Outlines: el más popular
Python library que funciona con muchos modelos (HF Transformers, llama.cpp, vLLM):
from outlines import models, generate
model = models.transformers("meta-llama/Llama-3-8B-Instruct")
# JSON con Pydantic
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
generator = generate.json(model, User)
user = generator("Extract user: Ana has 25 years old")
print(user) # User(name='Ana', age=25)
Soporta:
- Pydantic models para estructura tipada.
- JSON Schema directo.
- Regex para formatos específicos.
- Gramáticas CFG para DSLs custom.
Guidance: más general
Guidance de Microsoft permite templates más complejos:
import guidance
from guidance import models, gen, select
llama = models.LlamaCpp("path/to/model.gguf")
lm = llama + "El color favorito es " + select(['rojo', 'azul', 'verde']) + "."
Permite mezclar texto fijo con regiones generadas bajo restricciones, ideal para prompts complejos.
jsonformer: simple y enfocado
Solo para JSON, pero muy simple:
from jsonformer import Jsonformer
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
}
}
jsonformer = Jsonformer(model, tokenizer, schema, prompt)
result = jsonformer()
Menos flexible pero más fácil de empezar.
vs OpenAI json_mode
OpenAI añadió response_format={"type": "json_object"} y luego Structured Outputs con JSON Schema. Comparación:
| Aspecto | Outlines local | OpenAI Structured Outputs |
|---|---|---|
| Garantía gramática | 100% | 100% (schema) |
| Modelos | Open (Llama, Mistral) | GPT-4o+ |
| Coste | Local (GPU) | API pricing |
| Privacidad | Local | OpenAI retention |
| Latencia | Variable GPU | ~depende API |
Para servicios SaaS, OpenAI es OK. Para self-hosted, Outlines/Guidance.
Regex mode para formatos específicos
Outlines regex:
from outlines import generate
phone_gen = generate.regex(
model,
r"[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4}"
)
phone_gen("Call me at ") # "555-123-4567"
Útil para:
- Fechas en formato específico.
- Números de serie.
- Códigos (SKU, referencias).
- Identifiers de tu dominio.
Gramáticas CFG
Para lenguajes estructurados propios. Ejemplo: SQL limitado, queries de tu DSL.
from outlines import generate
grammar = """
?start: expression
?expression: NUMBER | expression OP expression | "(" expression ")"
OP: "+" | "-" | "*" | "/"
%import common.NUMBER
"""
calc_gen = generate.cfg(model, grammar)
Útil cuando necesitas que el LLM produzca output procesable por otro sistema.
Trade-offs
Ventajas:
- Garantía absoluta de formato.
- Menos post-processing en aplicación.
- Reduce hallucination de formato.
- Usable con modelos pequeños (a veces mejor que prompt en grandes).
Desventajas:
- Overhead computacional: cada token necesita masking. 10-30% más lento.
- Integración: añadir a stack existente requiere trabajo.
- No ayuda con calidad semántica: el JSON será válido pero el contenido puede ser malo.
Cuándo vale la pena
Casos donde constrained decoding gana claro:
- Function calling / tool use: garantizar JSON de argumentos válido.
- Extracción estructurada masiva: batch de miles de docs a DB.
- Agentes con DSL propio: garantizar sintaxis válida.
- Data generation: synthetic data con schema fijo.
Cuando no:
- Chat conversacional: prompting es suficiente.
- Casos donde modelo grande con prompts detallados ya funciona >99%.
Integración con vLLM y TGI
Estos runtimes soportan constrained decoding nativamente:
- vLLM integra Outlines desde v0.4+.
- TGI tiene
GuidanceGrammarfeature. - llama.cpp tiene grammar mode (
--grammar).
Self-hosting con constrained decoding ya no requiere tu propia pipeline.
Ejemplos reales
Pattern que hemos visto:
- Data extraction de facturas: schema JSON con items, totales.
- Generación de SQL acotada a tu BD.
- Agent tool selection:
{"tool": "search", "args": {...}}. - Classification estructurada:
{"category": "X", "confidence": 0.85}.
Conclusión
Constrained decoding es una herramienta subutilizada en el ecosistema LLM. Para cualquier caso donde necesites output válido garantizado, vale la pena. Outlines es la opción más madura open-source; OpenAI Structured Outputs cubre SaaS. El overhead de 10-30% más lento es trade-off aceptable para la garantía de robustez. Adoptarlo reduce significativamente código de validación y retry en tu aplicación — mejor hacer que funcione en decoding que parchear post-hoc.
Síguenos en jacar.es para más sobre LLMs, structured outputs y decodificación avanzada.