Salta al contenuto principale

09 - Testing e Evaluation: Metriche e Benchmark

Come testare un agente non deterministico: copioni al posto del modello, prove ripetute con pass@k e pass^k, giudici automatici controllati, benchmark.

Cosa ne pensi di questo articolo?

09 - Testing e Evaluation: Metriche e Benchmark
AI AgentsFoto:Pavel DanilyukPexels

In Breve

Come testare un agente non deterministico: copioni al posto del modello, prove ripetute con pass@k e pass^k, giudici automatici controllati, benchmark.

Il problema: l'agente che oggi passa e domani no

Un test normale dice che una funzione, dato un ingresso, restituisce sempre la stessa uscita. Con un agente non è vero: ripetendo la stessa richiesta il modello può scegliere strumenti diversi, in ordine diverso, e riuscire il lunedì e fallire il martedì.

Verificare l'agente una sola volta e dichiararlo funzionante è l'errore più comune, e la correzione non è un test più grosso ma tre tipi di controlli con scopi diversi: test deterministici sul tuo codice, con un modello sostituito da un copione; ripetizioni statistiche sul comportamento vero, per sapere quanto è affidabile e non solo se può riuscire; controlli continui in produzione, per accorgersi quando qualcosa cambia. Questo articolo li mette in quest'ordine, con listati eseguiti senza chiamare alcun modello.

Il vocabolario è quello della guida di Anthropic «Demystifying evals for AI agents»: una prova (trial) è una singola esecuzione di un compito; un valutatore (grader) è la logica che assegna un punteggio; la traccia (transcript, trajectory) è il registro completo di una prova, con uscite, chiamate agli strumenti e risultati intermedi; l'esito (outcome) è lo stato finale dell'ambiente. La distinzione tra traccia ed esito è la prima cosa da capire: un agente che dice «volo prenotato» ha prodotto una frase, e se la prenotazione esiste davvero lo dice solo lo stato finale.

Primo livello: testare la traiettoria con un modello finto

La parte di un agente che è codice tuo (il ciclo, i tetti, gli strumenti, la validazione) si testa in modo deterministico sostituendo il modello con un copione di mosse.

Il listato definisce un agente minimo per prenotare un volo: il «modello» è una lista di mosse (chiamate a strumenti o risposta finale), gli strumenti sono funzioni locali che modificano uno stato, e controlli applica quattro verifiche, due sull'esito (c'è una sola prenotazione, quella giusta), due sulla traiettoria (cerca prima di prenotare, nessuna chiamata ripetuta) e una sul limite (la sequenza termina con una risposta). Tre copioni, uno corretto e due difettosi, mostrano che cosa vede ogni controllo.

def esegui_agente(copione, max_passi=5):
    """Agente minimo: il 'modello' e' un copione di mosse. Restituisce traccia ed esito."""
    stato = {"prenotazioni": []}
    def cerca_volo(destinazione):
        return {"id": "V1", "a": destinazione}
    def prenota(id_volo):
        stato["prenotazioni"].append(id_volo)
        return "ok"
    strumenti = {"cerca_volo": cerca_volo, "prenota": prenota}
    traccia = []
    for passo, mossa in enumerate(copione):
        if passo >= max_passi:
            traccia.append(("interrotto", "tetto di passi"))
            break
        if "finale" in mossa:
            traccia.append(("finale", mossa["finale"]))
            break
        traccia.append((mossa["strumento"], mossa["argomenti"]))
        strumenti[mossa["strumento"]](**mossa["argomenti"])
    return traccia, stato

def controlli(traccia, stato):
    chiamate = [t[0] for t in traccia]
    return {
        "esito: una sola prenotazione di V1": stato["prenotazioni"] == ["V1"],
        "traiettoria: cerca prima di prenotare": chiamate[:2] == ["cerca_volo", "prenota"],
        "traiettoria: nessuna chiamata ripetuta": len(chiamate) == len(set(map(str, traccia))),
        "limite: finisce con una risposta": traccia[-1][0] == "finale",
    }

