Peça a um LLM para gerar um objeto JSON e ele eventualmente emitirá uma vírgula no final, uma quebra de linha não escapada dentro de uma string, ou um bare word onde deveria haver uma key entre aspas. O erro não é um bug no modelo. É uma consequência de como o autoregressive sampling funciona: a cada passo, o modelo vê cada token em seu vocabulário como um candidato, incluindo tokens que tornariam a saída parcial sintaticamente inválida.

Você pode corrigir isso restringindo o próprio vocabulário de tokens. Em vez de deixar o modelo escolher entre trinta e dois mil tokens, dê a ele apenas o subconjunto que mantém o parse parcial em um estado válido. A técnica se chama grammar-constrained decoding, e transforma um LLM de um gerador de texto probabilístico em um sintetizador de código consciente da sintaxe.

Por que o prompt engineering não pode resolver a validade da sintaxe

A maioria dos desenvolvedores começa com a abordagem óbvia: adicionar “Output valid JSON only” ao system prompt, repetir o schema na user message, e esperar. Isso ajuda, mas não é uma garantia.

O modelo não faz parse de sua própria saída enquanto a gera. Ele prediz o próximo token com base na distribuição de probabilidade sobre o vocabulário, condicionada no prompt e em tudo o que foi gerado até agora. Um token como , poderia ter alta probabilidade após uma chave de fechamento porque vírgulas frequentemente aparecem nessa posição nos dados de treinamento. Se a vírgula é sintaticamente legal neste ponto exato deste objeto JSON específico não faz parte do processo de decisão do modelo.

Mesmo modelos fine-tunados com RLHF apenas deslocam a probability mass em direção a padrões geralmente corretos. Eles não eliminam caminhos inválidos. Se você precisa de uma garantia, não de uma alta probabilidade, você precisa mudar o próprio sampling mechanism.

Como funciona o grammar-constrained decoding

A ideia central é simples: manter um parser state junto com a geração do LLM, e em cada posição de token, mascarar todo token que transicionaria o parser para um error state.

No passo t, o modelo produz um vector de logits sobre todo o vocabulário. Normalmente você aplica temperature scaling e faz sampling. Com constrained decoding, você primeiro pergunta a uma grammar engine: dados os tokens gerados até agora, quais tokens são legais a seguir? O engine retorna uma bitmask sobre o vocabulário. Você coloca os logits dos tokens ilegais em infinito negativo, executa softmax sobre o restante, e faz sampling da distribuição filtrada.

A grammar engine não precisa fazer parse da saída completa depois do fato. Ela faz parse incrementalmente. Depois que cada token é aceito, ela atualiza sua state machine interna. Quando o parser alcança um accepting state, a geração pode parar. Quando está em um non-accepting state, a geração continua.

Isso significa que o modelo ainda pode escolher livremente entre todas as continuações sintaticamente válidas. Ele retém criatividade dentro da grammar. Ele simplesmente não pode sair dela.

Como a implementação se parece

Aqui está um sketch Python simplificado usando lark, uma library de parsing moderna, para construir uma grammar mask. Em produção você usaria um engine mais rápido como outlines, guidance, ou o suporte GBNF integrado de llama.cpp, mas o princípio é 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 é uma implementação ingênua. Um sistema real não itera sobre todo o vocabulário em cada passo. Ele constrói um finite-state automaton a partir da grammar antecipadamente e pré-computa quais tokens do vocabulário são válidos a partir de cada automaton state. Em tempo de geração, a mask é um único table lookup.

Onde o constrained decoding se encaixa em um bounded context

Bounded contexts em Domain-Driven Design são definidos por sua ubiquitous language. Essa linguagem é frequentemente formalizada como uma DSL pequena: uma sintaxe de query, uma rule grammar, um formato de config, ou uma expression language. Quando você pede a um LLM para gerar ou editar artefatos dentro desse contexto, você quer que ele fale a linguagem corretamente.

Grammar constraints reforçam a boundary. O LLM não pode inventar fields que não existem na sua DSL, não pode emitir mismatched brackets, e não pode produzir valores fora do enum set permitido. A sintaxe se torna uma garantia de compile-time, não uma oração de runtime.

Isso é especialmente valioso quando a saída do LLM alimenta diretamente um parser ou interpretador. Se a sua DSL é parseada por um recursive descent parser estrito, um único unexpected token aborta todo o pipeline. O constrained decoding remove esse failure mode.

Os trade-offs que você deve conhecer

O constrained decoding não é de graça. O overhead depende de como você implementa a grammar engine.

Throughput. Construir a mask em cada forward pass adiciona latência. Engines rápidos como outlines ou llama.cpp com GBNF pré-computam mapeamentos de token para estado, reduzindo o custo por passo para aproximadamente 5-10% de overhead. Implementações ingênuas que re-parse em cada token candidato podem desacelerar a geração por ordens de magnitude.

