Un agente è fatto di cinque parti, e ogni framework che ho incontrato le ricompone senza aggiungerne una sesta: le istruzioni (il mandato), il catalogo dei tool (le capacità), lo stato (cosa è già successo), il motore di decisione (il modello, che a ogni giro sceglie se parlare o agire) e la condizione di terminazione (quando si smette). Quando un agente si comporta male, il difetto sta sempre in una di queste cinque, e saper dire in quale è la differenza fra sistemare in dieci minuti e girare a vuoto per due giorni.
L'analogia: il nuovo assunto del primo giorno
Immagina di affidare un compito a una persona appena arrivata in azienda, sveglissima e senza nessuna memoria dell'azienda stessa. Devi darle quattro cose: la descrizione del compito con i limiti («puoi rimborsare fino a 50 euro, sopra chiedi a me»), l'elenco dei sistemi a cui ha accesso con le credenziali, un quaderno dove annota quello che ha già fatto, e la regola per cui a fine giornata smette anche se non ha finito. Il «quanto è sveglia» è il modello e non lo controlli; le altre quattro cose sono tutte tue, e sono le quattro in cui si annidano i problemi.
Il ciclo, in nove righe
Partiamo dallo scheletro. Questo è l'agente ridotto all'osso, framework-agnostico. È uno schema da leggere, non eseguito: schemi_di, esegui e LimiteGiri sono nomi segnaposto che più sotto, nella versione completa, diventano codice vero ed eseguito.
def ciclo(modello, istruzioni, tool, richiesta, max_giri):
stato = [{"role": "system", "content": istruzioni},
{"role": "user", "content": richiesta}]
for _ in range(max_giri):
risposta = modello(stato, schemi_di(tool)) # 1. decidi
stato.append(risposta)
if not risposta.chiamate: # 2. hai finito?
return risposta.testo
for c in risposta.chiamate: # 3. agisci
stato.append(esegui(tool, c)) # 4. registra
raise LimiteGiri(max_giri) # 5. stop di sicurezza
I commenti numerati dicono l'ordine che conta. Prima si decide (1), poi si controlla se il modello ha chiesto qualcosa (2): se non ha chiesto nulla, la risposta è testo e il ciclo finisce. Altrimenti si esegue ogni richiesta (3) e si accoda il risultato allo stato (4), che il giro successivo rimanda al modello. Il for con max_giri è l'uscita di sicurezza (5): senza di esso il ciclo sarebbe un while True alla mercé del modello.
Ogni framework agentico, dai più leggeri ai più verbosi, è questo con sopra: tipizzazione, retry, tracing, guardrail, gestione del parallelismo e serializzazione dello stato. Utile, a volte indispensabile — ma se hai in testa queste nove righe, nessuna astrazione ti sembrerà più misteriosa.
Il ciclo come vocabolario: le quattro fasi e come si guastano
Nella lezione precedente abbiamo disegnato il ciclo come percezione → decisione → azione → osservazione. Non è una formula decorativa presa in prestito dalla robotica: è il vocabolario con cui si localizza un guasto. Ogni fase ha un modo caratteristico di rompersi, e il sintomo che vedi in superficie è diverso per ciascuna. Vale la pena percorrerle una per una, perché è il contenuto che riuserai in tutte le lezioni successive.
Percezione è ciò che il modello ha davanti quando decide: le istruzioni, la cronologia, i risultati dei tool, e le descrizioni dei tool disponibili. Si guasta per assenza o per eccesso. Per assenza: il modello non vede un'informazione che tu credi gli sia nota — la data di oggi, il nome dell'utente, il fatto che ha già chiamato un tool. Per eccesso: il contesto contiene così tanto materiale che il segnale utile si perde. È il fenomeno documentato da Liu e colleghi (Lost in the Middle: How Language Models Use Long Contexts, arXiv:2307.03172), per cui l'informazione collocata al centro di un contesto lungo viene usata peggio di quella all'inizio o alla fine. Il sintomo tipico del guasto di percezione è un agente che «ignora» qualcosa che gli hai detto: spesso non lo ignora, non lo vede — oppure lo vede sepolto in mezzo a tremila token di risultati di tool.
Decisione è la scelta fra parlare e agire, quale tool e con quali argomenti. Si guasta in tre modi distinti, descritti più sotto, nella parte sul motore di decisione, e la cosa importante è che hanno cure diverse: un errore di «parlare o agire» si cura nelle istruzioni, un errore di «quale tool» si cura nel catalogo, un errore di «quali argomenti» si cura nello schema. Mescolare le tre cure è il modo più efficace di non capire mai cosa ha funzionato.
Azione è l'unica fase che non coinvolge il modello: la esegue il tuo codice. Per questo è anche l'unica completamente testabile con strumenti normali, e l'unica dove hai garanzie. Si guasta come si guasta qualunque funzione — eccezioni, timeout, dati inattesi — ma con una complicazione tutta sua: gli argomenti arrivano da un generatore di testo, quindi è un confine di sistema con input non fidato. Tenere questa fase piccola, validata e priva di sorprese è l'investimento con il miglior ritorno in tutto il lavoro su un agente.
Osservazione è il ritorno del risultato nello stato. È la fase più trascurata e quella che produce i bug più confondenti. Se il risultato non viene accodato, il modello richiama il tool in eterno. Se viene accodato nel posto sbagliato, l'endpoint rifiuta la richiesta con un errore di validazione che non parla del tuo bug. Se viene accodato come prosa invece che come struttura, il modello deve re-interpretarlo e a volte lo interpreta male. E se il risultato è enorme, hai spostato il problema nella fase di percezione del giro successivo — perché quello che osservi oggi è quello che percepisci domani, e lo paghi a ogni giro rimanente.
Quest'ultimo punto merita di essere detto esplicitamente, perché è la connessione che non si vede: osservazione e percezione sono la stessa coda vista da due lati. Ogni byte che un tool restituisce entra nel contesto e viene rimandato al modello a ogni giro successivo. Un tool che restituisce, poniamo, 3000 token di JSON non ha solo un costo locale: ha un costo che si ripete, e contemporaneamente peggiora la qualità della percezione nei giri a venire. Questa è la ragione tecnica, e non stilistica, per cui un tool deve essere un riduttore di informazione.
Le istruzioni che funzionano: cosa ci va e cosa no
Il system prompt è l'unico posto dove il comportamento si definisce in modo persistente, e proprio per questo è il posto dove si accumula spazzatura. Dopo qualche settimana di aggiustamenti, un system prompt tipico contiene cinque regole utili, tre contraddittorie e due che descrivono un problema risolto da tempo. Vale la pena avere un criterio su cosa merita di starci.
Ci va quello che vale per ogni giro. Le istruzioni vengono rimandate n volte in una run di n giri. Quindi: una riga che evita un errore ricorrente è un investimento eccellente, una riga che riguarda un caso raro è un costo fisso pagato per un beneficio occasionale. Per i casi rari esiste un posto migliore: la descrizione del tool pertinente, che il modello legge nel momento in cui quel caso è rilevante.
Ci va il criterio di completamento, scritto in modo verificabile. «Hai finito quando hai prodotto un elenco di SKU con la quantità» si può controllare; «aiuta l'utente nel modo migliore» non si può né controllare né violare, quindi non vincola nulla. Un criterio non verificabile non è un'istruzione debole: è rumore che paghi.
Ci vanno i divieti con l'alternativa. «Non chiamare invia_ordine sopra 1000» lascia il modello senza una strada; «non chiamare invia_ordine sopra 1000: usa richiedi_approvazione» gliene dà una. Un divieto senza alternativa produce agenti che si bloccano o che improvvisano, e improvvisare è la cosa che vuoi evitare.
Non ci va niente che cambi. Data odierna, nome utente, listino, saldo: tutta roba che invecchia. Un prompt che contiene la data di ieri è un prompt che ieri funzionava, e il guasto che produce è particolarmente insidioso perché intermittente. Per ciascuna di queste cose esiste un tool di tre righe.
Non ci vanno esempi lunghi, se non servono. Qualche esempio nel prompt (la tecnica del few-shot) è potente per fissare un formato, e costa un moltiplicatore su ogni giro. Prima di aggiungere tre esempi da duecento token, vale la pena verificare se una frase precisa ottiene lo stesso effetto.
Un'ultima raccomandazione pratica, che sembra burocrazia e ripaga subito: tieni il system prompt in un file versionato, non in una stringa dentro il codice, e annota accanto a ogni regola perché c'è. Fra un mese quella riga ti sembrerà inutile, e senza il perché la toglierai — riaprendo il bug che aveva chiuso.
Lo stato visto da vicino: la sequenza esatta dei messaggi
Prima di parlare di progetto, bisogna vedere com'è fatta la lista. Questo è lo stato completo di una run che ha fatto due giri. L'ho stampato così com'è, perché quasi tutti i bug di un agente si leggono qui.
In forma di dati, lo stesso stato. Non l'ho scritto a mano: è stampato dallo script completo della Parte 5, con un modello finto che segue un copione (due richieste di tool e una risposta finale), una riga JSON per messaggio. Il formato è quello delle API di tipo chat completions, in cui il risultato del tool riporta tool_call_id; l'API nativa di Ollama usa invece il campo tool_name, come nella lezione precedente. Il formato esatto cambia da un fornitore all'altro, quindi controllalo sulla documentazione del tuo.
for m in stato:
print(json.dumps(m, ensure_ascii=False))
# {"role": "system", "content": "Decidi se riordinare. Leggi i dati con i tool..."}
# {"role": "user", "content": "Lo SKU-11 va riordinato?"}
# {"role": "assistant", "content": null, "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "leggi_giacenza", "arguments": "{\"sku\": \"SKU-11\"}"}}]}
# {"role": "tool", "tool_call_id": "c1", "content": "{\"giacenza\": 4, \"soglia\": 10}"}
# {"role": "assistant", "content": null, "tool_calls": [{"id": "c2", "type": "function", "function": {"name": "leggi_ordini_in_arrivo", "arguments": "{\"sku\": \"SKU-11\"}"}}]}
# {"role": "tool", "tool_call_id": "c2", "content": "{\"in_arrivo\": 20}"}
# {"role": "assistant", "content": "NON RIORDINARE: 4 in giacenza ma 20 in arrivo."}
Tre dettagli che fanno la differenza fra codice che funziona e codice che dà errori incomprensibili. Il campo tool_calls[].id è l'identificativo di correlazione: il messaggio di tool lo ripete in tool_call_id, ed è così che il modello sa quale risultato appartiene a quale richiesta. Se il modello chiede due tool in un colpo e tu rispondi senza quell'id (o con l'id sbagliato), l'abbinamento si perde. Il campo arguments è una stringa JSON, non un oggetto: va deserializzata, e può contenere JSON malformato se il modello ha sbagliato, quindi quel json.loads va dentro un try (nello script lo fa esegui). E content del messaggio di tool è testo: qualunque struttura tu restituisca viene serializzata, quindi tanto vale serializzarla tu con chiavi parlanti.
Parte 1: le istruzioni sono una specifica, non un augurio
Le istruzioni sono già state discusse sopra; qui il punto è metterle in forma di specifica. Un testo utile dice quattro cose in modo verificabile: l'obiettivo con il suo criterio di completamento, i confini (cosa non fare mai, e cosa fare delegando: «non chiamare invia_ordine per importi sopra 1000, usa richiedi_approvazione»), la politica d'uso dei tool («non stimare mai una giacenza: leggila; se il tool dà errore, dillo, non indovinare») e il formato della risposta finale. Anche quando userai l'output strutturato (Output strutturato), scrivere il formato a parole riduce le derive. Il test è semplice: per ogni frase, chiediti se potresti guardare una run e dire se l'agente l'ha rispettata. Se no, la frase non vincola nulla.
Parte 2: il catalogo dei tool è la superficie dell'agente
Il modello conosce del mondo esterno solo quello che il catalogo descrive, e da qui discendono tre regole di progetto. La prima è la granularità: un tool per intenzione, non per endpoint, perché cerca_cliente(nome|email|partita_iva) è meglio di tre tool quasi uguali (il modello sbaglia meno a scegliere e tu occupi meno contesto). La seconda: nomi e descrizioni sono prompt; q è un argomento che il modello riempirà male, testo_da_cercare no, e la descrizione dice quando usare il tool, non cosa fa internamente. La terza: il risultato va progettato, perché un tool che restituisce migliaia di token annega il contesto, mentre uno che restituisce i cinque campi che servono evita giri e costo; il tool è un riduttore di informazione, non un tubo. Non ho misurato l'effetto sul numero di giri: è il ragionamento sul contesto fatto sopra, non un risultato sperimentale.
La serie dedicata approfondisce il lato pratico in tool calling avanzato: API, web e tool custom; nella lezione Tool: function calling, schemi JSON e errori entriamo invece dentro lo schema JSON e la gestione degli errori.
Parte 3: lo stato, cioè il collo di bottiglia
Lo stato è una lista di messaggi di quattro tipi: system, user, le risposte dell'assistente (incluse le richieste di tool) e i risultati dei tool. Tre proprietà da avere chiare:
- È append-only durante la run. Non riscrivi il passato, aggiungi.
- Si rimanda tutto, ogni volta. Al giro n paghi la somma di tutti i messaggi fino a n. Da cui la crescita quadratica del costo: con messaggi di dimensione simile, 10 giri costano circa 55 unità, non 10.
- Le coppie richiesta-risultato vanno tenute insieme. Se tronchi la cronologia e lasci una richiesta di tool senza il suo risultato, molti endpoint rifiutano la chiamata con un errore di validazione. È uno dei bug più noiosi da diagnosticare: vedi Memoria conversazionale.
La seconda proprietà è quella che costa. Se ogni giro aggiunge circa la stessa quantità di testo, il totale pagato dopo n giri è la somma 1 + 2 + … + n, che cresce come n². Con lo script della Parte 5 l'ho calcolato per dieci giri: 1, 3, 6, 10, 15, 21, 28, 36, 45, 55 unità cumulative. Dieci giri costano quindi 55 unità e non 10, ed è un'ipotesi di dimensione costante: nella realtà i risultati dei tool pesano di più dei messaggi di sistema, e la curva è ancora più ripida.
Parte 4: il motore di decisione, e cosa decide davvero
A ogni giro il modello compie tre scelte annidate: parlare o agire; se agire, quale tool; e con quali argomenti. Sono tre punti di fallimento distinti e si diagnosticano in modo diverso.
Sbaglia «parlare o agire» (risponde senza controllare)? Il problema è nelle istruzioni o nelle descrizioni dei tool. Sbaglia «quale tool»? Il catalogo ha confini sovrapposti o nomi ambigui. Sbaglia «quali argomenti»? Lo schema è troppo permissivo, oppure il tool precedente non gli ha dato il dato che serviva. Tre cure diverse per tre guasti diversi, ed è per questo che conviene saperli distinguere prima di toccare qualunque cosa.
C'è un parametro che spesso risolve il primo caso meglio di mille parole di prompt: la politica di scelta del tool. Alcune API di function calling accettano un campo, tool_choice, che può valere «auto», «nessuno» o «obbligatorio» (l'SDK Agents di OpenAI lo espone in ModelSettings). Forzare «obbligatorio» al primo giro impedisce a un agente di rispondere a vanvera prima di aver guardato i dati. Attenzione però: la pagina di compatibilità OpenAI di Ollama (consultata il 2026-11-12) elenca tool_choice fra i campi non supportati, quindi con un modello locale servito da Ollama devi ottenere lo stesso effetto in altro modo, per esempio controllando nel tuo ciclo che il primo giro contenga una richiesta di tool.
Parte 5: la terminazione, in quattro forme
Qui si separano i prototipi dai sistemi. Un agente in produzione ha quattro uscite, non una:
- Successo. Il modello risponde con testo (o con l'oggetto strutturato richiesto) invece di chiedere tool.
- Limite di giri. Il tetto duro. Sotto, l'agente viene troncato a metà pensiero, quindi la gestione dell'eccezione deve restituire qualcosa di sensato all'utente, non uno stack trace.
- Limite di budget. Token o euro accumulati nella run. È il freno che serve quando il numero di giri è basso ma ogni giro è enorme.
- Interruzione esterna. Guardrail scattato, timeout complessivo, cancellazione dell'utente.
Mettiamoli tutti e quattro nello scheletro, perché la versione completa è quella che poi userai. Prima il supporto, che rende il blocco davvero eseguibile: il magazzino e i due tool (gli stessi della lezione precedente), esegui che deserializza gli argomenti e trasforma un'eccezione del tool in un messaggio leggibile dal modello, e Risposta, la forma in cui un modello (vero o finto) restituisce un giro. Sono qui perché ogni nome dello scheletro iniziale abbia un corpo.
import json
import time
class LimiteGiri(Exception):
pass
class Esito:
def __init__(self, testo, motivo, giri, token):
self.testo, self.motivo, self.giri, self.token = testo, motivo, giri, token
class Risposta:
"""Quello che il modello restituisce a un giro."""
def __init__(self, messaggio, token_usati):
self.messaggio = messaggio
self.token_usati = token_usati
self.chiamate = messaggio.get("tool_calls") or []
self.testo = messaggio.get("content")
MAGAZZINO = {"SKU-11": {"giacenza": 4, "soglia": 10, "in_arrivo": 20}}
def leggi_giacenza(sku):
r = MAGAZZINO[sku]
return {"giacenza": r["giacenza"], "soglia": r["soglia"]}
def leggi_ordini_in_arrivo(sku):
return {"in_arrivo": MAGAZZINO[sku]["in_arrivo"]}
TOOL = {"leggi_giacenza": leggi_giacenza, "leggi_ordini_in_arrivo": leggi_ordini_in_arrivo}
def schemi_di(tool):
return [{"type": "function", "function": {"name": n}} for n in tool]
def esegui(tool, chiamata):
f = chiamata["function"]
try:
esito = tool[f["name"]](**json.loads(f["arguments"])) # arguments e' una STRINGA JSON
except Exception as e: # errore del tool: lo legge il modello
esito = {"errore": f"{type(e).__name__}: {e}"}
return {"role": "tool", "tool_call_id": chiamata["id"],
"content": json.dumps(esito, ensure_ascii=False)}
Poi il ciclo vero, con le quattro uscite. Il controllo di scadenza e di budget avviene prima di chiamare il modello: significa che il budget può essere superato di una chiamata (l'ultima), e che un giro già lanciato non viene interrotto. Se ti serve un tetto rigido, il limite va messo anche sulla singola chiamata (max_tokens dell'endpoint). Restituisco anche lo stato, perché è ciò che vorrai ispezionare.
def ciclo(modello, istruzioni, tool, richiesta,
max_giri=8, max_token=20_000, scadenza_s=60.0):
stato = [{"role": "system", "content": istruzioni},
{"role": "user", "content": richiesta}]
token = 0
inizio = time.monotonic()
for giro in range(1, max_giri + 1):
if time.monotonic() - inizio > scadenza_s:
return Esito(None, "scadenza", giro, token), stato
if token > max_token:
return Esito(None, "budget", giro, token), stato
risposta = modello(stato, schemi_di(tool))
token += risposta.token_usati
stato.append(risposta.messaggio)
if not risposta.chiamate:
return Esito(risposta.testo, "successo", giro, token), stato
for c in risposta.chiamate:
stato.append(esegui(tool, c))
return Esito(None, "max_giri", max_giri, token), stato
Infine il modello finto: chiede costruisce un messaggio di assistente con una richiesta di tool, modello_a_copione restituisce a ogni giro la risposta successiva di una lista, con un conteggio di token fisso (100, inventato) per poter provare il budget. Così provo le quattro uscite senza chiamare un modello (Python 3.14.4, due esecuzioni identiche, uscita sotto).
def chiede(id_, nome, sku):
return {"role": "assistant", "content": None,
"tool_calls": [{"id": id_, "type": "function",
"function": {"name": nome, "arguments": json.dumps({"sku": sku})}}]}
def modello_a_copione(copione):
it = iter(copione)
return lambda stato, schemi: Risposta(next(it), 100) # 100 "token" per giro: valore finto
copione = [chiede("c1", "leggi_giacenza", "SKU-11"),
chiede("c2", "leggi_ordini_in_arrivo", "SKU-11"),
{"role": "assistant", "content": "NON RIORDINARE: 4 in giacenza ma 20 in arrivo."}]
esito, stato = ciclo(modello_a_copione(copione), "Decidi se riordinare. Leggi i dati con i tool...",
TOOL, "Lo SKU-11 va riordinato?")
print(esito.motivo, esito.giri, esito.token)
for m in stato:
print(json.dumps(m, ensure_ascii=False))
esito, _ = ciclo(lambda s, t: Risposta(chiede("cx", "leggi_giacenza", "SKU-11"), 100),
"", TOOL, "loop", max_giri=3)
print(esito.motivo, esito.giri)
esito, _ = ciclo(lambda s, t: Risposta(chiede("cx", "leggi_giacenza", "SKU-11"), 600),
"", TOOL, "loop", max_giri=50, max_token=1000)
print(esito.motivo, esito.giri, esito.token)
esito, _ = ciclo(lambda s, t: Risposta(chiede("cx", "leggi_giacenza", "SKU-11"), 100),
"", TOOL, "loop", scadenza_s=-1)
print(esito.motivo, esito.giri)
# costo cumulativo: al giro n si rimanda tutto, quindi paghi la somma 1..n
costo = [sum(range(1, n + 1)) for n in range(1, 11)]
print(costo)
Uscita ottenuta (le sette righe JSON dello stato sono quelle mostrate più sopra e qui le ometto):
# successo 3 300
# [... sette righe JSON ...]
# max_giri 3
# budget 3 1200
# scadenza 1
# [1, 3, 6, 10, 15, 21, 28, 36, 45, 55]
Le quattro righe di esito sono le quattro uscite. successo 3 300: tre giri a 100 token finti ciascuno. max_giri 3: il modello chiede sempre lo stesso tool e il tetto lo ferma. budget 3 1200: con 600 token per giro e tetto 1000, il controllo scatta all'inizio del terzo giro, quando i token sono già 1200, cioè il tetto è stato superato di una chiamata; è il comportamento di cui sopra. scadenza 1: con scadenza negativa il ciclo esce prima di chiamare il modello. Il guardrail, quarta forma di interruzione esterna, non è simulato qui.
Nota il cambio di forma: non solleva eccezioni, restituisce un Esito con il motivo. Il motivo di uscita è il dato più utile che un agente produce dopo la risposta: aggregato su mille run, ti dice se il problema è il prompt («troppi max_giri»), il catalogo («troppi budget») o l'infrastruttura («troppe scadenze»).
Il motivo di uscita, aggregato: la metrica più utile che avrai
Il campo motivo nell'oggetto Esito sembra un dettaglio di implementazione. Aggregato su un volume di run, diventa lo strumento diagnostico principale di un agente in esercizio, perché ogni valore punta a una causa diversa e a un intervento diverso. Immagina questa tabella, costruita su mille run di una settimana. I numeri sono inventati per l'esempio, non una misura: servono a mostrare come si legge, non a dirti quali valori aspettarti.
motivo quante quota
successo 871 87,1%
max_giri 62 6,2%
budget 31 3,1%
scadenza 24 2,4%
guardrail 12 1,2%
Da sola, questa tabella non dice se il sistema funziona: dice dove guardare, e ogni riga ha una lettura precisa.
Un max_giri alto significa che l'agente non riesce a chiudere. Le cause possibili sono due e si distinguono guardando la sequenza dei tool nelle run fallite: se vedi lo stesso tool ripetuto, il problema è l'osservazione (il risultato non torna, o torna in una forma che il modello non riconosce come risposta); se vedi tool diversi che si susseguono senza convergere, il problema è il criterio di completamento nelle istruzioni, oppure il compito è davvero più grande del tetto che hai messo.
Un budget alto con pochi giri indica tool che restituiscono troppo. Il conto esplode non perché l'agente faccia molti passi, ma perché ogni passo trascina un contesto enorme. Si risolve nei valori di ritorno dei tool, non nel prompt.
Una scadenza alta è quasi sempre infrastruttura: un tool lento, una rete instabile, un modello sotto carico. Non è un problema di progetto dell'agente, ed è importante distinguerlo, perché altrimenti passi giorni a riscrivere prompt per curare un database che manca gli indici.
Un guardrail alto va letto con sospetto in entrambe le direzioni. Se è alto, può voler dire che il sistema è sotto pressione da richieste fuori perimetro, o che il tuo controllo ha troppi falsi positivi e stai bloccando utenti legittimi. I due casi si distinguono solo leggendo un campione di run bloccate — ed è il motivo per cui ogni guardrail deve registrare perché è scattato, come vedremo più avanti nel modulo.
La cosa da notare è che nessuna di queste diagnosi richiede strumenti sofisticati. Richiede che il tuo ciclo, invece di sollevare un'eccezione generica, restituisca un oggetto con quattro campi, e che tu li scriva in una tabella. È mezz'ora di lavoro che cambia il modo in cui si discutono i problemi: da «a volte non funziona» a una frase del tipo «una quota delle run non converge, e in molte di quelle un tool è ripetuto tre volte» — con le cifre vere che tu avrai misurato.
Lo stesso agente, tre configurazioni
Per chiudere, una cosa che chiarisce quanto le cinque parti siano indipendenti dal modello: lo stesso compito, tre configurazioni con lo stesso codice e lo stesso modello, cambiando soltanto le cinque parti. Uso l'SDK Agents di OpenAI (pacchetto openai-agents, che si importa come agents), che nelle lezioni successive useremo anche su modelli locali. Una correzione rispetto a come si scrive spesso questo esempio: i tool vanno registrati con il decoratore function_tool, perché l'SDK non accetta funzioni nude, e Decisione deve esistere (qui è un modello pydantic con tre campi). Ho installato openai-agents 0.24.0 in un ambiente virtuale fuori dal repo e ho eseguito il blocco due volte: costruisce i tre agenti, non chiama nessun modello.
from agents import Agent, ModelSettings, function_tool
from pydantic import BaseModel
@function_tool
def leggi_giacenza(sku: str) -> dict:
"""Giacenza attuale e soglia di riordino di uno SKU."""
return {"giacenza": 4, "soglia": 10}
@function_tool
def leggi_ordini_in_arrivo(sku: str) -> dict:
"""Quantità già ordinata e in transito per uno SKU."""
return {"in_arrivo": 20}
class Decisione(BaseModel):
esito: str # "riordina" oppure "non_riordinare"
quantita: int
ragione: str
BASE = dict(name="Magazzino", tools=[leggi_giacenza, leggi_ordini_in_arrivo])
# A - permissivo: fa quello che vuole
a = Agent(**BASE, instructions="Aiuta con le domande sul magazzino.")
# B - vincolato: criterio di completamento e politica dei tool
b = Agent(**BASE, instructions=(
"Decidi se riordinare. Leggi SEMPRE i dati con i tool, mai a memoria. "
"Tieni conto di ciò che è in arrivo. "
"Hai finito quando hai scritto una riga: RIORDINA <n> oppure NON RIORDINARE, "
"più la ragione in una frase. Non aggiungere altro."))
# C - vincolato e tipizzato: nessuna prosa, un oggetto
c = Agent(**BASE, instructions=b.instructions, output_type=Decisione,
model_settings=ModelSettings(temperature=0.0, tool_choice="required"))
for ag in (a, b, c):
print(ag.name, [t.name for t in ag.tools], ag.output_type, ag.model_settings.tool_choice)
# Magazzino ['leggi_giacenza', 'leggi_ordini_in_arrivo'] None None
# Magazzino ['leggi_giacenza', 'leggi_ordini_in_arrivo'] None None
# Magazzino ['leggi_giacenza', 'leggi_ordini_in_arrivo'] <class '__main__.Decisione'> required
L'uscita dice una cosa sola, e va letta per quello che è: le tre configurazioni differiscono per istruzioni, tipo di output e tool_choice, e per nient'altro. Il comportamento a runtime non l'ho misurato, perché servirebbe un modello. Quello che segue è ciò che ci si aspetta dalla teoria della lezione, da verificare con i tuoi dati. A è libero di rispondere senza chiamare i tool, perché nulla glielo vieta, e il formato della risposta non è vincolato. B ha un criterio di completamento e una politica sui tool, ma l'uscita resta prosa e a valle devi interpretarla. C non può non chiamare un tool al primo giro (lo impone tool_choice, se l'endpoint lo supporta: vedi la nota su Ollama) e non può rispondere in prosa (lo impone output_type), quindi è l'unica che puoi collegare ad altro software senza scrivere un parser.
La morale operativa vale per tutto il modulo: il comportamento di un agente è in buona parte una tua scelta di progetto, non solo una proprietà del modello. Quando qualcosa non funziona, la prima domanda è «quale delle cinque parti ho lasciato vaga?» prima ancora di «serve un modello migliore?». Spesso la risposta è «il criterio di completamento» oppure «la politica d'uso dei tool», e si corregge in poche righe — è la mia esperienza, non una statistica.
Diagnosi: dal sintomo alla parte guasta
Il motivo per cui vale la pena tenere in testa le cinque parti è questo: trasforma «l'agente non funziona» in una domanda con una risposta. Questa tabella è il percorso che seguo davvero quando qualcosa va storto.
- «Risponde senza guardare i dati.» → Istruzioni (manca la politica d'uso dei tool) oppure descrizioni dei tool troppo vaghe. Rimedio rapido: forza
tool_choicea «obbligatorio» al primo giro e verifica se il comportamento cambia; se cambia, il problema era la decisione, non la capacità. - «Chiama il tool sbagliato.» → Catalogo: nomi sovrapposti o confini poco chiari. Guarda i due tool candidati e chiediti se tu sapresti scegliere leggendo solo nome e descrizione.
- «Chiama lo stesso tool in continuazione.» → Stato: con alta probabilità non stai accodando correttamente il messaggio dell'assistente o il risultato, e il modello non vede di aver già chiamato. Secondo sospetto: il tool restituisce qualcosa che il modello non riconosce come risposta (un
None, una stringa vuota). - «Si ferma troppo presto.» → Terminazione: il criterio di completamento nelle istruzioni è vago, quindi il modello si dichiara soddisfatto quando gli pare.
- «Non si ferma mai.» → Terminazione, lato opposto: manca il limite, oppure due tool si rimandano la palla. Guarda la sequenza delle chiamate: se vedi A, B, A, B hai un ciclo.
- «Dice di aver fatto una cosa che non ha fatto.» → Nessuna delle cinque: è la natura del modello. Il rimedio non è un prompt migliore, è non credere alla prosa — verifica sul registro delle chiamate eseguite.
Questa lista si usa in un ordine preciso: prima guardi cosa è successo (la sequenza delle chiamate), poi formuli l'ipotesi, poi cambi una cosa. Cambiare prompt e catalogo nello stesso tentativo è il modo più efficace di non capire mai cosa ha funzionato.
Trade-off di progetto
Quattro scelte di progetto tirano in direzioni opposte. Istruzioni lunghe contro contesto: un system prompt dettagliato riduce gli errori di comportamento e aumenta il costo di ogni giro, quindi vince la riga che elimina un intero giro e perde il paragrafo che ripete un concetto. Pochi tool ricchi contro molti tool semplici: pochi tool con parametri opzionali confondono meno la scelta, ma ognuno diventa una funzione con troppe responsabilità da validare e testare.
Limite di giri alto contro basso: con un tetto alto più compiti riescono e i casi patologici costano molto; con uno basso il costo è prevedibile e qualche compito legittimo viene troncato, quindi si decide guardando la distribuzione reale dei giri e non a intuito. Stato completo contro stato riassunto: tutta la cronologia è fedele e cara, il riassunto è economico e perde dettagli proprio quando servono (una citazione esatta, un identificativo).
Le trappole in cui si cade davvero
- Dimenticare di accodare la risposta dell'assistente prima del risultato del tool. L'ordine è: messaggio dell'assistente con le richieste, poi i messaggi di tool. Invertirli, o saltare il primo, produce errori di validazione o un modello che chiama lo stesso tool in eterno perché non vede di averlo già chiamato.
- Un solo
exceptattorno a tutto il ciclo. Così non distingui «il tool è andato in errore» (recuperabile: lo dici al modello) da «l'endpoint è giù» (non recuperabile: esci). Duetrydistinti, due comportamenti. - Mettere dati volatili nelle istruzioni. La data di oggi dentro il system prompt è un bug a scoppio ritardato. Dagli un tool
data_odierna(). - Ignorare le chiamate parallele. Il modello può chiedere tre tool in un colpo. Se il tuo ciclo ne esegue solo il primo, gli altri due restano senza risultato e la conversazione si rompe o entra in loop. Itera sempre su tutte le chiamate.
- Non registrare il motivo di uscita. Senza quel campo, sai solo che «a volte non funziona». Con quel campo hai una diagnosi in una query.
Le cinque trappole sono tutte di una famiglia: il ciclo è semplice e per questo sembra non meritare cura. Le prime quattro producono bug che si vedono solo in condizioni particolari (una chiamata parallela, un errore di rete, il giorno dopo), la quinta ti toglie la possibilità di accorgertene. Se devi scegliere una sola difesa, scegli la quinta: il motivo di uscita registrato in ogni run costa quattro campi e rende diagnosticabili le altre quattro.
Che cosa sai fare adesso
Istruzioni, tool, stato, decisione, terminazione: cinque parti, e ogni comportamento sbagliato di un agente si riconduce a una di esse. Ora sai scrivere a mano il ciclo con le sue quattro uscite, provarlo senza un modello con un copione, e localizzare un sintomo in una delle cinque parti. Il ciclo è banale, il progetto non lo è: la parte meno romantica (le quattro uscite e il motivo registrato) è quella che distingue un giocattolo da qualcosa che puoi mettere davanti a degli utenti. Nella prossima lezione, asyncio: l'infrastruttura su cui gira un agente, affrontiamo la parte che il ciclo nasconde: tutti quei «aspetta la risposta» sono I/O di rete, e in Python si gestiscono con asyncio.
Fonti
- Lost in the Middle: How Language Models Use Long Contexts (Liu et al., arXiv:2307.03172) - il risultato sull'uso peggiore dell'informazione al centro di un contesto lungo; scheda raggiunta il 2026-11-12 (HTTP 200), abstract non riletto per intero
- OpenAI Agents SDK: Agents - parametri di
Agent(instructions,tools,output_type), consultata il 2026-11-12; la costruzione degli agenti è stata verificata conopenai-agents0.24.0 - OpenAI Agents SDK: ModelSettings - il campo
tool_choice, consultata il 2026-11-12 - Ollama: compatibilità OpenAI -
tool_choicefra i campi non supportati, consultata il 2026-11-12 - Ollama: tool calling - il campo
tool_namedell'API nativa, consultata il 2026-11-12
Eseguiti due volte con uscita identica: lo script del ciclo con modello a copione (Python 3.14.4) e la costruzione dei tre agenti (ambiente virtuale temporaneo fuori dal repo). Non eseguito: lo scheletro iniziale, che è uno schema, e qualunque run con un modello vero.
Commenti
Caricamento commenti...
Accedi per commentare