Testes de regressão
A execução que quebrou na terça-feira vira o caso que toda versão seguinte tem que passar.
O ciclo que isto existe para fechar
Um agente erra numa pergunta real. Alguém reproduz o erro na mão, muda um prompt, e a coisa parece melhor. Três semanas depois ele erra na mesma pergunta de novo, porque nada ficou guardado.
Um dataset guarda. Capture a execução que falhou e ela deixa de ser um caso isolado para virar um caso com uma entrada, uma resposta esperada quando você tem uma, e um ponteiro de volta para o trace de onde ela veio. Toda versão seguinte roda contra ele.
Capturando uma execução
Encontre a execução na interface ou pela API, e depois entregue o id do trace dela ao dataset.
capture uma execução que falhou como um caso
curl -X POST localhost:4317/v1/api/datasets/$DATASET/capture \
-H 'content-type: application/json' \
-d '{"trace_ids": ["b3d9e71a4c0f428eb6a5d2317f90c684"]}'
O caso capturado guarda o source_trace_id, então meses depois
você pode abrir a execução que motivou o caso e ler o que aconteceu de
verdade, em vez de adivinhar a partir da entrada.
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- tool returns_policy
The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.
A resposta da própria execução não é a resposta esperada
use_output_as_expected vem desligado, e esse padrão é a parte
importante. As execuções que valem a captura costumam ser as que deram
errado, e registrar uma resposta errada como referência consagraria o
defeito: toda versão seguinte passaria a ser medida pela capacidade de
reproduzi-lo.
Ligue quando estiver capturando execuções que deram certo, para segurar no lugar um comportamento de que você já gosta. Deixe desligado, e escreva a resposta esperada você mesmo, quando estiver capturando uma falha.
Os avaliadores nativos
O mcpd traz treze avaliadores determinísticos: código comum que lê uma execução guardada e devolve um número. Eles não custam nada, nunca chamam um modelo, e dão a mesma resposta duas vezes, que é a propriedade de que um teste de regressão precisa e que um modelo juiz não tem.
| Avaliador | Passa quando |
|---|---|
answered | A execução produziu uma resposta não vazia. |
no_errors | Nenhum span da execução falhou. |
exact_match | A resposta é igual à esperada. |
contains | A resposta contém um texto exigido. |
json_valid | A resposta é analisada como JSON. |
latency_budget | A execução terminou dentro de um orçamento em milissegundos. |
cost_budget | A execução não custou mais do que um valor definido. |
no_tool_loop | Nenhuma ferramenta foi chamada duas vezes com a mesma entrada. |
tool_efficiency | A execução ficou dentro de um orçamento de chamadas de ferramenta. |
not_a_refusal | A resposta não é uma recusa nem um pedido de desculpas. |
no_leaked_secret | Nenhuma chave, token ou credencial aparece na resposta. |
span_present | A execução registrou um span do tipo ou do nome exigido. |
tool_succeeded | A ferramenta exigida rodou e ao menos uma chamada terminou bem. |
GET /v1/api/scorers devolve a mesma lista com uma marca
needs_config. Um avaliador que precisa de uma resposta
esperada e não recebe uma se recusa em vez de chutar, e a recusa é
reportada.
Quando é um modelo que dá a nota
Algumas perguntas nenhuma quantidade de código determinístico responde. Se a resposta de fato tratou da pergunta, se um resumo preservou o sentido, se o tom estava certo: isso precisa de um leitor, e um modelo é o leitor barato.
A avaliação por juiz fica desligada até você configurá-la, e um avaliador por juiz nunca roda sem que você o nomeie. Os dois padrões existem pelo mesmo motivo: dar nota gasta dinheiro em cada execução que ele lê, e um recurso que faz isso caladinho é um recurso de que você fica sabendo pela fatura.
{
"judge": {
"endpoint": "http://localhost:11434/v1",
"model": "qwen2.5:14b",
"api_key": "",
"timeout_seconds": 30,
"criteria": {
"answer_relevance": "Does the answer address the question that was asked?",
"no_hedging": "Does the answer commit, rather than hedge?"
}
}
}
A requisição tem o formato de chat-completions da OpenAI, o que não é uma
preferência por um fornecedor: Ollama, vLLM, llama.cpp, LM Studio,
OpenRouter e os provedores hospedados aceitam todos esse formato, então a
mesma configuração alcança um modelo no seu notebook e um gateway em
produção. Deixe api_key vazio para um modelo local que não
quer nenhuma. A temperatura é zero, porque um juiz que responde diferente a
cada vez não pode ser comparado consigo mesmo, que é a única coisa para a
qual as notas servem.
Os critérios são configurados, e não passados a cada requisição. O id do avaliador fica guardado em toda nota que ele produz e é por ele que uma comparação posterior agrupa, então deixar cada chamada inventar tanto o id quanto a pergunta construiria uma coluna em que ninguém pode confiar.
Os critérios configurados não aparecem em GET /v1/api/scorers.
Esse endpoint lista os treze avaliadores nativos e mais nada, então um
cliente que monta o menu de avaliadores a partir dele não vai oferecer os
seus critérios. O único lugar em que o daemon nomeia os critérios de volta
para você é o erro que você recebe por um id de avaliador desconhecido, que
lista os nativos e os critérios configurados juntos.
rode um avaliador por juiz sobre a execução mais recente
curl -X POST localhost:4317/v1/api/score \
-H 'content-type: application/json' \
-d '{"limit": 1, "scorers": ["answer_relevance"]}' timeout_seconds é quanto uma chamada de avaliação pode levar,
da conexão ao último byte da resposta: 30 por padrão, de 1 a 300. Uma
chamada que passa disso termina em recusa com a chave nomeada, e o mcpd lê
no máximo 1 MiB de uma resposta.
Cada avaliador por juiz é uma chamada ao modelo por execução, e a requisição
precisa terminar dentro de --request-timeout-seconds. Então uma
requisição avalia no máximo as execuções que cabem nesse prazo se cada
chamada levar todo o timeout_seconds. Com os padrões, 30 e 30,
e um avaliador por juiz, isso é uma execução. Uma requisição que pede mais,
em trace_ids ou em limit, é recusada com um 400
que diz o teto, antes de qualquer modelo ser chamado. Sem limit,
a requisição avalia tantas execuções recentes quanto couberem. Aumente
--request-timeout-seconds para avaliar mais em uma requisição.
Cada nota por juiz é gravada assim que o modelo responde, então uma
requisição interrompida guarda as notas que terminou.
Ele recusa em vez de chutar
Um juiz que devolve um número que ele não deduziu é pior do que juiz nenhum,
porque parece prova. Por isso cada um destes casos é reportado em
skipped com o seu motivo, e nenhum deles produz uma nota.
| O que aconteceu | O que o mcpd faz |
|---|---|
| A execução não registrou nenhuma resposta | Recusa. Uma execução sem resposta não foi mal em relevância, ela não foi medida. |
| O modelo respondeu em prosa | Recusa, citando a resposta. JSON dentro de um bloco de código ou de uma frase ainda é lido. |
| O modelo respondeu fora da escala | Recusa. Um sete numa escala de um não é nove décimos, e limitar o valor à escala inventaria um número. |
| O modelo não pôde ser alcançado | Recusa, com o status e o corpo. Um limite de taxa é um motivo, não um zero. |
| A resposta passou de 1 MiB | Recusa sem ler o resto. Um veredito tem algumas centenas de bytes, então uma resposta desse tamanho não é um. |
| O prazo da requisição não deixou tempo para a chamada | Recusa sem chamar o modelo. A passada responde com as pontuações que terminou, dentro do prazo, em vez de um 503. |
Toda nota dada por juiz registra o modelo como o seu
scorer_version. Duas notas de dois juízes diferentes não são
comparáveis, e é esse campo que diz isso no registro.
A avaliação acontece depois do fato
POST /v1/api/score roda avaliadores sobre execuções que já
estão guardadas. Nada é avaliado no caminho da requisição. Uma avaliação
dentro do caminho da requisição soma a sua latência à coisa que ela está
julgando, e uma avaliação que lança uma exceção nunca deveria derrubar a
requisição de um usuário.
avalie as últimas cem execuções
curl -X POST localhost:4317/v1/api/score \
-H 'content-type: application/json' \
-d '{"project": "support-desk", "limit": 100, "config": {"max_ms": 4000}}'
A resposta carrega as notas que ela escreveu e, em separado, todo avaliador
que recusou, com o motivo da recusa. Um avaliador que não conseguiu
responder é mais interessante do que um que devolveu um número, então o
mcpd nunca engole a recusa. O teto de ingestão do projeto vale nesta porta
também: uma nota que a passagem calculou e que levaria o projeto acima de
--quota-per-minute não é guardada, e aparece em
skipped dizendo isso, em vez de ser escrita por cima do teto
ou jogada fora em silêncio.
Mande "dry_run": true para ver o que uma passagem produziria
sem guardar nada.
Quais execuções uma passagem lê é decidido por trace_ids,
from e limit. Nomeie as execuções em
trace_ids para avaliar exatamente aquelas. Deixe o campo vazio
e a passagem pega as limit execuções mais novas do projeto, da
mais nova para a mais antiga, e from limita essa página: uma
passagem que recebe from lê apenas execuções que começaram no
instante dele ou depois, que é como você avalia tudo desde uma implantação
sem listar ids.
Comparando duas versões
Registre um experimento para cada versão que você quer comparar, marque as notas dela com o id daquele experimento, e peça a diferença ao mcpd.
o prompt novo ajudou
curl 'localhost:4317/v1/api/experiments/compare?baseline=v3&candidate=v4'
A resposta é um delta por avaliador com o tamanho da amostra de cada lado, e
uma marca decidable. Abaixo de vinte observações o mcpd diz que
a comparação não pode ser decidida, em vez de reportar um movimento.
Essa é uma regra propositalmente grosseira, e não um teste de significância. A falha que ela evita é a mais comum: uma média que se moveu em quatro execuções, impressa sem a contagem, lida como resultado e colocada no ar. Imprimir as contagens ao lado do delta é o que impede que ruído seja confundido com achado.
Onde isto roda
Tudo isto é a API HTTP, então o ciclo pertence ao CI: capture as falhas
conforme elas acontecem, avalie as execuções que a sua suíte de testes
produz, registre o build como um experimento, e faça o job falhar quando
regressed voltar verdadeiro numa comparação decidível.
A seguir
- Os dois sinais ao lado dos quais uma nota fica: Notas e feedback.
- Vigiar a média de um avaliador em produção: Monitores e alertas.
- Todo endpoint e todo campo: API HTTP.