Por que “parece certo” não é um teste para um prompt

Você aperta a redação de um prompt que roteia tickets, testa nos três exemplos abertos no seu notebook de rascunho e coloca no ar. Uma semana depois, reclamações de cobrança que antes caíam na fila certa começam a cair em “pergunta”. Não os três casos que você checou: os outros quatrocentos que você não checou. Nada quebra. Não há stack trace. O prompt, lido em voz alta, ainda parece bom.

A revisão de código e algumas checagens manuais não pegam isso porque o mesmo prompt com o mesmo input não devolve o mesmo texto duas vezes. Você não pode comparar duas execuções byte a byte e chamar uma diferença de bug, como faria com uma função pura. O que você pode checar é se cada saída tem as propriedades que importam: a categoria certa, um resumo dentro do limite de tamanho, nenhum campo que a fonte nunca mencionou. Um eval roda essas checagens sobre um conjunto de inputs reais sempre que o prompt ou o modelo muda, então a regressão aparece no seu terminal em vez de no ticket de um cliente.

Quais são as partes de um LLM eval

Todo eval, seja qual for a ferramenta que o executa, tem três partes: um dataset de inputs representativos, uma assertion que checa cada saída contra o que aquele input deveria produzir, e uma taxa de aprovação que você acompanha de uma execução para outra.

Pense nisso como a suíte de testes de uma função que devolve uma frase ligeiramente diferente a cada vez. A assertion não pode checar uma correspondência exata, então checa uma propriedade da frase: se ela nomeia a categoria certa, se o número dentro dela dá para parsear. Uma vez que você aceita que o teste é “isso vale” e não “isso é idêntico”, escrever um é engenharia comum.

Como construir um dataset golden para um LLM eval

Pegue um prompt que transforma um e-mail de suporte bruto em um ticket estruturado:

1
2
3
4
5
6
7
8
9
TRIAGE_PROMPT = """Read this support email and return JSON with exactly these keys:
- category: one of "billing", "bug", "question"
- summary: the complaint in 12 words or fewer
- urgent: true if the customer can't use the product right now, else false

Email:
{email_text}

Return only the JSON object, no other text."""

O dataset golden é um conjunto de e-mails reais pareados com a resposta que você já sabe estar correta. Reais, não inventados para fazer o prompt parecer bom:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
golden_set = [
    {
        "email": "I was charged twice for my October invoice, can you refund the duplicate?",
        "category": "billing",
        "urgent": False,
    },
    {
        "email": "The app crashes every time I try to export a PDF, I have a deadline in an hour.",
        "category": "bug",
        "urgent": True,
    },
    {
        "email": "Does the Pro plan include API access, or do I need Enterprise?",
        "category": "question",
        "urgent": False,
    },
]

De cinco a dez casos bastam para começar. O tamanho importa menos do que de onde vêm os casos seguintes: falhas reais de produção.

Como escrever assertions determinísticas para a saída de um LLM

A maior parte do que dá errado nesse prompt não tem nada a ver com gosto. O modelo devolve JSON malformado, ou inventa uma quarta categoria, ou escreve quarenta palavras quando você pediu doze. Código comum pega tudo isso, sem precisar de um segundo modelo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import json

def run_triage_case(case, call_model):
    raw = call_model(TRIAGE_PROMPT.format(email_text=case["email"]))
    try:
        output = json.loads(raw)
    except json.JSONDecodeError:
        return {"passed": False, "reason": "not valid JSON"}

    if output.get("category") not in {"billing", "bug", "question"}:
        return {"passed": False, "reason": f"bad category: {output.get('category')!r}"}

    if len(output.get("summary", "").split()) > 12:
        return {"passed": False, "reason": "summary over 12 words"}

    if not isinstance(output.get("urgent"), bool):
        return {"passed": False, "reason": "urgent is not a boolean"}

    if output["category"] != case["category"]:
        return {"passed": False, "reason": f"expected {case['category']}, got {output['category']}"}

    return {"passed": True, "reason": None}

Rode isso sobre todo o dataset e você tem uma taxa de aprovação, não um simples sim ou não:

1
2
results = [run_triage_case(c, call_model) for c in golden_set]
pass_rate = sum(r["passed"] for r in results) / len(results)

Essas checagens não custam nada e nunca falham de forma aleatória: a mesma saída ruim falha do mesmo jeito sempre. Escreva-as primeiro. Elas pegam a maioria das regressões reais, como uma mudança de schema ou um resumo que silenciosamente dobrou de tamanho.

Quando usar LLM-as-judge e como calibrá-lo

Categoria e urgência são objetivas, e o dataset já traz a resposta certa. A qualidade do resumo não. “Reembolso necessário, cobrança duplicada” e “Cliente tem uma pergunta de cobrança” são ambos JSON válido, ambos com menos de 12 palavras, e só um dos dois descreve de fato o e-mail. Nenhuma regex pega isso, então um segundo modelo avalia isso contra uma rubrica:

1
2
3
4
5
6
7
8
9
JUDGE_PROMPT = """Email: {email}
Summary: {summary}

Does the summary accurately capture the main complaint in the email?
Answer with only "yes" or "no"."""

