Programmare con agenti IA: incarico, test e controllo
Come scrivere all’agente un incarico con criteri di accettazione, perché lavorare a piccoli passi, un esperimento di mutation testing (quanti errori scoprono i test del percorso felice, i casi limite e i test basati su proprietà), chi deve scrivere i test e a cosa fare attenzione.
Serie Basi di IA Parte 51 di 52
- Mappa dell’intelligenza artificiale: che cos’è ciascuna cosa e come si incastra tutto
- Breve storia dell’intelligenza artificiale: da Turing ai modelli linguistici
- Dati, modello, addestramento, inferenza: i concetti di base su un unico esempio
- Come leggere la notazione matematica: simboli, indici, sommatorie e quantificatori
- Insiemi e relazioni: i mattoncini con cui si costruiscono le definizioni nell’IA
- Funzioni e applicazioni: perché ogni modello di IA è una funzione
- Logica e dimostrazioni: le regole con cui si ragiona nell’IA
- Grafi e alberi: la struttura su cui poggiano la ricerca e le reti neurali
- Algebra lineare per l’apprendimento automatico: vettori, matrici e perché l’IA soprattutto moltiplica
- Derivate, gradiente e regola della catena: come un modello capisce in quale direzione imparare
- Discesa del gradiente: come impara un modello
- Che cos’è la probabilità: dal lancio di un dado agli assiomi di Kolmogorov
- Probabilità condizionata e teorema di Bayes: come cambiare una stima quando scopri qualcosa di nuovo
- Variabile aleatoria e funzione di ripartizione: una sola funzione che dice tutto sulla distribuzione
- Funzione di densità e funzione di massa: perché una variabile continua ha probabilità zero in un punto
- Valore atteso, varianza e covarianza: dove si trova una distribuzione, quanto è larga e che cosa è legato a che cosa
- Panoramica delle distribuzioni di probabilità: Bernoulli, binomiale, Poisson, uniforme, normale ed esponenziale
- Legge dei grandi numeri e teorema del limite centrale: perché le medie si comportano in modo così prevedibile
- Statistica e stima dei parametri: massima verosimiglianza, MAP e intervalli di confidenza
- Teoria dell’informazione: entropia, entropia incrociata e divergenza KL — da dove vengono le funzioni di perdita
- Ricerca nello spazio degli stati: BFS, DFS, UCS e A* con animazioni
- Giochi e avversari: minimax, potatura alfa-beta e Monte Carlo tree search
- Soddisfacimento di vincoli: backtracking, propagazione dei vincoli e AC-3 su un sudoku e una mappa delle regioni ceche
- Logica e sistemi basati sulla conoscenza: concatenazione in avanti e all’indietro, risoluzione e sistemi esperti
- Pianificazione: STRIPS, il mondo dei blocchi, euristiche dal rilassamento e pianificazione negli agenti IA
- Ricerca locale e ricottura simulata: un commesso viaggiatore tra i capoluoghi di regione cechi
- Algoritmi genetici: popolazione, incrocio, mutazione e selezione su uno zaino per l’escursione
- Strategie evolutive e programmazione genetica: dalla regola di un quinto alla legge di Keplero
- Ottimizzazione con colonie di formiche: come una traccia di feromone trova un percorso breve
- Ottimizzazione a sciame di particelle: inerzia, esperienza propria ed esperienza dello sciame
- Neuroevoluzione: evoluzione dei pesi e della forma delle reti neurali e l’algoritmo NEAT
- Insiemi fuzzy e funzioni di appartenenza: come scrivere «un po’ caldo»
- Logica fuzzy e probabilità: perché «un po’ caldo» non significa «probabilmente caldo»
- Logica fuzzy: t-norme, implicazioni e regole SE–ALLORA
- Controllo fuzzy: Mamdani e Sugeno, dal ventilatore al pendolo inverso su un carrello
- Clustering fuzzy: l’algoritmo fuzzy c-means e i punti tra i cluster
- Sistemi neuro-fuzzy (ANFIS): regole fuzzy che imparano dai dati
- Catene di Markov: matrice di transizione, distribuzione stazionaria e PageRank
- Modelli di Markov nascosti: l’algoritmo di Viterbi e il casinò disonesto
- Reti bayesiane: perché l’erba è bagnata e che cosa significa per l’irrigatore
- Filtro di Kalman: come stimare da misure imprecise dove si trova un’auto e a che velocità va
- Tipi di apprendimento automatico: supervisionato, non supervisionato, semi-supervisionato e altri
- Prompt, contesto e RAG: come dare a un modello le informazioni giuste
- Autoencoder variazionali (VAE): immagini nuove da uno spazio latente continuo
- Reti generative avversarie (GAN): il falsario contro il detective
- Modelli di diffusione: come il rumore puro diventa un’immagine
- Processi decisionali di Markov: stati, azioni, ricompense ed equazioni di Bellman
- Q-learning e DQN: imparare il valore delle azioni, dalla tabella alla rete neurale
- Policy gradient e PPO: imparare direttamente la politica
- Come funziona un agente IA: un modello linguistico in un ciclo con strumenti
- Programmare con agenti IA: incarico, test e controllo
- Valutazione e sicurezza dei sistemi di IA: come misurare e come sorvegliare
Programma completo della serie 52 di 83 argomenti pubblicati
- Cosa deve contenere l’incarico per l’agente perché sappia quando ha finito.
- Quanti errori scoprono test più o meno accurati e perché i casi limite contano.
- Quali errori fanno gli agenti e come trovarli durante il controllo.
Utile sapere prima: Come funziona un agente IA: un modello linguistico in un ciclo con strumenti, Come affrontare lo sviluppo con agenti IA
Nella parte precedente abbiamo visto come funziona un agente al suo interno. Questa parte è pratica: come assegnargli il lavoro, come controllarlo e come non perdere di vista ciò che succede nel progetto. La maggior parte dei consigli si riassume in una frase: devi dare all’agente un modo per capire da solo che ha finito, e tu devi avere un modo per verificarlo. Con un esperimento vedremo quanto conta.
Riprende le parti Come funziona un agente IA e Come affrontare lo sviluppo con agenti IA, dove si parla di più del ruolo della persona e del perché conviene lasciare che l’agente proponga prima una soluzione.
1. L’incarico: cosa, dove e come capire che è finito
Definizione — criteri di accettazione
I criteri di accettazione sono condizioni verificabili che il risultato deve soddisfare perché il compito sia finito: un comportamento concreto («per un carrello vuoto restituisce 0 CZK»), test che devono passare, un comando che deve terminare senza errori. A differenza della descrizione dell’obiettivo, per loro si può decidere senza ambiguità se valgono.
«Metti in ordine la camera» è un obiettivo. «Sul pavimento non c’è niente, il letto è rifatto e il cestino è svuotato» sono criteri di accettazione. Solo con questi si può dire se la camera è in ordine, e il bambino (o l’agente) sa quando può fermarsi.
Definizione — la specifica come presupposto e impegno
I criteri di accettazione si possono scrivere con precisione con una tripla di Hoare (C. A. R. Hoare, 1969; la notazione odierna usa le parentesi graffe): {P} C {Q} dice che se prima di eseguire il programma C vale la condizione P e C termina, allora vale la condizione Q. P è il presupposto (ciò che il programma può aspettarsi dall’input), Q l’impegno (ciò che deve valere per il risultato). Un programma è corretto rispetto alla specifica se la tripla vale per ogni stato che soddisfa P.
{ castka ≥ 0 } s := sazba(castka) { s = 0 per castka < 1 000; s = 0,05 per 1 000 ≤ castka < 5 000; s = 0,1 altrimenti }
- castka ≥ 0
- presupposto: per un importo negativo la funzione non garantisce nulla; serve a chiarire che cosa non dobbiamo testare e che cosa deve controllare chi la chiama
- s := sazba(castka)
- il programma di cui parliamo; la specifica non dice nulla su come è scritto, quindi l’agente può soddisfarla in qualsiasi modo
- s = 0 / 0,05 / 0,1
- impegno: esattamente tre fasce e i loro confini (≤ o <); è proprio qui che si sbaglia più spesso, per questo nei criteri di accettazione rientrano i valori 999, 1 000, 4 999 e 5 000
Un buon incarico per un agente ha quattro parti. Non perché sia un rito, ma perché ognuna corrisponde a una cosa che altrimenti l’agente dovrebbe indovinare:
Obiettivo: Nella pagina del carrello mostra uno sconto in base all’importo (da 1 000 CZK 5 %, da 5 000 CZK 10 %).
Contesto: Il calcolo del prezzo è in src/kosik.js, il template in pohledy/kosik.ejs.
La spedizione si calcola in modo simile nella funzione doprava(): mantieni lo stesso stile.
Vincoli: Non cambiare il formato dei dati nel database. Nessuna nuova libreria.
Finito quando:
- la funzione sazba(castka) restituisce 0 / 0,05 / 0,1 e i test in test/kosik.test.js passano,
- aggiungi test per i confini 999, 1 000, 4 999 e 5 000 CZK,
- npm test termina senza errori e mi mostri le differenze (git diff).
Il contesto risparmia all’agente la ricerca (e a te i token; vedi il costo del contesto). I vincoli gli impediscono di «risolvere» il problema in un modo diverso da quello che vuoi. E i criteri di accettazione gli danno il riscontro senza il quale i compiti lunghi si sfaldano.
2. Piccoli passi, controlli frequenti
Nella parte sugli agenti abbiamo calcolato che un compito di venti passi, ciascuno dei quali riesce nel 90 % dei casi, senza controlli riesce solo nel 12 % dei casi. La stessa regola vale per te: più grande è il pezzo di lavoro che l’agente consegna in una volta, più è difficile controllarlo e più costa correggerlo. Conseguenze pratiche:
- Una modifica = un compito. Dividi le cose grandi in passi che si possano controllare separatamente (prima il calcolo, poi la visualizzazione, poi la modifica dei dati).
- Il controllo di versione come rete di sicurezza. Prima di ogni compito uno stato pulito in git, dopo un commit. Se qualcosa si rompe, torni indietro con un solo comando.
- Leggi le differenze, non il riepilogo. Il «fatto, funziona tutto» dell’agente è un’affermazione. Le differenze nel codice (git diff) e l’output dei test sono prove.
Psuccesso = pn
- p
- probabilità che un passo riesca; c’è perché ogni passo è un’altra occasione di sbagliare
- n
- numero di passi tra due controlli; sta all’esponente perché le probabilità di passi indipendenti si moltiplicano, e con ogni passo in più senza controllo la probabilità cala in modo geometrico, non lineare
- Psuccesso
- probabilità che l’intero tratto tra due controlli vada bene e non ci sia niente da rifare
Esempio — quanto lavoro fa risparmiare un controllo ogni cinque passi
Senza controlli: 0,920 = 0,1216, cioè il 12 %. Con un controllo ogni cinque passi: 0,95 = 0,5905 per ciascuno dei quattro tratti.
Supponiamo che il controllo scopra sempre l’errore e che il tratto sbagliato venga rifatto. Il numero di tentativi fino al primo successo ha una distribuzione geometrica con valore atteso 1 / Psuccesso. L’intero compito senza controlli costa quindi in media 20 / 0,1216 ≈ 165 passi, con un controllo ogni cinque 4 · 5 / 0,5905 ≈ 34 passi e con un controllo dopo ogni passo 20 / 0,9 ≈ 22 passi. I piccoli passi non aumentano la probabilità che un passo riesca: riducono quanto lavoro si butta quando c’è un errore.
3. I test come assicurazione: un esperimento
Quanto aiutano i test? Prendiamo cinque piccole funzioni come quelle che un agente scrive di continuo (media, anno bisestile, troncamento di un testo, ricerca binaria e sconto in base all’importo) e ne creiamo versioni guastate di proposito.
Definizione — mutation testing
Un mutante è una copia del programma con una sola piccola modifica automatica: < al posto di <=, - 1 al posto di + 1, || al posto di &&, un’altra costante. Una suite di test scopre il mutante se almeno un test fallisce su di esso. La quota di mutanti scoperti misura quanto bene i test sorvegliano gli errori. I mutanti che si comportano come l’originale (equivalenti) non si contano.
È come provare un rilevatore di fumo: accendi apposta una piccola candela e guardi se suona. Qui inserisci apposta nel codice un piccolo errore (proprio del tipo che fanno le persone e gli agenti) e guardi se i test se ne accorgono.
Dal codice sorgente delle cinque funzioni sono stati generati automaticamente 32 mutanti. 3 di questi
non si sono mai distinti dall’originale su 2 000 input casuali (per esempio a[s] <= x al posto di a[s] < x
nella ricerca binaria, dove l’uguaglianza era già gestita dalla riga precedente), quindi restano 29 errori reali. Contro di essi,
quattro livelli di test, ognuno dei quali comprende anche i precedenti:
- Percorso felice: uno o due esempi tipici per funzione. Sono i test che nascono quando si dice soltanto «aggiungi dei test».
- Casi limite: array vuoto, confini delle fasce (999, 1 000, 4 999, 5 000 CZK), gli anni 1900, 2000 e 2100, un testo esattamente al limite di lunghezza.
- Proprietà: 200 input casuali e regole che devono valere sempre: il risultato del troncamento ha al massimo max caratteri ed è l’inizio del testo, l’indice trovato punta davvero al numero cercato, la media coincide con un calcolo scritto in un altro modo.
I test del percorso felice catturano solo 15 errori su 29 (il 52 %). Passano anche con una funzione che per un array vuoto restituisce 1, con uno sconto che parte solo da 1 001 CZK o con una ricerca binaria che in un array di un solo elemento non trova nulla. Aggiungere i casi limite porta la quota al 90 %, i test di proprietà al 97 %.
| funzione | mutanti | percorso felice | + casi limite | + proprietà |
|---|---|---|---|---|
prumer | 5 | 4 | 5 | 5 |
jePrestupny | 8 | 5 | 5 | 7 |
zkrat | 3 | 2 | 3 | 3 |
najdi | 6 | 3 | 6 | 6 |
sazba | 7 | 1 | 7 | 7 |
MS = |O| / ( |M| − |E| )
- MS
- mutation score: la quota di errori reali che la suite di test cattura; è l’altezza della barra nel grafico
- |O|
- numero di mutanti scoperti (almeno un test è fallito su di essi, oppure sono andati in crash o in ciclo infinito); ciò che i test sanno fare
- |M|
- tutti i mutanti generati: la riserva di errori rispetto alla quale misuriamo i test
- |E|
- mutanti equivalenti; si sottraggono perché non si distinguono dall’originale e quindi nessun test può scoprirli; altrimenti nemmeno dei test perfetti arriverebbero al 100 %
Nel nostro caso |M| = 32 e |E| = 3, quindi il denominatore è 29. Percorso felice: 15 / 29 = 0,517; con i casi limite 26 / 29 = 0,897; con le proprietà 28 / 29 = 0,966. Il procedimento con cui sono nati i numeri:
per ogni funzione f:
originale = compila(codice di f)
per ogni operatore (da → a) e ogni occorrenza di «da» nel codice:
mutante = compila(codice con questa occorrenza di «da» sostituita da «a»)
se il mutante su 2 000 input casuali non differisce mai dall’originale:
equivalente: non si conta, avanti
per ogni livello di test u:
scoperto[u] = qualche test del livello u fallisce sul mutante,
oppure questo va in crash o in ciclo (limite dopo 10 000 iterazioni)
MS(u) = numero di scoperti al livello u / numero di non equivalenti
Il caso più interessante è l’anno bisestile. I casi limite lasciano passare i mutanti che considerano bisestile ogni anno pari
o che escludono gli anni divisibili per dieci, quindi anche il 2020. I test con gli anni 1900, 2000 e 2100 non se ne accorgono, perché
su quegli anni per caso rispondono correttamente. Il test di proprietà con anni casuali trova entrambi gli errori. L’unico mutante sopravvissuto
a tutto è (rok % 4 === 0 && rok % 100 !== 0) || rok % 200 === 0:
differisce solo per gli anni divisibili per 200 ma non per 400 (200, 600, 1000…), e tra i 200 anni casuali non ce n’era nessuno.
Nemmeno i test casuali sono onnipotenti.
Psopravvive = ( 1 − q )n
- q
- quota di input su cui il mutante differisce dall’originale; 1 − q è la probabilità che un singolo input casuale non lo scopra
- n
- numero di input casuali; gli input si estraggono in modo indipendente, quindi le probabilità di «non l’ha scoperto» si moltiplicano
- Psopravvive
- probabilità che tutti gli n input manchino il mutante: l’errore passa anche se la proprietà è scritta correttamente
Esempio — perché è sopravvissuto il mutante con l’anno divisibile per 200
Il test di proprietà estrae anni da 0 a 2 399. Il mutante rok % 200 differisce dall’originale solo in 6 di essi (200, 600, 1000, 1400, 1800, 2200), quindi q = 6 / 2400 = 0,0025 e con n = 200 sopravvive con probabilità (1 − 0,0025)200 = 0,606, all’incirca in sei tentativi su dieci. Che sia sopravvissuto non è stata sfortuna, ma il risultato atteso.
Il mutante rok % 2 differisce in 600 anni (q = 0,25): sopravvive con probabilità 0,75200 ≈ 10-25, cioè praticamente mai. Il mutante rok % 10 differisce in 96 anni (q = 0,04) e sopravvive con probabilità 0,96200 = 0,00028.
Quanti input servirebbero per catturare il mutante dell’anno 200 con una probabilità del 95 %? Da (1 − q)n ≤ 0,05 segue n ≥ ln 0,05 / ln(1 − q) = −2,996 / −0,00250 ≈ 1 196,8, cioè 1 197 input. Costa meno aggiungere il caso limite dell’anno 200 o 600: chi conosce la regola del calendario sa dove cercare.
4. Chi deve scrivere i test
Nell’esperimento abbiamo scritto i test in base all’incarico (cosa deve fare la funzione), non in base al codice. È fondamentale. Quando l’agente scrive il codice e subito dopo i relativi test, c’è il rischio che i test ripetano soltanto ciò che fa il codice, errori compresi. Passano, e non dimostrano nulla. Aiuta:
- Prima i test (o almeno il loro elenco). Lascia che l’agente scriva prima i test in base all’incarico, leggili, e solo dopo il codice. I criteri di accettazione dell’incarico sono esattamente quell’elenco.
- Elencare i casi limite. Input vuoto, un solo elemento, confini delle fasce, numeri negativi, lettere accentate. Spesso l’agente li aggiunge da solo, ma solo se li chiedi.
- Proprietà dove possibile. «Un array ordinato resta ordinato», «codificare e decodificare = testo originale», «il nuovo calcolo dà lo stesso risultato del vecchio». Librerie come fast-check (JavaScript) o Hypothesis (Python) generano da sole gli input casuali.
Definizione — test basati su proprietà (property-based testing)
Invece di coppie concrete input–output si scrive una proprietà che deve valere per tutti gli input, e lo strumento la verifica su molti input generati a caso. Quando trova un controesempio, di solito lo riduce al caso fallito più semplice. L’idea è stata resa popolare dalla libreria QuickCheck (Koen Claessen e John Hughes, 2000).
Non chiedi «quanto fa 7 + 5?», ma «è vero che a + b è sempre uguale a b + a?», e lasci che il computer provi mille coppie casuali. Troverà casi a cui non avresti pensato.
Una proprietà è un enunciato con il quantificatore «per ogni». Per la funzione zkrat dell’esperimento il test la scrive così:
∀ t, m ≥ 1: |v| ≤ m ∧ ( |t| ≤ m ⇒ v = t ) ∧ ( |t| > m ⇒ v = t[0, |v| − 1) + «…» ), dove v = zkrat(t, m)
- ∀ t, m ≥ 1
- per ogni testo e ogni lunghezza; per questo si può verificare su input casuali senza dover conoscere in anticipo il risultato corretto
- |v| ≤ m
- la promessa principale della funzione: il risultato ci sta; cattura il mutante che dimentica di contare i «…» (
slice(0, max)) - |t| ≤ m ⇒ v = t
- un testo corto non cambia; senza questo passerebbe una funzione che tronca sempre
- v = t[0, |v| − 1) + «…»
- il risultato troncato è l’inizio del testo originale e finisce con i puntini di sospensione; senza questo passerebbe una funzione che restituisce, per esempio, un testo vuoto
Uno strumento per i test basati su proprietà fa poi più o meno questo:
per i = 1 … N: # di solito N va da 100 a 1 000
x = genera un input casuale
se proprietà(x, f(x)) non vale:
# riduzione: cerca un input più semplice su cui fallisce ancora
finché esiste y = semplifica(x) su cui la proprietà non vale:
x = y # testo più corto, numero più piccolo, array più corto
segnala il controesempio x
segnala «passati N input» # non è una dimostrazione, solo una buona notizia
L’ultima riga è fondamentale. I test, anche quelli casuali, non dimostrano la correttezza: cercano soltanto controesempi. Edsger Dijkstra lo disse nel 1969 così: il testing può mostrare la presenza di errori, mai la loro assenza. La probabilità che un errore raro sfugga l’abbiamo calcolata più sopra.
5. A cosa fare attenzione
Gli agenti fanno errori diversi da quelli delle persone. I più frequenti, che vale la pena cercare durante il controllo:
- «Fatto» senza verifica. L’agente annuncia il successo senza aver eseguito i test, o eseguendoli solo in parte. Chiedi l’output dei test.
- Modificare i test invece del codice. Quando un test fallisce, la «correzione» più facile è cambiare il valore atteso o cancellare il test. Nelle differenze cerca le modifiche ai test.
- Errori zittiti. Un
catchvuoto, un valore predefinito al posto di un errore, un risultato scritto a mano per l’input di test. - Lavoro in più. Un file riscritto che non c’entra con il compito, una nuova libreria, un formato dei dati «migliorato». I vincoli nell’incarico e i piccoli passi lo tengono entro i limiti.
- Segreti e passi irreversibili. Password nel codice, cancellazione di file, scrittura nel database di produzione, invio al repository remoto. Fai in modo che queste azioni richiedano la tua approvazione.
6. Non perdere la visione d’insieme
L’agente non ricorda nulla tra una sessione e l’altra. Ciò che deve sapere ogni volta (come si avvia e si testa il progetto, quali convenzioni ha, che cosa evitare) va in un file di istruzioni nella radice del progetto, che l’agente legge all’inizio. Scrivici ciò su cui hai dovuto richiamare l’attenzione dell’agente più di una volta. E ciò che sai solo tu (perché una cosa è fatta così, cosa si è provato e non ha funzionato) va nei commit e nella documentazione, per l’agente e per te stesso tra sei mesi.
L’ultimo consiglio è il più semplice: resta sul pezzo. Non necessariamente davanti al computer: l’agente può lavorare da solo e tu dai un’occhiata quando ha bisogno di una decisione. Per questo è nato coden: l’incarico, l’approvazione dei passi e il controllo del risultato li hai sul telefono, mentre l’agente lavora sul tuo computer.
7. Riepilogo
- Un buon incarico: obiettivo, contesto (file, modelli da seguire), vincoli e criteri di accettazione verificabili.
- Piccoli passi, un commit dopo ognuno, e controllo delle differenze nel codice e dell’output dei test, non del riepilogo.
- Nell’esperimento i test del percorso felice hanno scoperto il 52 % degli errori, con i casi limite il 90 %, con le proprietà il 97 %.
- Scrivi (o approva) i test in base all’incarico e, idealmente, prima del codice; altrimenti ripeteranno soltanto gli errori dell’implementazione.
- Tieni d’occhio le modifiche ai test, gli errori zittiti, il lavoro in più e i passi irreversibili.
Vuoi provarlo con mano? Chiedi a un agente IA di eseguire sul tuo progetto uno strumento di mutation testing (Stryker per JavaScript, mutmut per Python) e di mostrarti i mutanti sopravvissuti. Ognuno di loro è un test che manca.
La vedi diversamente? Scrivi a support@coden-app.com.