CERCA = {"strumento": "cerca_volo", "argomenti": {"destinazione": "Lisbona"}}
PRENOTA = {"strumento": "prenota", "argomenti": {"id_volo": "V1"}}
CASI = {
    "corretto": [CERCA, PRENOTA, {"finale": "Prenotato."}],
    "doppia prenotazione": [CERCA, PRENOTA, {**PRENOTA, "argomenti": {"id_volo": "V1"}}, {"finale": "Fatto."}],
    "non finisce mai": [CERCA] * 8,
}
for nome, copione in CASI.items():
    esito = controlli(*esegui_agente(copione))
    print(f"{nome}: {sum(esito.values())}/{len(esito)}", [k for k, v in esito.items() if not v])

# Output:
# corretto: 4/4 []
# doppia prenotazione: 2/4 ['esito: una sola prenotazione di V1', 'traiettoria: nessuna chiamata ripetuta']
# non finisce mai: 0/4 ['esito: una sola prenotazione di V1', 'traiettoria: cerca prima di prenotare', 'traiettoria: nessuna chiamata ripetuta', 'limite: finisce con una risposta']

Il copione corretto passa tutto. Quello con la doppia prenotazione supera «cerca prima di prenotare» e il limite, ma viene fermato da due verifiche: l'esito (due prenotazioni, non una) e la chiamata ripetuta. Il terzo non finisce mai, e il tetto di passi lo interrompe: se avessi controllato solo la risposta finale, un agente che non conclude non sarebbe nemmeno stato notato.

Il punto di progetto è che gli errori più costosi non stanno nel testo della risposta, stanno nello stato del mondo dopo l'esecuzione. Questi controlli girano in millisecondi, senza costi di modello, e sono i soli che puoi eseguire a ogni modifica del codice. Non dicono nulla sulle scelte che farebbe un modello vero: per quelle servono le ripetizioni.

Secondo livello: ripetere, perché un solo run non misura

La guida di Anthropic spiega perché: ogni compito ha un proprio tasso di successo (magari 90% su uno e 50% su un altro) e un compito riuscito in una prova può fallire nella successiva. Servono quindi più prove per compito e due metriche. pass@k è la probabilità di ottenere almeno una soluzione corretta in k tentativi, e cresce con k;

pass^k è la probabilità che tutte le k prove riescano, e cala con k. La guida fa l'esempio di un agente con il 75% di successo per prova: su tre prove la probabilità di passarle tutte è circa 42%. Il benchmark τ-bench ha introdotto pass^k per misurare l'affidabilità e riporta, per gli agenti provati nel suo studio, che sul dominio «retail» il valore a k=8 scende sotto il 25%.

Il listato simula tre compiti con una probabilità di successo nota (0,9, 0,5 e 0,1) e un generatore con seme fisso, esegue dieci prove ciascuno, e calcola le due metriche con la formula combinatoria: se in n prove ci sono c riuscite, la probabilità che k prove scelte a caso riescano tutte è C(c,k)/C(n,k). In fondo calcola l'intervallo di Wilson per una proporzione, il metodo di Wilson, che il manuale NIST presenta come raccomandato da Brown, Cai e DasGupta, per mostrare quanto è larga l'incertezza.

import random
from math import comb, sqrt
from statistics import NormalDist

TASK = {"A": 0.9, "B": 0.5, "C": 0.1}          # probabilita' di successo dell'agente finto, per compito
N = 10                                          # prove per compito
rng = random.Random(2026)                       # seme fisso: stessa uscita a ogni esecuzione
successi = {t: sum(rng.random() < p for _ in range(N)) for t, p in TASK.items()}

def pass_a_k(c, n, k):                          # almeno una riuscita su k prove scelte tra le n
    return 1 - comb(n - c, k) / comb(n, k)

def pass_alla_k(c, n, k):                       # tutte e k riuscite
    return comb(c, k) / comb(n, k)

print("compito riuscite pass@1 pass@3 pass^3 pass^8")
for t, c in successi.items():
    print(f"{t:8} {c:2}/{N}   {c/N:.2f}   {pass_a_k(c, N, 3):.2f}   {pass_alla_k(c, N, 3):.2f}   {pass_alla_k(c, N, 8):.2f}")
