Hai un modello che sa scrivere una risposta, ma la risposta che ti serve dipende da un dato che il modello non ha: quante copie di un libro sono libere oggi in una biblioteca, che cosa dice un file, quanto fa un calcolo con i numeri giusti. Un agente è la soluzione più semplice a questo problema: un ciclo in cui il modello propone un'azione, il tuo programma la esegue e gli restituisce il risultato, e il modello riparte da quel risultato finché non decide di rispondere. Il modello non esegue mai nulla: scrive testo, e tutto ciò che accade nel mondo lo fa il programma che gli sta intorno.
Questo è il quinto articolo della serie Intelligenza Artificiale Moderna. Segue il precedente sul RAG avanzato, perché cercare in una base documentale è uno dei modi in cui un agente consulta il mondo (le basi le trovi nell'articolo su embedding e vector store), e prepara il successivo sul Model Context Protocol, che standardizza il modo in cui gli strumenti vengono offerti. Basta Python di base. Non chiameremo nessun modello: il «cervello» dell'agente, negli esperimenti, è una funzione scritta a mano, e lo ricordo ogni volta che conta. Gli esempi usano una biblioteca di quartiere inventata per l'occasione, con dati inventati.
ReAct: ragionamento e azione nello stesso testo
Il nome unisce reasoning e acting. Nel lavoro che lo ha introdotto, Yao e colleghi (2022) fanno generare a un modello linguistico, in modo intrecciato, tracce di ragionamento e azioni specifiche del compito: il ragionamento serve a costruire, seguire e correggere un piano e a gestire le eccezioni, le azioni permettono di interrogare fonti esterne, come una base di conoscenza o un ambiente, per raccogliere informazioni nuove. Il testo che ne esce ha tre tipi di riga che si ripetono. Thought è il ragionamento, Action è lo strumento da chiamare con il suo argomento, Observation è il risultato, e la scrive il programma, mai il modello. Si chiude con una riga Final Answer.
Gli autori riportano nell'abstract che su due benchmark di decisione interattiva (ALFWorld e WebShop) ReAct supera i metodi di imitazione e di apprendimento per rinforzo di 34 e 10 punti assoluti di tasso di successo, con uno o due esempi nel prompt. Sono numeri loro, su benchmark loro: non dicono nulla di come andrà il tuo compito, e li riporto come fatto della letteratura, non come promessa.
Per toccare il meccanismo costruiamo il ciclo con una domanda da biblioteca: quante copie de «Il Gattopardo» sono disponibili? Un modello non può saperlo, ma può saper scomporre il problema in due ricerche e una sottrazione (copie totali meno copie in prestito). Il listato che segue contiene tutto: due tabelle di dati, tre strumenti, il formato dell'azione, l'esecutore e il ciclo, con tre «policy» che al posto del modello producono testo già scritto.
"""Un ciclo ReAct in piccolo. La "policy" e' una funzione scritta a mano
al posto del modello: serve a mostrare il ciclo, non a misurare un modello."""
import ast
import operator
import re
CATALOGO = {"Il Gattopardo": 5, "Se questo e' un uomo": 2}
PRESTITI = {"Il Gattopardo": 3, "Se questo e' un uomo": 2}
OPS = {ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv}
def calc(espressione):
"""Valuta solo numeri e + - * /: niente eval()."""
def ev(n):
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)):
return n.value
if isinstance(n, ast.BinOp) and type(n.op) in OPS:
return OPS[type(n.op)](ev(n.left), ev(n.right))
raise ValueError("espressione non ammessa")
return ev(ast.parse(espressione, mode="eval").body)
TOOLS = {
"catalogo": lambda a: CATALOGO[a],
"prestiti": lambda a: PRESTITI[a],
"calc": calc,
}
AZIONE = re.compile(r"^Action: (\w+)\[(.*)\]$", re.M)
def esegui(testo_azione):
"""Il modello propone, il programma esegue: errori diventano osservazioni."""
m = AZIONE.search(testo_azione)
if not m:
return "ERRORE: formato non valido, usa Action: nome[argomento]"
nome, arg = m.groups()
if nome not in TOOLS:
return f"ERRORE: strumento '{nome}' inesistente, disponibili: {sorted(TOOLS)}"
try:
return str(TOOLS[nome](arg))
except (KeyError, ValueError, SyntaxError, ZeroDivisionError) as e:
return f"ERRORE: {type(e).__name__}"
def osservazioni_valide(trascrizione):
tutte = re.findall(r"^Observation: (.*)$", trascrizione, re.M)
return [o for o in tutte if not o.startswith("ERRORE")]
def policy_buona(trascrizione):
ok = osservazioni_valide(trascrizione)
if len(ok) == 0:
return "Thought: servono le copie totali.\nAction: catalogo[Il Gattopardo]"
if len(ok) == 1:
return "Thought: ora le copie in prestito.\nAction: prestiti[Il Gattopardo]"
if len(ok) == 2:
return f"Thought: sottraggo.\nAction: calc[{ok[0]} - {ok[1]}]"
return f"Thought: ho il numero.\nFinal Answer: {ok[2]}"
def policy_allucina(trascrizione):
if "Observation:" not in trascrizione:
return "Thought: cerco online.\nAction: cerca_web[Il Gattopardo copie]"
return policy_buona(trascrizione)
def policy_bloccata(trascrizione):
return "Thought: servono le copie totali.\nAction: catalogo[Il Gattopardo]"
def agente(policy, domanda, max_passi=6):
trascrizione = f"Question: {domanda}"
visti = []
for passo in range(1, max_passi + 1):
uscita = policy(trascrizione)
trascrizione += "\n" + uscita
if "Final Answer:" in uscita:
return passo, uscita.split("Final Answer: ")[1], trascrizione
azione = AZIONE.search(uscita)
if azione and azione.group(0) in visti:
return passo, "INTERROTTO: azione ripetuta", trascrizione
visti.append(azione.group(0) if azione else "")
trascrizione += "\nObservation: " + esegui(uscita)
return max_passi, "INTERROTTO: budget di passi esaurito", trascrizione
DOMANDA = "Quante copie de Il Gattopardo sono disponibili?"
for nome, p in [("buona", policy_buona), ("allucina", policy_allucina),
("bloccata", policy_bloccata)]:
passi, esito, t = agente(p, DOMANDA)
print(f"--- policy {nome}: {passi} passi, esito: {esito}")
if nome != "buona":
print(t)
print("--- argomenti ostili o sbagliati")
for a in ['calc[__import__("os").getcwd()]', "calc[1/0]", "catalogo[Libro Inesistente]"]:
print(a, "->", esegui("Action: " + a))
attese = CATALOGO["Il Gattopardo"] - PRESTITI["Il Gattopardo"]
print("controllo indipendente dai dati:", attese)
# Output:
# --- policy buona: 4 passi, esito: 2
# --- policy allucina: 5 passi, esito: 2
# Question: Quante copie de Il Gattopardo sono disponibili?
# Thought: cerco online.
# Action: cerca_web[Il Gattopardo copie]
# Observation: ERRORE: strumento 'cerca_web' inesistente, disponibili: ['calc', 'catalogo', 'prestiti']
# Thought: servono le copie totali.
# Action: catalogo[Il Gattopardo]
# Observation: 5
# Thought: ora le copie in prestito.
# Action: prestiti[Il Gattopardo]
# Observation: 3
# Thought: sottraggo.
# Action: calc[5 - 3]
# Observation: 2
# Thought: ho il numero.
# Final Answer: 2
# --- policy bloccata: 2 passi, esito: INTERROTTO: azione ripetuta
# Question: Quante copie de Il Gattopardo sono disponibili?
# Thought: servono le copie totali.
# Action: catalogo[Il Gattopardo]
# Observation: 5
# Thought: servono le copie totali.
# Action: catalogo[Il Gattopardo]
# --- argomenti ostili o sbagliati
# calc[__import__("os").getcwd()] -> ERRORE: ValueError
# calc[1/0] -> ERRORE: ZeroDivisionError
# catalogo[Libro Inesistente] -> ERRORE: KeyError
# controllo indipendente dai dati: 2
Le righe che contano sono quattro. La regex AZIONE definisce il contratto di formato: se il modello non scrive Action: nome[argomento], la funzione esegui non indovina, restituisce un'osservazione d'errore. Sempre in esegui, il controllo nome not in TOOLS è ciò che separa uno strumento reale da uno inventato dal modello. La funzione calc non usa eval ma percorre l'albero sintattico e ammette solo numeri e le quattro operazioni, per cui un argomento ostile come __import__("os") si ferma su un ValueError senza eseguire nulla. Infine agente ha due freni, max_passi e la lista visti: toglili e la policy bloccata consuma tutti i passi concessi; toglili entrambi e non termina mai.
Leggi l'uscita come tre storie. Con la policy buona servono quattro passi (due ricerche, una sottrazione, la risposta) e il risultato è 2, che coincide con il controllo indipendente fatto direttamente sui dati in coda al listato. La policy che inventa lo strumento cerca_web riceve un'osservazione che elenca gli strumenti veri e si riprende, ma paga un passo in più: cinque invece di quattro. La policy bloccata ripete la stessa azione e viene fermata al secondo tentativo: senza il freno non avresti una risposta sbagliata, avresti un programma che non termina e un conto da pagare a ogni giro.
Dove un ciclo di agente si rompe
Gli errori di un agente non sono eccezioni rare da gestire in un angolo: sono il percorso normale, e un buon esecutore li trasforma in osservazioni che il modello può leggere. Quattro casi si incontrano subito, e il listato ne mostra tre.
- Strumento inesistente. Il modello nomina qualcosa che non hai dichiarato; l'esecutore risponde con l'elenco degli strumenti validi.
- Argomento sbagliato o ostile. Un titolo che non esiste, una divisione per zero, un'espressione che prova a importare un modulo: ciascuno produce una riga
ERRORE: ..., non un'eccezione che abbatte il programma. - Ripetizione. Il modello riscrive lo stesso pensiero e la stessa azione senza uscire dal circolo.
- Budget esaurito. Il compito non converge entro il numero di passi concesso.
Le quattro voci non hanno un ordine di priorità: sono quattro modi diversi di sbagliare, e ognuno chiede una contromisura diversa. I primi due si correggono con un messaggio che aiuta il modello a riprovare; gli ultimi due non si correggono, si limitano, perché un modello che si ripete non migliora insistendo. Nell'articolo di ReAct l'analisi degli errori segnala proprio uno schema tipico del metodo, in cui il modello genera ripetutamente gli stessi pensieri e le stesse azioni senza uscire dal circolo, e i passi concessi sono 7 su HotpotQA e 5 su FEVER, scelti perché, scrivono gli autori, più passi non miglioravano le prestazioni. Il numero giusto per il tuo compito lo trovi misurandolo, non copiandolo. Un'avvertenza sul mio freno: visti vieta ogni ripetizione, e per uno strumento il cui risultato cambia nel tempo (un contatore, uno stato) sarebbe troppo severo. In quel caso conta le ripetizioni consecutive e fermati oltre una soglia.
Chiamata strutturata e addestrare il modello a usare gli strumenti
Molti servizi di modelli offrono una forma strutturata del ciclo: dichiari gli strumenti con nome, descrizione e schema degli argomenti, e il modello risponde con una chiamata in JSON anziché con una riga di testo da leggere con una regex. Non descrivo qui nessuna interfaccia in particolare, perché i campi cambiano da un fornitore all'altro e non li ho verificati oggi. Il principio vale per tutte: la chiamata strutturata sposta il problema del formato, non quello della verità. Una chiamata in JSON ben formata può comunque nominare lo strumento sbagliato o passare un argomento assurdo, perciò la validazione di esegui resta al suo posto.
Il lavoro Toolformer (Schick e colleghi, 2023) affronta lo stesso problema da un altro lato: invece di scrivere il ciclo intorno a un modello fisso, addestra il modello a decidere da solo quali interfacce chiamare, quando, con quali argomenti e come usare il risultato, in modo auto-supervisionato e con poche dimostrazioni per ogni strumento. Gli strumenti dell'esperimento sono una calcolatrice, un sistema di domande e risposte, due motori di ricerca, un traduttore e un calendario. Per te la conseguenza pratica è che la capacità di scegliere lo strumento giusto può stare nel modello oppure nel prompt e nel codice che lo circonda, e il tuo ciclo deve funzionare in entrambi i casi.
Pianificare o reagire: un esperimento su griglia
Un agente ReAct decide un passo alla volta guardando ciò che ha davanti. Quando il compito è lineare basta e costa poco; quando ci sono vicoli ciechi, qualcosa deve ricordare o prevedere. Per vederlo senza alcun modello costruisco un mondo minuscolo: una griglia 8 per 8 con ostacoli (circa il 28% delle celle, a seme fisso), dall'angolo in alto a sinistra a quello in basso a destra. Tre agenti la affrontano su 200 griglie diverse, tutte risolvibili. Il primo è puramente reattivo: vede i vicini liberi e va in quello più vicino all'arrivo. Il secondo ha la stessa vista locale ma ricorda le celle viste e torna indietro quando si blocca. Il terzo conosce l'intera mappa e pianifica con una ricerca in ampiezza.
"""Reagire o pianificare: tre agenti su 200 griglie 8x8 con ostacoli."""
import heapq
import random
from collections import deque
N = 8
MOSSE = [(0, 1), (1, 0), (0, -1), (-1, 0)] # ordine fisso: determinismo
def griglia(rng):
while True:
muri = {(r, c) for r in range(N) for c in range(N)
if rng.random() < 0.28} - {(0, 0), (N - 1, N - 1)}
if bfs(muri) is not None:
return muri
def vicini(p, muri):
for dr, dc in MOSSE:
q = (p[0] + dr, p[1] + dc)
if 0 <= q[0] < N and 0 <= q[1] < N and q not in muri:
yield q
def bfs(muri):
"""Pianificatore con la mappa intera: lunghezza minima o None."""
coda, dist = deque([(0, 0)]), {(0, 0): 0}
while coda:
p = coda.popleft()
if p == (N - 1, N - 1):
return dist[p]
for q in vicini(p, muri):
if q not in dist:
dist[q] = dist[p] + 1
coda.append(q)
return None
def dijkstra(muri):
"""Secondo calcolo, indipendente: stesso risultato atteso da bfs."""
h, best = [(0, (0, 0))], {(0, 0): 0}
while h:
d, p = heapq.heappop(h)
if p == (N - 1, N - 1):
return d
if d > best[p]:
continue
for q in vicini(p, muri):
if d + 1 < best.get(q, 10**9):
best[q] = d + 1
heapq.heappush(h, (d + 1, q))
return None
def manhattan(p):
return (N - 1 - p[0]) + (N - 1 - p[1])
def reattivo(muri, limite=200):
"""Vede solo i vicini liberi, nessuna memoria: sceglie il piu' vicino."""
p = (0, 0)
for passo in range(limite):
if p == (N - 1, N - 1):
return passo
p = min(vicini(p, muri), key=manhattan, default=p)
return None
def con_memoria(muri, limite=200):
"""Stessa vista locale, ma ricorda le celle viste e torna indietro (DFS)."""
p, pila, visti, passo = (0, 0), [(0, 0)], {(0, 0)}, 0
while passo < limite:
if p == (N - 1, N - 1):
return passo
nuovi = sorted((q for q in vicini(p, muri) if q not in visti),
key=manhattan)
if nuovi:
p = nuovi[0]
visti.add(p)
pila.append(p)
else:
pila.pop()
if not pila:
return None
p = pila[-1]
passo += 1
return None
rng = random.Random(7)
campione = [griglia(rng) for _ in range(200)]
ottimo = [bfs(m) for m in campione]
assert ottimo == [dijkstra(m) for m in campione]
for nome, f in [("reattivo", reattivo), ("con memoria", con_memoria)]:
esiti = [f(m) for m in campione]
riusciti = [(e, o) for e, o in zip(esiti, ottimo) if e is not None]
assert all(e >= o for e, o in riusciti) # nessuno batte il minimo
extra = sum(e - o for e, o in riusciti) / len(riusciti)
print(f"{nome:12s} arriva in {len(riusciti):3d}/200, "
f"passi in piu' del minimo (media sui riusciti): {extra:.1f}")
print(f"pianificatore: 200/200, lunghezza media {sum(ottimo) / 200:.1f} "
f"(minimo 14 per N=8)")
# Output:
# reattivo arriva in 37/200, passi in piu' del minimo (media sui riusciti): 0.0
# con memoria arriva in 200/200, passi in piu' del minimo (media sui riusciti): 6.9
# pianificatore: 200/200, lunghezza media 14.1 (minimo 14 per N=8)
Nel listato, reattivo sceglie con min(..., key=manhattan) e non ha stato: dentro un vicolo cieco resta fermo o oscilla finché scade il limite di 200 passi. con_memoria tiene visti e pila, che sono la sua «memoria di lavoro» e la sua possibilità di tornare indietro. bfs parte da una conoscenza che gli altri non hanno, la mappa completa. Come secondo calcolo indipendente ho riscritto la ricerca del cammino minimo con l'algoritmo di Dijkstra e le due lunghezze coincidono su tutte le 200 griglie (l'assert sotto il campione); un secondo controllo verifica che nessun agente batta il minimo.
Il risultato si legge così. L'agente senza memoria arriva a destinazione solo in 37 griglie su 200, e in quelle 37 non spreca nemmeno un passo, perché c'era un percorso sempre in discesa verso l'arrivo. L'agente con memoria arriva sempre, ma con in media 6,9 passi oltre il minimo, che è la fatica di scoprire la strada camminandola. Il pianificatore arriva sempre e in meno passi possibile, a patto di avere la mappa: nei compiti reali quella mappa è un modello del mondo (lo schema di un database, la struttura di un sito) e non è mai gratis. L'esperimento non dice quanto un modello linguistico sia bravo a pianificare. Dice che la forma del ciclo, cioè che cosa l'agente vede e che cosa ricorda, decide molto del risultato prima ancora di parlare di modelli.
Sicurezza minima per un agente
Un agente trasforma testo generato in azioni, e ciò che sta nel testo non è affidabile per definizione. Cinque difese costano poco e si mettono nel ciclo, non nel prompt.
- Un elenco chiuso di strumenti, dichiarato dal programma e non dal modello.
- La validazione di ogni argomento prima dell'esecuzione, con errori leggibili dal modello.
- Un tetto ai passi e un freno alle ripetizioni.
- Una conferma umana prima di ogni azione irreversibile o con effetti all'esterno.
- Le osservazioni trattate come dati, mai come istruzioni.
L'ultima voce è la meno ovvia: se uno strumento legge una pagina o un documento che contiene la frase «ignora le istruzioni precedenti», quel testo entra nella trascrizione come qualunque altra osservazione, e un modello potrebbe trattarlo come un ordine. Non lo dimostro con un esperimento, perché la mia policy è una funzione senza istruzioni da seguire, e un test del genere non direbbe nulla di un modello vero. La contromisura è strutturale: dai agli strumenti soltanto i permessi che servono al compito, così un'osservazione ostile non ha nulla di grave da far fare. Nell'articolo sul Model Context Protocol vedrai come lo standard tratta il consenso dell'utente.
Che cosa non dimostra questo articolo
Le «policy» dei listati sono funzioni scritte da me: provano che il ciclo, il formato e i freni si comportano come descritto, non provano nulla sul comportamento di un modello vero né sulla sua tendenza a ripetersi o a inventare strumenti. Le cifre sui benchmark e sui passi concessi vengono dall'articolo di ReAct e riguardano i suoi compiti. L'esperimento sulla griglia confronta tre forme di agente su dati sintetici, a seme fisso, con le misure riportate sopra; con una griglia più grande o più o meno densa le cifre cambiano, e la differenza qualitativa fra reagire, ricordare e pianificare è l'unica cosa che l'esperimento sostiene. Per vedere come i modelli recenti spendono più calcolo per ragionare prima di rispondere, vai all'articolo sui reasoning models.
Domande frequenti
Un agente è un modello più intelligente?
No: è lo stesso modello inserito in un ciclo che esegue le sue azioni e gli riporta i risultati. Ciò che cambia è il programma intorno, che decide quali strumenti esistono, che cosa succede in caso di errore e quando fermarsi.
Quanti passi devo concedere a un agente?
Dipende dal compito, e il numero non si importa da un articolo. Nel lavoro su ReAct gli autori hanno scelto 7 passi su HotpotQA e 5 su FEVER perché oltre non vedevano miglioramenti: per il tuo compito misura quanti passi servono ai casi riusciti e concedi poco più di quelli.
Perché la calcolatrice dell'esempio non usa eval?
Perché eval esegue qualsiasi espressione Python, comprese quelle che importano moduli, e l'argomento lo scrive un modello che può essere indotto in errore. Percorrere l'albero sintattico e ammettere solo numeri e quattro operatori restringe ciò che può succedere a ciò che hai previsto.
Che cosa sai fare adesso
Sai scrivere il ciclo di un agente con un formato di azione verificabile, un esecutore che trasforma gli errori in osservazioni e due freni contro i giri a vuoto. Sai leggere un risultato di benchmark come un'affermazione degli autori su un compito preciso, e sai misurare con un esperimento piccolo la differenza fra reagire, ricordare e pianificare. Il passo naturale è chiederti come offrire gli strumenti agli agenti senza riscrivere l'integrazione ogni volta: ne parla l'articolo successivo della serie.
Fonti
- ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2022) - abstract (ragionamento e azioni intrecciati, risultati su ALFWorld e WebShop), limite di 7 e 5 passi e schema di errore ripetitivo letti nel testo del paper, consultata il 2026-11-13
- Toolformer: Language Models Can Teach Themselves to Use Tools (Schick et al., 2023) - abstract (addestramento auto-supervisionato all'uso di API, elenco degli strumenti), consultata il 2026-11-13
I due listati sono stati eseguiti con Python 3.14.4 (python3 -I), due volte ciascuno, con uscita identica; la griglia usa un generatore a seme fisso e ogni esecuzione dura meno di un secondo. Le «policy» sono funzioni scritte a mano, non modelli, e i dati della biblioteca sono inventati.
Commenti
Caricamento commenti...
Accedi per commentare