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 praJSON.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_casenuma chamada ecamelCasena 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_choiceespecifica 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.
- Chama o modelo com
tool_choiceforçado - Valida
tool_use.inputcontra o schema - Se inválido, envia o erro específico de volta no próximo turno
- 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
descriptionespecífica, não genérica? - Campos de conjunto fechado usam
enumem vez destringlivre? - 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?
