Skip to content

English · Español

00 — Por qué existe la generación estructurada (structured generation)

Que un LLM devuelva JSON válido "casi siempre" es indistinguible de "nunca devuelve JSON válido" para un programa downstream: el primer fallo rompe la tubería. La generación estructurada cambia "casi siempre" por "siempre, por construcción" — y la única forma honesta de garantizarlo es restringir los logits paso a paso.

Esta es la página de motivación. Léela antes que las páginas de matemáticas — las fórmulas son fáciles; entender por qué nos molestamos es la parte que cuesta tiempo.


La trampa del 99%

Le haces prompt a un modelo: "Responde con JSON: {verb: ..., tense: ..., person: ...}.". Normalmente cumple. Quizá 99 veces de cada 100. La centésima dice: "¡Claro! Aquí va la conjugación: json{...} ¡Espero que ayude!". O se olvida una comilla. O emite "tense": "past" cuando el esquema espera "past_simple". Tu parser downstream lanza una excepción.

Una tasa de éxito del 99% parece buena en una demo y es catastrófica en producción. Si llamas a este LLM 1000 veces al día, tienes diez fallos al día. Si cada fallo dispara un retry o un camino de fallback, tienes diez incidentes al día que necesitan atención. Una tasa del 99,9% deja uno al día. Una del 99,99% deja uno cada diez días. El coste de cada nueve adicional se reduce por un factor de diez.

No puedes hacer prompt engineering para llegar a infinitos nueves. El modelo es una distribución de probabilidad sobre tokens; siempre hay alguna masa no nula sobre continuaciones ilegales. La masa se encoge a medida que el modelo se entrena más y el prompt se ajusta mejor — pero nunca llega a cero, porque el modelo no tiene noción alguna de "legal" más allá de las vibras.

Las dos salidas

Puedes (a) validar a posteriori y reintentar al fallar, o (b) restringir durante y nunca producir una salida inválida. La opción (a) es lo que hicieron la mayoría de los stacks de producción hasta ~2023: emitir, parsear, reintentar. Funciona, pero tiene tres inconvenientes:

  1. Latencia. Una generación fallida que reintenta duplica tu coste wall-clock para esa request.
  2. Coste. Si pagas por token, pagas por la salida mala y luego otra vez por el retry.
  3. Caso peor no acotado. Teóricamente el retry también podría fallar. Pones un máximo de retries; aceptas el suelo de fallos.

La opción (b) — restringir durante — hace que la tasa de fallos sea exactamente cero por construcción. En cada paso de decode, miramos qué tokens siguientes podrían llevar a una compleción legal del grammar y ponemos los logits del resto a \(-\infty\). Tras el softmax, los tokens ilegales tienen probabilidad exactamente cero. No pueden ser muestreados. La salida, por construcción, parsea.

Esto es lo que hacen outlines, lm-format-enforcer, jsonformer, el "JSON mode" de OpenAI, los grammars GBNF de llama.cpp, y las funcionalidades de structured output de todo proveedor de LLM importante por debajo. Difieren en qué grammar, cuán ingeniosamente se precomputa la máscara y qué exponen al usuario. No difieren en el mecanismo básico.

La afirmación pedagógica

Si entiendes el enmascarado de logits, entiendes la generación estructurada. El resto es ingeniería: cómo escribir un grammar, cómo compilarlo a un autómata, cómo hacer que la construcción de la máscara sea suficientemente rápida como para no duplicar el coste por token.

Derivaremos el enmascarado desde primeros principios en 02-logit-masks.md. Lo implementaremos de forma ingenua en lab/00-regex-mask.md y lab/01-json-schema-mask.md. Mediremos su coste en lab/03-mask-overhead.md. No implementaremos un FSM consciente del tokenizer apto para producción y para grammars arbitrarios, porque hacerlo consumiría una fase entera por sí solo; describiremos el algoritmo en 03-grammar-as-dfa.md para que Borja pueda leer luego el código fuente de Outlines y reconocer lo que está haciendo.

Por qué esto importa para el tutor de gramática

El agente capstone de la Fase 32 es un tutor de gramática: lee una frase en inglés, identifica cualquier error de tense / person / concordancia y propone una corrección. Mecánicamente es una función:

tutor: English sentence  ->  {verb, tense, person, correction?, spanish?}

El codominio es un esquema fijo. La Fase 32 no se puede permitir un fallo de parseo: un agente que emite JSON inválido una vez por cada 100 requests es un agente que rompe el resto de la tubería una vez por cada 100 requests. La estrategia de retry (Opción a) no basta — el agente tiene herramientas que llamar (Fase 31), una sandbox con la que coordinarse, y consumidores downstream (la capa de serving de la Fase 33, la observabilidad de la Fase 34) que todos esperan input estructurado.

Así que la Fase 30 es la fase de definición del contrato para el resto del stack del agente. El esquema de salida que elijamos aquí es la firma de tipo de todo lo de downstream. Trátalo como una API pública.

