latentSource

El auge de la generación estructurada: de JSON Mode a Grammar-Constrained Decoding

Garantizar una salida válida de los LLMs requiere más que solo prompting. El grammar-constrained decoding impone la estructura a nivel de token; aquí explicamos cómo funciona.

·7 min read
Compartir
El auge de la generación estructurada: de JSON Mode a Grammar-Constrained Decoding

Pedir JSON de forma amable funciona la mayoría de las veces. El grammar-constrained decoding funciona siempre.

He dedicado muchos ciclos a limpiar "casi-JSON" de los LLMs. Comas sobrantes. Corchetes faltantes. Un null perdido donde debería haber un string. La solución no es un mejor prompt. La solución es hacer que el modelo sea físicamente incapaz de emitir una salida inválida. Eso es lo que hace la generación estructurada.

El espectro del "structured output"

Cuatro niveles de garantía, desde el más débil al más fuerte:

Prompt engineering — "Responde solo con JSON, sin ningún otro texto". Funciona alrededor del 80% de las veces en un buen modelo. Falla de formas interesantes: el modelo envuelve su JSON en un bloque de código (code fence), se disculpa primero, o alcanza un límite de tokens y se trunca a mitad del objeto.

JSON mode — una flag en la API (response_format={{"type": "json_object"}} en OpenAI, equivalente en otros) que sesga al modelo hacia JSON. La salida es un JSON parseable, pero el esquema queda a merced del prompt. Sigues sin saber si devolvió las claves que pediste.

Function calling / tool use — la API recibe un JSON schema y el modelo devuelve argumentos que coinciden con ese esquema. Las claves están garantizadas. Los tipos están mayormente garantizados. Los casos de borde (tipos unidos, esquemas profundamente anidados) todavía pueden fallar dependiendo del proveedor.

Grammar-constrained decoding — en cada paso de generación, el modelo solo puede muestrear tokens que mantengan la salida válida frente a una gramática formal. Los tokens inválidos se enmascaran antes del muestreo. La salida es demostrablemente correcta por construcción.

Cada nivel es más restringido y más confiable. Cada uno desplaza el trabajo de "validar después del hecho" a "hacer que lo inválido sea imposible". El cuarto nivel es donde quiero que resida el código de producción.

Cómo funciona el grammar-constrained decoding

El modelo mental es simple. El modelo produce una distribución de probabilidad sobre el vocabulario en cada paso. Sin restricciones, realiza el muestreo directamente de esa distribución. Con restricciones, se enmascara cada token que haría que la salida fuera inválida, luego se renormaliza y se muestrea de lo que queda.

La gramática te dice qué es válido. Puede ser regex, BNF, JSON Schema, o cualquier formalismo que pueda compilarse en una máquina de estados finitos (FSM). En cada paso de generación, la FSM rastrea qué estados son alcanzables. Los tokens que llevarían a un estado inalcanzable se enmascaran.

Concretamente: si la gramática dice que el siguiente token debe ser un dígito, y el vocabulario tiene 50,000 tokens, estableces los logits de todos los tokens que no son dígitos a infinito negativo antes del muestreo. El modelo solo puede elegir un dígito. No hay un modo de falla donde elija otra cosa, porque cualquier otra cosa es matemáticamente inalcanzable.

# Pseudo-code for one step of constrained generation logits = model.forward(input_ids) # raw token logits allowed = grammar_fsm.allowed_tokens(state) # which tokens keep the output valid mask = build_mask(vocabulary, allowed) # -inf for disallowed masked_logits = logits + mask # disallowed → -inf next_token = sample(softmax(masked_logits)) # sample from valid tokens only state = grammar_fsm.advance(state, next_token) # update state for next step

Esa es toda la idea. La matemática es un muestreo por rechazo (rejection sampling) realizado a nivel de token, antes del muestreo en lugar de después. El modelo nunca tiene la oportunidad de equivocarse.

Las librerías que lo implementan

Tres librerías open-source están haciendo un trabajo serio aquí.

Outlines (Python) es la primera a la que recurro. Compila patrones regex y esquemas JSON a FSMs, luego los ejecuta contra cualquier modelo de HuggingFace o motor de inferencia compatible. La API es pequeña:

import outlines from pydantic import BaseModel class Person(BaseModel): name: str age: int email: str model = outlines.models.transformers("meta-llama/Llama-3-8B-Instruct") generator = outlines.generate.json(model, Person) result = generator("Generate a person with realistic data.") # result is a Person instance, guaranteed

Guidance (Microsoft) toma un enfoque más declarativo. Escribes un template con espacios (slots) restringidos, y la librería se encarga de la construcción de la FSM. Es útil cuando quieres intercalar texto fijo con generación restringida.

LMQL (Language Model Query Language) está más cerca de SQL para prompts. Escribes una consulta con restricciones de tipo y se compila en un plan de generación restringida. Es una abstracción más pesada, pero potente para prompts complejos de varios pasos.

Para la mayoría de los casos, Outlines es suficiente. Vale la pena saber que las otras dos existen.

Lo que te cuesta

La sabiduría convencional solía decir que restringir un modelo arruinaría su calidad. El modelo "quiere" decir algo más, y forzarlo a una gramática produciría una salida forzada y de baja calidad.

