Vai al contenuto
mcpd
nessun daemon
Codice

Test di regressione

L’esecuzione che si è rotta martedì diventa il caso che ogni versione successiva deve superare.

Il ciclo che serve a chiudere

Un agente fallisce su una domanda vera. Qualcuno la riproduce a mano, cambia un prompt, e sembra andare meglio. Tre settimane dopo fallisce di nuovo sulla stessa domanda, perché non l’aveva tenuta nessuno.

Un dataset la tiene. Cattura l’esecuzione fallita e quella smette di essere un aneddoto: diventa un caso, con un input, una risposta attesa quando ce l’hai, e un rimando alla traccia da cui viene. Ogni versione successiva ci gira contro.

Catturare un’esecuzione

Trova l’esecuzione nell’interfaccia o attraverso l’API, poi passa al dataset il suo id di traccia.

catturare un’esecuzione fallita come caso

curl -X POST localhost:4317/v1/api/datasets/$DATASET/capture \ -H 'content-type: application/json' \ -d '{"trace_ids": ["b3d9e71a4c0f428eb6a5d2317f90c684"]}'

Il caso catturato tiene source_trace_id, così mesi dopo puoi aprire l’esecuzione che lo ha motivato e leggere che cosa è successo davvero invece di indovinarlo dall’input.

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.

La risposta dell’esecuzione non è la risposta attesa

use_output_as_expected è spento per impostazione predefinita, e quel valore predefinito è la parte importante. Le esecuzioni che vale la pena catturare di solito sono quelle andate male, e registrare una risposta sbagliata come riferimento consacrerebbe il difetto: ogni versione successiva verrebbe poi misurata sulla sua capacità di riprodurlo.

Accendilo quando stai catturando esecuzioni andate bene, per tenere fermo un comportamento che già ti piace. Lascialo spento, e scriviti la risposta attesa da solo, quando stai catturando un fallimento.

Gli scorer integrati

mcpd porta tredici scorer deterministici: codice normale che legge un’esecuzione archiviata e restituisce un numero. Non costano niente, non chiamano mai un modello, e danno due volte la stessa risposta, che è la proprietà di cui un test di regressione ha bisogno e che un modello giudice non ha.

ScorerPassa quando
answeredL’esecuzione ha prodotto una risposta non vuota.
no_errorsNessuno span dell’esecuzione è fallito.
exact_matchLa risposta è uguale a quella attesa.
containsLa risposta contiene una stringa richiesta.
json_validLa risposta si lascia leggere come JSON.
latency_budgetL’esecuzione è finita dentro un budget in millisecondi.
cost_budgetL’esecuzione non è costata più di una cifra fissata.
no_tool_loopNessuno strumento è stato chiamato due volte con lo stesso input.
tool_efficiencyL’esecuzione è rimasta dentro un budget di chiamate a strumenti.
not_a_refusalLa risposta non è un rifiuto né una scusa.
no_leaked_secretNella risposta non compare nessuna chiave, nessun token e nessuna credenziale.
span_presentL’esecuzione ha registrato uno span del tipo o del nome richiesto.
tool_succeededLo strumento richiesto è stato eseguito e almeno una chiamata si è conclusa bene.

GET /v1/api/scorers restituisce lo stesso elenco con un flag needs_config. Uno scorer che ha bisogno di una risposta attesa e non la riceve si astiene invece di indovinare, e l’astensione viene riferita.

Quando è un modello a dare il voto

Ad alcune domande non risponde nessuna quantità di codice deterministico. Se una risposta abbia davvero affrontato la domanda, se un riassunto abbia tenuto il senso, se il tono fosse quello giusto: lì serve un lettore, e un modello è il lettore che costa poco.

Il punteggio giudicato è spento finché non lo configuri, e uno scorer giudicato non gira mai se non lo nomini. Tutte e due le impostazioni predefinite esistono per la stessa ragione: dare un voto spende soldi su ogni esecuzione che legge, e una funzionalità che lo fa in silenzio è una funzionalità che scopri su una fattura.

{
  "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?"
    }
  }
}

La richiesta ha la forma chat-completions di OpenAI, e non è una preferenza per un fornitore: Ollama, vLLM, llama.cpp, LM Studio, OpenRouter e i provider ospitati la accettano tutti, quindi la stessa configurazione arriva a un modello sul tuo portatile e a un gateway in produzione. Lascia api_key vuota per un modello locale che non ne vuole. La temperatura è zero, perché un giudice che risponde ogni volta in modo diverso non si può confrontare con sé stesso, che è l’unica cosa per cui i punteggi esistono.

I criteri si configurano invece di essere passati a ogni richiesta. L’id di uno scorer è archiviato su ogni punteggio che produce ed è quello per cui un confronto successivo raggruppa, quindi lasciare che ogni chiamata si inventi sia l’id sia la domanda costruirebbe una colonna di cui nessuno si può fidare.

I criteri configurati non compaiono in GET /v1/api/scorers. Quell’endpoint elenca i tredici scorer integrati e nient’altro, quindi un client che costruisce da lì il proprio menu di scorer non offrirà i tuoi criteri. L’unico posto in cui il daemon te li nomina è l’errore che ricevi per un id di scorer sconosciuto, che elenca insieme gli integrati e i criteri configurati.

passare uno scorer giudicato sull’esecuzione più recente

