AI Engineering · 60 min

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.

Baixar o projeto inicial

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

Pré-requisitos

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:

  1. Um schema — a única fonte de verdade para o formato que você aceita.
  2. Um prompt — que pede exatamente esse formato, e nada além disso.
  3. Um parse defensivo — que sobrevive a cercas e prosa perdida.
  4. 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.
  5. 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

  1. 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
    de tags e uma difficulty.

    {
      "$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:

    • required lista todos os campos, então um campo faltando é erro fatal.
    • enum em difficulty rejeita valores criativos como "medium".
    • additionalProperties: false rejeita 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.

  2. 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.

  3. 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 cercas

    
    Uma 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: +1 em
      {, -1 em }, 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.
  4. 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_errors deve 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_error e 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.

  5. Trate a falha final e teste com um LLM stubado

    O loop precisa terminar honestamente. Quando toda tentativa falha, retorne o
    Err tipado 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 cujo complete() 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 == 3
    

    Dica: 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:

    1. 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.
    2. 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.
    3. 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.