Pídele a un LLM que genere un objeto JSON y eventualmente emitirá una coma al final, un salto de línea sin escapar dentro de un string, o un bare word donde debería haber una key entre comillas. El error no es un bug en el modelo. Es una consecuencia de cómo funciona el autoregressive sampling: en cada paso, el modelo ve cada token en su vocabulario como candidato, incluyendo tokens que harían la salida parcial sintácticamente inválida.

Puedes arreglar esto restringiendo el vocabulario de tokens mismo. En lugar de dejar que el modelo elija entre treinta y dos mil tokens, dale solo el subconjunto que mantiene el parse parcial en un estado válido. La técnica se llama grammar-constrained decoding, y convierte un LLM de un generador de texto probabilístico en un sintetizador de código consciente de la sintaxis.

Por qué el prompt engineering no puede resolver la validez de la sintaxis

La mayoría de los desarrolladores empieza con el enfoque obvio: agregar “Output valid JSON only” al system prompt, repetir el schema en el user message, y esperar. Esto ayuda, pero no es una garantía.

El modelo no parsea su propia salida mientras la genera. Predice el siguiente token basado en la distribución de probabilidad sobre el vocabulario, condicionada en el prompt y todo lo generado hasta ahora. Un token como , podría tener alta probabilidad después de una llave de cierre porque las comas a menudo aparecen en esa posición en los datos de entrenamiento. Si la coma es sintácticamente legal en este punto exacto de este objeto JSON específico no es parte del proceso de decisión del modelo.

Incluso los modelos fine-tuneados con RLHF solo desplazan la probability mass hacia patrones generalmente correctos. No eliminan los caminos inválidos. Si necesitas una garantía, no una alta probabilidad, necesitas cambiar el sampling mechanism mismo.

Cómo funciona el grammar-constrained decoding

La idea central es simple: mantener un parser state junto con la generación del LLM, y en cada posición de token, enmascarar todo token que transicionaría el parser a un error state.

En el paso t, el modelo produce un vector de logits sobre todo el vocabulario. Normalmente aplicas temperature scaling y sampleas. Con constrained decoding, primero le preguntas a una grammar engine: dados los tokens generados hasta ahora, ¿qué tokens son legales a continuación? El engine devuelve una bitmask sobre el vocabulario. Pones los logits de los tokens ilegales a infinito negativo, ejecutas softmax sobre el resto, y sampleas de la distribución filtrada.

El grammar engine no necesita parsear la salida completa después del hecho. Parsea incrementalmente. Después de que cada token es aceptado, actualiza su state machine interna. Cuando el parser alcanza un accepting state, la generación puede detenerse. Cuando está en un non-accepting state, la generación continúa.

Esto significa que el modelo todavía puede elegir libremente entre todas las continuaciones sintácticamente válidas. Retiene creatividad dentro de la grammar. Simplemente no puede salirse de ella.

Cómo se ve la implementación

Aquí hay un sketch simplificado en Python usando lark, una library de parsing moderna, para construir una grammar mask. En producción usarías un engine más rápido como outlines, guidance, o el soporte GBNF integrado de llama.cpp, pero el principio es idéntico.

from lark import Lark, Token
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch

# Define a tiny grammar for a simple config DSL.
grammar = r"""
    start: pair+
    pair: KEY "=" VALUE
    KEY: /[a-z_]+/
    VALUE: /"[^"]*"/
"""

parser = Lark(grammar, parser="lalr")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")

def legal_next_tokens(partial_text: str) -> set[int]:
    """Return token IDs that do not cause a parse error."""
    legal = set()
    for token_id in range(tokenizer.vocab_size):
        candidate = tokenizer.decode([token_id], skip_special_tokens=True)
        try:
            # Try parsing partial_text + candidate as a prefix.
            parser.parse(partial_text + candidate)
            legal.add(token_id)
        except Exception:
            # Some exceptions are expected mid-parse.
            # A production engine tracks parser state, not exceptions.
            pass
    return legal