def judge_summary(email, summary, judge_model):
    verdict = judge_model(JUDGE_PROMPT.format(email=email, summary=summary), temperature=0)
    return verdict.strip().lower() == "yes"

Antes de confiar nesse veredito sobre o dataset inteiro, rode o judge em 15-20 casos que uma pessoa já avaliou manualmente e conte com que frequência eles concordam. Um judge que discorda das pessoas uma vez em cada três é uma assertion fraca disfarçada de autoridade de modelo.

Dois vieses pesam contra confiar demais nesse veredito: judges tendem a avaliar melhor respostas mais longas e elaboradas mesmo quando uma mais curta é igualmente correta, e um modelo que avalia suas próprias saídas as avalia de forma mais favorável do que um modelo independente faria. Use um modelo diferente como judge quando possível, e mantenha o prompt estreito: uma pergunta sim/não por chamada, não cinco notas em uma única requisição.

Assertion determinísticaLLM-as-judge
Custo por casoGrátisUma chamada extra ao modelo
O que pegaSchema, formato, fatos já presentes no datasetQualidade subjetiva, relevância, tom
RepetibilidadeMesma saída, mesmo vereditoAlguma variação mesmo em temperatura 0
Custo de configuraçãoEscrever a checagem e prontoPrecisa de calibração prévia com exemplos avaliados por humanos

Em termos de suíte de testes, as checagens determinísticas funcionam como testes unitários: rodam em toda mudança sem adicionar custo, e o veredito nunca varia entre execuções. O judge se aproxima mais de uma checagem de aceitação, a que uma pessoa faria lendo cada saída. As trocas entre esses níveis são as mesmas tratadas em teste unitário vs teste de integração vs end-to-end.

Como rodar LLM evals na CI para pegar regressões

Um eval que só roda quando alguém se lembra não pega nada. Coloque o script junto do prompt que ele testa (evals/triage.py ao lado de prompts/triage.py) e rode na CI a cada mudança em qualquer um dos dois arquivos, antes do merge. Acompanhe a taxa de aprovação ao longo do tempo e falhe o build quando ela cair abaixo de uma baseline que você definiu. Uma queda de 95% para 88% é uma regressão que vale bloquear, mesmo que a maioria dos casos ainda passe.

Mantenha um conjunto de regressão dentro do dataset: todo caso que já foi um bug reportado, fixado permanentemente depois da correção. Para um prompt, isso te dá o ciclo descrito em o que o TDD realmente é. Mude o prompt, rode a suíte de novo, veja exatamente quais assertions viraram, corrija, repita. Alimente o dataset com as novas falhas de produção conforme elas acontecem, para não testar apenas os inputs para os quais o prompt foi projetado.

Como avaliar pipelines de tool calling e RAG

As mesmas três partes se aplicam aqui também. Para um ciclo de tool calling, o dataset pareia cada input com o nome correto da ferramenta e os argumentos corretos, e a assertion checa se o modelo escolheu essa ferramenta com os campos obrigatórios preenchidos. A mecânica do próprio ciclo está em function calling e tool use em LLMs.

Um pipeline RAG precisa de duas notas separadas. O retrieval trouxe o chunk que contém a resposta? A geração usou esse chunk corretamente? Se você avaliar isso junto, não dá para saber qual das duas metades quebrou. Veja como o RAG funciona para o pipeline que você está dividindo em dois.

Erros comuns com LLM evals

Construir o dataset com casos que já passam. Cinco exemplos escolhidos porque o prompt lida bem com eles vão passar para sempre e não vão te dizer nada sobre o input que vai quebrar tudo no mês que vem. Toda falha real que chegar até você deve entrar no dataset no mesmo dia.

Checar correspondência exata numa saída variável. summary == "Reembolso necessário por cobrança duplicada" falha no momento em que o modelo troca uma única palavra, mesmo quando a nova frase é igualmente correta. Cheque a propriedade, não o texto: a contagem de palavras, a categoria, se um fato obrigatório aparece.

Pular a calibração do judge. Um judge que você nunca comparou nem com uma dúzia de exemplos avaliados por humanos entrega os próprios vieses como se fossem verdade, incluindo o que favorece a própria saída.

Rodar de novo só o caso que você acabou de corrigir. É assim que um bug antigo volta sem ninguém notar. O conjunto de regressão existe exatamente para isso não acontecer.

Quando não vale a pena construir uma suíte de LLM eval

Um prompt que você vai rodar só uma vez, ou que uma pessoa revisa em cada chamada, não precisa de um harness. Coloque a checagem de validade do JSON inline se for barata e siga adiante. A suíte se paga quando um prompt vai para produção com usuários reais repetidamente ou você espera ajustá-lo mais de algumas vezes. É nesse momento que “pareceu bom nos meus três exemplos” deixa de ser suficiente.

Para começar: cinco inputs reais, uma assertion determinística para cada propriedade que importa, e um judge LLM só para a dimensão que o código não consegue checar. Rode antes do seu próximo ajuste de prompt, não depois da reclamação.

Artigos relacionados