Perché “sembra giusto” non è un test per un prompt

Stringi il testo di un prompt che instrada i ticket, lo provi sui tre esempi che hai aperti nel tuo notebook e lo metti in produzione. Una settimana dopo, i reclami di fatturazione che prima finivano nella coda giusta cominciano a finire in “domanda”. Non i tre casi che hai controllato: gli altri quattrocento che non hai controllato. Niente va in crash. Non c’è nessuno stack trace. Il prompt, letto ad alta voce, suona ancora bene.

Il code review e qualche controllo manuale non colgono il problema perché lo stesso prompt sullo stesso input non restituisce lo stesso testo due volte. Non puoi fare un diff byte per byte tra due run e chiamare bug una discrepanza, come faresti con una funzione pura. Quello che puoi controllare è se ogni output ha le proprietà che ti interessano: la categoria giusta, un riassunto sotto il limite di lunghezza, nessun campo che la fonte non menzionava. Un eval esegue questi controlli su un insieme di input reali ogni volta che il prompt o il modello cambiano, così la regressione salta fuori nel tuo terminale invece che nel ticket di un cliente.

Quali sono le parti di un LLM eval

Ogni eval, qualunque sia il tool che lo esegue, ha tre parti: un dataset di input rappresentativi, un assert che verifica ogni output rispetto a cosa quell’input dovrebbe produrre, e un pass rate che tieni traccia da una run all’altra.

Pensalo come la suite di test per una funzione che restituisce ogni volta una frase leggermente diversa. L’assert non può verificare una corrispondenza esatta, quindi verifica una proprietà della frase: nomina la categoria giusta, il numero al suo interno è parsabile. Una volta accettato che il test chiede “questo vale” e non “questo è identico”, scriverne uno è ordinaria amministrazione.

Come costruire un dataset golden per un LLM eval

Prendi un prompt che trasforma una email di supporto grezza in un ticket strutturato:

1
2
3
4
5
6
7
8
9
TRIAGE_PROMPT = """Read this support email and return JSON with exactly these keys:
- category: one of "billing", "bug", "question"
- summary: the complaint in 12 words or fewer
- urgent: true if the customer can't use the product right now, else false

Email:
{email_text}

Return only the JSON object, no other text."""

Il dataset golden è un insieme di email reali accoppiate alla risposta che già sai essere corretta. Reali, non inventate per far sembrare bello il prompt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
golden_set = [
    {
        "email": "I was charged twice for my October invoice, can you refund the duplicate?",
        "category": "billing",
        "urgent": False,
    },
    {
        "email": "The app crashes every time I try to export a PDF, I have a deadline in an hour.",
        "category": "bug",
        "urgent": True,
    },
    {
        "email": "Does the Pro plan include API access, or do I need Enterprise?",
        "category": "question",
        "urgent": False,
    },
]

Da cinque a dieci casi bastano per iniziare. La dimensione conta meno di dove arrivano i casi successivi: fallimenti reali in produzione.

Come scrivere assert deterministici per l’output di un LLM

Gran parte di quello che va storto in questo prompt non ha niente a che fare con il gusto. Il modello restituisce JSON malformato, oppure inventa una quarta categoria, oppure scrive quaranta parole quando ne avevi chieste dodici. Il codice normale intercetta tutto questo, senza bisogno di un secondo modello:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import json

def run_triage_case(case, call_model):
    raw = call_model(TRIAGE_PROMPT.format(email_text=case["email"]))
    try:
        output = json.loads(raw)
    except json.JSONDecodeError:
        return {"passed": False, "reason": "not valid JSON"}

    if output.get("category") not in {"billing", "bug", "question"}:
        return {"passed": False, "reason": f"bad category: {output.get('category')!r}"}

    if len(output.get("summary", "").split()) > 12:
        return {"passed": False, "reason": "summary over 12 words"}

    if not isinstance(output.get("urgent"), bool):
        return {"passed": False, "reason": "urgent is not a boolean"}

    if output["category"] != case["category"]:
        return {"passed": False, "reason": f"expected {case['category']}, got {output['category']}"}

    return {"passed": True, "reason": None}

Eseguilo su tutto il dataset e ottieni un pass rate, non un semplice sì o no:

1
2
results = [run_triage_case(c, call_model) for c in golden_set]
pass_rate = sum(r["passed"] for r in results) / len(results)

Questi controlli non costano niente e non danno mai falsi positivi casuali: lo stesso output cattivo fallisce nello stesso modo ogni volta. Scrivili per primi. Intercettano la maggior parte delle regressioni reali, come un cambio di schema o un riassunto che si è raddoppiato in silenzio.

Quando usare LLM-as-judge e come calibrarlo

Categoria e urgenza sono oggettive, e il dataset contiene già la risposta giusta. La qualità del riassunto no. “Rimborso necessario, doppio addebito” e “Il cliente ha una domanda di fatturazione” sono entrambi JSON validi, entrambi sotto le 12 parole, e solo uno dei due descrive davvero l’email. Nessuna regex lo intercetta, quindi un secondo modello lo valuta rispetto a una rubrica:

