aicert.study

Saída Estruturada com JSON Schema no Claude: Guia Prático para Reduzir Falhas de Parsing

15 de julho de 2026

Saída Estruturada com JSON Schema no Claude: Guia Prático para Reduzir Falhas de Parsing

A maioria das falhas de "parsing de JSON" em integrações com o Claude em produção não são bugs de JSON — são bugs de prompting disfarçados de erro de parser. Se o seu pipeline lança json.JSONDecodeError ou SyntaxError: Unexpected token, o problema geralmente não é um parser melhor. É forçar estrutura no nível da API, em vez de pedir estrutura em texto livre.

O problema real: você não tem um bug de JSON

Pedir para o modelo "responder em JSON" dentro de um prompt de texto comum trata a estrutura como sugestão de estilo, não como contrato. O modelo fica livre para:

  • Envolver a resposta em json ... (ótimo pra um humano lendo no chat, inútil pra JSON.parse()).
  • Abrir com uma frase tipo "Claro, aqui está o JSON solicitado:" antes da chave {.
  • Omitir um campo obrigatório quando o input de origem é ambíguo.
  • Produzir snake_case numa chamada e camelCase na próxima.

Nada disso é problema de parser. É o que acontece quando estrutura é sugestão, não contrato.

A solução: tratar saída estruturada como tool use

A Claude API não tem um "modo JSON" separado — e não precisa ter. O mecanismo de tool use (function calling) já resolve isso, porque desloca a garantia estrutural do texto livre para o input_schema da ferramenta. Quando o modelo chama uma ferramenta, tool_use.input chega como objeto já processado pelo SDK — não como string para você re-parsear.

A parte que realmente torna isso confiável é forçar a chamada com tool_choice, em vez de deixar o modelo decidir se chama a ferramenta ou só responde em texto:

# pip install anthropic
import anthropic

client = anthropic.Anthropic()

INVOICE_SCHEMA = {
    "type": "object",
    "properties": {
        "invoice_number": {
            "type": "string",
            "description": "Identificador único da nota, ex: INV-2026-0001."
        },
        "total_amount": {
            "type": "number",
            "description": "Valor total, sem símbolo de moeda."
        },
        "currency": {
            "type": "string",
            "enum": ["BRL", "USD", "EUR"]
        }
    },
    "required": ["invoice_number", "total_amount", "currency"]
}

# qualquer texto de entrada real — nota fiscal, e-mail, PDF extraído, etc.
document_text = """Nota Fiscal INV-2026-0042
Valor total: R$ 1.240,00"""

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    tools=[{
        "name": "extract_invoice",
        "description": "Extrai os dados estruturados da nota fiscal.",
        "input_schema": INVOICE_SCHEMA
    }],
    # força a chamada — o modelo não pode "só responder em texto"
    tool_choice={"type": "tool", "name": "extract_invoice"},
    messages=[{"role": "user", "content": document_text}]
)

tool_block = next(b for b in response.content if b.type == "tool_use")
data = tool_block.input  # já é um dict — nada para json.loads()
print(data)
# {'invoice_number': 'INV-2026-0042', 'total_amount': 1240.0, 'currency': 'BRL'}

Esse trecho é completo — exporte ANTHROPIC_API_KEY e rode como está. Duas escolhas fazem o trabalho de verdade aqui:

  • tool_choice especifica o nome exato da ferramenta. Sem isso, o modelo ainda pode preferir responder em texto puro quando o input for ambíguo.
  • Cada campo do schema tem uma description. Ela não é decoração — é instrução de prompt embutida no schema, e o modelo a usa pra decidir o que colocar em cada campo.

Schemas que reduzem ambiguidade, não só validam

Um input_schema bem escrito faz o trabalho de um prompt inteiro. Três hábitos que reduzem erro de preenchimento antes mesmo de qualquer validação rodar:

Prefira enum a texto livre sempre que o conjunto de valores é fechado. "currency": {"type": "string"} deixa o modelo livre pra escrever "real", "R$" ou "brl" em chamadas diferentes. "enum": ["BRL", "USD", "EUR"] elimina essa variação estruturalmente — o modelo escolhe entre opções, não inventa uma string.

Evite aninhamento profundo sem necessidade. Schemas com 4+ níveis de profundidade (objeto dentro de array dentro de objeto dentro de objeto) aumentam a chance de o modelo perder um campo obrigatório no nível mais interno. Achate a estrutura quando o domínio permitir.

Escreva a description pensando em quem nunca viu o documento de origem. "Nome do cliente" é ambíguo se o documento tiver nome de contato e razão social. "Razão social da empresa compradora, como aparece no cabeçalho da nota" resolve a ambiguidade sem precisar de um exemplo.

