# Perché lo stato condiviso compromette i sistemi multi-agente oltre i tre agenti

> Le lavagne condivise reggono nelle demo e falliscono sotto carico: i modi di fallimento dello stato condiviso e perché vince il message-passing con supervisor.

Published: 2024-12-10
Canonical: https://gianlucamazza.it/it/blog/agent-to-agent-communication
Tags: LangChain, Agents, Multi-Agent, Architecture

## Perché conta

Il primo sistema multi-agent che ho costruito usava un dict di stato condiviso. Tre agenti — un planner, un researcher e un writer — leggevano e scrivevano tutti nello stesso `AgentState` di LangGraph. In demo funzionava bene. Due settimane dopo, in produzione, con cinque agenti ed esecuzione concorrente, ha iniziato a fallire in modi difficili da debuggare.

Non crash. Peggio: output quasi corretti, ma sbagliati nei dettagli. Il planner sovrascriveva un risultato di ricerca mentre il writer lo stava ancora usando. Un agente leggeva stato obsoleto da una sessione precedente perché non avevo isolato il checkpoint. Il supervisor instradava verso un agente che aveva già completato il task e stava aspettando il proprio output; nel frattempo, un agente parallelo lo aveva sovrascritto.

Lo stato condiviso non è sbagliato in assoluto. Diventa fragile quando aumenta il coordinamento tra agenti. Capire dove inizia a rompersi — e con cosa sostituirlo — è la differenza tra un sistema che passa i test e uno che resta controllabile in produzione.

## 1. Le tre modalità di fallimento delle blackboard condivise

Una blackboard condivisa è un design in cui più agenti leggono e scrivono nello stesso oggetto di stato senza ownership esplicita. Rientrano in questa categoria un `TypedDict` piatto di LangGraph senza annotazioni di reducer, dict Python condivise passate per riferimento e tabelle di database senza row-level locking.

**Modalità di fallimento 1: conflitti di scrittura.** Quando due agenti scrivono lo stesso campo, è la regola di merge a decidere l’esito, e il default raramente è ciò che voglio. Senza reducer, due scritture sulla stessa chiave nello stesso super-step parallelo non vengono fuse in silenzio: LangGraph solleva `InvalidUpdateError`, che in produzione si manifesta come crash anziché come risposta sbagliata. Il fallimento più silenzioso è cross-step: un nodo successivo sovrascrive un campo impostato da un nodo precedente. Se non ispeziono lo stato a ogni checkpoint, quella scrittura persa resta invisibile. In entrambi i casi è il modello di coordinamento, non un bug, a forzare la scelta.

**Modalità di fallimento 2: letture obsolete.** L’Agent B legge stato che l’Agent A ha scritto due step prima. In una pipeline sequenziale può essere corretto; in una pipeline parallela può diventare un bug di correttezza. Se l’Agent B assume che l’output del researcher sia aggiornato, ma il planner ha nel frattempo cambiato la direzione della ricerca, l’Agent B risponde a una domanda che non è più quella richiesta.

**Modalità di fallimento 3: stato fantasma da checkpoint.** Fare checkpointing di un sistema multi-agent significa salvare lo stato condiviso in un punto nel tempo. Se l’Agent C legge lo stato, lavora e viene checkpointato, poi l’Agent A sovrascrive gli stessi campi, un replay dal checkpoint dell’Agent C può ripristinare una versione dello stato che l’Agent A aveva già superato. Il replay è coerente con il checkpoint, ma non con lo stato reale del sistema.

Questi fallimenti non sono per forza bug di LangGraph o del checkpointer. Derivano dal modello di coordinamento basato su stato condiviso. Aggiungere lock, campi versionati o garanzie read-your-writes significa ricostruire una parte di un database distribuito. Per un sistema agentico, spesso non è la primitiva più semplice.

## 2. L’alternativa del message bus

