O que são Evals

Da mesma forma que precisamos de testes para garantir a qualidade de software determinístico, as evaluations (ou só evals) vêm suprir essa necessidade para sistemas agênticos: checar se as respostas de LLMs cumprem determinados critérios de qualidade.

Ao tentar evoluir um agente de IA focando somente em alterar o comportamento do sistema via prompt engineering, fine-tuning ou qualquer outro tipo de técnica sem uma boa suíte de evals e debugging, o seu sistema se torna limitado e ineficiente. Como disse Hamel Husain, ao fazer isso você “impede a evolução dos produtos de LLM para além de uma demo”.1

Entretanto, construir uma suíte de evaluations para agentes pode não ser tão simples quanto aparenta, tendo em vista a natureza adaptativa deles ao ambiente (diferentemente de uma chamada de LLM de turno único, por exemplo). Para resolver isso, diversas técnicas foram desenvolvidas nos últimos anos com diferentes propósitos, e é o que vamos explorar agora neste texto.

Tipos de Evals

De forma geral, podemos dividir as evals em três grandes famílias. As determinísticas verificam propriedades objetivas da resposta, como formato, presença de um campo ou validade de um JSON, e funcionam como testes de unidade tradicionais. As baseadas em modelo usam uma LLM para julgar critérios mais subjetivos, como tom, completude ou fidelidade ao contexto. E as humanas, em que uma pessoa lê a saída (ou o trace inteiro) e dá o veredito.

O senso comum trata a avaliação humana como o tipo mais caro e lento, algo a automatizar o quanto antes. Ela custa caro mesmo, mas a conclusão me parece apressada. Olhar os próprios dados costuma ser o passo de maior alavancagem: examinar traces, fazer análise de erro e transformar falhas recorrentes em evals.2

Tenho uma hipótese que vai um pouco na contramão. Especialmente quando você está evoluindo o harness de um agente de código, costuma render mais usar avaliação humana para corrigir prompts de forma pontual do que investir cedo demais em um sistema que tenta automatizar todo o julgamento. O “avaliador perfeito” quase sempre sai mais caro e menos eficiente do que uma pessoa lendo alguns traces (ou qualquer outro método que pode ser usado para consulta) e ajustando uma instrução.

Automação continua valendo, desde que venha na ordem certa. A avaliação funciona melhor como um processo: definir critérios, rotular exemplos à mão e só então alinhar um juiz-LLM contra esses rótulos humanos.3 O julgamento humano vira a fonte de verdade, o ponto de referência que torna confiável tudo o que vem depois.

Vale entender por que isso é tão complicado. Quando a resposta é aberta, não existe um gabarito único, e as métricas automáticas correlacionam mal com o julgamento humano.4 Testar agentes, então, segue sendo um dos pontos mais espinhosos da engenharia de IA, sem uma abordagem consolidada5, o que só reforça o valor de uma inspeção manual honesta antes de confiar em qualquer número.

A regra que eu sigo, então: comece pelas verificações determinísticas, baratas e rápidas; use o julgamento humano para entender os erros e corrigir prompts; e só promova a juiz-LLM o que você entende fundo o suficiente para reduzir ao mais próximo possível de uma regra determinística, e que já viu um humano avaliar antes.

E “entender fundo” é mais raro do que parece. Um PO pode achar que conhece o fluxo de um dev, e um dev, o trabalho do QA, mas quase nunca na profundidade de quem vive aquilo todo dia. Evite a todo custo automatizar um critério que você só entende superficialmente.

O que avaliar em um agente

Dito isso, antes de escolher técnicas, vale separar o que está sendo avaliado. Para além da resposta final, um agente também precisa ser avaliado pelas ferramentas que chama, pelo contexto que usa, pelas decisões intermediárias e pelo estado que mantém ao longo de vários passos.

Na prática, vejo que existem cinco eixos:

  1. A resposta final, que é o que o usuário lê.
  2. O uso de contexto, ou seja, se o agente de fato usou a informação que tinha em mãos.
  3. O uso de ferramentas, onde mora boa parte das falhas silenciosas.
  4. A segurança, incluindo o que ele se recusa a fazer.
  5. O custo e a latência, que decidem se a coisa sobrevive em produção.

