Il problema: mettere insieme i pezzi senza raccontare una storia che non c'è
Dopo dodici articoli sui singoli ingredienti (cicli, strumenti, memoria, orchestrazione, test, sicurezza, costi) la domanda naturale è come stiano insieme in un sistema che fa un lavoro. La risposta più comoda sarebbe un «caso reale» con tempi, costi e percentuali di successo. Questo articolo non lo è: è un esempio costruito, e lo dichiaro subito. Le fonti sono inventate (la città di Valdoro non esiste), nessun modello è chiamato, e non ci sono cifre di produzione perché non c'è nessuna produzione. Quello che l'esempio può mostrare, e mostra, è la struttura: dove vive lo stato, dove si ferma un ciclo, dove un controllo in codice impedisce al sistema di inventare.
Il compito è un assistente di ricerca a tre ruoli. Un ricercatore recupera fonti pertinenti alla domanda, un analista ne estrae il valore richiesto e pesa quanto le fonti concordano, un editore scrive il report; un quarto nodo, il verificatore, rifiuta qualunque citazione che non sia nello stato. Se le fonti non bastano, il sistema allarga la ricerca una volta e poi ammette di non sapere. I listati sono eseguiti con LangGraph 1.2.21 (da PyPI, in un ambiente fuori dal repository), due volte con uscita identica.
Prima di tutto: serve davvero più di un agente?
Anthropic distingue tra workflow, sistemi in cui modelli e strumenti sono orchestrati da percorsi di codice predefiniti, e agenti, in cui il modello dirige dinamicamente il proprio processo. E raccomanda di cercare la soluzione più semplice e di aumentare la complessità solo quando serve, il che può voler dire non costruire un sistema agentico affatto. Il sistema di questo articolo è, a rigore, un workflow: l'ordine dei nodi è fisso e l'unico ciclo ha un tetto. L'autonomia starebbe dentro i nodi, quando un modello decidesse quali sotto-domande cercare (si è visto negli articoli su ReAct e sul tool calling).
Perché tre ruoli, allora, e non una funzione? Perché in una funzione sola non si distinguono né i permessi né i guasti. Dividere ha senso quando:
- gli strumenti sono diversi: chi legge il web non ha bisogno di scrivere report, e chi scrive il report non ha bisogno della rete;
- si vogliono valutare i pezzi separatamente: se il report è sbagliato, devi poter dire se ha sbagliato chi cercava, chi estraeva o chi scriveva;
- il confine è un punto di controllo: tra analista ed editore passa un dato strutturato che il codice può verificare.
Nessuna di queste ragioni è «ogni agente ha un nome e una personalità». E ci sono costi da mettere in conto: nei suoi dati Anthropic riporta che i sistemi multi-agente usano circa 15 volte i token di una chat (contro circa 4 volte per un agente singolo), e avverte che i compiti con molte dipendenze fra gli agenti non sono oggi un buon terreno per questi sistemi. Per un compito semplice, tre ruoli sono un'aggiunta di costo e di punti di rottura; ne parla l'articolo sull'orchestrazione.
Lo stato come contratto: fonti, recupero e dati condivisi
Il primo frammento definisce i dati con cui si lavora e il modo di recuperarli. Le cinque fonti sono frasi inventate, con un tipo (ente o blog) che nel seguito diventa un peso. Il recupero è volutamente rozzo: conta le parole in comune tra domanda e testo, e considera pertinente una fonte solo se ne ha almeno due. Non è un motore di ricerca: è il sostituto di uno strumento che nel sistema vero sarebbe un'API di ricerca o un indice vettoriale (si veda l'articolo sulla memoria), ed è deterministico, quindi l'esecuzione si ripete.
import operator
import re
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
# ---- parte 1: le fonti (INVENTATE: la città di Valdoro non esiste) e il recupero
FONTI = {
"F1": ("ente", "Bilancio idrico di Valdoro: le perdite della rete idrica sono il 31 per cento dell'acqua immessa"),
"F2": ("ente", "Relazione tecnica sulla rete idrica di Valdoro: perdite al 31 per cento nel 2023"),
"F3": ("blog", "A Valdoro la rete idrica perde circa il 28 per cento, secondo i nostri calcoli"),
"F4": ("ente", "Piano del verde di Valdoro: nuove aree pedonali"),
"F5": ("ente", "Tariffe del teleriscaldamento a Valdoro"),
}
PESO = {"ente": 2, "blog": 1}
VUOTE = {"di", "del", "della", "dei", "il", "la", "le", "a", "al", "e", "nel", "sono", "su", "per"}
def parole(testo):
return set(re.findall(r"[a-zà-ù]+", testo.lower())) - VUOTE
def recupera(domanda, k):
"""Le k fonti con più parole in comune con la domanda; sotto 2 parole non è pertinente."""
q = parole(domanda)
punteggi = sorted(((-len(q & parole(t)), id_) for id_, (_, t) in FONTI.items()))
return [id_ for p, id_ in punteggi if -p >= 2][:k]
L'insieme VUOTE non è un dettaglio: senza di esso la domanda sulle tariffe del condominio recupera le fonti sull'acqua e sul verde, perché condividono con la domanda solo «di» e «del» (misurato eseguendo il listato senza il filtro). È un errore piccolo e tipico: un recupero per sovrapposizione senza filtro delle parole vuote risponde con fonti irrilevanti, e un analista a valle le tratta come pertinenti. Un recupero reale ha lo stesso rischio sotto altre forme, ed è un buon motivo per testare il ricercatore da solo.
Lo stato condiviso e i nodi che lavorano
Lo stato è un TypedDict: ogni nodo riceve lo stato intero e restituisce solo gli aggiornamenti. La documentazione di LangGraph spiega che gli aggiornamenti passano per un riduttore per ciascun campo: per impostazione predefinita vince l'ultima scrittura, mentre con un riduttore i valori si combinano, per esempio aggiungendosi a una lista. Nel listato solo avvisi ha il riduttore (operator.add), perché è l'unico campo a cui più nodi possono aggiungere senza cancellare le segnalazioni degli altri.
# ---- parte 2: lo stato condiviso e i due nodi che lavorano sui dati
class Stato(TypedDict):
domanda: str
giro: int
fonti: list
valori: dict
bastano: bool
report: str
avvisi: Annotated[list, operator.add] # i nodi aggiungono, non sovrascrivono
MAX_GIRI = 2
MIN_FONTI = 3
def ricercatore(stato):
giro = stato["giro"] + 1
fonti = recupera(stato["domanda"], k=2 * giro) # a ogni giro si allarga la rete
return {"giro": giro, "fonti": fonti}
def analista(stato):
valori = {}
for id_ in stato["fonti"]:
tipo, testo = FONTI[id_]
trovato = re.search(r"(\d+) per cento", testo)
if trovato:
valori.setdefault(int(trovato.group(1)), []).append(id_)
return {"valori": valori, "bastano": len(stato["fonti"]) >= MIN_FONTI}
def dopo_analisi(stato):
return "editore" if stato["bastano"] or stato["giro"] >= MAX_GIRI else "ricercatore"
Tre scelte da leggere. Il ricercatore allarga la rete a ogni giro (k = 2 * giro): al primo giro prende due fonti, al secondo quattro. L'analista estrae il valore con un'espressione regolare e fissa la regola di sufficienza: servono almeno tre fonti pertinenti, altrimenti bastano è falso. E dopo_analisi decide se andare avanti o tornare indietro: il tetto MAX_GIRI sta nel mio codice, perché il limite di ricorsione di LangGraph esiste ma conta i passi del grafo (per la versione 1.1.4 in poi la documentazione indica 10.007 super-passi come valore predefinito) e non è pensato come tetto di ricerca; l'articolo su LangChain e LangGraph mostra quando scatta.
L'editore scrive, il verificatore controlla
Il report lo scrive l'editore, ma la regola che conta non è scritta nel suo prompt: è un nodo di codice che confronta le citazioni del testo con le fonti presenti nello stato. Questo è il principio dell'articolo sulla sicurezza applicato a un caso piccolo: un controllo che non dipende dalla buona volontà del modello. Qui l'editore è una funzione, quindi non inventa; per vedere il verificatore all'opera c'è anche un editore_bugiardo che cita una fonte inesistente.
# ---- parte 3: l'editore scrive, il verificatore controlla le citazioni
def editore(stato):
if not stato["valori"] or not stato["bastano"]:
return {"report": f"Evidenze insufficienti (giri di ricerca: {stato['giro']}): nessuna conclusione.",
"avvisi": ["report senza conclusione"]}
pesi = {v: sum(PESO[FONTI[i][0]] for i in ids) for v, ids in stato["valori"].items()}
vincente = max(pesi, key=pesi.get)
confidenza = pesi[vincente] / sum(pesi.values())
righe = [f"Perdite della rete: {vincente}% [{', '.join(stato['valori'][vincente])}], "
f"confidenza {confidenza:.2f}."]
for v, ids in stato["valori"].items():
if v != vincente:
righe.append(f"Valore discordante: {v}% [{', '.join(ids)}].")
return {"report": " ".join(righe)}
def editore_bugiardo(stato):
return {"report": "Perdite della rete: 12% [F9]."}
def verificatore(stato):
citate = set(re.findall(r"F\d+", stato["report"]))
inventate = citate - set(stato["fonti"])
if inventate:
raise ValueError(f"citazioni non presenti nello stato: {sorted(inventate)}")
return {}
Nell'editore onesto la confidenza è il peso delle fonti concordi sul peso totale, con pesi 2 per l'ente e 1 per il blog: con due enti a 31% e un blog a 28% la confidenza è 4 su 5. È una convenzione mia, non una misura di verità; la sua utilità è che il report dichiara il valore discordante invece di nasconderlo. Se le evidenze non bastano l'editore non scrive una conclusione: scrive che mancano, e il campo avvisi lo segnala a chi legge l'esito.
Il grafo e le tre prove
L'ultimo frammento assembla il grafo e lo lancia su due domande e su un editore che mente. L'arco condizionale riceve la funzione di decisione e l'elenco dei nodi possibili; gli archi fissi dicono l'ordine. Le due domande sono scelte perché esercitano percorsi diversi: la prima richiede un secondo giro per raggiungere tre fonti, la seconda non ha fonti sufficienti e deve fermarsi.
# ---- parte 4: il grafo e tre prove
def costruisci(nodo_editore):
g = StateGraph(Stato)
g.add_node("ricercatore", ricercatore)
g.add_node("analista", analista)
g.add_node("editore", nodo_editore)
g.add_node("verificatore", verificatore)
g.add_edge(START, "ricercatore")
g.add_edge("ricercatore", "analista")
g.add_conditional_edges("analista", dopo_analisi, ["editore", "ricercatore"])
g.add_edge("editore", "verificatore")
g.add_edge("verificatore", END)
return g.compile()
def parti(domanda):
return {"domanda": domanda, "giro": 0, "fonti": [], "valori": {}, "bastano": False,
"report": "", "avvisi": []}
if __name__ == "__main__":
onesto = costruisci(editore)
for domanda in ("perdite della rete idrica di Valdoro", "tariffe del condominio di Valdoro"):
fine = onesto.invoke(parti(domanda))
print(f"{domanda!r}: giri={fine['giro']} fonti={fine['fonti']}")
print(" ", fine["report"], fine["avvisi"])
try:
costruisci(editore_bugiardo).invoke(parti("perdite della rete idrica di Valdoro"))
except ValueError as errore:
print("editore bugiardo ->", errore)
# Output:
# 'perdite della rete idrica di Valdoro': giri=2 fonti=['F1', 'F2', 'F3']
# Perdite della rete: 31% [F1, F2], confidenza 0.80. Valore discordante: 28% [F3]. []
# 'tariffe del condominio di Valdoro': giri=2 fonti=['F5']
# Evidenze insufficienti (giri di ricerca: 2): nessuna conclusione. ['report senza conclusione']
# editore bugiardo -> citazioni non presenti nello stato: ['F9']
Nell'uscita, la prima domanda impiega due giri, trova tre fonti, sceglie il 31% e riporta il 28% come discordante. La seconda arriva al tetto con una sola fonte e il report dice che non c'è conclusione: è il comportamento onesto, e nei sistemi con un modello è il primo che si perde, perché un modello tende a produrre una risposta comunque. L'ultima riga mostra il verificatore che ferma la citazione inventata. L'eccezione interrompe l'esecuzione: in un sistema vero decidi se rilanciare l'editore, passare a una persona o restituire un errore.
Provare il sistema: esito, traiettoria, regressione
Un sistema a più nodi si guasta in punti diversi, quindi i controlli si scrivono sul percorso e non solo sull'esito, come nell'articolo sulla valutazione. Il listato ha tre prove, una per il comportamento che deve esserci, una per quello che deve mancare e una per il controllo che deve scattare. Poi simula una regressione: il tetto di giri portato a uno, un cambio plausibile fatto «per risparmiare» (si veda l'articolo sui costi).
import ricerca
from ricerca import costruisci, editore, editore_bugiardo, parti
CON_PROVE = "perdite della rete idrica di Valdoro"
SENZA_PROVE = "tariffe del condominio di Valdoro"
def risponde_con_fonti():
fine = costruisci(editore).invoke(parti(CON_PROVE))
assert "31%" in fine["report"], fine["report"]
assert len(fine["valori"][31]) >= 2
def ammette_di_non_sapere():
fine = costruisci(editore).invoke(parti(SENZA_PROVE))
assert "nessuna conclusione" in fine["report"]
assert fine["avvisi"] == ["report senza conclusione"]
def non_cita_fonti_inventate():
try:
costruisci(editore_bugiardo).invoke(parti(CON_PROVE))
except ValueError:
return
raise AssertionError("la citazione inventata non è stata fermata")
PROVE = [risponde_con_fonti, ammette_di_non_sapere, non_cita_fonti_inventate]
def esegui(titolo):
print(titolo)
for prova in PROVE:
try:
prova()
esito = "ok"
except AssertionError as errore:
esito = f"FALLISCE ({str(errore)[:48]})"
print(f" {prova.__name__:28} {esito}")
esegui("codice com'è")
ricerca.MAX_GIRI = 1 # regressione simulata: un solo giro di ricerca
esegui("con MAX_GIRI = 1")
# Output:
# codice com'è
# risponde_con_fonti ok
# ammette_di_non_sapere ok
# non_cita_fonti_inventate ok
# con MAX_GIRI = 1
# risponde_con_fonti FALLISCE (Evidenze insufficienti (giri di ricerca: 1): nes)
# ammette_di_non_sapere ok
# non_cita_fonti_inventate ok
La prova risponde_con_fonti cade con il tetto a un giro: il sistema, con due sole fonti, ammette di non sapere, e un risparmio sul numero di giri ha cambiato la qualità del risultato senza alcun errore visibile. È la ragione per cui le prove stanno accanto al codice e girano a ogni modifica. Le altre due restano verdi, e questo è informativo quanto il rosso: dice che la regressione tocca il ricercatore e non i controlli. Le prove sono funzioni semplici, non un framework di test.
Che cosa cambierebbe con modelli veri
Il listato rimuove di proposito tutto ciò che è non deterministico. Con un modello al posto delle funzioni, ogni nodo acquisisce un rischio che gli altri articoli della serie hanno già descritto: la tabella dice quale, e dove andare a rileggere.
| Nodo | Nel listato | Con un modello | Che cosa rileggere |
|---|---|---|---|
| Ricercatore | Sovrapposizione di parole | Sceglie le sotto-domande e chiama una ricerca; legge testo altrui | Iniezione indiretta e permessi: articolo 10; strumenti: articolo 8 |
| Analista | Espressione regolare | Estrae il valore con un modello, che può sbagliare numero o unità | Valutare l'estrazione su casi con risposta nota: articolo 9 |
| Editore | Modello di frase | Scrive in libertà; può aggiungere ciò che non c'è | Il verificatore in codice resta, non si sposta nel prompt |
| Ciclo | Tetto di due giri | Il costo cresce con ogni giro, perché si rimanda il contesto | Costo e cache: articolo 12 |
| Processo | Un'esecuzione | Un compito può durare minuti e il pod può essere fermato | Stato fuori dal processo e SIGTERM: articolo 11 |
La riga dell'editore è quella da non dimenticare: il verificatore è l'unica parte del sistema che resta valida qualunque modello scriva. Quando sostituisci le funzioni con chiamate al modello, tieni i test sugli esiti e sul verificatore così come sono, e aggiungi la valutazione ripetuta del nono articolo: lo stesso compito eseguito più volte, perché il risultato non sarà più lo stesso a ogni prova. Per vedere come si costruisce un agente singolo su LangGraph rimando all'articolo sul framework.
Domande frequenti
Questo è un caso reale?
No, è un esempio costruito. Le fonti sono inventate, nessun modello è chiamato e non ci sono metriche di produzione. Serve a mostrare la struttura: stato condiviso, ciclo con tetto, verificatore delle citazioni e prove sul percorso.
Servono davvero tre agenti per un assistente di ricerca?
Non necessariamente. Anthropic raccomanda la soluzione più semplice e avverte che i sistemi multi-agente usano molti più token. Dividere in ruoli ha senso quando gli strumenti e i permessi sono diversi, quando si vogliono valutare i pezzi separatamente o quando il confine tra due ruoli è un punto di controllo.
Come si evita che l'editore inventi una fonte?
Con un controllo in codice fuori dal modello: un nodo confronta le citazioni del report con le fonti presenti nello stato e interrompe l'esecuzione se ne trova una assente. Nel listato lo dimostra l'editore bugiardo, che cita una fonte inesistente.
Che cosa sai fare adesso
Sai strutturare un sistema a ruoli come un grafo con uno stato dichiarato, un ciclo con un tetto tuo e un nodo di controllo che non dipende dal modello. Sai far ammettere al sistema di non avere prove, scrivere prove sull'esito, sul comportamento che manca e sul controllo, e riconoscere una regressione che nessuna eccezione segnala. Sai anche rispondere alla domanda a monte: un workflow con un ciclo basta, e i ruoli separati si giustificano con permessi, valutazione e punti di controllo diversi, non con il numero di agenti. Il capitolo conclusivo, Il Futuro degli AI Agents: Capacità Emergenti e Limitazioni, riprende i criteri per decidere se un compito merita un agente.
Fonti
- Building effective agents (Anthropic) - distinzione tra workflow e agenti, raccomandazione della soluzione più semplice; consultata il 2026-11-12
- How we built our multi-agent research system (Anthropic) - consumo di token di agenti e sistemi multi-agente rispetto alla chat («in our data»), domini poco adatti; consultata il 2026-11-12
- Graph API overview (LangChain) - stato, riduttori, aggiornamenti parziali dei nodi, limite di ricorsione e suo valore predefinito; consultata il 2026-11-12
- Prove eseguite in locale:
langgraph1.2.21 da PyPI in un ambiente virtuale fuori dal repository, Python 3.14.4; i due listati girano due volte con uscita identica. Fonti, città e numeri dell'esempio sono inventati

Commenti
Caricamento commenti...
Accedi per commentare