Un message bus cambia il modo in cui gli agenti si coordinano: invece di leggere tutti lo stesso oggetto condiviso, si inviano messaggi tipizzati attraverso un canale esplicito. Nessun agente legge l’ultimo output disponibile di un altro agente. Riceve solo messaggi inviati deliberatamente a lui.

In LangGraph, questo significa usare `Annotated[list[Message], operator.add]` come canale primario di coordinamento, insieme a un nodo supervisor che legge la coda dei messaggi e instrada in base al tipo e al contenuto del messaggio, non in base a campi globali di stato.

La differenza pratica è semplice: ogni agente produce un `Message` con `to`, `from_`, `type` e `payload` espliciti. Il supervisor legge la coda, invia i messaggi all’agente corretto e l’agente elabora la propria inbox. Lo stato esiste ancora — la lista `messages` è globale — ma l’ownership è esplicita. Nessun agente scrive nei “propri” campi; aggiunge solo elementi al canale condiviso.

```python
from typing import Annotated
import operator

class Message(TypedDict):
    id: str
    from_: str
    to: str
    type: Literal["request", "result", "error", "status"]
    payload: dict

class AgentState(TypedDict):
    messages: Annotated[list[Message], operator.add]  # append-only
    session_id: str
    completed_agents: Annotated[set[str], lambda a, b: a | b]
```

Cosa cambia: il researcher non scrive in `research_results`. Invia `Message(from_="researcher", to="writer", type="result", payload={"findings": ...})`. Il nodo writer filtra `state["messages"]` per i messaggi indirizzati a lui ed elabora la propria inbox. Non esiste un campo condiviso su cui entrambi gli agenti scrivono; esiste solo un canale a cui entrambi fanno append.

## 3. Il pattern supervisor e quando giustifica il costo

Un agent supervisor è un router con memoria. Riceve tutti i messaggi, decide quale agente eseguire dopo e mantiene lo stato globale del task. Può aggiungere una chiamata al modello a ogni step di routing; in una pipeline da 10 step, se ogni step passa dal supervisor, può quindi aggiungere fino a 10 chiamate LLM.

Il costo è reale. La domanda è cosa ottieni in cambio.

Senza supervisor: aggiungere un nuovo agente richiede aggiornare ogni agente che potrebbe fare handoff verso di lui. Cambiare la logica di routing richiede aggiornare le edge function. Fare debug del motivo per cui un task si è bloccato significa leggere manualmente la coda dei messaggi.

Con supervisor: la logica di routing è centralizzata. Aggiungere un nuovo agente significa aggiungerlo alla tool list del supervisor. La decisione di routing del supervisor su “chi gestisce questo dopo” è ispezionabile. Fare debug di un task bloccato significa guardare l’ultima decisione del supervisor.

Come regola pratica basata sulla mia esperienza, il punto di pareggio operativo compare spesso intorno a tre agenti. Sotto i tre, handoff peer-to-peer con edge condizionali tendono a essere più economici e più semplici. Sopra i tre agenti, in molti casi, il supervisor può iniziare a ripagarsi — soprattutto se l’insieme degli agenti cambia nel tempo.

L’anti-pattern: usare il supervisor come un router if-else con una chiamata LLM in più, con regole esplicite come “se l’utente menziona Python, instrada al coder”. Un supervisor aggiunge valore quando deve valutare lo stato del task su più step, non quando fa pattern matching su un singolo campo. Se la logica di routing finisce in uno switch statement, rimuoverlo e usare edge condizionali è spesso più semplice.

## 4. Governance dei tool nei sistemi multi-agent

Quando passi da un agente a molti, l’accesso ai tool diventa un problema di correttezza, non solo di sicurezza. Un agente con accesso a tool non previsti per il suo ruolo può usarli — soprattutto se il suo contesto include istruzioni provenienti da un altro agente che glielo suggeriscono.