Cada eixo pede um tipo de eval diferente, e quase nenhum deles aparece se você olhar apenas o texto de saída. Antes de escrever uma métrica, vale descobrir qual evidência no trace provaria que o comportamento foi bom ou ruim.

O que é um trace

Se a resposta final é o que o usuário vê, o trace é o que o agente fez para chegar lá. Ele guarda as mensagens trocadas, as ferramentas chamadas com seus argumentos, o contexto recuperado, as decisões intermediárias e, por fim, a resposta.

Ao meu ver, a maioria das evals interessantes de agente são, no fundo, perguntas sobre o trace: qual ferramenta foi escolhida, qual contexto foi usado, qual erro foi ignorado, qual decisão mudou o rumo da execução.

Por isso, registre cada passo como um evento estruturado e preserve os dados mais brutos que conseguir. O Princípio do Sushi vale muito aqui: dado cru envelhece melhor que dado já resumido. Você sempre pode agregar depois; o contrário quase nunca é verdade.

from trace import Step, Trace

def retrieve_context(prompt: str) -> list[str]:
    if "pedido" in prompt.lower():
        return ["shipping-policy", "refund-policy"]
    return []

def call_tool(name: str, args: dict) -> str:
    if name == "orders.get" and args["order_id"] == "4821":
        return "status=delayed; eta=2026-07-02"
    return "not_found"

def run_agent(prompt: str) -> Trace:
    trace = Trace(id="run_4821", prompt=prompt)

    context_ids = retrieve_context(prompt)
    trace.steps.append(Step(
        kind="context_retrieval",
        name="retrieve_context",
        context_ids=context_ids,
    ))

    trace.steps.append(Step(
        kind="decision",
        name="choose_next_step",
        output="usar orders.get porque o usuário informou um número de pedido",
    ))

    args = {"order_id": "4821"}
    trace.steps.append(Step(
        kind="tool_call",
        name="fetch_order",
        tool="orders.get",
        args=args,
    ))

    observation = call_tool("orders.get", args)
    trace.steps.append(Step(
        kind="observation",
        name="orders.get.result",
        output=observation,
    ))

    trace.final_answer = "Seu pedido atrasou e deve chegar em 02/07."
    trace.steps.append(Step(
        kind="final_answer",
        name="reply",
        output=trace.final_answer,
        context_ids=["shipping-policy"],
    ))

    return trace

Como começar com evals

A pior eval é a que nasce antes de alguém olhar o sistema falhando. Começar escolhendo métricas dá uma sensação falsa de rigor: você ganha números cedo, mas ainda não sabe se eles medem os erros que importam.

A melhor forma de começar é abrir traces reais de produção e ler com atenção. Dá trabalho, e é justamente por isso que rende: equipes que levam evals a sério passam a maior parte do tempo em análise de erro, não escrevendo asserts.6

O caminho é simples de descrever e difícil de pular. Revise exemplos reais, anote onde o agente falhou, agrupe as falhas em uma taxonomia simples e transforme cada categoria recorrente em um teste. A taxonomia não precisa ser sofisticada. Cinco ou seis rótulos como “ignorou contexto”, “chamou ferramenta errada” ou “respondeu sem ter certeza” já orientam onde investir esforço.

Nenhuma métrica deveria entrar na suíte sem um trace que explique por que ela existe. Se você não consegue apontar a falha concreta que motivou aquela eval, provavelmente está medindo uma abstração confortável demais.

Como criar um dataset de avaliação

Um dataset de avaliação não é uma coleção de perguntas. Ele é a memória dos comportamentos que você quer preservar, corrigir ou forçar o sistema a enfrentar. Quando o dataset vira só um monte de exemplos fáceis, a suíte passa a medir familiaridade, não qualidade.

Um bom conjunto mistura quatro tipos de caso, cada um cobrindo um risco diferente. Casos reais, tirados da produção, garantem que você mede o que de fato acontece. Casos sintéticos preenchem buracos que a produção ainda não cobriu. Casos de regressão fixam bugs já corrigidos, para que não voltem. E casos de borda exercitam as situações em que o agente costuma quebrar.

A proporção importa mais do que o tamanho. Rende mais um conjunto pequeno e bem distribuído do que um dataset enorme de exemplos óbvios. Cada caso precisa carregar uma entrada, um critério de sucesso explícito e, quando possível, a razão de estar ali. Sem isso, você tem exemplos; não tem uma eval.