El universo §A13 hace esto casi trivial

El alcance de §A13 (5 tenses × 3 persons × 20 verbos) hace que el esquema de conjugación sea una enumeración cerrada. Hay exactamente 20 verbos legales, exactamente 5 tenses legales, exactamente 3 persons legales. La precomputación de la máscara es por tanto esencialmente gratis: una tabla de tamaño \(20 + 5 + 3 = 28\) valores legales. La mayor parte del trabajo en stacks de producción es manejar campos string de vocabulario abierto (nombres en formato libre, direcciones); nosotros no tenemos ninguno. Eso es una característica del currículo microscópico, no una casualidad.

Por esto la Fase 30 entrega una implementación completa en vez de un stub — el universo es suficientemente pequeño como para ser exhaustivos.

Una nota sobre lo que no estamos resolviendo

La generación estructurada garantiza que la salida parsea contra el esquema. NO garantiza que el contenido sea correcto. El agente puede emitir con confianza {"verb": "eat", "tense": "past_simple", "person": "3sg"} para la frase "I am eating now" — válido por esquema, semánticamente incorrecto — y el parser lo aceptará. La corrección es un problema de la Fase 20 / Fase 28 / Fase 37 (evaluación, ajuste fino (fine-tuning), sondeo adversario). La Fase 30 solo asegura que la respuesta tiene la forma correcta.

Esto es genuinamente útil. La mayoría de sistemas en producción fallan en tiempo de parseo, no en tiempo de contenido. Eliminar los fallos de parseo por sí solo es una mejora de 10×–100× en el coste operacional del sistema.

Cómo es "la máscara"

Una máscara de logits es un array de longitud \(|V|\) (tamaño del vocabulario). Cada entrada es o \(0\) (el token es legal en este paso) o \(-\infty\) (el token rompería el grammar). Sumamos esta máscara a los logits del modelo antes del softmax. Tras el softmax, los tokens ilegales tienen probabilidad \(0\).

logits         = [ 1.2,  0.3,  4.1, -0.5,  2.0,  ...]    ← del modelo
mask           = [   0, -inf,    0, -inf, -inf,  ...]    ← del grammar
masked_logits  = [ 1.2, -inf,  4.1, -inf, -inf,  ...]
probs          = softmax(masked_logits)
               = [0.052,  0.0, 0.948,  0.0,  0.0,  ...]

Después muestreamos de probs usando la estrategia de muestreo que la Fase 21 introdujo (greedy, top-p, temperature). Al sampler no le importa que algunas entradas sean cero; simplemente no las elige.

El trabajo de la generación estructurada es: dada la salida parcial hasta el momento, computar la máscara. El resto es fontanería.

Una nota sobre temperature

El escalado por temperature, top-k, top-p, las penalizaciones de repetición — todas estas se componen con el enmascarado. La máscara se aplica primero (poner ilegales a \(-\infty\)), después cualquier otra modificación (temperature, top-p) opera sobre los logits supervivientes. Este orden de composición importa y se deriva en 02-logit-masks.md §"composición con muestreo".

Hacia dónde va esto

Al final de la Fase 30 tienes:

  1. Una abstracción LogitMask (src/ministruct/mask.py) que toma "lo que se ha emitido hasta ahora" y devuelve "qué tokens siguientes son legales".
  2. Una JSONSchemaMask concreta (y RegexMask para el calentamiento) que implementan esto para el esquema de conjugación.
  3. Un bucle de decodificación modificado (src/miniinfer/generate.py) que respeta la máscara.
  4. Una CLI end-to-end: python scripts/conjugate_structured.py "She wrote a book" → un {"verb": "write", "tense": "past_simple", "person": "3sg"} válido.
  5. El esquema bloqueado en src/ministruct/schemas.py — la API pública para las Fases 31, 32, 33.

En la Fase 31, la capa de herramientas usará este esquema para validar argumentos y valores de retorno de llamadas a herramientas (conjugate(verb, tense, person) toma exactamente estos tipos). En la Fase 32, el agente tutor de gramática usará la CLI como una de sus herramientas. En la Fase 33, la capa de serving la expondrá sobre HTTP.

Lo que esta fase NO cubre

  • Corrección de las conjugaciones del modelo. Territorio de Fase 20 / Fase 28.
  • Constrained beam search. La interacción del estado beam × máscara es un tema aparte; solo hacemos greedy / top-p aquí.
  • Implementación del parser GBNF. Describimos el formato; leer el parser de llama.cpp es un objetivo de ampliación.
  • FSMs conscientes del tokenizer de propósito general. Nuestro FSM apunta al vocabulario BPE de §A13, no a tokenizers arbitrarios.
  • Actualizaciones de máscara en streaming / SSE. Asunto de la Fase 33.

Siguiente: theory/01-jsonmode-vs-grammar.md — el espectro "pedirlo amablemente" → "JSON mode" → "grammar GBNF", y qué cede cada uno.