Ir para o conteúdo
mcpd
sem daemon
Código

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.

Trace b3d9e71a Fault
0ms 2.1s 4.2s 6.3s 8.4s
  1. agent support_agent fail
  2. model chat
  3. tool returns_policy
  4. model chat
  5. tool returns_policy
  6. model chat
  7. 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.

AvaliadorPassa quando
answeredA execução produziu uma resposta não vazia.
no_errorsNenhum span da execução falhou.
exact_matchA resposta é igual à esperada.
containsA resposta contém um texto exigido.
json_validA resposta é analisada como JSON.
latency_budgetA execução terminou dentro de um orçamento em milissegundos.
cost_budgetA execução não custou mais do que um valor definido.
no_tool_loopNenhuma ferramenta foi chamada duas vezes com a mesma entrada.
tool_efficiencyA execução ficou dentro de um orçamento de chamadas de ferramenta.
not_a_refusalA resposta não é uma recusa nem um pedido de desculpas.
no_leaked_secretNenhuma chave, token ou credencial aparece na resposta.
span_presentA execução registrou um span do tipo ou do nome exigido.
tool_succeededA 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 aconteceuO 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