Il principio: l’accesso ai tool deve corrispondere al ruolo dell’agente, non alle capacità generali del modello. Il researcher agent non dovrebbe avere accesso in scrittura al file system anche se il modello sottostante potrebbe generare comandi validi di scrittura file. Il writer non dovrebbe avere tool di ricerca anche se potrebbe usarli per “verificare” il proprio output — quello è compito del researcher.

```python
# Researcher: read-only tools
researcher_agent = create_react_agent(
    llm,
    tools=[search_web, fetch_url, read_document],
    prompt="You retrieve and synthesize information. Do not write or modify files."
)

# Writer: write tools only, no search
writer_agent = create_react_agent(
    llm,
    tools=[write_draft, format_output],
    prompt="You write and format content based on researcher findings. Do not search."
)

# Supervisor: routing tools only, no domain tools
supervisor_agent = create_react_agent(
    llm,
    tools=[route_to_researcher, route_to_writer, mark_complete],
    prompt="You coordinate the research and writing workflow."
)
```

Non si tratta principalmente di sicurezza. Si tratta di limitare lo spazio d’azione dell’agente, cioè la sua action space, a ciò che è appropriato per il ruolo. Meno azioni disponibili significa meno opzioni non pertinenti e decisioni più semplici da verificare. Il researcher non può scrivere accidentalmente un file; il writer non può fare ricerca invece di scrivere; il supervisor non può eseguire azioni di dominio che dovrebbero passare da un worker.

## 5. Rilevamento dei fallimenti e recovery

I sistemi a stato condiviso tendono a fallire in modo silenzioso. I sistemi basati su message bus rendono il fallimento più esplicito: un messaggio arriva oppure no, e puoi ispezionare la coda per vedere quale delle due cose è successa.

La primitiva di recovery è diretta: se un agente non risponde entro un timeout, il supervisor può reinstradare la richiesta o fare escalation a HITL. Per farlo, ogni messaggio di richiesta deve avere un `id` e il supervisor deve mantenere un registro delle richieste in sospeso.

```python
import time

class SupervisorState(TypedDict):
    messages: Annotated[list[Message], operator.add]
    outstanding: dict[str, float]  # message_id -> sent_at timestamp

def supervisor_node(state: SupervisorState) -> dict:
    now = time.time()
    for msg_id, sent_at in list(state["outstanding"].items()):
        if now - sent_at > 30:  # 30-second timeout
            return {
                "messages": [Message(
                    id=new_id(),
                    from_="supervisor",
                    to="hitl",
                    type="request",
                    payload={"reason": f"Agent timeout on message {msg_id}"}
                )]
            }
    pending = [m for m in state["messages"] if m["to"] == "supervisor" and m["type"] == "result"]
    # route based on pending results ...
```

Questo pattern — registro delle request in sospeso con timeout ed escalation — è l’equivalente agentico di un circuit breaker. Sostituisce “attendere indefinitamente un agente bloccato” con “rilevare l’agente bloccato e instradare verso la recovery”. Senza questo controllo, un singolo agente lento o in errore può bloccare l’intera pipeline senza un segnale chiaro.

## 6. Pattern di coordinamento per dimensione del sistema

Il modello di coordinamento corretto dipende dal numero di agenti e da come interagiscono. Nella mia esperienza, queste soglie sono euristiche utili, non regole generali:

**2 agenti:** Handoff diretto. L’Agent A viene eseguito, scrive l’output in un campo nominato con un reducer chiaro, l’Agent B legge quel campo. Non serve un supervisor. Un edge condizionale instrada da A a B in base allo stato dell’output di A. Questo è spesso il design più semplice e sufficiente; non aggiungere complessità che non serve.

**3–5 agenti:** Supervisor con message bus. Il supervisor instrada tra agenti; gli agenti comunicano tramite il canale di messaggi append-only, non tramite campi nominati. Ogni agente ha un filtro inbox; il supervisor garantisce che il messaggio giusto raggiunga l’agente giusto. Come regola pratica, questa è spesso la fascia in cui incontro sistemi multi-agent che devono restare controllabili in produzione.

