~/beer-and-code
▪ próximo evento AI Engineering Lab 3ª Edição — Da Arquitetura à Produção · 19—20 Set · 19 e 20 de setembro, das 09h às 13h — ao vivo via Google Meet garantir vaga
~ / tutoriais / json-que-nao-quebra-structured-output-llm $
Tutoriais

JSON que não quebra: structured output em LLM sem parser defensivo

LS Lucas Souza · · 16 min de leitura
JSON que não quebra: structured output em LLM sem parser defensivo

Se o seu código tem um try/catch em volta de um json_decode da resposta do modelo, você não tem contrato. Tem esperança.

E esperança não escala. Funciona nos seus dez testes manuais, passa no code review porque "o LLM é assim mesmo", e quebra na terça-feira às 14h quando um cliente manda um input que empurra a resposta contra o limite de tokens. Aí o JSON vem pela metade, o parser explode, e o job vai pra fila de falha sem ninguém entender por quê.

A boa notícia: structured output em LLM deixou de ser problema de prompt e virou problema de arquitetura, com solução de engenharia. Neste post você vai ver por que o modelo quebra o JSON, o que muda quando você garante a saída estruturada da IA no próprio decoder em vez de pedir por favor, como desenhar um schema que o modelo consegue cumprir, e como montar validação e retry que não viram loop infinito.

TL;DR

  • O que é: garantir que a saída do LLM chegue como estrutura tipada e validável, não como texto que às vezes parece JSON.
  • Como se faz: constrained decoding no provider (output_config na Anthropic, text.format com strict: true na OpenAI, responseSchema no Gemini) + validação semântica na sua camada + retry com orçamento fixo.
  • Custo/Acesso: disponível nas APIs pagas dos três grandes. Cobra tokens extras de system prompt e adiciona latência só na primeira request de cada schema.
  • Escopo: este post é sobre a saída do modelo. O desenho do schema de entrada das ferramentas está em Tool use na prática.

Por que o modelo quebra o JSON (e quando)

Primeiro, tire da cabeça a ideia de que o modelo "erra JSON" aleatoriamente. Ele erra em padrões, e padrão dá pra tratar.

O modo mais comum é o modelo ser prestativo demais: ele devolve o objeto certo, mas embrulhado numa cerca de markdown, com um "Claro! Aqui está o JSON solicitado:" antes. Depois vem a vírgula sobrando no último item do array. Depois a aspa não escapada dentro de um campo de texto livre. Depois o campo que você nunca definiu, inventado porque parecia útil. Depois o enum com um valor fora do domínio, tipo "status": "pendente_aprovacao" quando o seu contrato só aceita pending, approved e rejected.

E tem a falha que mais dói em produção, porque não é culpa do modelo: truncamento. Se a geração bate no teto de tokens antes de fechar o objeto, você recebe JSON pela metade. Na OpenAI isso volta com status: "incomplete" e incomplete_details.reason: "max_output_tokens" — um sinal explícito que o seu código provavelmente ignora, porque foi direto tentar parsear o corpo. Existe ainda o caso da recusa por segurança, em que a API devolve um campo refusal em vez de seguir o schema.

A causa raiz é simples e vale internalizar: sem restrição no decoder, o formato é apenas mais uma preferência estatística. Você escreveu "responda em JSON" no prompt e o modelo aprendeu que, depois dessa instrução, tokens de JSON são prováveis. Prováveis. Não obrigatórios. Amostragem é probabilística, e cauda longa existe. Quanto maior o schema, mais longo o campo de texto livre e mais atípico o input, mais gorda fica essa cauda.

Isso é exatamente o tipo de decisão que separa protótipo de sistema em produção, e é um dos módulos que a gente destrincha ao vivo no AI Engineering Lab 3ª Edição, nos dias 19 e 20 de setembro, junto com tool calling, roteamento, memória, grounding e tracing. Dois dias de imersão online, das 9h às 13h, com código rodando e não slide.

Structured output em LLM vs. "responda em JSON, por favor"

Existem três níveis de garantia, e a maioria dos times está no primeiro achando que está no terceiro.

Nível 1 — pedir no prompt. Zero garantia. Você está negociando com um sistema probabilístico. Funciona bem o suficiente pra enganar em desenvolvimento e mal o suficiente pra te acordar de madrugada.

Nível 2 — JSON mode. O response_format: { type: "json_object" } da OpenAI garante que a saída é JSON sintaticamente válido. Só isso. Nada impede o modelo de devolver {"foo": "bar"} quando você esperava quinze campos. Sintaxe garantida, contrato não.

Nível 3 — structured outputs. Aqui o provider compila o seu JSON Schema numa gramática e restringe a amostragem token a token. Tokens que violariam o schema simplesmente não são candidatos. Não é o modelo "tentando obedecer": é o decoder impedindo a saída inválida de existir.