1
2
3
4
5
6
7
8
9
JUDGE_PROMPT = """Email: {email}
Summary: {summary}

Does the summary accurately capture the main complaint in the email?
Answer with only "yes" or "no"."""

def judge_summary(email, summary, judge_model):
    verdict = judge_model(JUDGE_PROMPT.format(email=email, summary=summary), temperature=0)
    return verdict.strip().lower() == "yes"

Prima di fidarti di quel verdetto sull’intero dataset, esegui il judge su 15-20 casi che una persona ha già valutato a mano e conta quanto spesso sono d’accordo. Un judge che non è d’accordo con le persone una volta su tre è un assert debole travestito da autorità di un modello.

Ci sono due bias da tenere d’occhio. I judge tendono a valutare meglio le risposte più lunghe ed elaborate anche quando una più corta è ugualmente corretta. E un modello che valuta i propri output li giudica più favorevolmente di quanto farebbe un modello indipendente. Usa un modello diverso come judge quando puoi, e tieni il prompt stretto: una domanda sì/no per chiamata, non cinque punteggi in una sola richiesta.

Assert deterministicoLLM-as-judge
Costo per casoGratisUna chiamata al modello in più
Cosa intercettaSchema, formato, fatti già nel datasetQualità soggettiva, pertinenza, tono
RipetibilitàStesso output, stesso verdettoUn po’ di deriva anche a temperatura 0
Costo di setupScrivi il controllo e bastaServe prima la calibrazione su esempi valutati da persone

In termini di suite di test, i controlli deterministici sono i tuoi unit test: rapidi ed esatti, e li esegui a ogni modifica. Il judge è più vicino a un controllo di accettazione, quello che altrimenti farebbe una persona leggendo ogni output. I compromessi tra questi livelli sono gli stessi trattati in test unitari vs test di integrazione vs test end-to-end.

Come eseguire gli LLM eval in CI per intercettare le regressioni

Un eval che viene eseguito solo quando qualcuno se ne ricorda non intercetta niente. Metti lo script vicino al prompt che testa (evals/triage.py insieme a prompts/triage.py) ed eseguilo in CI a ogni modifica di entrambi i file, prima del merge. Tieni traccia del pass rate nel tempo e fai fallire la build quando scende sotto una baseline che hai fissato. Un calo dal 95% all'88% è una regressione da bloccare, anche se la maggior parte dei casi passa ancora.

Mantieni un set di regressione dentro il dataset: ogni caso che è stato un bug segnalato, fissato in modo permanente dopo la correzione. Per un prompt, questo ti dà il ciclo descritto in cos’è davvero il TDD. Cambia il prompt, rilancia la suite, vedi esattamente quali assert si sono ribaltati, correggi, ripeti. Alimenta il dataset con i nuovi fallimenti di produzione man mano che accadono, così non stai testando solo gli input su cui il prompt è stato progettato.

Come valutare pipeline di tool calling e RAG

Le stesse tre parti si applicano anche qui. Per un ciclo di tool calling, il dataset accoppia ogni input al nome del tool corretto e agli argomenti corretti, e l’assert verifica che il modello abbia scelto quel tool con i campi richiesti compilati. La meccanica del ciclo stesso è in function calling e tool use negli LLM.

Una pipeline RAG ha bisogno di due punteggi separati. Il retrieval ha recuperato il chunk che contiene la risposta? La generazione ha usato quel chunk correttamente? Se li valuti insieme, non puoi sapere quale delle due metà si è rotta. Guarda come funziona il RAG per capire quale pipeline stai dividendo in due.

Errori comuni con gli LLM eval

Costruire il dataset con casi che già passano. Cinque esempi scelti perché il prompt li gestisce bene passeranno per sempre e non ti diranno niente sull’input che romperà tutto il mese prossimo. Ogni fallimento reale che ti arriva va messo nel dataset lo stesso giorno.

Verificare una corrispondenza esatta su un output variabile. summary == "Rimborso necessario per doppio addebito" fallisce nel momento in cui il modello cambia una parola, anche quando la nuova formulazione è altrettanto corretta. Verifica la proprietà, non il testo: il conteggio delle parole, la categoria, se un fatto richiesto compare.

Saltare la calibrazione del judge. Un judge che non hai mai confrontato nemmeno con una dozzina di esempi valutati da persone ti consegna i suoi bias come verità di fatto, incluso quello che favorisce il proprio stesso output.

Rilanciare solo il caso che hai appena corretto. È così che un vecchio bug torna senza che nessuno se ne accorga. Il set di regressione esiste proprio per questo.

Quando non vale la pena costruire una suite di LLM eval

Un prompt che eseguirai una volta sola, o che una persona revisiona a ogni chiamata, non ha bisogno di un harness. Metti il controllo di validità JSON in linea se è poco costoso e vai avanti. La suite si ripaga quando un prompt va in produzione agli utenti in modo ripetuto o pensi di modificarlo più di un paio di volte. È il momento in cui “sembrava a posto sui miei tre esempi” smette di bastare.

Per iniziare: cinque input reali, un assert deterministico per ogni proprietà che ti interessa, e un judge LLM solo per la dimensione che il codice non può controllare. Eseguilo prima della prossima modifica al prompt, non dopo il reclamo.

Articoli correlati