English · Español
01 — El espectro: "Pedirlo amablemente" → "JSON Mode" → "Restringido por grammar"¶
Hay tres niveles de garantía sobre la salida estructurada: pedirla en el prompt (cero garantía), modo JSON (garantiza que parsea pero no la forma), y decodificación con grammar (garantiza la forma exacta). Solo el tercero es una garantía de tipo en el sentido fuerte.
Esta página sitúa la técnica de la Fase 30 (enmascarado de logits) dentro del panorama más amplio de técnicas que la gente usa para obtener salida estructurada de los LLMs. El objetivo es saber qué nivel de garantía te da cada una y por qué vamos directos a la más fuerte.
Nivel 0: pedirlo amablemente (cero garantía)¶
You are a helpful grammar tutor. Reply ONLY in JSON with the keys 'verb', 'tense', 'person'.
Do not include any prose. Do not include markdown fences.
El modelo normalmente cumple. La frecuencia-de-cumplimiento escala con el tamaño del modelo, los datos de entrenamiento y el esfuerzo en ingeniería de prompts. No es cero. Los fallos están correlacionados con entradas adversarias, fraseos inusuales, casos extremos — exactamente los casos para los que tu código downstream está menos preparado.
Garantía: ninguna. Coste: cero esfuerzo de implementación. Usar cuando: se prototipa; el consumidor puede tolerar reintentos.
Nivel 1: parseo a posteriori + retry¶
for attempt in range(MAX_RETRIES):
out = model.generate(prompt)
try:
parsed = json.loads(out)
if validates_against_schema(parsed, schema):
return parsed
except (json.JSONDecodeError, ValidationError):
continue
raise GiveUp()
Esto es lo que hicieron la mayoría de los stacks de producción hasta ~2023. Funciona, pero la tasa de fallos se multiplica por el número de veces que te rindes. Con MAX_RETRIES=3 y una tasa por intento del 99%, la tasa de fallos es \((1 - 0.99)^3 = 10^{-6}\) — seis nueves. Estupendo. Con MAX_RETRIES=3 y una tasa por intento del 90%, la tasa de fallos es \(10^{-3}\) — tres nueves. La estrategia de retry es multiplicativa solo cuando es independiente, y los retries no son independientes (el mismo modelo en el mismo prompt falla de la misma forma).
Garantía: probabilística; depende de la tasa por intento y de MAX_RETRIES.
Coste: N× latencia en la ruta de fallo; trabajo parcial descartado.
Usar cuando: el esquema es laxo; el presupuesto de latencia lo permite.
Nivel 2: "JSON mode" (máscara del lado del proveedor)¶
response_format: {type: "json_object"} de OpenAI y equivalentes. El proveedor aplica un grammar — algún grammar — internamente. La salida está garantizada como JSON sintácticamente válido. Pero:
- No está garantizado que cumpla tu esquema. Aún necesitas validación de esquema a posteriori.
- El grammar exacto lo controla el proveedor y puede cambiar.
- Suele soportar objetos pero no restricciones anidadas (p. ej., "este campo debe ser uno de estos 20 valores de enum de verbos").
Garantía: el JSON parsea; la coincidencia con esquema sigue siendo problema tuyo. Coste: sobrecoste de latencia mínimo (el proveedor ya pagó por la máscara). Usar cuando: el esquema es lo bastante laxo como para que "parsea como JSON" sea suficiente.
Nivel 3: restringido por esquema ("structured outputs")¶
Los structured outputs de OpenAI (response_format: {type: "json_schema", schema: ...}), el forzado de tool-use de Anthropic, outlines.generate.json(model, schema) de Outlines. Estos compilan tu esquema a una máscara a nivel de token. La salida está garantizada para parsear contra el esquema — cada campo presente, cada tipo correcto, cada valor de enum extraído del conjunto declarado.
Garantía: parseo + validación de esquema, por construcción. Coste: algo de coste en tiempo de compilación para el paso esquema → autómata; la consulta de máscara por paso es O(1) después. Usar cuando: necesitas un contrato fuerte.
Este es el nivel que implementa la Fase 30 (manualmente, en NumPy, para un esquema específico).
Nivel 4: restringido por grammar (GBNF y compañía)¶
Los grammars GBNF de llama.cpp, los grammars de lark, EBNF. Escribes un grammar libre de contexto; la implementación lo compila a un autómata de pila; las máscaras de logits se derivan del estado actual del parser. Este es el nivel más expresivo — puedes restringir a "frase en inglés válida", "SQL válido", "respuestas SMTP válidas", cualquier cosa que puedas escribir como un CFG.
Garantía: la salida es un miembro del lenguaje del grammar. Coste: la compilación no es trivial; el autómata puede ser grande; la compilación consciente del tokenizer es difícil. Usar cuando: el esquema es demasiado complejo para JSON Schema y demasiado importante como para dejarlo laxo.
Describimos este nivel en 03-grammar-as-dfa.md. No lo implementamos.
Qué elegimos para la Fase 30¶
Nivel 3, hecho a mano, solo NumPy. El esquema de conjugación es lo bastante pequeño como para implementar la máscara como una máquina de estados en Python:
- Esquema (congelado al cerrar la fase en
src/ministruct/schemas.py):{ "verb": enum-of-20 (work, play, walk, talk, listen, watch, study, finish, start, look, want, like, be, have, do, go, come, see, eat, write), "tense": enum-of-5 (infinitive, present_simple, past_simple, past_participle, simple_future), "person": enum-of-3 (1sg, 2sg, 3sg), "spanish": optional string (traducción al español de la forma conjugada) } - Estados:
expecting-{,expecting-quote-for-key,expecting-key-name-from-{verb,tense,person,spanish}-minus-already-seen,expecting-quote-close,expecting-colon,expecting-value-from-the-key's-enum,expecting-comma-or-brace-close. - Transiciones: en cada paso, dado el estado y el token que se emite, computar el nuevo estado. Solo los tokens que llevan a un nuevo estado válido quedan desenmascarados.
Escribimos esto a mano porque (a) es pequeño, (b) ver la máquina de estados como código es más instructivo que verla como artefacto generado, © los bugs que pillaremos (cruce de tokens, desajuste tokenizer-grammar) son los bugs que también pillan las implementaciones de producción, y (d) no podemos importar outlines sin violar el §0.4 de CLAUDE.md ("construir antes de abstraer").
El problema token-vs-carácter (vista previa)¶
Un punto sutil que pillaremos en lab/01: el modelo emite tokens, pero el grammar razona sobre caracteres. El vocabulario BPE contiene tokens como "verb":, {", ",". Un solo token puede cruzar múltiples transiciones del grammar. La máscara debe comprobar: "si emito este token, ¿dónde acaba el parser del grammar?". Esto requiere simular el parseo de la expansión a caracteres del token candidato.
En nuestro vocabulario pequeño (≤ 512 tokens) esto es barato: para cada token, decodificarlo, avanzar el parser, comprobar legalidad, marcar máscara. Trabajo total por paso: O(\(|V| \cdot \text{longitud-media-de-token}\)). Para 512 tokens × 3 caracteres por token, eso son ~1500 operaciones por paso. Despreciable.
En un vocabulario LLM real (50k–200k tokens), esto es demasiado caro. Las implementaciones de producción precomputan un trie de tokens intersectado con el FSM del grammar, dando consultas O(1) por paso. Lo describimos en 03-grammar-as-dfa.md.
La ventaja del enum cerrado¶
El universo de §A13 es cerrado: cada valor legal de cada campo es uno de un pequeño conjunto enumerado. No hay strings de formato libre en los campos requeridos, ni enteros en rangos no acotados. Este es el caso más fácil de toda la generación estructurada — más fácil que lo que tienen que manejar la mayoría de los stacks de producción.
Concretamente, todo el espacio de estados de compleciones legales está acotado por:
states_count ≈ (#campos_aún_abiertos) × (#estados_de_valor_parcial_por_campo)
≤ 4 × 30 ≈ 120 estados
Compáralo con un JSON Schema típico del mundo real con campos name: string y description: string en formato libre, donde la máquina de estados tiene que manejar texto arbitrario. Nuestro universo nos permite ser exhaustivos. Precomputamos cada transición.
Tabla comparativa¶
| Nivel | Garantía | Coste de implementación | Sobrecoste de latencia | La Fase 30 implementa |
|---|---|---|---|---|
| 0 — Pedirlo amablemente | Ninguna | Cero | Cero | No |
| 1 — Retry + validar | Probabilística | Pequeño | N× al fallar | No |
| 2 — JSON mode | Parsea como JSON | Del proveedor | Despreciable | No |
| 3 — Restringido por esquema | Cumple esquema | Moderado | Máscara por paso | Sí |
| 4 — Restringido por grammar | En el lenguaje del grammar | Alto | Máscara por paso + parser | Solo descrito |
Dónde deja esto al agente (Fase 32)¶
El agente usará el Nivel 3 (la JSONSchemaMask de la Fase 30) para su salida. No necesita el Nivel 4. El esquema de conjugación es fijo, simple y conocido de antemano. Pagamos el coste de escribir una máscara consciente de JSON Schema una sola vez, y el resto del proyecto la consume.
Si alguna vez quisiéramos que el agente emitiese una frase corregida en inglés directamente (en vez de solo identificar la corrección en forma estructurada), querríamos el Nivel 4 — un grammar de frases en inglés. No lo necesitamos. El agente emite la corrección estructurada ({"verb": "go", "tense": "past_simple", "person": "3sg"} para la entrada "Yesterday he goed home"); el renderizado de la frase en lenguaje natural lo hace un paso de templating separado que consume la salida estructurada. No hace falta grammar allí porque la estructura dicta la plantilla.
Lo que esta página NO cubre¶
- Las matemáticas de por qué funciona el enmascarado — ver
02-logit-masks.md. - La implementación del FSM — ver
03-grammar-as-dfa.md. - Cómo los tokenizers y los grammars realmente se intersectan al nivel del token-trie — descrito en
03-grammar-as-dfa.md, no implementado en la Fase 30.
Siguiente: theory/02-logit-masks.md — la derivación y las matemáticas.