tot = sum(successi.values())
print("media pass@1:", round(tot / (N * len(TASK)), 2),
      "| media pass^8:", round(sum(pass_alla_k(c, N, 8) for c in successi.values()) / len(TASK), 2))

def wilson(c, n, livello=0.95):
    z = NormalDist().inv_cdf(0.5 + livello / 2)
    p = c / n
    centro = (p + z * z / (2 * n)) / (1 + z * z / n)
    margine = z * sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
    return round(centro - margine, 2), round(centro + margine, 2)

print("90% osservato su 10 prove :", wilson(9, 10))
print("90% osservato su 100 prove:", wilson(90, 100))

# Output:
# compito riuscite pass@1 pass@3 pass^3 pass^8
# A        10/10   1.00   1.00   1.00   1.00
# B         3/10   0.30   0.71   0.01   0.00
# C         1/10   0.10   0.30   0.00   0.00
# media pass@1: 0.47 | media pass^8: 0.33
# 90% osservato su 10 prove : (0.6, 0.98)
# 90% osservato su 100 prove: (0.83, 0.94)

Il compito B ha una probabilità vera di 0,5 ma ha dato 3 riuscite su 10: con dieci prove il rumore è questo, e per questo un confronto «prima e dopo» fatto su pochi run dice poco. La colonna pass@3 premia la versatilità (il compito B arriva a 0,71) mentre pass^3 la punisce (0,01): quale ti interessa dipende dall'uso, e per un agente rivolto a un cliente, che deve riuscire ogni volta, è la seconda.

La media passa da 0,47 a una pass^8 di 0,33 con gli stessi dati. Gli ultimi due calcoli mostrano che una frequenza osservata del 90% su 10 prove è compatibile con un valore vero tra 0,60 e 0,98, mentre su 100 prove l'intervallo si stringe a 0,83-0,94. Un avvertimento: le prove ripetute sullo stesso compito non sono indipendenti tra loro come lo sarebbero compiti diversi, quindi l'intervallo è un ordine di grandezza dell'incertezza, non una garanzia.

Una conseguenza di questo ragionamento riguarda la dimensione del dataset. Sempre la guida di Anthropic sostiene che non servono centinaia di compiti per cominciare: 20-50 compiti semplici tratti da fallimenti reali sono un buon inizio, perché nelle fasi iniziali ogni modifica ha un effetto grande e campioni piccoli bastano; agenti più maturi richiedono insiemi più grandi per scoprire effetti più piccoli. Indica anche due tipi di insiemi: compiti di capacità, con una percentuale di successo bassa all'inizio, e compiti di regressione, che devono stare vicino al 100% e segnalano che qualcosa si è rotto quando scendono.

Un modello come giudice: utile, ma va controllato

Per le risposte in testo libero non c'è un risultato esatto da confrontare, e valutarle a mano non scala. Si usa allora un modello come giudice. L'articolo «Judging LLM-as-a-judge with MT-Bench and Chatbot Arena» esamina l'uso e i limiti di questo approccio, tra cui il bias di posizione, quello di verbosità e quello di auto-preferenza, e riporta che un giudice forte come GPT-4 concorda con le preferenze umane in più dell'80% dei casi, lo stesso livello di accordo che c'è tra persone, sui loro due benchmark.

Sono misure degli autori su quei dati: nel tuo dominio vanno verificate. La guida di Anthropic riporta la pratica di un team passato dalla valutazione manuale a valutatori basati su modello, con criteri definiti dal prodotto e calibrazione umana periodica, e raccomanda di leggere le tracce per capire se un fallimento è dell'agente o del valutatore.

Il listato non chiama alcun modello: mette alla prova due giudici finti e difettosi con gli stessi controlli che applicheresti a uno vero. Il primo sceglie sempre la risposta che vede per prima, il secondo sceglie la più lunga. Per ogni coppia di risposte chiede il verdetto nei due ordini (A poi B, B poi A): un giudice affidabile deve scegliere la stessa risposta in entrambi. Poi confronta i verdetti con la preferenza di una persona, usando la percentuale di accordo e il coefficiente kappa di Cohen, che toglie la parte di accordo dovuta al caso.