Na Anthropic, o parâmetro é output_config:

import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": email_bruto}],
    output_config={
        "format": {
            "type": "json_schema",
            "schema": {
                "type": "object",
                "properties": {
                    "nome": {"type": "string"},
                    "email": {"type": "string", "format": "email"},
                    "plano": {"type": "string", "enum": ["free", "pro", "enterprise"]},
                    "quer_demo": {"type": "boolean"},
                },
                "required": ["nome", "email", "plano", "quer_demo"],
                "additionalProperties": False,
            },
        }
    },
)

Atenção a uma pegadinha de migração: o parâmetro antigo output_format ainda é aceito pela API durante a transição, mas o SDK Python v1.0+ levanta TypeError se você passar ele em client.beta.messages.create(). Use output_config. O recurso entrou em beta público em novembro de 2025 e virou GA em fevereiro de 2026.

Na OpenAI, o formato é text.format com strict: true:

text={
    "format": {
        "type": "json_schema",
        "name": "extracao_lead",
        "strict": True,
        "schema": schema,
    }
}

No Gemini, você combina responseMimeType: "application/json" com responseSchema, e tem um extra que vale conhecer: propertyOrdering, que fixa a ordem em que o modelo emite os campos. JSON Schema padrão ignora ordem de chave; geração autoregressiva não. Isso importa mais do que parece, e já volto nesse ponto.

Uma divisão que confunde muita gente: schema de tool é entrada, schema de resposta é saída. Quando você declara uma ferramenta com strict: true, está garantindo o formato dos argumentos que o modelo manda para a sua função. Isso é assunto do post sobre desenhar ferramentas que o LLM realmente consegue usar. Aqui a gente está do outro lado do fluxo: o que o modelo devolve para o seu sistema no fim da conversa. Os dois usam JSON Schema, os dois têm modo estrito, e os dois se combinam: é o mesmo function calling schema que você já escreve, apontado para lados opostos do fluxo. Mas são contratos diferentes, em pontos diferentes da arquitetura.

E structured output não é de graça. Ele injeta um system prompt adicional explicando o formato, o que sobe a contagem de tokens de entrada. A primeira request com um schema novo paga a compilação da gramática (algo entre 100 e 300ms na Anthropic), e o resultado fica em cache por 24 horas a partir do último uso. Mudar o schema invalida esse cache. Mudar o conjunto de tools também. E mexer no output_config.format invalida seu prompt cache. Guarde isso: vai virar decisão de deploy lá na frente.

Desenhando o schema que o modelo consegue cumprir

Aqui está o erro caro: achar que structured output aceita JSON Schema inteiro. Não aceita. Cada provider implementa um subconjunto, e o que está fora não é ignorado silenciosamente — costuma virar erro 400 no deploy.

Na Anthropic, ficam de fora schemas recursivos, $ref externo, restrições numéricas (minimum, maximum, multipleOf), restrições de string (minLength, maxLength) e additionalProperties com qualquer valor que não seja false. Passa enum, const, anyOf, $ref interno, os formatos de string usuais (date-time, email, uri, uuid) e minItems apenas com 0 ou 1.

Na OpenAI, o modo estrito é ainda mais rígido em um ponto específico: todo campo precisa estar em required. Não existe campo opcional. Se você quer opcionalidade, emula com união de tipos:

{
  "cnpj": { "type": ["string", "null"] }
}

E existem tetos: cerca de 100 properties no schema inteiro e profundidade de aninhamento até 5. O objeto raiz não pode ser anyOf, e default não é suportado.

No Gemini, schemas muito grandes ou muito aninhados são rejeitados com 400 e uma mensagem que raramente aponta o campo culpado.

Com isso na mão, quatro regras práticas de desenho:

1. Achate. Aninhamento profundo é onde tudo quebra: bate no teto do provider, come tokens, e aumenta a chance de o modelo perder o fio no meio de um objeto. Se você tem um cliente.endereco.geo.coordenadas.lat, provavelmente dá pra ter cliente_lat.

2. Feche o domínio com enum. Todo campo que na prática tem cinco valores possíveis deve ser um enum, não uma string livre. É a diferença entre validar depois e não conseguir gerar errado. Enum é a ferramenta mais subutilizada em schema de LLM.

3. Use description como micro-prompt. O campo description de cada propriedade vai junto pro modelo. {"prazo_dias": {"type": "integer", "description": "Dias úteis até a entrega. Se o texto não disser, use 0."}} resolve ambiguidade no ponto onde ela acontece, e é muito mais eficaz do que outro parágrafo no system prompt.

4. Ordene os campos para pensar antes de decidir. Geração é autoregressiva: o que sai primeiro condiciona o que vem depois. Se o objeto começa com "decisao": "aprovado" e só depois tem "justificativa", o modelo decidiu antes de raciocinar e a justificativa virou racionalização. Inverta. Coloque um campo curto de raciocínio antes dos campos de decisão.