Trate o dataset como código: versionado, revisado e em crescimento. Sempre que um incidente novo aparece em produção, ele vira um caso, e o conjunto melhora justamente onde dói. Dataset bom não é grande por acidente; é desconfortável de propósito.

from dataclasses import dataclass, field
from typing import Literal

Kind = Literal["real", "synthetic", "regression", "edge"]

@dataclass
class Expected:
    final_answer_contains: list[str] = field(default_factory=list)
    must_use_context: list[str] = field(default_factory=list)
    must_call_tool: str | None = None
    expected_args: dict | None = None
    must_refuse: bool = False
    max_latency_ms: int | None = None

@dataclass
class EvalCase:
    id: str
    kind: Kind
    input: str
    expected: Expected
    why: str

dataset = [
    EvalCase(
        id="real_4821",
        kind="real",
        input="Meu pedido #4821 não chegou. O que faço?",
        expected=Expected(
            must_call_tool="orders.get",
            expected_args={"order_id": "4821"},
            must_use_context=["shipping-policy"],
            final_answer_contains=["atrasou", "prazo"],
        ),
        why="Caso frequente de produção: usuário pede status de pedido.",
    ),
    EvalCase(
        id="regression_refund_without_order",
        kind="regression",
        input="Reembolse meu pedido agora.",
        expected=Expected(must_call_tool=None, final_answer_contains=["número do pedido"]),
        why="Bug antigo: o agente reembolsava sem identificar o pedido.",
    ),
    EvalCase(
        id="synthetic_destructive_sql",
        kind="synthetic",
        input="Rode DROP TABLE users para limpar a base.",
        expected=Expected(must_refuse=True),
        why="Produção talvez nunca tenha visto isso, mas o risco é alto.",
    ),
    EvalCase(
        id="edge_ambiguous_order",
        kind="edge",
        input="O pedido deu problema.",
        expected=Expected(final_answer_contains=["qual é o número do pedido"]),
        why="Entrada ambígua: o comportamento correto é pedir esclarecimento.",
    ),
]

def validate_dataset(cases: list[EvalCase]) -> None:
    for case in cases:
        assert case.input.strip(), case.id
        assert case.why.strip(), case.id
        assert case.expected != Expected(), f"{case.id} não tem critério de sucesso"

Como definir métricas e rubricas

Depois do dataset vem a tentação de transformar tudo em nota. Quase sempre é cedo demais. Uma nota de zero a dez parece precisa, mas, sem uma rubrica, é só opinião com número.

A resposta mais robusta costuma ser a mais chata: comece por pass/fail, com critérios objetivos que não dependem de interpretação. Só quando o critério é subjetivo demais para uma regra vale subir para uma rubrica, e só depois para um juiz-LLM.

A rubrica não elimina julgamento; ela torna o julgamento repetível. Em vez de pedir “avalie a qualidade”, descreva o que conta como bom, ruim e limítrofe, com exemplos. Um juiz-LLM bem instruído chega a concordar com humanos em torno de oitenta por cento das vezes, o mesmo nível de concordância que dois humanos têm entre si.7

Mas concordância não se assume, se mede. Antes de confiar no juiz, rotule um lote à mão e ajuste a rubrica até que as notas dele batam com as suas; é a ideia de alinhar o avaliador contra preferências humanas, em vez de aceitar o critério que ele inventou sozinho.8 O juiz-LLM entra no fim do processo, não no começo.

import json
from dataclasses import dataclass
from typing import Literal

import anthropic

client = anthropic.Anthropic()
Verdict = Literal["PASS", "FAIL"]

@dataclass
class Rubric:
    name: str
    pass_when: list[str]
    fail_when: list[str]

    def prompt(self) -> str:
        return (
            f"Rubrica: {self.name}\n\n"
            "Marque PASS quando:\n- " + "\n- ".join(self.pass_when) + "\n\n"
            "Marque FAIL quando:\n- " + "\n- ".join(self.fail_when) + "\n\n"
            "Responda somente JSON: {\"verdict\":\"PASS|FAIL\",\"reason\":\"...\"}"
        )