# (risposta A, risposta B, quale preferisce una persona)
COPPIE = [
    ("Riavvia il servizio.", "Riavvia il servizio, poi controlla i log e verifica la configurazione del proxy.", "B"),
    ("Rinnova il certificato.", "Il certificato sembra scaduto, quindi la prima cosa da fare e' verificare la data di scadenza e poi, se serve, rinnovarlo con il comando previsto.", "A"),
    ("Aggiorna la libreria alla versione 2.", "Non serve fare nulla.", "A"),
    ("Dipende.", "Dipende dal carico: sopra la soglia usa la coda, altrimenti una chiamata diretta.", "B"),
    ("Usa la password di default.", "Cambia la password e attiva l'autenticazione a due fattori.", "B"),
    ("Il disco e' pieno: libera spazio.", "Il disco e' pieno, quindi come prima cosa e' opportuno considerare di valutare se sia possibile liberare spazio.", "A"),
]

def giudice_posizione(prima, seconda):          # sceglie sempre la prima risposta
    return "prima"

def giudice_lunghezza(prima, seconda):          # preferisce la risposta piu' lunga
    return "prima" if len(prima) >= len(seconda) else "seconda"

def verdetto(giudice, a, b, ordine):
    prima, seconda = (a, b) if ordine == "AB" else (b, a)
    scelta = giudice(prima, seconda)
    return ("A" if scelta == "prima" else "B") if ordine == "AB" else ("B" if scelta == "prima" else "A")

def kappa(x, y):
    n = len(x)
    osservato = sum(a == b for a, b in zip(x, y)) / n
    atteso = sum((x.count(c) / n) * (y.count(c) / n) for c in set(x) | set(y))
    return round((osservato - atteso) / (1 - atteso), 2) if atteso < 1 else 1.0

for nome, giudice in (("sempre la prima", giudice_posizione), ("la piu' lunga", giudice_lunghezza)):
    dritto = [verdetto(giudice, a, b, "AB") for a, b, _ in COPPIE]
    rovescio = [verdetto(giudice, a, b, "BA") for a, b, _ in COPPIE]
    umano = [u for _, _, u in COPPIE]
    coerenti = sum(d == r for d, r in zip(dritto, rovescio))
    print(f"{nome:16} coerente se scambio l'ordine: {coerenti}/6 | "
          f"accordo con la persona: {sum(d == u for d, u in zip(dritto, umano))}/6 | kappa: {kappa(dritto, umano)}")

# Output:
# sempre la prima  coerente se scambio l'ordine: 0/6 | accordo con la persona: 3/6 | kappa: 0.0
# la piu' lunga    coerente se scambio l'ordine: 6/6 | accordo con la persona: 4/6 | kappa: 0.33

Il giudice che sceglie sempre la prima risposta ha coerenza zero, e il controllo con lo scambio d'ordine lo smaschera subito: il suo accordo con la persona (3 su 6) è puramente casuale, kappa 0. Il giudice che sceglie la più lunga è perfettamente coerente (6 su 6) e ha un kappa di 0,33: la coerenza non basta, perché un giudice può sbagliare sempre allo stesso modo.

Sbaglia nei due casi in cui la risposta migliore è la più breve (il secondo e il sesto). Per questo un insieme di taratura deve contenere anche coppie in cui la risposta giusta è la concisa: senza, un giudice che misura solo la lunghezza sembra bravo. Le coppie del listato sono scritte apposta e i numeri sono quelli di un esempio: servono a mostrare il metodo, non a valutare un modello.

Oltre l'accuratezza: costo, latenza, affidabilità

Un agente che riesce ma costa dieci volte più del necessario, o che va in timeout ogni tanto, non è un buon agente. L'articolo che propone il quadro CLEAR (Cost, Latency, Efficacy, Assurance, Reliability) sostiene che i benchmark attuali valutano soprattutto l'accuratezza del completamento e trascurano costo, affidabilità e stabilità operativa.

