Valide a saída de um LLM com schema
Construa uma função robusta que força um LLM a devolver JSON estruturado, valida contra um schema e faz retry no erro — para nunca confiar cegamente na saída bruta do modelo em produção.
Faça fork ou clone (Ruby/Rails, Python/FastAPI ou TypeScript) e faça os testes que falham passarem.
Problema
LLMs são geradores probabilísticos de texto, não APIs tipadas. Peça JSON a um modelo e, na maioria das vezes, ele obedece — mas "na maioria das vezes" é exatamente o que quebra em produção. O mesmo prompt pode devolver: - Prosa em volta do JSON: `Claro! Aqui está o objeto que você pediu: {...}` - Cercas de markdown: ```json ... ``` - Um campo obrigatório faltando, ou um campo com o tipo errado (`"3"` em vez de `3`) - Um valor de enum que você nunca definiu (`"medium-hard"`) - JSON truncado ou inválido quando a resposta bate no limite de tokens A abordagem ingênua — `JSON.parse(response)` e confiar no resultado — é uma mina terrestre. Funciona na demo e explode na 200ª requisição real, muitas vezes lá no fundo de um código que assumiu que o formato estava garantido. Neste lab você vai construir uma fronteira **anti-frágil** em volta do modelo: definir um **JSON Schema** para a saída exata que você espera, instruir o modelo a devolver somente esse JSON, extrair e parsear defensivamente, **validar** contra o schema e **fazer retry com feedback** quando a validação falha (dizendo ao modelo exatamente o que estava errado). Após um número limitado de tentativas, você retorna uma falha clara — nunca um objeto meia-boca. O chamador decide o que fazer em seguida.
Objetivos
- Definir um JSON Schema preciso (campos, tipos, obrigatórios, enums) para um caso de uso real
- Escrever um prompt que instrui o modelo a devolver somente JSON válido conforme o schema
- Extrair e parsear JSON defensivamente, tolerando cercas ```json e prosa em volta
- Validar a saída parseada contra o schema e montar um erro claro e legível por máquina
- Implementar um loop de retry que devolve o erro de validação ao modelo, com limite máximo de tentativas e backoff
- Retornar uma falha tipada (estilo Result) após esgotar os retries, em vez de um objeto parcial
- Injetar o cliente LLM para que a função seja determinística e testável com um stub
Pré-requisitos
- Acesso a qualquer API de LLM (OpenAI, Anthropic, um modelo local, etc.) — ou um cliente falso para os testes
- Uma biblioteca de validação de JSON Schema para sua linguagem (ex.:
ajvem JS,jsonschemaem Python,json_schemerem Ruby,everit/networkntem Java) - A linguagem de programação de sua escolha (o lab é stack-agnóstico; os exemplos são em pseudocódigo + JSON)
- Um test runner que você já use, para escrever os testes com LLM stubado no passo final
Trate toda chamada de LLM como uma fronteira de rede não confiável. O modelo é um
terceiro prestativo mas não confiável: ele geralmente devolve o que você pediu, e
seu trabalho é tornar os casos incomuns seguros e auto-corretivos, em vez de
catastróficos.
O padrão que você vai construir tem cinco peças:
- Um schema — a única fonte de verdade para o formato que você aceita.
- Um prompt — que pede exatamente esse formato, e nada além disso.
- Um parse defensivo — que sobrevive a cercas e prosa perdida.
-
Um loop de validar-e-retry — que transforma um erro de validação em um prompt
corretivo de follow-up, limitado por um número máximo de tentativas. -
Uma falha final — um erro honesto quando o modelo não consegue cumprir, para
o chamador continuar no controle.
Ao longo do lab usamos um caso de uso concreto: extrair metadados estruturados
{ title, tags[], difficulty } de um bloco de texto livre. Tudo generaliza para
qualquer tarefa de saída estruturada (extração de entidades, classificação,
argumentos de função, preenchimento de formulário).
Passos
-
Defina o schema de saída e o caso de uso
Comece pelo formato que você quer, não pelo modelo. Escreva um JSON Schema que
nomeie cada campo, seu tipo, quais campos são obrigatórios e quaisquer
restrições de enum. Esse schema é o contrato: o prompt o descreve, o validador
o impõe e os testes verificam contra ele.Nosso caso de uso: dado um parágrafo arbitrário, extrair um
title, uma lista
detagse umadifficulty.{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "ExtractedMetadata", "type": "object", "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 120 }, "tags": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 8 }, "difficulty": { "type": "string", "enum": ["beginner", "intermediate", "advanced"] } }, "required": ["title", "tags", "difficulty"], "additionalProperties": false }Repare na rigidez proposital:
-
requiredlista todos os campos, então um campo faltando é erro fatal. -
enumemdifficultyrejeita valores criativos como"medium". -
additionalProperties: falserejeita qualquer chave extra que o modelo invente. - Limites (
minItems,maxLength) pegam saídas vazias ou descontroladas.
Mantenha esse schema em uma constante que seu código possa referenciar — você
vai passá-lo tanto ao validador quanto (no próximo passo) ao prompt. -
-
Escreva um prompt que exige JSON estrito
O prompt é sua primeira linha de defesa. Torne o contrato explícito: mostre o
schema ao modelo, proíba prosa e cercas, e dê um exemplo concreto de resposta
válida. Se seu provedor suportar structured output (response_format
json_schema), tool/function calling ou um modo JSON, prefira essas opções
— elas tornam JSON bem-formado muito mais provável. Mas nunca confie só nelas:
ainda assim valide, porque "JSON válido" não é o mesmo que "está de acordo com
o seu schema".Um template de prompt portável:
Você é uma função de extração de metadados. Extraia dados estruturados do INPUT. Retorne SOMENTE um único objeto JSON que esteja de acordo com este JSON Schema. Não inclua cercas de código markdown, comentários, nem qualquer prosa antes ou depois. JSON Schema: <SCHEMA_AQUI> Exemplo de resposta válida: {"title":"Introdução a Vetores","tags":["matematica","algebra-linear"],"difficulty":"beginner"} INPUT: <TEXTO_AQUI>Princípios de design:
- Enquadre o modelo como uma função, não um chatbot — isso suprime conversa fiada.
- Embuta o schema para que nomes de campos, tipos e enums fiquem sem ambiguidade.
- Mostre um exemplo válido — modelos imitam formato fortemente.
-
Proíba explicitamente cercas e prosa — mas ainda trate isso no passo 3,
porque o modelo vai ocasionalmente ignorar você.
Se usar structured output / tool calling, passe o mesmo schema ao parâmetro
nativo da API e mantenha-o para validação. Defesa em profundidade. -
Extraia e parseie a resposta defensivamente
Mesmo um prompt perfeito recebe respostas imperfeitas. Antes de validar, você
precisa extrair de forma confiável um objeto JSON de qualquer coisa que o modelo
devolveu. Trate, nesta ordem: um objeto limpo, um objeto envolto em cercasUma rotina de extração robusta: ```text função extract_json(raw_text): text = raw_text.strip() # 1) Remove um bloco de código cercado, se houver: ```json ... ``` ou ``` ... ``` fence = casar text contra /```(?:json)?\s*([\s\S]*?)```/i se fence encontrado: text = fence.group(1).strip() # 2) Tenta um parse direto primeiro (o caminho feliz) tente: retorne Ok(parse_json(text)) exceto ParseError: siga # 3) Fallback: o primeiro objeto {...} balanceado no texto candidate = fatia do primeiro '{' até seu '}' correspondente se candidate existe: tente: retorne Ok(parse_json(candidate)) exceto ParseError: siga retorne Err("nenhum objeto JSON parseável encontrado")Observações:
- Prefira uma varredura de chaves balanceadas a um regex guloso
{.*}, para
que objetos aninhados não sejam truncados. Rastreie a profundidade:+1em
{,-1em}, pare no zero. - Essa função só garante JSON parseável, não JSON válido conforme seu schema.
Isso é trabalho do próximo passo — mantenha as duas preocupações separadas. - Retorne um Result (
Ok/Err), não uma exceção, para que o loop de retry do
passo 4 possa tratar uma falha de parse da mesma forma que trata uma falha de
validação: como um motivo para fazer retry com feedback.
- Prefira uma varredura de chaves balanceadas a um regex guloso
-
Valide contra o schema e faça retry com feedback
Agora conecte as peças em um único loop. Cada iteração: chama o modelo, extrai
JSON, valida contra o schema. Em qualquer falha — de parse ou de validação —
monte uma mensagem de erro precisa e a devolva no próximo prompt, para o modelo
se corrigir. Limite as tentativas (ex.: 3) e aplique backoff entre elas.função get_validated_output(input_text, llm, schema, max_attempts = 3): prompt = build_prompt(schema, input_text) # do passo 2 last_error = null para attempt em 1..max_attempts: raw = llm.complete(prompt) parsed = extract_json(raw) # do passo 3 se parsed é Err: last_error = "A resposta não era JSON parseável: " + parsed.error senão: errors = validate(parsed.value, schema) # lib de JSON Schema se errors está vazio: retorne Ok(parsed.value) # sucesso last_error = format_validation_errors(errors) # Monta um prompt corretivo de follow-up com a falha exata prompt = build_prompt(schema, input_text) + "\n\nSua saída anterior foi rejeitada.\n" + "Erro: " + last_error + "\n" + "Retorne JSON corrigido que satisfaça o schema. Apenas JSON." sleep(backoff(attempt)) # ex.: 0.5s, 1s, 2s — exponencial retorne Err({ reason: "validation_failed", attempts: max_attempts, detail: last_error })O que torna isso anti-frágil:
-
O erro é específico.
format_validation_errorsdeve dizer
"tags: esperava array, recebeu string"ou"difficulty: 'medium' não está em [beginner, intermediate, advanced]"— não um genérico "inválido". Feedback
específico aumenta drasticamente a chance de o retry dar certo. -
Falhas de parse e de validação compartilham um caminho. Ambas definem
last_errore disparam o mesmo retry corretivo. - As tentativas são limitadas. Sem loop infinito, sem conta de API descontrolada.
- Backoff espaça os retries (também ajuda com rate limit / erros transitórios).
Uma execução típica: tentativa 1 devolve
difficulty: "medium"→ rejeitada → o
follow-up nomeia o enum → tentativa 2 devolve"intermediate"→ valida →Ok. -
O erro é específico.
-
Trate a falha final e teste com um LLM stubado
O loop precisa terminar honestamente. Quando toda tentativa falha, retorne o
Errtipado do passo 4 — nunca um objeto parcial ou fabricado. O chamador
inspeciona o Result e decide: mostrar um erro ao usuário, cair para um default,
enfileirar para revisão humana, etc. O contrato da sua função é simples e total:
ela sempre retorna ou saída validada ou uma falha clara.Como você injetou o cliente
llm, você pode testar os três caminhos de forma
determinística com um stub — sem rede, sem flakiness, sem custo. Um stub é apenas
um objeto cujocomplete()devolve respostas pré-roteirizadas em ordem.teste "tem sucesso na primeira resposta válida": llm = StubLLM(responses: [ '{"title":"Vetores 101","tags":["math"],"difficulty":"beginner"}' ]) result = get_validated_output("...", llm, SCHEMA) assert result é Ok assert result.value.difficulty == "beginner" assert llm.call_count == 1 teste "faz retry após uma resposta inválida, depois tem sucesso": llm = StubLLM(responses: [ '```json\n{"title":"Vetores 101","tags":[],"difficulty":"medium"}\n```', # inválido: tags vazio + enum errado '{"title":"Vetores 101","tags":["math"],"difficulty":"intermediate"}' # válido ]) result = get_validated_output("...", llm, SCHEMA, max_attempts: 3) assert result é Ok assert result.value.difficulty == "intermediate" assert llm.call_count == 2 # opcional: assert que o 2º prompt continha o texto do erro de validação teste "retorna uma falha após esgotar todas as tentativas": llm = StubLLM(responses: [ 'Não posso ajudar com isso.', # não é JSON '{"title":"X"}', # campos obrigatórios faltando '{"title":"X","tags":"nope","difficulty":"medium"}' # tipos errados + enum errado ]) result = get_validated_output("...", llm, SCHEMA, max_attempts: 3) assert result é Err assert result.error.reason == "validation_failed" assert result.error.attempts == 3 assert llm.call_count == 3Dica: stube o
sleep/backoff também (injete um relógio no-op), para os testes
rodarem instantaneamente. A primeira resposta do teste de retry inclui de
propósito cercas ```json para provar que seu extrator do passo 3 é exercitado de
ponta a ponta.Critério de submissão
Você terminou quando:
- Você tem uma única função que, dado um texto de entrada e um cliente LLM,
sempre retorna ou saída validada (de acordo com o schema) ou uma falha
tipada — nunca um objeto parcial e nunca um parse cru e não validado. - A função define e impõe um JSON Schema, pede JSON estrito, extrai
defensivamente (tolerando cercas e prosa), valida e faz retry com o erro de
validação específico, limitada por um número máximo de tentativas com backoff. - Você tem testes automatizados com um LLM stubado cobrindo os três caminhos:
sucesso, retry-depois-sucesso e falha final após esgotar os retries,
verificando a contagem de chamadas em cada um.
- Você tem uma única função que, dado um texto de entrada e um cliente LLM,