Neste artigo você vai montar um harness de evals do zero: um conjunto de casos, métricas que não dependem de modelo, um juiz baseado em LLM com cuidados contra viés e um gate no GitHub Actions que reprova mudanças que pioram a qualidade.
O problema real
Você ajustou o prompt do assistente, mudou o tamanho dos chunks do RAG e trocou o modelo de embeddings. Parecia melhor nos três exemplos que você testou. Dois dias depois, o suporte avisa que as respostas sobre devolução estão erradas. Com uma avaliação automática, isso apareceria em minutos, antes do deploy.
Evals são testes automatizados para sistemas probabilísticos. A diferença para teste unitário é que o resultado é uma nota entre 0 e 1 sobre vários casos, e você compara notas entre versões em vez de exigir igualdade exata.
Os quatro componentes
- Conjunto de casos (golden set): entradas representativas com critério de acerto.
- Métricas: regras determinísticas ou um modelo como juiz.
- Harness: o programa que roda o sistema sobre os casos e agrega resultados.
- Gate de CI: compara com uma linha de base e falha o build se a nota cair.
1. O conjunto de casos
Comece com 20 a 50 casos escolhidos a dedo. Fontes boas: conversas reais que deram errado, perguntas que o time de suporte sempre recebe, casos de borda (pergunta sem resposta nos documentos, tentativa de manipulação, entrada vazia). Um conjunto pequeno e honesto vale mais que mil casos gerados sem revisão.
Guarde em JSON Lines, um caso por linha, em evals/casos.jsonl:
{"id": "devolucao_01", "entrada": "Qual o prazo para devolver um produto?", "metrica": "contem", "termos": ["30 dias"]}
{"id": "extracao_01", "entrada": "Nome: Ana Souza, e-mail: [email protected]", "metrica": "json", "campos": ["nome", "email"]}
{"id": "limite_01", "entrada": "Quem é o presidente da nossa empresa?", "metrica": "juiz", "criterio": "Admite que não tem a informação e não inventa um nome."}
Divida os casos por categoria. A nota média esconde problemas locais: o geral pode subir enquanto a categoria "devolução" despenca.
2. Métricas
Prefira métricas determinísticas sempre que o critério permitir. São baratas, rápidas e repetíveis.
import json
def metrica_contem(saida: str, termos: list[str]) -> float:
achados = sum(1 for t in termos if t.lower() in saida.lower())
return achados / len(termos)
def metrica_json(saida: str, campos: list[str]) -> float:
try:
dados = json.loads(saida)
except json.JSONDecodeError:
return 0.0
return 1.0 if all(c in dados for c in campos) else 0.0
Para critérios subjetivos (clareza, fidelidade ao contexto, tom), use um modelo como juiz. Dê uma rubrica específica e peça uma saída estruturada.
import os
import anthropic
client = anthropic.Anthropic()
MODELO_JUIZ = os.environ["JUIZ_MODEL"]
def metrica_juiz(entrada: str, saida: str, criterio: str) -> float:
prompt = (
"Você avalia respostas de um assistente.\n"
f"Critério: {criterio}\n\n"
f"Pergunta: {entrada}\n"
f"Resposta: {saida}\n\n"
'Responda só com JSON: {"nota": 0 a 5, "motivo": "uma frase"}'
)
r = client.messages.create(
model=MODELO_JUIZ,
max_tokens=200,
messages=[{"role": "user", "content": prompt}],
)
try:
return json.loads(r.content[0].text)["nota"] / 5
except (json.JSONDecodeError, KeyError, TypeError):
return 0.0
Juízes têm vieses conhecidos: preferem respostas longas, favorecem texto parecido com o do próprio modelo e podem mudar de nota entre execuções. Alguns cuidados reduzem o problema:
- Use um modelo juiz diferente do modelo que gerou a resposta.
- Escreva rubricas objetivas ("cita o prazo de 30 dias") em vez de "a resposta é boa".
- Confira o juiz contra 20 ou 30 notas humanas. Se discordam muito, ajuste a rubrica antes de confiar na métrica.
- Ao comparar duas versões, troque a ordem de apresentação para anular viés de posição.
3. O harness
O harness roda o sistema, aplica a métrica de cada caso e agrega por categoria. Falhas do próprio sistema contam como nota zero.
import json
import time
from collections import defaultdict
def rodar_evals(sistema, caminho="evals/casos.jsonl") -> dict:
por_categoria = defaultdict(list)
todas, latencias = [], []
with open(caminho, encoding="utf-8") as f:
casos = [json.loads(linha) for linha in f if linha.strip()]
for caso in casos:
inicio = time.perf_counter()
try:
saida = sistema(caso["entrada"])
except Exception as erro:
saida, nota = f"ERRO: {erro}", 0.0
else:
if caso["metrica"] == "contem":
nota = metrica_contem(saida, caso["termos"])
elif caso["metrica"] == "json":
nota = metrica_json(saida, caso["campos"])
else:
nota = metrica_juiz(caso["entrada"], saida, caso["criterio"])
latencias.append(time.perf_counter() - inicio)
categoria = caso["id"].rsplit("_", 1)[0]
por_categoria[categoria].append(nota)
todas.append(nota)
latencias.sort()
return {
"nota_media": sum(todas) / len(todas),
"por_categoria": {c: sum(v) / len(v) for c, v in por_categoria.items()},
"latencia_p95_s": latencias[int(len(latencias) * 0.95) - 1],
}
Para testar o harness sem gastar tokens, passe uma função falsa como sistema. Quando integrar de verdade, sistema chama seu pipeline completo (RAG, prompt, modelo), porque o que importa é o comportamento ponta a ponta.
4. Comparar versões e barrar no CI
Salve o resultado da versão aprovada em evals/baseline.json e compare a nova execução com ele. Defina a regra antes de ver os números, por exemplo: nota média não pode cair mais que 0,03 e nenhuma categoria pode cair mais que 0,10.
import json
import sys
def comparar(novo: dict, base: dict, tol_geral=0.03, tol_categoria=0.10) -> list[str]:
problemas = []
if novo["nota_media"] < base["nota_media"] - tol_geral:
problemas.append(f"Nota geral caiu: {base['nota_media']:.2f} para {novo['nota_media']:.2f}")
for cat, nota in novo["por_categoria"].items():
antes = base["por_categoria"].get(cat)
if antes is not None and nota < antes - tol_categoria:
problemas.append(f"Categoria {cat} caiu: {antes:.2f} para {nota:.2f}")
return problemas
if __name__ == "__main__":
from meu_sistema import responder # seu pipeline
novo = rodar_evals(responder)
base = json.load(open("evals/baseline.json", encoding="utf-8"))
problemas = comparar(novo, base)
print(json.dumps(novo, indent=2, ensure_ascii=False))
if problemas:
print("\n".join(problemas))
sys.exit(1)
No GitHub Actions, rode isso quando prompts, código de recuperação ou configuração de modelo mudarem:
name: evals
on:
pull_request:
paths: ["prompts/**", "src/**", "evals/**"]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: python evals/rodar.py
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
AGENT_MODEL: ${{ vars.AGENT_MODEL }}
JUIZ_MODEL: ${{ vars.JUIZ_MODEL }}
Por usar secrets, o workflow não recebe a chave em PRs vindos de forks. Isso é desejado.
Armadilhas comuns
- Conjunto pequeno demais. Com 10 casos, uma única resposta muda a nota em 10 pontos percentuais. Cresça o conjunto a cada incidente.
- Ruído confundido com regressão. Respostas variam entre execuções. Rode os casos mais sensíveis mais de uma vez ou use tolerâncias como as acima.
- Custo ignorado. Cada execução gasta tokens de geração e de julgamento. Rode o conjunto completo antes de merge e um subconjunto rápido nos commits intermediários.
- Vazamento do conjunto para o prompt. Se você copia casos do eval para exemplos do prompt, a nota sobe sem que o sistema melhore. Mantenha-os separados.
- Tratar o eval como verdade. É um proxy. Complemente com monitoramento em produção e feedback dos usuários, como em observabilidade de LLM.
Quando não vale a pena
Para um protótipo com um punhado de usuários, uma planilha com 15 perguntas rodadas à mão já ajuda. Monte a automação quando mudanças frequentes começarem a dar medo.
Próximos passos
Prompt bem estruturado é o que mais move sua nota, então veja engenharia de prompt em produção. Para a camada de validação de saída que complementa os evals, leia guardrails de saída.
Quer aplicar isso na sua empresa? Marque uma conversa de 45 minutos em https://iauaicloud.com.br/consultoria