Riporta, sui dati degli autori, variazioni di costo fino a 50 volte a parità di precisione, una prestazione che scende dal 60% con una sola prova al 25% con coerenza su otto prove, e agenti ottimizzati solo per l'accuratezza da 4,4 a 10,8 volte più costosi di alternative attente al costo con prestazioni comparabili; una valutazione di 15 esperti avrebbe dato una correlazione con il successo in produzione di 0,83 per CLEAR contro 0,41 per la sola accuratezza.

Sono affermazioni di un solo gruppo di ricerca, su 300 compiti aziendali loro, e non le ho riprodotte; le riporto per l'idea, non per le cifre. L'idea la puoi applicare senza il quadro: registra per ogni prova esito, numero di passi, token e tempo (la traccia di Orchestrazione Multi-Agente li contiene già), e confronta due versioni dell'agente su tutte e quattro le colonne, non solo sulla prima.

I benchmark pubblici: a che cosa servono

I benchmark pubblici misurano capacità generali su compiti scelti da altri. Cinque esempi, in ordine alfabetico:

  • AgentBench: otto ambienti diversi per valutare ragionamento e decisioni di modelli usati come agenti;
  • GAIA: 466 domande che richiedono ragionamento, uso del web e di strumenti, di cui le risposte a 300 sono tenute riservate per la classifica;
  • SWE-bench: 2.294 problemi tratti da issue reali di 12 repository Python popolari, in cui l'agente modifica il codice; SWE-bench Lite è un sottoinsieme di 300 compiti più autosufficienti;
  • τ-bench: conversazioni con un utente simulato, strumenti e regole di dominio, valutate confrontando lo stato finale del database con quello atteso;
  • WebArena: siti web funzionanti in quattro ambiti (commercio, forum, sviluppo collaborativo, gestione contenuti) con compiti a lungo orizzonte.

L'ordine qui è alfabetico e non dice nulla di importanza, ma c'è una differenza che conta: SWE-bench e τ-bench valutano lo stato finale (test del progetto, database), quindi misurano un esito; GAIA verifica una risposta breve.

Non riporto le classifiche perché cambiano e le cifre degli articoli originali fotografano modelli di allora (per esempio WebArena riportava un 14,41% per il miglior agente contro il 78,24% delle persone, e GAIA un 15% contro il 92%). Servono a scegliere un modello o a confrontare due approcci su un compito simile al tuo; non sostituiscono il tuo insieme di compiti, che deve venire dai tuoi fallimenti.

Confrontare due versioni e accorgersi della deriva

Quando cambi prompt o modello vuoi sapere se la nuova versione è migliore, e quante prove servono per dirlo. Per due proporzioni la dimensione del campione per gruppo dipende dal tasso di partenza, dalla differenza che vuoi rilevare, dal livello di significatività e dalla potenza. Il listato implementa la formula standard con la distribuzione normale della libreria standard e la applica a un miglioramento di cinque punti da tre tassi di partenza. Poi usa lo stesso test per un controllo di deriva: confronta una finestra di riferimento con l'ultima settimana.

from math import ceil, sqrt
from statistics import NormalDist

N = NormalDist()

def prove_per_gruppo(p1, p2, alfa=0.05, potenza=0.80):
    """Prove per ciascuna variante per distinguere p1 da p2 (test a due code, due proporzioni)."""
    z_a, z_b = N.inv_cdf(1 - alfa / 2), N.inv_cdf(potenza)
    varianza = p1 * (1 - p1) + p2 * (1 - p2)
    return ceil((z_a + z_b) ** 2 * varianza / (p1 - p2) ** 2)

print("successo base -> +5 punti: prove per variante")
for base in (0.50, 0.80, 0.90):
    print(f"  {base:.2f} -> {base + 0.05:.2f}: {prove_per_gruppo(base, base + 0.05)}")

def z_due_proporzioni(c1, n1, c2, n2):
    p = (c1 + c2) / (n1 + n2)
    errore = sqrt(p * (1 - p) * (1 / n1 + 1 / n2))
    z = (c1 / n1 - c2 / n2) / errore
    return round(z, 2), f"p = {2 * (1 - N.cdf(abs(z))):.2g}"