# Generate one token at a time with grammar masking.
prompt = "Generate a config: "
input_ids = tokenizer.encode(prompt, return_tensors="pt")
generated = input_ids.clone()

for _ in range(50):
    outputs = model(generated)
    logits = outputs.logits[:, -1, :]

    partial = tokenizer.decode(generated[0], skip_special_tokens=True)
    legal_ids = legal_next_tokens(partial)

    mask = torch.full_like(logits, float("-inf"))
    for tid in legal_ids:
        mask[0, tid] = logits[0, tid]

    next_token = torch.argmax(mask, dim=-1).unsqueeze(0)
    generated = torch.cat([generated, next_token], dim=-1)

    # Stop if the parser is in an accepting state.
    if parser.parse(partial):
        break

print(tokenizer.decode(generated[0], skip_special_tokens=True))

Esta es una implementación ingenua. Un sistema real no itera sobre todo el vocabulario en cada paso. Construye un finite-state automaton a partir de la grammar de antemano y precomputa qué tokens del vocabulario son válidos desde cada automaton state. En tiempo de generación, la mask es una sola búsqueda en tabla.

Dónde encaja el constrained decoding en un bounded context

Los bounded contexts en Domain-Driven Design están definidos por su ubiquitous language. Ese lenguaje a menudo se formaliza como una DSL pequeña: una sintaxis de query, una rule grammar, un formato de config, o una expression language. Cuando le pides a un LLM que genere o edite artefactos dentro de ese contexto, quieres que hable el lenguaje correctamente.

Las grammar constraints hacen cumplir el boundary. El LLM no puede inventar fields que no existen en tu DSL, no puede emitir mismatched brackets, y no puede producir valores fuera del enum set permitido. La sintaxis se convierte en una garantía de compile-time, no en una oración de runtime.

Esto es especialmente valioso cuando la salida del LLM se alimenta directamente a un parser o intérprete. Si tu DSL es parseado por un recursive descent parser estricto, un solo unexpected token aborta todo el pipeline. El constrained decoding elimina ese failure mode.

Los trade-offs que deberías conocer

El constrained decoding no es gratis. El overhead depende de cómo implementes el grammar engine.

Throughput. Construir la mask en cada forward pass agrega latencia. Engines rápidos como outlines o llama.cpp con GBNF precomputan los mapeos de token a estado, reduciendo el costo por paso a aproximadamente un 5-10% de overhead. Implementaciones ingenuas que reparsean en cada token candidato pueden ralentizar la generación por órdenes de magnitud.

Grammar expressiveness. No toda sintaxis es fácil de expresar en una grammar que mapee limpiamente a token masks. Reglas sensibles al contexto, como “este identifier debe haber sido declarado anteriormente en el scope”, no son capturadas por las context-free grammars estándar. Puedes restringir la sintaxis con una grammar. No puedes restringir la semántica sin maquinaria adicional.

Model compatibility. Algunas inference APIs, especialmente APIs cloud hospedadas, no exponen logits ni permiten masking personalizado. La API de OpenAI ofrece JSON mode, que es una grammar constraint hardcodeada específicamente para JSON, pero no puedes traer tu propia grammar. Para DSLs personalizadas, típicamente necesitas ejecutar inference localmente o usar un framework que exponga los logits.

Partial token problems. Un token boundary no siempre se alinea con un grammar boundary. Una grammar podría esperar un string entre comillas, pero el siguiente token podría ser "hel seguido de lo". El grammar engine debe razonar sobre partial token matches, no solo tokens completos. Las libraries de producción manejan esto mapeando tokens a character prefixes y verificando la prefix validity contra la grammar.

Cómo implementar esto hoy en día

No necesitas escribir un grammar engine desde cero. Varias libraries manejan las partes difíciles.