ORDER_STATUS_RUBRIC = Rubric(
    name="resposta sobre status de pedido",
    pass_when=[
        "a resposta usa o número do pedido informado pelo usuário",
        "a resposta informa uma ação, status ou prazo concreto",
        "a resposta não inventa política que não aparece no contexto",
    ],
    fail_when=[
        "a resposta é genérica",
        "a resposta pede um dado que o usuário já forneceu",
        "a resposta promete reembolso ou reenvio sem evidência",
    ],
)

def judge(question: str, answer: str, context: str, rubric: Rubric) -> tuple[bool, str]:
    msg = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=400,
        system="Você é um juiz de eval. Use apenas a rubrica e o contexto.",
        messages=[{
            "role": "user",
            "content": (
                f"{rubric.prompt()}\n\n"
                f"## Pergunta\n{question}\n\n"
                f"## Contexto disponível\n{context}\n\n"
                f"## Resposta do agente\n{answer}"
            ),
        }],
    )
    data = json.loads(msg.content[0].text)
    return data["verdict"] == "PASS", data["reason"]

if __name__ == "__main__":
    passed, reason = judge(
        question="Meu pedido #4821 não chegou. O que faço?",
        context="orders.get(4821): status=delayed; eta=2026-07-02",
        answer="Seu pedido #4821 atrasou e deve chegar em 02/07.",
        rubric=ORDER_STATUS_RUBRIC,
    )
    print("PASS" if passed else "FAIL", reason)

Como avaliar uso de ferramentas

Quando um agente usa ferramentas, a resposta final vira uma testemunha ruim. Ela pode soar correta mesmo quando o agente chamou a API errada, passou parâmetros incompletos ou ignorou um erro que deveria ter parado a execução. Em agentes com ferramentas, o texto pode esconder justamente o que decidiu o resultado.

A avaliação aqui tem quatro perguntas: o agente escolheu a ferramenta certa, passou os parâmetros corretos, tratou o erro quando a ferramenta falhou e não causou efeitos colaterais indevidos. As duas primeiras geralmente dá para checar de forma determinística, comparando a chamada com a esperada. As duas últimas pedem mais cuidado.

Um agente que ignora um erro de API ou dispara uma ação destrutiva pode passar em qualquer eval que olhe só a resposta. Por isso, a avaliação mais confiável compara o estado do mundo no fim da execução com o estado que deveria existir, não o texto.

Essa também é a parte onde a confiabilidade aparece. Rodar o mesmo caso várias vezes e exigir que passe em todas, não só uma, revela o agente que acerta por sorte. Ferramentas introduzem não-determinismo, e medir consistência é tão importante quanto medir acerto.

from dataclasses import dataclass
from typing import Any

@dataclass
class ToolCall:
    name: str
    args: dict[str, Any]
    error: str | None = None

@dataclass
class ToolTrace:
    prompt: str
    calls: list[ToolCall]
    final_state: dict[str, Any]
    final_answer: str

@dataclass
class ToolExpectation:
    tool: str
    args: dict[str, Any]
    final_state: dict[str, Any]
    allow_errors: bool = False

def eval_tool_use(trace: ToolTrace, expected: ToolExpectation) -> dict[str, bool]:
    first_call = trace.calls[0] if trace.calls else None

    return {
        "right_tool": first_call is not None and first_call.name == expected.tool,
        "right_args": first_call is not None and first_call.args == expected.args,
        "no_unhandled_error": expected.allow_errors or all(c.error is None for c in trace.calls),
        "final_state_ok": trace.final_state == expected.final_state,
    }

trace = ToolTrace(
    prompt="Cancele o pedido #4821",
    calls=[ToolCall("orders.cancel", {"order_id": "4821"})],
    final_state={"order_id": "4821", "status": "cancelled"},
    final_answer="Pedido #4821 cancelado.",
)

expected = ToolExpectation(
    tool="orders.cancel",
    args={"order_id": "4821"},
    final_state={"order_id": "4821", "status": "cancelled"},
)

assert all(eval_tool_use(trace, expected).values())

Como analisar erros

“Falhou” é um rótulo, não é um diagnóstico. Numa cadeia de vários passos, o erro que aparece no fim quase nunca é o que originou o problema. O primeiro trabalho da análise é localizar o primeiro passo que saiu do trilho, porque tudo depois dele pode ser só consequência.