Resultó que eso era mayormente erróneo. Investigaciones recientes (y mis propios benchmarks) muestran que el grammar-constrained decoding tiene un impacto mínimo en la calidad en tareas donde la gramática es la forma correcta. La salida JSON mejora ligeramente porque el modelo no está luchando contra sí mismo por la sintaxis. El lenguaje natural de forma libre empeora si se restringe demasiado. Ajusta la gramática a la tarea y estarás bien.

El costo de rendimiento es real pero menor de lo que esperarías. La construcción de la máscara añade tal vez un 5-15% de latencia dependiendo de la complejidad de la FSM. Para esquemas JSON complejos, eso puede subir hasta el 25%. Aun así, no es nada comparado con el costo de regenerar una respuesta mal formada.

El costo de integración es el factor más importante. Necesitas una librería que se conecte al decoding loop del modelo. Eso es fácil con vLLM, llama.cpp y HuggingFace Transformers. Es más difícil (a veces imposible) con APIs alojadas que no exponen los logits.

JSON Schema como la lingua franca

JSON Schema ha ganado como la forma en que los desarrolladores describen lo que quieren. Cada librería importante puede compilar un JSON Schema en una FSM. El esquema funciona como documentación, validación y restricción, todo desde una única fuente de verdad.

schema = {{ "type": "object", "properties": {{ "title": {{"type": "string", "minLength": 1}}, "tags": {{ "type": "array", "items": {{"type": "string"}}, "maxItems": 5 }}, "publish_date": {{ "type": "string", "format": "date" }} }}, "required": ["title", "tags"] }} generator = outlines.generate.json(model, schema) result = generator("Suggest a blog post about distributed databases.") # Output: {{"title": "...", "tags": [...], "publish_date": "..."}}

Esto se integra muy bien con todo lo demás en un proyecto típico de Python. Los modelos de Pydantic se compilan a JSON Schema. FastAPI utiliza los mismos esquemas. Puedes tomar un modelo de Pydantic de tu capa de API, entregárselo a Outlines y obtener una salida de LLM que encaje directamente en la respuesta de tu API.

Más allá de JSON

Una vez que tienes el decodificado basado en FSM, JSON es solo una aplicación. Cualquier cosa que puedas describir con una gramática, puedes restringirla.

SQL válido — define una gramática SQL, obtén consultas que siempre se pueden parsear. Útil para sistemas de text-to-SQL donde las consultas inválidas te cuestan un viaje de ida y vuelta a la base de datos.

Python válido — define la gramática de Python, obtén código que siempre se parsee. No garantiza la corrección lógica, pero elimina toda una clase de fallas.

YAML, XML, HTML válido — el mismo patrón, diferentes gramáticas.

Lenguajes específicos de dominio — si tienes un lenguaje de configuración personalizado, un DSL interno o un lenguaje de consultas estructurado, puedes escribir su gramática y restringir al LLM para que produzca solo expresiones válidas.

He visto equipos usar esto para la generación de RegEx, donde el modelo estaba restringido a producir solo expresiones regulares válidas. La tasa de error cayó de "salida inválida ocasional" a cero.

Integración en motores de inferencia

vLLM incluye soporte para structured output de fábrica. Pasas un esquema JSON o un regex y él se encarga del resto:

from vllm import LLM, SamplingParams from pydantic import BaseModel class Movie(BaseModel): title: str year: int llm = LLM(model="meta-llama/Llama-3-8B-Instruct") params = SamplingParams( guided_json=Movie.model_json_schema(), max_tokens=200 ) output = llm.generate("Recommend a movie about space.", params) # output is guaranteed to parse as Movie

llama.cpp tiene archivos de gramática GBNF para el mismo propósito. Escribes una gramática y el motor la impone durante la generación.

OpenAI añadió el modo "Structured Outputs" en 2024 que es aproximadamente equivalente: pasas un esquema y obtienes una salida que cumple garantizadamente. No está etiquetado como grammar-constrained decoding, pero funcionalmente eso es lo que están haciendo bajo el capó.

Cuándo NO restringir

El grammar-constrained decoding no es la herramienta adecuada para todo.

Escritura creativa. Si estás generando prosa, poesía o cualquier cosa donde la expresión libre importe, no lo restrinjas. La gramática no tendría nada útil que aportar sobre una buena prosa.

Generación abierta. Brainstorming, resúmenes, investigación exploratoria; tareas donde no conoces la forma de la salida de antemano. No puedes escribir una gramática para "ideas interesantes".

IA conversacional. Las respuestas de chat son donde quieres la expresividad total del modelo. Restringe las llamadas a herramientas (tool calls) y sus argumentos, pero deja la conversación libre.

El patrón que sigo: restringir en los límites donde la estructura importa (llamadas a herramientas, datos estructurados, generación de código). Dejar al modelo libre en el medio, donde el razonamiento y la calidad del lenguaje importan.

El cambio mayor

La generación estructurada es parte de un movimiento más amplio que pasa de "los LLMs son impredecibles, construye validaciones robustas a su alrededor" a "los LLMs pueden hacerse deterministas en su forma, incluso si son no-deterministas en su contenido". La forma se bloquea. El contenido sigue siendo creativo. Esa separación es lo que hace que los LLMs estén listos para producción en tareas que antes no podían realizar.

Si estás construyendo un agente que usa herramientas, un asistente que genera código o cualquier cosa que necesite que la salida del LLM se integre en un sistema downstream, esto debería estar activado por defecto. El costo es mínimo. La ganancia en confiabilidad es enorme. Ya no hay una buena razón para lanzar un sistema con la esperanza de que el modelo devuelva un JSON válido.