O erro silencioso: truncamento por max_tokens

Um tool_use.input incompleto não lança um erro de schema — ele simplesmente para no meio, e o SDK entrega o que conseguiu montar até ali. Se o seu schema tem uma lista longa (dezenas de line_items, por exemplo), um max_tokens insuficiente corta a resposta antes do fechamento do JSON, e você só descobre isso ao tentar acessar um campo que nunca chegou.

Sempre confira response.stop_reason antes de confiar no resultado. Se o valor for "max_tokens", o objeto pode estar incompleto — trate isso como falha e tente de novo com um orçamento de tokens maior, nunca como dado parcialmente válido.

O padrão que resolve o resto: validar e devolver o erro ao modelo

Nenhum schema elimina 100% dos erros. O objetivo realista não é "nunca falhar" — é falhar de um jeito que se autocorrige. O padrão mais robusto trata a resposta do modelo como uma tentativa, valida contra o schema com uma biblioteca de verdade (não um try/except solto), e devolve o erro específico da validação no próximo turno — deixando o próprio modelo corrigir o campo que errou.

  1. Chama o modelo com tool_choice forçado
  2. Valida tool_use.input contra o schema
  3. Se inválido, envia o erro específico de volta no próximo turno
  4. Repete até validar ou esgotar as tentativas
# pip install jsonschema
from jsonschema import validate, ValidationError

def get_structured_output(client, schema, tool_name, tool_description, messages, max_retries=2):
    max_tokens = 1024
    for attempt in range(max_retries + 1):
        response = client.messages.create(
            model="claude-opus-4-8",
            max_tokens=max_tokens,
            tools=[{"name": tool_name, "description": tool_description, "input_schema": schema}],
            tool_choice={"type": "tool", "name": tool_name},
            messages=messages,
        )

        if response.stop_reason == "max_tokens":
            # não reaproveita a mensagem truncada — a doc oficial da Anthropic
            # recomenda reenviar com mais orçamento, não consertar o que veio incompleto
            max_tokens *= 2
            continue

        # só chega aqui com uma resposta completa — agora sim vira histórico
        messages.append({"role": "assistant", "content": response.content})

        tool_block = next((b for b in response.content if b.type == "tool_use"), None)
        if tool_block is None:
            messages.append({"role": "user", "content": "Use a ferramenta para responder."})
            continue

        try:
            validate(instance=tool_block.input, schema=schema)
            return tool_block.input
        except ValidationError as e:
            messages.append({"role": "user", "content":
                f"Falhou a validação: {e.message}. Chame a ferramenta de novo com o valor corrigido."})

    raise RuntimeError("Não foi possível obter saída válida após as tentativas.")


# uso — reaproveitando o schema e o document_text do exemplo anterior
data = get_structured_output(
    client=client,
    schema=INVOICE_SCHEMA,
    tool_name="extract_invoice",
    tool_description="Extrai os dados estruturados da nota fiscal.",
    messages=[{"role": "user", "content": document_text}],
)
print(data)

O ponto chave: o turno anterior do assistente (messages.append({"role": "assistant", ...})) precisa entrar no histórico antes da correção — sem ele, o modelo não tem memória do que tentou da última vez. Isso espelha exatamente o task statement "loops de validação e retry" cobrado no domínio de Prompt Engineering da CCA-F: o modelo recebe feedback específico do que quebrou, em vez de um "tente de novo" genérico.

Em escala: o batch não valida o schema por você

Processar milhares de documentos pela Message Batches API (até 10.000 requisições por lote, cada uma tratada de forma independente) é tentador de assumir que o volume tira a necessidade de validação por item. Não tira. Cada resposta do lote ainda pode truncar, ainda pode falhar validação, e num lote de 10 mil itens, mesmo uma taxa de erro de 1% são 100 casos exigindo tratamento. O padrão de validar e devolver o erro continua sendo a unidade de trabalho — o batch só paraleliza quantas vezes essa unidade roda, não elimina a necessidade dela.

Checklist rápido antes de subir pra produção

  • Trocou "responda em JSON" no prompt por tools + input_schema?
  • Forçou a chamada com tool_choice: {"type": "tool", "name": "..."}?
  • Toda propriedade do schema tem uma description específica, não genérica?
  • Campos de conjunto fechado usam enum em vez de string livre?
  • Confere stop_reason == "max_tokens" antes de usar o resultado?
  • Seu loop de retry devolve o erro específico ao modelo, em vez de um retry cego?

Pronto pra praticar pra Claude Certified Architect — Foundations (CCA-F)?

Faça um simulado de amostra grátis e veja seu desempenho, domínio por domínio.

Testar o simulado