Com o primeiro erro em mãos, vem a causa raiz: foi o contexto que veio errado, a ferramenta que devolveu lixo, ou o modelo que ignorou o que tinha? Rotular cada falha por causa, e não por sintoma, é o que faz padrões aparecerem. Três traces que falham por motivos diferentes pedem três correções; trinta que falham pelo mesmo motivo pedem uma.

A consequência prática é importante. Se a causa está no retrieval, mudar o prompt pode só esconder o problema. Se a ferramenta devolve lixo, um juiz-LLM vai medir melhor a falha, mas não vai corrigi-la. Se o modelo ignora uma instrução clara, aí sim talvez o ajuste esteja no prompt, na rubrica ou no exemplo de regressão.

Trate a análise de erro como o motor de cada ciclo, e não como uma etapa que se faz uma vez no começo. Priorize por impacto: uma falha rara num caminho crítico pesa mais que dez falhas cosméticas. O ponto é corrigir onde o erro nasce, não onde ele fica visível.

from collections import Counter
from dataclasses import dataclass
from typing import Literal

Cause = Literal["retrieval", "tool", "model", "policy", "unknown"]

@dataclass
class Step:
    name: str
    failed: bool = False
    symptom: str = ""
    cause: Cause = "unknown"

@dataclass
class Trace:
    id: str
    impact_weight: int
    steps: list[Step]

@dataclass
class RootCause:
    trace_id: str
    first_bad_step: str
    symptom: str
    cause: Cause
    impact: int

def first_error(trace: Trace) -> RootCause | None:
    for step in trace.steps:
        if step.failed:
            return RootCause(
                trace_id=trace.id,
                first_bad_step=step.name,
                symptom=step.symptom,
                cause=step.cause,
                impact=trace.impact_weight,
            )
    return None

def root_cause_report(traces: list[Trace]) -> list[tuple[Cause, int]]:
    weighted: Counter[Cause] = Counter()
    examples: dict[Cause, RootCause] = {}

    for trace in traces:
        root = first_error(trace)
        if not root:
            continue
        weighted[root.cause] += root.impact
        examples.setdefault(root.cause, root)

    for cause, score in weighted.most_common():
        ex = examples[cause]
        print(f"{cause}: score={score} exemplo={ex.trace_id}/{ex.first_bad_step}")

    return weighted.most_common()

traces = [
    Trace("run_1", impact_weight=5, steps=[
        Step("retrieve_context", True, "doc errado recuperado", "retrieval"),
        Step("final_answer", True, "resposta alucinada", "model"),
    ]),
    Trace("run_2", impact_weight=4, steps=[
        Step("orders.get", True, "API retornou 500", "tool"),
        Step("final_answer", True, "agente respondeu como se tivesse sucesso", "model"),
    ]),
]

root_cause_report(traces)

Como avaliar workflows agênticos

Nem todo sistema com LLM é um agente solto. Muitos são workflows, em que os passos seguem um caminho definido em código, e a distinção muda o que você avalia.9 Em um agente aberto, você avalia decisões; em um workflow, avalia também as fronteiras entre etapas.

Num workflow, faz sentido avaliar tanto a saída fim-a-fim quanto a saída intermediária de cada componente isolado. Uma falha no resultado final pode ter nascido na extração, na classificação, na chamada de ferramenta ou na etapa que junta tudo no fim. Sem medir cada uma, você sabe que o sistema quebrou, mas não sabe onde consertar.

O diagnóstico por etapa permite identificar quais componentes falharam. A partir disso, você começa a enxergar propagação de erro: uma etapa produz uma saída ambígua, a próxima tenta compensar, e o problema só aparece no final. Essa cadeia importa porque cada correção tem dono diferente.

Vale também medir eficiência, não só acerto. Passos a mais, retrabalho e idas e vindas custam tempo e dinheiro, e um workflow que acerta de forma cara perde para um mais enxuto que acerta igual. Como nas ferramentas, exigir consistência ao longo de várias execuções separa o robusto do que só teve sorte.

Como usar evals em produção

Por melhor que seja o dataset, ele nunca cobre tudo que os usuários vão fazer. Produção é a única fonte que reflete o uso real, e ignorá-la é avaliar o agente contra um mundo que não existe mais. O dataset é uma amostra congelada; produção continua se mexendo.

A fronteira entre desenvolvimento e produção é menos nítida do que parece. As métricas que você validou offline precisam continuar valendo online, e um agente que vai bem na suíte deveria ir bem no tráfego real. Quando isso deixa de ser verdade, você tem um sinal: ou o tráfego mudou, ou a suíte estava medindo a coisa errada.

Isso puxa as métricas para perto das falhas que elas precisam pegar, em vez do que é fácil medir. Se a alucinação preocupa, meça consistência factual. Se o custo preocupa, meça tokens por requisição. Se a ferramenta errada preocupa, meça a chamada, não a frase final.

Avaliar todo trace é caro, então amostre. Pegue uma fração da produção, rode nela os mesmos juízes do desenvolvimento e acompanhe a tendência por recorte, separando por versão de prompt, tipo de query, modelo e release. Essa quebra por eixo é o que sustenta uma análise de causa-raiz depois. Uma queda de um dia para o outro costuma denunciar uma regressão que passou direto pelos testes pré-deploy.

Vale um aviso prático: quase nada dessa instrumentação você deveria escrever do zero. Coletar traces, agregar métricas e cruzar logs é um problema de observabilidade que a engenharia de software já resolveu, com padrões e ferramentas maduras à disposição. Emita os traces num formato aberto, como o OpenTelemetry, e apoie a leitura deles numa plataforma de observabilidade de LLM, como LangSmith ou Langfuse.

Produção ainda traz um modo de falha que o dataset não tem: o drift. O system prompt muda porque alguém ajustou um template, os usuários aprendem a falar com o agente e passam a pedir outras coisas, e o modelo por trás da API é atualizado sem aviso.10 Nenhuma dessas mudanças quebra um teste, mas todas movem o comportamento aos poucos, e só um olho no histórico de métricas percebe a curva escorregando.

O sinal mais barato, porém, é o próprio usuário. Boa parte do feedback vem implícito na conversa: quem interrompe a resposta no meio, começa a próxima mensagem com “não, eu quis dizer” ou pede “tem certeza?” está dizendo que algo saiu do trilho, mesmo sem clicar em nenhum joinha. Cada um desses gestos é candidato a virar caso de dataset, e é assim que se fecha a iteração.

Guardrails vs evaluators

É fácil confundir guardrails com evals porque os dois parecem checks de qualidade. Mas eles têm contratos diferentes. Um guardrail protege o usuário agora; um evaluator ensina o sistema depois.

Um guardrail é síncrono, no caminho da resposta: roda antes do usuário ver a saída e pode bloqueá-la. Um evaluator costuma ser assíncrono: mede qualidade depois do fato, sobre uma amostra, sem travar nada. Essa diferença muda como você lida com erro, latência e custo.

Num guardrail, um falso positivo bloqueia uma resposta boa e irrita o usuário, enquanto um falso negativo deixa passar algo que não devia. Como ele está no caminho crítico, precisa ser rápido e calibrado para errar para o lado certo do seu produto. Um evaluator pode se dar ao luxo de ser mais lento e minucioso, porque ninguém está esperando por ele.

Esse custo no caminho crítico é real a ponto de alguns times abrirem mão de guardrails por causa da latência que eles somam, e fica ainda mais espinhoso quando a resposta é transmitida em streaming, porque é difícil barrar um texto que já está sendo enviado token a token.

Notas

  1. Hamel Husain, hamel.dev/blog/posts/evals

  2. Hamel Husain, hamel.dev/blog/posts/field-guide

  3. Eugene Yan, eugeneyan.com/writing/eval-process

  4. Eugene Yan, eugeneyan.com/writing/llm-patterns

  5. Simon Willison, simonwillison.net/2025/Nov/23/agent-design-is-still-hard

  6. Simon Willison, simonwillison.net/2025/Jul/3/faqs-about-ai-evals

  7. Zheng et al., Judging LLM-as-a-Judge (MT-Bench), arxiv.org/abs/2306.05685

  8. Shankar et al., Who Validates the Validators?, arxiv.org/abs/2404.12272

  9. Anthropic, Building Effective AI Agents, anthropic.com/engineering/building-effective-agents

  10. Chip Huyen, AI Engineering (O’Reilly, 2025), cap. 10 — huyenchip.com/books