curl -X POST localhost:4317/v1/api/score \ -H 'content-type: application/json' \ -d '{"limit": 1, "scorers": ["answer_relevance"]}'

timeout_seconds è quanto può durare una chiamata di valutazione, dalla connessione all’ultimo byte della risposta: 30 di default, da 1 a 300. Una chiamata che lo supera finisce in un’astensione che nomina la chiave, e mcpd legge al massimo 1 MiB di una risposta.

Ogni scorer giudicato è una chiamata al modello per esecuzione, e la richiesta deve finire entro --request-timeout-seconds. Quindi una richiesta valuta al massimo le esecuzioni che stanno in quella scadenza se ogni chiamata usa tutto il suo timeout_seconds. Con i default, 30 e 30, e uno scorer giudicato, è una sola esecuzione. Una richiesta che ne chiede di più, in trace_ids o in limit, viene rifiutata con un 400 che nomina il tetto, prima di chiamare qualsiasi modello. Se ometti limit, la richiesta valuta tante esecuzioni recenti quante ne entrano. Alza --request-timeout-seconds per valutare di più in una richiesta. Ogni punteggio giudicato viene salvato appena il modello risponde, quindi una richiesta interrotta tiene i punteggi che ha finito.

Si astiene invece di indovinare

Un giudice che restituisce un numero che non ha ricavato è peggio di nessun giudice, perché sembra una prova. Quindi ognuno di questi casi viene riferito in skipped con la sua ragione, e nessuno di loro produce un punteggio.

Che cosa è successoChe cosa fa mcpd
L’esecuzione non ha registrato nessuna risposta Si astiene. Un’esecuzione senza risposta non ha preso un voto basso di pertinenza: non è stata misurata.
Il modello ha risposto in prosa Si astiene, citando la risposta. Un JSON avvolto in un blocco delimitato o in una frase viene comunque letto.
Il modello ha risposto fuori dalla scala Si astiene. Un sette su una scala che arriva a uno non è nove decimi, e schiacciarlo dentro la scala si inventerebbe un numero.
Il modello non è stato raggiungibile Si astiene, con lo stato e il corpo. Un rate limit è una ragione, non uno zero.
La risposta superava 1 MiB Si astiene senza leggere il resto. Un verdetto occupa qualche centinaio di byte, quindi una risposta di quella misura non lo è.
La scadenza della richiesta non lasciava tempo per la chiamata Rifiuta senza chiamare il modello. La passata risponde con i punteggi che ha finito, entro la scadenza, invece che con un 503.

Ogni punteggio giudicato registra il modello come proprio scorer_version. Due punteggi di due giudici diversi non sono confrontabili, e quel campo è quello che lo dice sul record.

Il punteggio arriva dopo

POST /v1/api/score passa gli scorer su esecuzioni già archiviate. Niente viene valutato in linea. Una valutazione sul percorso della richiesta aggiunge la propria latenza alla cosa che sta giudicando, e una valutazione che solleva un’eccezione non deve mai far fallire la richiesta di una persona.

dare un punteggio alle ultime cento esecuzioni

curl -X POST localhost:4317/v1/api/score \ -H 'content-type: application/json' \ -d '{"project": "support-desk", "limit": 100, "config": {"max_ms": 4000}}'

La risposta porta i punteggi che ha scritto e, separatamente, ogni scorer che si è astenuto con la ragione per cui lo ha fatto. Uno scorer che non ha saputo rispondere è più interessante di uno che ha restituito un numero, quindi mcpd non se lo tiene mai per sé. A questa porta arriva anche il tetto di ingest del progetto: un punteggio calcolato dalla passata che porterebbe il progetto oltre --quota-per-minute non viene archiviato, e compare in skipped con quel motivo, invece di essere scritto oltre il tetto o buttato via in silenzio.

Manda "dry_run": true per vedere che cosa produrrebbe una passata senza archiviare niente.

Quali esecuzioni legge una passata lo decidono trace_ids, from e limit. Nomina le esecuzioni in trace_ids per dare un punteggio esattamente a quelle. Lascialo vuoto e la passata prende le limit esecuzioni più recenti del progetto, dalla più recente in giù, e from delimita quella pagina: una passata a cui mandi from legge solo le esecuzioni partite in quel momento o dopo, ed è così che dai un punteggio a tutto quello che è successo da un deploy in poi senza elencare id.

Confrontare due versioni

Registra un esperimento per ogni versione che vuoi confrontare, marca i suoi punteggi con l’id di quell’esperimento, e chiedi a mcpd la differenza.

il prompt nuovo è servito a qualcosa

curl 'localhost:4317/v1/api/experiments/compare?baseline=v3&candidate=v4'

La risposta è un delta per scorer con la dimensione del campione da ogni lato, e un flag decidable. Sotto le venti osservazioni mcpd dice che il confronto non si può decidere, invece di riferire un movimento.

È una regola volutamente grossolana e non è un test di significatività. Il fallimento che previene è quello di sempre: una media che si è mossa su quattro esecuzioni, stampata senza il suo conteggio, letta come un risultato e mandata in produzione. Stampare i conteggi accanto al delta è quello che impedisce di scambiare il rumore per una scoperta.

Dove gira tutto questo

È tutta API HTTP, quindi il giro sta in CI: cattura i fallimenti man mano che capitano, dai un punteggio alle esecuzioni che produce la tua suite di test, registra la build come esperimento, e fai fallire il job quando regressed torna vero su un confronto decidibile.

Poi