O problema real
Você mudou o prompt do seu sistema de RAG, fez uns ajustes no chunking, trocou o embedding model. Achava que melhorou, mas só especulação. Coloca em produção. Dois dias depois, taxa de erro sobe — o modelo está errando mais em um domínio específico. Se tivesse um eval (avaliação automatizada), teria descoberto em 5 minutos. Agora custa caro: tickets de suporte, confiança abalada, rollback urgente.
Sem eval, você está navegando cego. Qualidade de LLM não é binary (funciona / não funciona) — é gradual. Precisa medir continuamente. Este artigo mostra como montar um harness de eval que roda em CI, detecta regressão e te dá confiança para fazer deploy.
Como montar avaliações de LLM?
Avaliação de LLM tem 4 componentes:
- Golden set (dataset de testes): Casos representativos, manualmente verificados
- Métricas (how you measure): Determinísticas (regex, schema) ou LLM-as-judge
- Harness (programa que roda): Processa golden set, coleta resultados
- CI/CD (integração): Roda a cada commit, bloqueia deploy se score cair
Vamos construir cada uma.
Mão na massa
1. Montar o golden set
Comece pequeno: 20-50 casos bem escolhidos são melhores que 1.000 aleatórios.
# eval_dataset.json
{
"cases": [
{
"id": "rag_001",
"category": "devolucao",
"input": "Qual é a política de devolução de produtos?",
"expected_output": "Deve conter 'até 30 dias' e 'reembolso integral'",
"metric": "contains_keywords",
"keywords": ["30 dias", "reembolso"]
},
{
"id": "rag_002",
"category": "preco",
"input": "Qual é o preço do produto PROD-001?",
"expected_output": "R$ 99,90",
"metric": "exact_match"
},
{
"id": "extraction_001",
"category": "extraccion",
"input": "Nome: João Silva, CPF: 123.456.789-00, Email: joao@example.com",
"expected_schema": {
"name": "string",
"cpf": "string",
"email": "string"
},
"metric": "json_schema_valid"
},
{
"id": "hallucination_001",
"category": "seguranca",
"input": "Qual é o CEO da empresa?",
"expected_output": "Deve dizer que não sabe",
"metric": "llm_as_judge",
"judge_rubric": "Resposta reconhece limitações e não inventa informação"
}
]
}
Regra: Golden set é manualmente validado. Você mesmo executa o sistema, verifica a saída, confirma se é aceitável. Isso toma tempo, mas é investimento.
def create_golden_set(sample_size: int = 30):
"""Coleta casos reais, executa, valida manualmente."""
print("=== Criando Golden Set ===")
cases = []
# Fonte 1: Casos de produção que falharam
production_failures = [
{
"input": "Como faço para devolver?",
"category": "devolucao",
"reason": "Usuário reclamou que resposta foi genérica"
},
# ... mais casos reais
]
# Fonte 2: Casos edge que você sabe que importa
edge_cases = [
{
"input": "Qual é seu nome?",
"category": "seguranca",
"reason": "Modelo pode alucinar identidade"
},
# ... mais edge cases
]
for case in production_failures + edge_cases:
input_text = case["input"]
# Executa sistema real
output = run_your_llm_system(input_text)
print(f"\nInput: {input_text}")
print(f"Output: {output}")
print(f"Reason: {case['reason']}")
# Valida manualmente (pode ser input() ou já vem validado)
is_valid = validate_output_manually(output)
if is_valid:
cases.append({
"id": f"{case['category']}_{len(cases)+1:03d}",
"input": input_text,
"expected_output": output,
"category": case["category"],
"metric": "llm_as_judge"
})
print(f"✓ Golden set criado com {len(cases)} casos")
return cases
2. Definir métricas
Determinísticas (não precisam LLM):
import re
import json
from typing import Any
def metric_exact_match(output: str, expected: str) -> float:
"""Score 1 ou 0 — deve ser idêntico."""
return 1.0 if output.strip() == expected.strip() else 0.0
def metric_contains_keywords(output: str, keywords: list) -> float:
"""Score proporcional ao número de keywords encontradas."""
found = sum(1 for kw in keywords if kw.lower() in output.lower())
return found / len(keywords)
def metric_regex_match(output: str, pattern: str) -> float:
"""Score 1 ou 0 — output faz match com regex."""
return 1.0 if re.search(pattern, output) else 0.0
def metric_json_schema_valid(output: str, schema: dict) -> float:
"""Score 1 ou 0 — JSON é válido e tem campos requeridos."""
try:
data = json.loads(output)
for field in schema.keys():
if field not in data:
return 0.0
return 1.0
except:
return 0.0
def metric_token_count(output: str, max_tokens: int = 100) -> float:
"""Penaliza respostas muito longas."""
token_count = len(output.split())
if token_count > max_tokens:
return max_tokens / token_count # Ratio
return 1.0
# Teste
print(metric_exact_match("Brasília", "Brasília")) # 1.0
print(metric_contains_keywords("Política de 30 dias com reembolso", ["30 dias", "reembolso"])) # 1.0
print(metric_token_count("Muito longo " * 100, max_tokens=50)) # ~0.5
LLM-as-judge (usa outro LLM para julgar):
from openai import OpenAI
def metric_llm_as_judge(
output: str,
input_text: str,
rubric: str,
expected: str = None
) -> float:
"""Usa GPT para julgar qualidade (0-1)."""
client = OpenAI()
prompt = f"""Você é um juiz de qualidade. Avalie a resposta (0-10) conforme este critério:
CRITÉRIO: {rubric}
INPUT: {input_text}
RESPOSTA: {output}
"""
if expected:
prompt += f"\nRESPOSTA ESPERADA: {expected}"
prompt += "\n\nDê uma nota de 0 a 10 (apenas o número)."
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
temperature=0
)
score_text = response.choices[0].message.content.strip()
try:
score = int(score_text) / 10.0 # Normaliza para 0-1
return score
except:
print(f"Erro ao parsear score: {score_text}")
return 0.5 # Assume médio se não conseguir parsear
# Teste
score = metric_llm_as_judge(
output="Brasília é a capital",
input_text="Qual é a capital?",
rubric="Resposta é correta e concisa"
)
print(f"Score: {score:.2f}")
3. Harness de eval
Código que roda os casos e coleta resultados.
import json
import time
from dataclasses import dataclass
from typing import List, Callable, Dict
@dataclass
class EvalResult:
case_id: str
category: str
input_text: str
output: str
expected: str
metric_name: str
score: float
latency_ms: float
class EvalHarness:
def __init__(self, system_func: Callable, golden_set: List[Dict]):
"""
Args:
system_func: Função que roda seu sistema (input -> output)
golden_set: Lista de casos de teste
"""
self.system_func = system_func
self.golden_set = golden_set
self.results: List[EvalResult] = []
def run(self) -> Dict:
"""Roda todos os casos e retorna relatório."""
print(f"Rodando {len(self.golden_set)} casos...")
for case in self.golden_set:
case_id = case["id"]
input_text = case["input"]
expected = case.get("expected_output", "")
metric_name = case.get("metric", "llm_as_judge")
# Executa sistema
start = time.time()
try:
output = self.system_func(input_text)
latency = (time.time() - start) * 1000 # ms
except Exception as e:
print(f"✗ {case_id}: Erro - {str(e)}")
output = f"ERROR: {str(e)}"
latency = (time.time() - start) * 1000
score = 0.0
else:
# Calcula score
if metric_name == "exact_match":
score = metric_exact_match(output, expected)
elif metric_name == "contains_keywords":
score = metric_contains_keywords(output, case["keywords"])
elif metric_name == "json_schema_valid":
score = metric_json_schema_valid(output, case["expected_schema"])
elif metric_name == "llm_as_judge":
score = metric_llm_as_judge(
output, input_text,
case.get("judge_rubric", "Resposta é correta e útil"),
expected
)
else:
score = 0.5
result = EvalResult(
case_id=case_id,
category=case.get("category", "unknown"),
input_text=input_text,
output=output[:100], # Trunca para relatório
expected=expected[:100],
metric_name=metric_name,
score=score,
latency_ms=latency
)
self.results.append(result)
status = "✓" if score > 0.8 else "⚠" if score > 0.5 else "✗"
print(f"{status} {case_id}: {score:.2f} ({latency:.0f}ms)")
return self.summarize()
def summarize(self) -> Dict:
"""Retorna sumário dos resultados."""
if not self.results:
return {}
scores = [r.score for r in self.results]
latencies = [r.latency_ms for r in self.results]
by_category = {}
for r in self.results:
if r.category not in by_category:
by_category[r.category] = []
by_category[r.category].append(r.score)
summary = {
"total_cases": len(self.results),
"passed": sum(1 for s in scores if s > 0.8),
"accuracy": sum(scores) / len(scores),
"latency_avg_ms": sum(latencies) / len(latencies),
"latency_p95_ms": sorted(latencies)[int(len(latencies) * 0.95)],
"by_category": {cat: sum(scores)/len(scores) for cat, scores in by_category.items()}
}
return summary
# Uso
def mock_system(input_text: str) -> str:
"""Sistema mock para teste."""
if "devolução" in input_text.lower():
return "Política de devolução: até 30 dias com reembolso integral."
return "Não encontrei informação."
golden_set = [
{
"id": "test_001",
"input": "Qual é a política de devolução?",
"expected_output": "30 dias",
"category": "policy",
"metric": "contains_keywords",
"keywords": ["30 dias"]
}
]
harness = EvalHarness(mock_system, golden_set)
summary = harness.run()
print("\n=== SUMÁRIO ===")
print(json.dumps(summary, indent=2))
Output:
Rodando 1 casos...
✓ test_001: 1.00 (50ms)
=== SUMÁRIO ===
{
"total_cases": 1,
"passed": 1,
"accuracy": 1.0,
"latency_avg_ms": 50.0,
"latency_p95_ms": 50.0,
"by_category": {"policy": 1.0}
}
4. Comparação entre versões
def compare_prompts(
prompt_v1: str,
prompt_v2: str,
golden_set: List[Dict]
) -> Dict:
"""Compara dois prompts usando mesmo golden set."""
print("=== Teste V1 (prompt antigo) ===")
harness_v1 = EvalHarness(
lambda inp: run_llm(inp, prompt_v1),
golden_set
)
summary_v1 = harness_v1.run()
print("\n=== Teste V2 (prompt novo) ===")
harness_v2 = EvalHarness(
lambda inp: run_llm(inp, prompt_v2),
golden_set
)
summary_v2 = harness_v2.run()
# Compara
improvement = summary_v2["accuracy"] - summary_v1["accuracy"]
latency_diff = summary_v2["latency_avg_ms"] - summary_v1["latency_avg_ms"]
print(f"\n=== RESULTADO ===")
print(f"Accuracy: {summary_v1['accuracy']:.2%} → {summary_v2['accuracy']:.2%} ({improvement:+.2%})")
print(f"Latência: {summary_v1['latency_avg_ms']:.0f}ms → {summary_v2['latency_avg_ms']:.0f}ms ({latency_diff:+.0f}ms)")
if improvement > 0.05:
print("✓ V2 é melhor — recomenda deploy")
elif improvement < -0.05:
print("✗ V2 é pior — não faz deploy")
else:
print("~ Trade-off: decida manualmente")
return {"v1": summary_v1, "v2": summary_v2, "improvement": improvement}
5. CI/CD integration (GitHub Actions)
# .github/workflows/eval.yml
name: LLM Evals
on: [pull_request, push]
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install openai pydantic
- name: Run evals
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/run_evals.py > eval_results.json
- name: Check for regression
run: |
python scripts/check_regression.py eval_results.json baseline.json
- name: Comment on PR
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = JSON.parse(fs.readFileSync('eval_results.json'));
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `📊 Eval Results:\n\`\`\`\n${JSON.stringify(results, null, 2)}\n\`\`\``
});
# scripts/check_regression.py
import json
import sys
def check_regression(current_file: str, baseline_file: str, threshold: float = 0.05):
"""Bloqueia deploy se acurácia cair > 5%."""
with open(current_file) as f:
current = json.load(f)
with open(baseline_file) as f:
baseline = json.load(f)
current_acc = current["accuracy"]
baseline_acc = baseline["accuracy"]
diff = current_acc - baseline_acc
print(f"Baseline: {baseline_acc:.2%}")
print(f"Current: {current_acc:.2%}")
print(f"Change: {diff:+.2%}")
if diff < -threshold:
print(f"✗ REGRESSÃO DETECTADA (queda > {threshold:.0%})")
sys.exit(1) # Bloqueia merge
print("✓ OK — sem regressão significativa")
if __name__ == "__main__":
check_regression(sys.argv[1], sys.argv[2])
Armadilhas comuns
Armadilha 1: Golden set muito pequeno
20 casos é mínimo viável. Se usar < 10, qualquer flutuação parece importante. Para cada categoria, mínimo 5 casos.
Armadilha 2: LLM-as-judge é enviesado
Se usar o mesmo modelo que testa como juiz, vai tendenciosamente favorecer. Use modelo diferente (ex: GPT-4 julga Llama 2).
# ❌ Enviesado
def judge(output):
return gpt_3_5_turbo_judge(output) # Mesmo modelo!
# ✅ Independente
def judge(output):
return gpt_4_judge(output) # Modelo mais forte julga
Armadilha 3: Não monitorar custo de evals
Cada LLM-as-judge custa (USD 0,015 per 1M input + output tokens). 100 casos × 3 tentativas = 300 chamadas. Se cada julga 500 tokens, são 150k tokens = USD 2,25 por rodada. Caro se rodar em cada commit.
Solução: Rode LLM-as-judge em CI nightly, não em cada PR.
Armadilha 4: Esquecer que eval é proxy
Seu eval pode passar mas o sistema falhar em produção. Evals capturam métricas, não a realidade inteira. Use também monitoramento em produção.
Quando não usar avaliação formal
- Prototipagem rápida: Se está experimentando, eval consome tempo.
- Modelo é black-box: Se tira tudo de uma API e não pode mudar, eval é pura observação.
- Qualidade não é crítica: Se errar 10% não causa dano, economia em evals pode compensar.
Próximos passos
- Integre com CI/CD com GitHub Actions — roda evals em pipeline.
- Combine com observabilidade de LLM para monitorar produção.
- Automatize coleta de casos com RAG na prática — seus retriever failures viram novos casos de eval.
Resumo prático
Avaliação de LLM em 4 passos:
- Golden set: 30-50 casos manualmente validados
- Métricas: Determinísticas (regex, schema) para 80%, LLM-as-judge para 20%
- Harness: Loop que executa e coleta scores
- CI: Roda a cada commit, bloqueia regressão > 5%
Custo: 1-2 horas setup, depois automático. Valor: nunca mais faz deploy cego. Em produção crítica, é não-negociável.