Grammar expressiveness. Nem toda sintaxe é fácil de expressar em uma grammar que mapeie limpamente para token masks. Regras sensíveis ao contexto, como “este identifier deve ter sido declarado anteriormente no scope”, não são capturadas pelas context-free grammars padrão. Você pode restringir a sintaxe com uma grammar. Você não pode restringir a semântica sem maquinaria adicional.

Model compatibility. Algumas inference APIs, especialmente APIs cloud hospedadas, não expõem logits ou permitem masking personalizado. A API da OpenAI oferece JSON mode, que é uma grammar constraint hardcoded especificamente para JSON, mas você não pode trazer sua própria grammar. Para DSLs personalizadas, você tipicamente precisa executar inference localmente ou usar um framework que exponha os logits.

Partial token problems. Uma token boundary nem sempre se alinha com uma grammar boundary. Uma grammar pode esperar uma string entre aspas, mas o próximo token pode ser "hel seguido de lo". A grammar engine deve raciocinar sobre partial token matches, não apenas tokens inteiros. Libraries de produção lidam com isso mapeando tokens para character prefixes e verificando prefix validity contra a grammar.

Como implementar isso de fato hoje

Você não precisa escrever uma grammar engine do zero. Várias libraries lidam com as partes difíceis.

outlines é o mais acessível para usuários Python. Você define um modelo Pydantic ou uma expressão regular, e ele compila a constraint em um finite-state automaton eficiente que se integra com Hugging Face transformers e 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 da Microsoft oferece um sistema de templating mais rico. Você entrelaça grammar constraints com prompt templates, e ele lida com o masking internamente.

llama.cpp suporta GBNF (GGML BNF), um formato de grammar similar a BNF. Você passa um arquivo .gbnf em tempo de inference, e o engine a impõe no nível de C++. Esta é a opção mais rápida para inference local.

jsonformer e instructor são alternativas mais leves especificamente para JSON. Eles usam constraints do tipo grammar mas são restritos a JSON schema. Se a sua DSL de bounded context acontece de ser JSON-shaped, eles são o ponto de partida mais fácil.

O que o constrained decoding não corrige

Um LLM com grammar constraint ainda pode gerar saída semanticamente incorreta. Ele pode emitir uma query SQL válida que referencie uma tabela inexistente, ou uma config válida que defina um número de porta inválido. A sintaxe é um guardrail necessário, não suficiente.

Você ainda precisa de validation layers abaixo do LLM. Faça parse da saída, type-check contra o seu domain model, e rejeite ou retry se a semântica estiver errada. O constrained decoding reduz os failure modes de “erro de sintaxe” para “erro de lógica”. Isso é uma grande melhoria, mas não é um passe livre.

Além disso, grammar constraints não tornam o modelo mais inteligente. Se a grammar for muito permissiva, o modelo ainda pode vaguear para território sem sentido mas sintaticamente válido. Se a grammar for muito restritiva, o modelo não tem um caminho válido para expressar uma resposta correta, e você obtém garbage de baixa probabilidade ou loops repetitivos. Projetar a grammar é parte do trabalho de engenharia.

Comece com o formato de saída, não com o modelo

Antes de ajustar o prompt ou mudar para um modelo maior, pergunte-se se o problema é realmente de reasoning ou de sintaxe. Se o LLM entende o que você quer mas ocasionalmente o formata errado, grammar constraints são o correto a fazer. Elas são mais baratas que fine-tuning, mais confiáveis que prompt engineering, e dão uma garantia que o sampling sozinho não pode.

Defina a grammar para a sua DSL de bounded context, plugue-a em um constrained decoder, e deixe o modelo gerar dentro das linhas. A saída ainda o surpreenderá ocasionalmente, mas nunca será um erro de sintaxe.

FAQ

O que é grammar-constrained decoding?

Grammar-constrained decoding é uma técnica que filtra o vocabulário de um LLM em cada passo de geração usando uma grammar formal. Apenas tokens que mantêm a saída parcial sintaticamente válida são considerados, garantindo que a saída final esteja em conformidade com a grammar.

O constrained decoding reduz a qualidade da saída?

Não para tarefas ligadas à sintaxe. O modelo ainda escolhe livremente entre todas as continuações gramaticalmente válidas. Para escrita criativa aberta, constraints prejudicariam a qualidade. Para código, config ou geração de DSLs, elas melhoram tanto a correção quanto a confiabilidade.

Posso usar isso com as APIs da OpenAI ou Claude?

A OpenAI oferece JSON mode, que é uma grammar constraint built-in especificamente para JSON. Você não pode trazer sua própria grammar. A Anthropic não expõe atualmente grammar constraints. Para DSLs personalizadas, você tipicamente precisa de inference local com vLLM, llama.cpp, ou um engine similar.

Quais formatos de grammar são suportados?

Os formatos comuns incluem EBNF, BNF, PEG e GBNF. Libraries como outlines aceitam expressões regulares e modelos Pydantic. llama.cpp usa GBNF. Escolha o formato que sua inference engine suporta.