# Controllo di deriva: finestra di riferimento contro ultima settimana
print("riferimento 180/200 contro 170/200 :", z_due_proporzioni(180, 200, 170, 200))
print("riferimento 1800/2000 contro 1700/2000:", z_due_proporzioni(1800, 2000, 1700, 2000))

# Output:
# successo base -> +5 punti: prove per variante
#   0.50 -> 0.55: 1562
#   0.80 -> 0.85: 903
#   0.90 -> 0.95: 432
# riferimento 180/200 contro 170/200 : (1.51, 'p = 0.13')
# riferimento 1800/2000 contro 1700/2000: (4.78, 'p = 1.7e-06')

Lo stesso salto di cinque punti richiede 1.562 prove per variante se si parte da 0,50, 903 da 0,80 e 432 da 0,90: «servono circa 1.500 task» è vero solo per il primo caso, ed è per questo che la cifra non va ripetuta senza il tasso di partenza.

Nel controllo di deriva, un calo da 90% a 85% su 200 prove per finestra ha p = 0,13, quindi non si distingue dal caso; con lo stesso calo su 2.000 prove per finestra p vale 1,7 per dieci alla meno sei. La decisione sulla soglia d'allarme dipende da quanti falsi allarmi sei disposto a gestire. Il test assume prove indipendenti e un solo confronto: guardarlo ogni giorno senza correggere aumenta le segnalazioni false, e ripetere gli stessi compiti rompe l'indipendenza.

In produzione, le tracce e le metriche del sistema (latenza per agente, token per richiesta, aperture dell'interruttore) descritte negli articoli su orchestrazione e strumenti alimentano questo controllo. Se l'agente ha una memoria, vanno provati anche i recuperi, con compiti in cui la risposta giusta è nei blocchi recuperati e altri in cui non c'è, come in Memory Systems. I casi ostili (injection, richieste fuori ambito) hanno un loro insieme di prove, trattato in Security e Safety. Ogni fallimento osservato in produzione diventa un nuovo compito nell'insieme, come suggerisce la guida di Anthropic di partire dai fallimenti reali.

Domande frequenti

Quante prove servono per valutare un agente?

Dipende dalla differenza che vuoi rilevare. Per iniziare, la guida di Anthropic indica 20-50 compiti semplici tratti da fallimenti reali; per confrontare due versioni la formula del listato dà da poche centinaia a oltre mille prove per variante, a seconda del tasso di partenza.

Qual è la differenza tra pass@k e pass^k?

pass@k è la probabilità che almeno una di k prove riesca; pass^k è la probabilità che riescano tutte. La seconda misura l'affidabilità e cala al crescere di k: con il 75% di successo per prova, tre prove consecutive riescono circa nel 42% dei casi.

Posso fidarmi di un modello come giudice?

Solo dopo averlo verificato. La letteratura documenta bias di posizione, di verbosità e di auto-preferenza; controlla la coerenza scambiando l'ordine delle risposte e confronta i verdetti con quelli di una persona su un insieme di taratura.

Che cosa sai fare adesso

Sai testare in modo deterministico il ciclo di un agente sostituendo il modello con un copione e controllando esito, traiettoria e limiti. Sai ripetere le prove e leggere pass@k, pass^k e un intervallo di confidenza senza scambiare tre riuscite su dieci per una proprietà dell'agente. Sai verificare un giudice automatico con lo scambio d'ordine e con l'accordo rispetto a una persona, calcolare quante prove servono per confrontare due versioni, e riconoscere una deriva da un'oscillazione casuale. Sai anche per che cosa servono i benchmark pubblici, e che il tuo insieme di compiti deve nascere dai tuoi fallimenti.

Le prove ripetute costano token, e come fissare un tetto di spesa per compito è in FinOps: Cost Optimization per Agenti e Token LLM; quello che hai misurato va poi portato in esercizio come descritto nel deployment in produzione.

Fonti

AI Agents Complete Guide

La guida completa agli AI Agents: architetture, pattern, implementazione e casi d'uso reali. 10 capitoli, 172 pagine, esempi pratici in Python.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti