Voltar ao início

AI Internals - Evals

14 min de leitura
Cover Image for AI Internals - Evals
Lucas LemosLucas Lemos

Introduction

Observabilidade diz por que uma resposta aconteceu: quais chunks chegaram, qual tool disparou, quantos tokens a generation queimou. Isso é debug. Não é prova.

Um ajuste de prompt que corrige o ticket aberto pode derrubar recall em outras cinquenta perguntas. Um modelo mais barato que "parecia ok" em três traces pode perder o caminho de recusa do qual o RAG depende. Evals são como você pega isso antes de todo mundo: um conjunto rotulado, um runner que percorre o mesmo caminho do produto, scorers que transformam outputs em números, e um baseline contra o qual você compara.

Traces continuam importando. Linhas de eval que falham devem carregar trace_id para você abrir o span de retrieve em vez de adivinhar de novo pela frase final.

Um eval é uma comparação

Quatro peças, nenhuma opcional por muito tempo:

  1. Dataset — casos com um input e um rótulo do que "bom" significa (IDs de chunks relevantes, um conjunto que precisa ser citado, uma sequência de tools, um flag de recusa).
  2. Runner — executa o caminho do produto, ou um recorte congelado dele, e grava os artefatos que a observabilidade já nomeou: chunk IDs, citações, nomes de tools, texto final.
  3. Scorer — mapeia esses artefatos para números ou booleanos. Código primeiro; um modelo juiz só onde código é cego.
  4. Baseline — a última versão que você ainda shipparia. Score sem comparação é feeling com passos a mais.

O quality gate da orquestração é primo, não substituto. Gate é um request, ao vivo: citações faltando → template de fallback. Eval é um conjunto, offline ou amostrado: essa mudança ajudou nos casos que importam? Você quer os dois. O gate para uma resposta ruim; o eval para um deploy ruim.

Gradue a falha que você de fato tem

"Helpfulness, 1–5" é um default fraco. Ele faz média de coisas diferentes e esconde o bug que você shippou. Escolha métricas da camada que quebra.

Retrieval (de Embeddings e Retrieval): recall@k e se a passagem gold está no conjunto empacotado, não se o cosseno "parecia alto." Uma resposta fluente sobre os vizinhos errados é miss de retrieval, mesmo que o juiz adore a prosa.

RAG: faithfulness à evidência empacotada, IDs de citação que existem no que você enviou, e o caminho de recusa quando o retrieval vem vazio ou irrelevante. Strings [chunk_id] sem verificação são baratas de inventar — cheque no código contra os IDs do span de retrieve.

Tools e agents: o handler que deveria ter rodado de fato rodou, com args válidos, dentro do budget de passos? Sucesso de tarefa é um fixture ("pedido 1842 é reembolsável, esperar create_credit uma vez"), não "o loop pareceu terminar." Tool calls ilegais ou extras são falha mesmo quando a frase visível ao usuário é educada.

Classifiers no pipeline de orquestração: acurácia contra intents rotulados. Se o classify erra, retrieve e tools nunca ganham uma chance justa — score esse estágio sozinho para não culpar a síntese.

Comece pelo que um teste unitário conseguiria assertar. Adicione uma rubrica depois para o resíduo que código não vê (tom, paráfrase que ainda é fiel).

Gold a partir de traces de produção

Você não inventa mil perguntas no primeiro dia. Você as pega do tráfego que já guarda.

Traces always-keep do artigo de observabilidade — fallbacks, falhas de quality gate, negações de approval — são os primeiros casos. Um humano marca: IDs de chunks relevantes, se a resposta deveria ter recusado, qual tool deveria ter disparado. Exporte trace_id na linha de eval para uma regressão abrir o mesmo span de retrieve seis semanas depois.

Cubra os modos de falha que você já escreveu, não um conjunto acadêmico equilibrado:

  • Retrieval vazio e "não sei"
  • Vizinho certo, chunk amputado (a regra de 14 dias morava no pai)
  • Citação inventada contra os IDs empacotados
  • Agent que chama a tool de escrita duas vezes
  • Compactação que derrubou a restrição do turno um — evals de sessão precisam de transcript, não de uma query só

Dois modos de runner, e eles respondem perguntas diferentes:

ModoO que fica congeladoO que você aprende
Só geraçãoChunks recuperados (ou resultados de tool) replayados do casoSe prompt / modelo / regras de grounding melhoraram, com evidência fixa
End-to-endSó o input do usuário; retrieval e tools rodam de verdadeSe o pipeline inteiro ainda funciona depois de mudança de índice ou handler

Se você só roda end-to-end, regressão de retrieval e regressão de prompt parecem iguais. Se você só congela chunks, vai shippar um índice que não acha mais a passagem gold. Mantenha os dois; marque cada caso com o modo a que pertence.

Versione o dataset como código. Quando o doc de política muda, os rótulos gold mudam — um caso que esperava "14 dias" está errado depois que o jurídico shippa "30 dias." Fixe a versão do corpus ao lado do caso, do mesmo jeito que você fixa o model id numa sessão do harness.

Cinquenta casos que acertam falhas reais batem duas mil paráfrases do happy path. Cresça o conjunto a partir de misses novos de produção, não de variedade sintética por ela mesma.

Scorers de código antes de juízes

Um scorer que parseia citações e calcula recall não precisa de outro modelo, não deriva quando o vendor do juiz solta um snapshot novo, e é barato o bastante para rodar em todo PR.

type Case struct {
  ID               string
  Query            string
  RelevantChunkIDs []string
  ExpectRefuse     bool
}

type Output struct {
ChunkIDs []string
Citations []string // parsed [id] markers, already extracted
Refused bool
}

type Score struct {
CaseID string
RecallAtK float64
CiteValid float64
RefuseOK bool
}

func recallAtK(got, relevant []string, k int) float64 {
if len(relevant) == 0 {
return 1
}
seen := map[string]bool{}
for i, id := range got {
if i >= k {
break
}
seen[id] = true
}
hits := 0
for _, id := range relevant {
if seen[id] {
hits++
}
}
return float64(hits) / float64(len(relevant))
}

func citeValid(cites, packed []string) float64 {
if len(cites) == 0 {
return 0
}
allowed := map[string]bool{}
for _, id := range packed {
allowed[id] = true
}
ok := 0
for _, id := range cites {
if allowed[id] {
ok++
}
}
return float64(ok) / float64(len(cites))
}

type Report struct {
MeanRecall float64
MeanCite float64
N int
Failures []string
}

func RunEval(cases []Case, k int, run func(Case) Output) Report {
var rec, cite float64
var fails []string
for _, c := range cases {
out := run(c)
r := recallAtK(out.ChunkIDs, c.RelevantChunkIDs, k)
v := citeValid(out.Citations, out.ChunkIDs)
refuseOK := c.ExpectRefuse == out.Refused
rec += r
cite += v
if r < 1 || !refuseOK || (!c.ExpectRefuse && v < 1) {
fails = append(fails, c.ID)
}
}
n := float64(len(cases))
return Report{
MeanRecall: rec / n,
MeanCite: cite / n,
N: len(cases),
Failures: fails,
}
}

run é o seu pipeline, não um parágrafo mockado. Para casos só de geração, ele replaya chunks empacotados do fixture; para end-to-end, chama retrieve de verdade. A lista Failures do report é a parte que você lê. Médias sem IDs são como um dip de 2% no único caso que trata reembolso se perde num bump de 0.01 na média.

Validade de citação 0 quando o modelo recusou é esperado — o check de recusa é dono desse caso, não o citeValid. Misturar os dois num único número de "qualidade" é como um modelo que nunca cita e nunca recusa fica "na média."

Juízes, quando código não enxerga

Faithfulness — "essa frase segue do bloco de evidência?" — é o buraco usual. Um LLM-as-judge é outra completion com uma rubrica, a evidência empacotada e a resposta candidata. Não é teste de personalidade.

Uma rubrica usável é curta e operacional:

  • Supported: toda afirmação factual aparece em Evidence (paráfrase permitida).
  • Unsupported: um número, data, cláusula de política ou nome que Evidence não contém.
  • Contradiction: Evidence diz 14 dias, a resposta diz 30.
  • Refuse: Evidence está vazia ou irrelevante e a resposta ainda inventa.

Peça um veredito estruturado (label, span da afirmação ruim, evidence_id) e parseie como qualquer outro schema. "7/10, pretty good" em texto livre é como juízes lavam feeling de volta para o dashboard.

Pairwise ("A é melhor que B?") costuma ser mais estável que scores absolutos em bake-offs de prompt. Troque a ordem e rode duas vezes; viés de posição é real. Não use a mesma família de modelo como candidato e juiz quando puder evitar — ela avalia o próprio estilo.

Humanos ainda rotulam o gold set e auditam uma amostra das discordâncias do juiz. O juiz é um multiplicador de rótulos em que você já confia, não um substituto.

Ruído, pin e o baseline

A parte 2 já avisou que temperature: 0 não é replay bit a bit. Réplicas do provedor, batching e roteamento MoE mexem nos tokens. Para evals isso significa:

  • Fixe o model id (e o id do juiz) no run, do jeito que o harness fixa o modelo da sessão.
  • Grave os params de sampling. Um eval de CI em temperature 0.7 está medindo ruído.
  • Trate exact string match como bônus. Prefira campos estruturados e scorers de código que não ligam para vírgula.
  • Se precisar de intervalo mais apertado, rode o mesmo caso n vezes e score a taxa — caro, então reserve para a fatia instável, não para o conjunto inteiro.

O número contra o qual você shippa é o report baseline do último commit known-good, mesma versão de dataset. "Faithfulness 0.81" não significa nada sem "era 0.84 em eval-set@v12 terça passada." Faça gate do deploy em deltas que você escolheu antes (recall@k não pode cair, casos de recusa precisam continuar verdes). Uma média que oscila dentro da banda de ruído não é vitória.

Rode a suíte no CI em mudanças de prompt, chunking, índice e handler de tool. Evals offline são os testes unitários desta stack. Sampling online — scorear uma fatia de traces de produção depois do fato — é o canário. Nenhum substitui o outro: CI nunca vê o tenant estranho; sampling de produção nunca tem gold chunk IDs a menos que você tenha rotulado.

Offline, online e o gate ao vivo

Três loops, três trabalhos:

Eval offline — dataset congelado, runner, report vs baseline. Você decide mergear.

Eval online — amostra produção, anexa scores depois (scorers de código em citações; humano ou juiz periódico numa subamostra). Você decide rollback. É também como o dataset cresce: discordâncias viram casos novos.

Quality gate ao vivo — orquestração, um request, sem rótulo gold. Checa parse, citation IDs ⊂ packed IDs, regex de recusa, schema. Falha → fallback, e mantenha o trace em sample rate 100% para ele poder entrar no gold set.

Se o gate ao vivo e o scorer offline discordam da mesma regra, o scorer está errado ou o gate nunca shippou. Mantenha a mesma função.

Qual scorer para qual pergunta

ScorerPegaMente quandoUse quando
Código / heurísticarecall@k, IDs de citação, schema, nomes de tool, flag de recusaResposta semanticamente errada com IDs válidosSempre primeiro
LLM-as-judgeFaithfulness, labels de rubricaViés de família compartilhada, rubrica vaga, efeitos de ordemResíduo que código não vê
Juiz pairwiseA vs B no mesmo casoPreferências intransitivas, posiçãoBake-offs de prompt / modelo
HumanoVerdade de produto, rótulos goldLento, caro, anotadores discordamSeed set + auditoria do juiz

Vendors (datasets no Langfuse, Promptfoo, o que você já usa para traces) são storage e UI nesse loop. Eles não escolhem a métrica. Se o produto é RAG, o primeiro scorer continua sendo recall e checagem de citação, esteja a linha no Postgres ou na aba de eval de outra pessoa.

Modos de falha

  • Caminho de demo, não o do produto. O eval chama o modelo com chunks inventados; produção roda retrieval híbrido. Você mediu um prompt que produção nunca envia.
  • Conjunto só de happy path. Sem retrieval vazio, sem caso de tool de escrita, sem sessão compactada. Médias sobem enquanto os incidentes ficam.
  • Um score misturado. Falhas da tool de reembolso afogam na faithfulness de Q&A. Separe reports por intent / estágio.
  • Exact match em prosa. Paráfrase é o ponto de um LLM; case IDs, schema e afirmações, não a string inteira.
  • Juiz sem rubrica. Você pagou a opinião de um segundo modelo sobre fluência.
  • Mesmo modelo como candidato e juiz. Ele gosta de si.
  • Iterar até 100%. Você ajustou o prompt a 50 casos. Segure uma fatia, ou o próximo miss de produção está garantido.
  • Gold sem versão. A política mudou; o eval ainda quer o número antigo.
  • Sem trace_id nas linhas de falha. Você não abre o span de retrieve, então reescreve o prompt de novo.
  • Declarar vitória em 20 casos e 0.02 na média. Isso é ruído. Olhe a lista de falhas.

Conclusão

Evals fecham o ciclo que Essenciais desenhou. A stack do modelo transforma tokens em texto; a stack do app é dona de contexto, retrieval, tools, sessões e traces. Nada disso diz se uma mudança ajudou até um conjunto rotulado e um scorer dizerem — contra um baseline, com falhas que você consegue abrir.

O resto da série é o que você mede. Meça a camada que de fato quebrou.