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.
- 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.
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.
| Scorer | Passa quando |
|---|---|
answered | L’esecuzione ha prodotto una risposta non vuota. |
no_errors | Nessuno span dell’esecuzione è fallito. |
exact_match | La risposta è uguale a quella attesa. |
contains | La risposta contiene una stringa richiesta. |
json_valid | La risposta si lascia leggere come JSON. |
latency_budget | L’esecuzione è finita dentro un budget in millisecondi. |
cost_budget | L’esecuzione non è costata più di una cifra fissata. |
no_tool_loop | Nessuno strumento è stato chiamato due volte con lo stesso input. |
tool_efficiency | L’esecuzione è rimasta dentro un budget di chiamate a strumenti. |
not_a_refusal | La risposta non è un rifiuto né una scusa. |
no_leaked_secret | Nella risposta non compare nessuna chiave, nessun token e nessuna credenziale. |
span_present | L’esecuzione ha registrato uno span del tipo o del nome richiesto. |
tool_succeeded | Lo 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 è successo | Che 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
- I due segnali accanto a cui sta un punteggio: Punteggi e feedback.
- Tenere d’occhio la media di uno scorer in produzione: Monitor e avvisi.
- Ogni endpoint e ogni campo: API HTTP.