Essa quarta regra tem respaldo na literatura, e vale conhecer o contra-argumento inteiro. O paper Let Me Speak Freely? mostrou que restrição de formato degrada a capacidade de raciocínio do modelo, e que quanto mais estrita a restrição, maior a queda. Em tarefas de classificação e preenchimento de campos o formato rígido ajuda. Em tarefas que exigem raciocínio de verdade, ele atrapalha. A mitigação recomendada é a mesma coisa que a regra 4 faz na prática: deixe o raciocínio acontecer solto primeiro, e só depois aperte a estrutura. Seja em dois campos do mesmo objeto, seja em duas chamadas separadas.

▪ Clã Beer and Code

Tutorial te mostra o caminho — no Clã você constrói junto. Aula ao vivo toda semana, projetos reais de Engenharia de IA, ao lado de quem já está em produção.

Entrar no Clã

Validação e retry sem loop infinito

Agora a parte que quase todo mundo pula: schema válido não é dado correto.

O decoder garante que {"cnpj": "00000000000000"} bate com o contrato. Ele não sabe que esse CNPJ não existe. Garante que {"prazo_dias": -5} é um inteiro — e como restrição numérica não é suportada em modo estrito na Anthropic, o negativo passa liso. Constrained decoding elimina a classe de erro de forma. A classe de erro de conteúdo continua inteira, e é sua.

Então são duas camadas, sempre:

from typing import Literal
from pydantic import BaseModel, Field, field_validator

class Lead(BaseModel):
    raciocinio: str = Field(description="Por que classificou assim. 1 frase.")
    nome: str
    email: str
    plano: Literal["free", "pro", "enterprise"]
    prazo_dias: int

    @field_validator("prazo_dias")
    @classmethod
    def prazo_positivo(cls, v: int) -> int:
        if v < 0:
            raise ValueError("prazo_dias não pode ser negativo")
        return v

E o retry. O padrão que funciona tem quatro propriedades, e a ordem importa:

MAX_TENTATIVAS = 2

def extrair(texto: str) -> Lead:
    erro_anterior = None
    for tentativa in range(MAX_TENTATIVAS + 1):
        mensagens = montar_mensagens(texto, erro_anterior)
        bruto = chamar_modelo(mensagens)          # já com structured output
        try:
            return Lead.model_validate_json(bruto)
        except ValidationError as e:
            erro_anterior = e.json()               # devolve o erro pro modelo
            metrics.increment("lead.retry", tags=[f"tentativa:{tentativa}"])
    raise ContratoQuebrado(texto, erro_anterior)   # dead letter, não loop

Primeiro: orçamento fixo. Duas tentativas extras, no máximo. Retry infinito com LLM não é resiliência, é queima de crédito com log bonito. Se falhou três vezes, o problema não é sorte.

Segundo: devolva o erro de validação pro modelo. A mensagem do Pydantic diz exatamente qual campo falhou e por quê. Isso é contexto de altíssimo valor e custa quase nada. Retry cego, repetindo o mesmo prompt, tende a produzir a mesma falha.

Terceiro: falha terminal é dead letter, não exceção engolida. O registro vai pra uma fila de revisão com o input original e o último erro. Alguém olha. Ninguém finge que não aconteceu.

Quarto: taxa de retry é métrica de produto, não ruído de log. Se um campo específico puxa 8% de retry, o schema está mal desenhado — descrição ambígua, enum incompleto, campo que deveria ser opcional. A métrica te diz onde consertar o contrato. Esse é o mesmo raciocínio de montar evals honestos pro seu agente: sem medir, você está no escuro.

E uma regra de fronteira: o objeto validado é o único que atravessa pro resto do sistema. Nada de array cru de LLM circulando por três camadas de serviço. Em Laravel isso é um DTO na borda, e daí pra dentro é código normal:

public function handle(array $payloadDoModelo): LeadData
{
    $validado = Validator::make($payloadDoModelo, [
        'nome'       => ['required', 'string', 'max:120'],
        'email'      => ['required', 'email'],
        'plano'      => ['required', Rule::in(['free', 'pro', 'enterprise'])],
        'prazo_dias' => ['required', 'integer', 'min:0'],
    ])->validate();

    return LeadData::from($validado);
}

Redundante com o schema? Em parte. E é de propósito. O schema protege contra o modelo; o validator protege contra o schema estar errado, contra troca de provider, contra o dia em que alguém mexer no prompt e esquecer do contrato.

Versionando o contrato quando o schema muda

Schema de saída é API interna. E API interna que muda sem versão quebra consumidor.

O momento em que isso morde é previsível: você adiciona um campo, faz deploy, e os registros gravados ontem não têm esse campo. Ou você renomeia status para situacao e o dashboard que lê a coluna JSON some com metade dos dados. Nada disso é problema de IA. É migration mal feita, com um LLM no meio.

