2026-06-16
Protocollo di regressione LLM: golden set, matcher deterministici e policy di aggiornamento
Come strutturare un golden set per testare regressioni su cambi prompt e modello senza usare un LLM come giudice: formato file, gerarchia matcher, policy CI e regole di aggiornamento.
Per chi è questo pezzo
Per chi ha già un sistema LLM in produzione (RAG, agente, classificatore) e ha vissuto almeno una volta: «ho cambiato il prompt o aggiornato il modello e non so se ho rotto qualcosa». Non è un tutorial per chi parte da zero: assume che tu sappia cosa sia un prompt, un endpoint di inferenza e una pipeline di deploy.
Il problema che questo risolve
Cambio prompt
Modifichi wording, aggiungi contesto, stringi il formato di output. Il modello è lo stesso, ma la superficie di comportamento cambia spesso e a piccoli incrementi. Ti serve un modo deterministico per sapere se, sui casi noti, il sistema risponde ancora secondo le asserzioni che consideri corrette (intent, campi JSON, assenza di frasi vietate).
Cambio modello
Passi da una versione a un’altra (es. famiglia o snapshot diversi). Il nuovo modello può essere “migliore” in benchmark generici e peggiorare su i tuoi input reali. La regressione qui è rara ma ad alto impatto sistemico: un solo deploy può alterare migliaia di risposte.
Perché separare i due scenari
- Cambio prompt: alta frequenza, impatto localizzato; vuoi feedback veloce in CI (pre-merge).
- Cambio modello: bassa frequenza, impatto globale; vuoi gate pre-deploy e spesso una finestra di review più larga.
Il punto operativo (non ideologico) sull’approccio “un LLM valuta l’output”: non è ripetibile al bit, non scala bene in CI senza varianza, e quando fallisce non ti dice quale asserzione ha rotto il contratto. Qui lo sostituiamo con golden set + matcher espliciti e policy di aggiornamento.
Cos’è un golden set (e cosa non è)
È: una collezione di casi (input, asserzioni attese) che fissano il comportamento accettabile su esempi rappresentativi. Ogni caso è una piccola specifica eseguibile.
Non è: dataset di fine-tuning, test suite esaustiva sulla lunga coda, né un archivio di “risposte perfette” da citare in tribunale. Non sostituisce il monitoraggio in produzione.
Principi di selezione:
- priorità ai casi limite già visti fallire in produzione o in staging;
- coprire le varianti principali dell’input (non tutte le permutazioni);
- se il sistema ha più tipi di output (es. tool JSON vs risposta libera), includere almeno un caso per tipo.
Dimensione: per un RAG o classificatore medio, 20–50 casi è una fascia ragionevole. Sotto ~20 il segnale è fragile; sopra ~200 la manutenzione diventa costosa senza tooling dedicato (split per dominio, ownership per team).
Formato file
YAML è un buon default: leggibile in review, diff-friendly su Git, adatto a yamllint e a merge controllati.
Struttura consigliata del repository
eval/
golden/
cases/
refund-intent-001.yaml
shipping-delay-002.yaml
README.md
Singolo caso (solo matcher fino al livello 2)
id: refund-intent-001
description: "Utente chiede rimborso ordine con ID esplicito"
created_at: "2026-05-01"
updated_at: "2026-05-01"
update_reason: "Creazione caso da ticket #4412"
deprecated: false
input:
user_message: "Voglio il rimborso dell'ordine #1234"
context: "Cliente verificato; ultimo ordine spedito 10 giorni fa."
matchers:
- type: json_field
field: intent
op: eq
value: refund
- type: json_field
field: confidence
op: gte
value: 0.85
- type: contains
value: "1234"
- type: not_contains
value: "non posso aiutarti"
level: 2
tags:
- refund
- entity-extraction
Esempio con livello 3 (json_subset e campi extra per versione modello)
L’API (o il tool) restituisce JSON con chiavi aggiuntive che cambiano tra versioni di modello o tra snapshot (model_reasoning, debug_trace, campi sperimentali). A te interessa solo che restino vere alcune coppie chiave/valore: intent, stato operativo, ecc. In quel caso livello 2 non basta senza elencare ogni chiave extra con asserzioni negative fragili; usi json_subset: l’output parsato deve contenere almeno l’oggetto atteso, ignorando il resto.
id: tool-output-contract-015
description: "Tool tracking: il modello aggiunge chiavi extra, il contratto è solo intent + status + confidence"
created_at: "2026-05-12"
updated_at: "2026-05-12"
update_reason: "Dopo upgrade modello compaiono chiavi opzionali non documentate nel client"
deprecated: false
input:
user_message: "Dov'è il mio pacco?"
matchers:
- type: json_subset
expected:
intent: tracking
status: ok
- type: json_field
field: confidence
op: gte
value: 0.72
- type: normalized_text_equal
field: summary
expected: "Il pacco è in transito verso il centro operativo."
level: 3
tags:
- tool-json
- tracking
json_subset confronta la struttura minima; json_field su confidence resta un controllo locale di soglia. Il matcher normalized_text_equal (sempre livello 3 qui) applica nel runner una normalizzazione fissata e documentata (es. NFC, minuscolo, collasso spazi) prima del confronto sul campo summary: utile quando il modello varia leggermente punteggiatura o spazi ma il contenuto deve restare equivalente. Il level del caso è 3 perché il matcher più “debole” strutturalmente (non byte-per-byte su tutto il JSON) è quello di insieme/normalizzazione.
Significato dei campi
| Campo | Ruolo |
|---|---|
id | Chiave stabile usata dal runner CI e nei report. Non rinominare: meglio deprecated + nuovo file. |
description | Contesto umano per review: perché questo caso esiste. |
created_at / updated_at | Audit trail; ogni modifica che cambia il contratto del caso aggiorna updated_at e update_reason. |
update_reason | Obbligatorio quando cambi asserzioni o input: spiega la decisione (bug golden, cambio spec, adeguamento modello). |
deprecated | true se il caso non va più eseguito come gate, ma resta in repo per storia. |
input | Payload che il runner passa alla funzione/endpoint sotto test (messaggio utente, chunk RAG, metadati sessione). |
matchers | Lista ordinata di controlli deterministici (vedi gerarchia livelli). |
level | Massimo livello di matcher usato in questo caso (1 … 5). Serve ad aggregare nei report (es. “quanti casi toccano livello 4?”). Deve essere coerente con i type presenti. |
tags | Filtri in dashboard o ownership per prodotto. |
Esempio con livello 4 (embedding)
Usa questo pattern solo quando la risposta è libera ma vuoi comunque un segnale automatico prima della review:
id: tone-paraphrase-010
description: "Risposta empatica equivalente, non letterale"
created_at: "2026-05-10"
updated_at: "2026-05-10"
update_reason: "Calibrazione soglia su 30 esempi umani"
deprecated: false
input:
user_message: "Sono molto deluso dal ritardo."
matchers:
- type: embedding_similarity
golden_text: "Capisco la frustrazione per il ritardo; ti aggiorno sullo stato della spedizione."
threshold: 0.88
model: text-embedding-3-small
level: 4
tags:
- tone
- shipping
Qui level è 4 perché il matcher di punta è semantico; non mischiare nel report con casi solo livello 2 senza aggiornare level.
La gerarchia dei matcher (livello 1 → 5)
Regola pratica da tenere appesa sopra la scrivania: usa il livello più basso possibile. Se la maggior parte dei casi finisce a livello 4 o 5, il problema è spesso il design del contratto di output (troppo aperto), non la qualità dei test.
Livello 1 — Uguaglianza esatta
- Cosa: confronto stringa byte-per-byte (o normalizzazione solo se esplicitata nello stesso livello, altrimenti resta 1 “strict”).
- Tipo esempio:
exact_textsul serializzato canonico dell’output (es. JSON con chiavi ordinate se il tuo runner serializza sempre uguale). - Quando: output rigidamente formattato, temperatura 0, nessuna variazione lecita.
- Costo computazionale: trascurabile.
Livello 2 — Predicati su struttura o sottostringhe
- Cosa:
json_field(eq,ne,gte,lte,in),contains,not_contains,regex. - Quando: structured output, intent + confidence, divieti lessicali (“non dire X”).
- Costo: lineare nella lunghezza dell’output, tutto locale.
Livello 3 — Contratto strutturale o normalizzazione controllata
- Cosa:
json_subset(l’output parsato contiene almeno le coppie annidate inexpected), validazionejson_schema,normalized_text_equal(regole di normalizzazione fissate nel runner, es. NFC + minuscolo + collasso spazi, poi uguaglianza sul campo indicato). - Quando: chiavi extra accettate o variabili tra versioni modello (vedi esempio
tool-output-contract-015sopra); testo libero in un campo dove solo la forma normalizzata deve combaciare. - Costo: parsing JSON + eventuale validazione schema; tutto locale.
Livello 2 vs 3: al livello 2 usi json_field su ogni campo che ti interessa e rischi di dover aggiornare il golden ogni volta che compare una chiave nuova irrilevante. Al livello 3 json_subset fissa il sotto-contratto che importa e ignora il rumore strutturale esterno.
Livello 4 — Similarità embedding
- Cosa:
embedding_similaritytra output egolden_text(o tra embedding cache del golden e dell’actual), con soglia fissata per calibrazione su un set di validazione umano. - Quando: parafrasi accettabili, risposte libere con tolleranza semantica misurabile.
- Costo: una o due chiamate embedding per caso (cache sul golden in CI se possibile).
Livello 5 — Solo review umana
- Cosa: il caso documenta input e criteri qualitativi (brand, compliance, rischio legale) che non codifichi in matcher binari affidabili.
- Quando: nessun automatismo generico è sufficiente; il gate è checklist umana o processo dedicato.
- Costo: tempo umano; non va usato come “pass automatico” in CI.
CI: contratto di integrazione
Non serve imporre Vitest, GitHub Actions o altro: serve un contratto che qualsiasi runner può implementare.
Input
- Directory o manifest dei file YAML del golden set.
- Funzione o endpoint sotto test (versione prompt e modello pin o identificati da variabili d’ambiente della pipeline).
- Configurazione runner: timeout, retry solo dove è accettabile (di solito no su test di regressione), path dei segreti per embedding se usi livello 4.
Output (report macchina-leggibile + umano)
- Conteggio pass / fail totale e per livello massimo (
leveldel caso). - Per ogni fallimento: quale
matcherha rotto, valore atteso vs attuale (stringa troncata, path JSON, score di similarità). - Flag
review_required: truesu ogni fallimento livello 4 (evidenza per la review legata all’eventuale override) e su tutti i casi 5. - Opzionale: diff testuale o JSON pretty-print tra output canonico e snapshot atteso (solo se non viola PII — mascherare ID ordine nei log).
Policy di esecuzione
- Ogni cambio prompt che tocca il sistema sotto test: run obbligatorio pre-merge (o pre-push se il team è piccolo).
- Ogni cambio modello (versione API, snapshot, temperatura di default prod-relevant): run obbligatorio pre-deploy.
- Run periodico (es. settimanale) anche senza cambi al codice: i provider aggiornano pesi e routing; il model drift esiste anche se il tuo
package.jsonnon è cambiato.
Soglie di fallimento (policy consigliata)
| Livello matcher coinvolto nel fallimento | Esito pipeline |
|---|---|
| 1–3 | Fail = blocco merge/deploy (rosso). |
| 4 | Default di questo protocollo: come 1–3 — fail = blocco merge/deploy. Override consentito solo con approvazione esplicita tracciata (ticket collegato, etichetta su PR, o campo obbligatorio nel report CI firmato da un owner) dopo lettura del report (score di similarità, snippet actual). Senza traccia non si “sblocca” il rosso: altrimenti il livello 4, per natura non binario come un eq, smorzerebbe la CI fino a renderla decorativa. I team più grandi possono adottare una policy più permissiva solo sostituendo per iscritto questo default interno. |
| 5 | Mai esito automatico verde/rosso definitivo: solo coda review umana. |
Il default di blocco anche sul livello 4 evita che un merge passi mentre l’unico segnale era “embedding sotto soglia”: la soglia ha varianza (modello embedding, testo, token); la review deve essere un passo consapevole, non un buco nella pipeline.
Policy di aggiornamento del golden set
Un golden set obsoleto è peggio dell’assenza di test: dà falsa sicurezza. Questa sezione è il contratto sociale tra ingegneria e prodotto.
Quando aggiornare
- Compare in produzione un nuovo tipo di input non coperto dal set → aggiungi un caso con
created_at/update_reason. - Un caso fallisce ma l’output nuovo è corretto per spec aggiornata → non “sistemi” il modello a forza: aggiorni il golden e documenti perché il vecchio atteso era sbagliato.
- Cambio intenzionale di comportamento (nuova policy rimborso, tono diverso) → aggiornamento golden è parte della stessa change request del codice o del prompt.
Come aggiornare (regole operative)
- Nessun aggiornamento silenzioso automatizzato da pipeline (tipo “sovrascrivi expected con actual”). Ogni modifica a
matchersoinputè una decisione umana tracciata. - Ogni modifica tocca
updated_ateupdate_reason(una riga frase nel YAML va bene). - I casi non si cancellano dal giorno alla notte:
deprecated: true, opzionalmentedeprecated_atedeprecated_reason, e restano almeno un ciclo di release per permettere grep e audit.
Esempio di deprecazione:
id: refund-intent-legacy-000
description: "Vecchia formulazione pre-spec 2026-04"
deprecated: true
deprecated_at: "2026-05-15"
deprecated_reason: "Sostituito da refund-intent-001 dopo modifica policy ID ordine"
Segnale di degrado del set
Se dopo un singolo cambio modello più del ~30% dei casi richiede riscrittura delle asserzioni, il set è troppo accoppiato alla forma superficiale dell’output (verbatim, punteggiatura) e non al contratto semantico. Rimedi: spostare matcher verso livello 2–3 (campi strutturati), stringere l’output con schema, ridurre testo libero dove non serve.
Cosa resta fuori
- Evaluator LLM-as-judge come gate primario: pattern diverso, non deterministico nel senso usato qui.
- A/B test su traffico reale, eval di fine-tuning, framework commerciali (LangSmith, RAGAS, …): utili, ma non necessari per implementare lo standard sopra con uno script interno.
Chiusura
La domanda da portarsi a casa: «Se domani mattina cambio modello, in quanto tempo so se ho rotto qualcosa sui miei casi reali?»
Se la risposta è non lo so o ci vuole un giorno, ti serve un golden set operativo come questo. Se la risposta diventa dieci minuti dopo il merge della pipeline, l’articolo ha fatto il suo lavoro.