**5+ agenti o insiemi dinamici:** Supervisor gerarchico. Un supervisor di livello superiore delega a sub-supervisor, ognuno dei quali gestisce un team di agenti specializzati. Il supervisor di livello superiore non parla direttamente con gli agenti di dominio: parla solo con i sub-supervisor, che instradano verso i propri agenti. Questo aggiunge overhead di coordinamento, ma rende il sistema più modulare: ogni sub-supervisor può essere sviluppato e testato indipendentemente.

Il passaggio tra questi livelli non è arbitrario, ma non è nemmeno una soglia rigida. Dipende da quando compaiono le modalità di fallimento. Con 2 agenti, i conflitti di scrittura sono più gestibili perché ci sono solo due writer. Intorno a 5 o più agenti, nella mia esperienza, diventano più difficili da evitare senza canali append-only. Quando un singolo supervisor diventa un collo di bottiglia di routing, considero la delega gerarchica.

## Il punto pratico

Lo stato condiviso è il modello corretto per un singolo agente con uno schema chiaro. Diventa rischioso per un team di agenti con accesso in scrittura sovrapposto. Le modalità di fallimento — conflitti di scrittura, letture obsolete, stato fantasma da checkpoint — non sono edge case. Sono proprietà del modello di coordinamento che emergono quando aggiungi parallelismo.

Il message-passing con supervisor non è architettonicamente puro. È un trade-off pragmatico. Il debugging è esplicito: ispezioni la coda dei messaggi. La recovery è trattabile: rimetti in coda i messaggi in timeout. La crescita è additiva: aggiungi un agente alla tool list del supervisor. Le chiamate LLM aggiuntive possono valere il costo quando comprano routing centralizzato, stato persistente più controllabile e recovery deterministica.

Se stai scalando un sistema multi-agent e incontri corruzione dello stato, instabilità di routing o bug di checkpoint replay, [scrivimi](#contact). Sono problemi che si possono mitigare con pattern di coordinamento noti.

## FAQ

### Quando lo stato condiviso diventa fragile in un sistema multi-agent?

Considero lo stato condiviso adeguato per un singolo agente con uno schema chiaro, ma fragile quando più agenti hanno accesso in scrittura sovrapposto. Con più coordinamento e parallelismo emergono conflitti di scrittura, letture obsolete e stato fantasma da checkpoint.

### Quali errori produce una blackboard condivisa tra agenti?

Le modalità principali sono tre: l’ultima scrittura può sovrascrivere output validi, un agente può leggere stato non più aggiornato rispetto alla direzione corrente del task, e un replay da checkpoint può ripristinare una versione coerente col checkpoint ma non con lo stato reale del sistema.

### Come cambia il coordinamento con un message bus?

Invece di far leggere e scrivere tutti nello stesso oggetto, faccio inviare messaggi tipizzati su un canale esplicito. Ogni messaggio indica destinatario, mittente, tipo e payload. Lo stato globale resta, ma gli agenti fanno append alla coda e filtrano la propria inbox.

### Quando ha senso introdurre un supervisor?

Lo introduco quando il costo di routing centralizzato è giustificato da debug e controllo migliori. Come euristica basata sulla mia esperienza, sotto i tre agenti handoff peer-to-peer ed edge condizionali sono spesso più semplici. Sopra i tre, il supervisor può ripagarsi, soprattutto se l’insieme degli agenti cambia nel tempo.

### Come gestisco timeout e recovery in un sistema a messaggi?

Uso request con id e un registro delle request in sospeso. Se un agente non risponde entro un timeout, il supervisor può reinstradare la request o fare escalation a HITL. In questo modo sostituisco l’attesa indefinita con un rilevamento esplicito del blocco.