outlines es el más accesible para usuarios de Python. Defines un modelo Pydantic o una expresión regular, y compila la constraint en un finite-state automaton eficiente que se integra con Hugging Face transformers y vLLM.

from outlines import models, generate

model = models.transformers("microsoft/Phi-3-mini-4k-instruct")
generator = generate.regex(model, r'\{[a-z_]+\}')
result = generator("Extract the key: ")

guidance de Microsoft ofrece un sistema de templating más rico. Entrelazas grammar constraints con prompt templates, y maneja el masking internamente.

llama.cpp soporta GBNF (GGML BNF), un formato de grammar similar a BNF. Pasas un archivo .gbnf en tiempo de inference, y el engine lo hace cumplir a nivel de C++. Esta es la opción más rápida para inference local.

jsonformer e instructor son alternativas más ligeras específicamente para JSON. Usan constraints similares a grammar pero están restringidos a JSON schema. Si tu DSL de bounded context resulta ser JSON-shaped, son el punto de partida más fácil.

Lo que el constrained decoding no arregla

Un LLM con grammar constraint todavía puede generar salida semánticamente incorrecta. Puede emitir una query SQL válida que referencie una tabla inexistente, o una config válida que establezca un número de puerto inválido. La sintaxis es un guardrail necesario, no suficiente.

Todavía necesitas capas de validación debajo del LLM. Parsea la salida, haz type-check contra tu modelo de dominio, y rechaza o reintenta si la semántica es incorrecta. El constrained decoding reduce los failure modes de “error de sintaxis” a “error de lógica”. Eso es una gran mejora, pero no es un pase libre.

Además, las grammar constraints no hacen al modelo más inteligente. Si la grammar es demasiado permisiva, el modelo todavía puede vagar a territorio sin sentido pero sintácticamente válido. Si la grammar es demasiado restrictiva, el modelo no tiene un camino válido para expresar una respuesta correcta, y obtienes garbage de baja probabilidad o loops repetitivos. Diseñar la grammar es parte del trabajo de ingeniería.

Empieza con el formato de salida, no con el modelo

Antes de afinar el prompt o cambiar a un modelo más grande, pregúntate si el problema es realmente de reasoning o de sintaxis. Si el LLM entiende lo que quieres pero ocasionalmente lo formatea mal, las grammar constraints son la solución correcta. Son más baratas que el fine-tuning, más confiables que el prompt engineering, y te dan una garantía que el sampling solo no puede.

Define la grammar para tu DSL de bounded context, conéctala a un constrained decoder, y deja que el modelo genere dentro de las líneas. La salida todavía te sorprenderá ocasionalmente, pero nunca será un error de sintaxis.

FAQ

¿Qué es grammar-constrained decoding?

Grammar-constrained decoding es una técnica que filtra el vocabulario de un LLM en cada paso de generación usando una grammar formal. Solo se consideran tokens que mantengan la salida parcial sintácticamente válida, garantizando que la salida final cumpla con la grammar.

¿El constrained decoding reduce la calidad de la salida?

No para tareas ligadas a la sintaxis. El modelo todavía elige libremente entre todas las continuaciones gramaticalmente válidas. Para escritura creativa abierta, las constraints dañarían la calidad. Para código, config o generación de DSLs, mejoran tanto la corrección como la confiabilidad.

¿Puedo usar esto con las APIs de OpenAI o Claude?

OpenAI ofrece JSON mode, que es una grammar constraint built-in específicamente para JSON. No puedes traer tu propia grammar. Anthropic no expone actualmente grammar constraints. Para DSLs personalizadas, típicamente necesitas inference local con vLLM, llama.cpp, o un engine similar.

¿Qué formatos de grammar se soportan?

Los formatos comunes incluyen EBNF, BNF, PEG y GBNF. Libraries como outlines aceptan expresiones regulares y modelos Pydantic. llama.cpp usa GBNF. Elige el formato que soporte tu inference engine.