Três práticas que resolvem:

Grave a versão junto com o dado. Um campo schema_version na tabela, preenchido pelo seu código (não pelo modelo). Quando você precisar reprocessar ou debugar um registro de três meses atrás, sabe contra qual contrato ele foi gerado.

Classifique a mudança antes de fazer. Adicionar campo com valor padrão no seu lado é aditivo e barato. Remover campo, renomear campo, apertar enum ou trocar tipo é breaking — e exige rodar v1 e v2 lado a lado até o consumidor migrar, exatamente como você faria com um endpoint REST.

Trave com golden set no CI. Vinte a cinquenta inputs reais, incluindo os feios: campo vazio, texto em dois idiomas, o PDF que veio com OCR ruim, o caso que gerou o incidente do mês passado. A cada mudança de schema ou de prompt, roda tudo e compara. É o mesmo princípio dos casos adversariais para descobrir onde o prompt quebra, aplicado ao contrato em vez do texto.

E lembre do custo operacional que mencionei antes: mudar o schema invalida o cache de gramática do provider e o seu prompt cache. Na primeira janela depois do deploy você vai ver latência e custo de token mais altos. Não é regressão. É a conta da mudança de contrato — mas é bom saber disso antes do alerta disparar, não depois.

Limitações e pontos de atenção

Estrutura cobra imposto de raciocínio. É a conclusão do "Let Me Speak Freely?" e vale repetir: não force um schema apertado em cima de uma tarefa que exige pensar. Separe as etapas.

Cobertura é desigual. Modelos menores, modelos open-source servidos por você e alguns endpoints de compatibilidade não implementam constrained decoding de verdade. Se a sua arquitetura tem fallback entre providers, teste o fallback com o mesmo golden set. Descobrir na hora do incidente que o modelo reserva só tem JSON mode é ruim.

Truncamento continua sendo seu inimigo. Structured output não garante que a resposta caiba em max_tokens. Cheque o motivo de parada antes de parsear, sempre. Um objeto grande com campos de texto livre é candidato natural a estourar.

Schema é superfície de dados. Os nomes e descrições dos campos vão pro provider em toda request. Não coloque regra de negócio sensível, nome de cliente ou estrutura interna confidencial dentro de description sem pensar.

FAQ rápido

Structured output substitui validação no meu backend? Não. Ele garante forma, não conteúdo. CNPJ inexistente, data no passado, valor fora da faixa e ID de um registro que não existe passam limpo pelo schema. Mantenha as duas camadas.

Por que meu schema válido dá 400? Quase sempre é keyword fora do subconjunto suportado. Os suspeitos de sempre: minLength/maxLength, minimum/maximum, schema recursivo, additionalProperties diferente de false, ou campo faltando em required no modo estrito da OpenAI. Compare seu schema com a tabela de suporte do provider antes de culpar o modelo.

Structured output deixa a resposta mais lenta ou mais cara? Um pouco das duas. Ele injeta um system prompt adicional e a primeira request com cada schema novo paga a compilação da gramática. Depois disso a gramática fica em cache por 24 horas e o overhead some. Na prática, é mais barato que o retry que você faz hoje.

Uso strict: true na tool ou schema na resposta? Os dois, em pontos diferentes. Modo estrito na tool garante os argumentos que chegam na sua função; schema na resposta garante o objeto final que entra no seu banco. Um cuida da entrada, o outro da saída.

O contrato é seu, não do modelo

O resumo cabe em uma frase: pare de tratar a saída do LLM como texto e comece a tratar como interface. Schema restringe o decoder, validação semântica cuida do que o schema não alcança, retry tem orçamento e devolve o erro pro modelo, e o contrato tem versão como qualquer outra API.

O que muda no seu código é modesto e o efeito é grande: some o parser defensivo, some o try/catch genérico, e a resposta do modelo passa a entrar nos sistemas internos como qualquer outro payload tipado. O LLM vira mais um serviço com contrato, e não uma exceção que todo mundo trata na base do medo.

E se você ainda não desenhou as ferramentas do outro lado do fluxo, é o próximo passo natural: tool use na prática, desenhando ferramentas que o LLM realmente consegue usar.

Lucas Souza
Escrito por
Lucas Souza

{AI Engineer} — apaixonado por Laravel, arquitetura de software e construir produtos com impacto. Compartilho aqui tutoriais, descobertas e reflexões sobre o dia a dia de engenharia.

▪ Clã Beer and Code

Conteúdo é o que não falta. Falta quem desembaralhe: o que importa agora é como implementar do jeito certo. No Clã você tem isso ao vivo, toda semana, com quem já filtrou o ruído.

Entrar no Clã
Conheça o Clã Beer and Code
tocando