Principi
- Il comportamento sta nei dati, non nel flusso di controllo. Un agente è una descrizione (nome, scopo, prompt, modello, tool). Il grafo si costruisce da queste descrizioni. Aggiungere capacità al sistema non richiede di riscrivere l'orchestrazione.
- Una sola decisione algoritmica: quando fermarsi. Il resto lo decide l'orchestratore a runtime. Le regole cablate sono limitate a limiti di sicurezza (turni massimi, timeout, fallback su errore di routing) e al passo fisso di classificazione in ingresso.
- Indipendenza dalle piattaforme. LangGraph è una libreria, non un servizio. Il tracing è nostro, i dati sono in un Postgres che controlli. LangSmith si può attivare, ma non serve.
- Degradazione graziosa. Senza database il sistema resta usabile in memoria volatile; senza prezzi in catalogo i costi sono zero, non un errore.
Mappa dei moduli
┌───────────────────────────┐
HTTP ──────────────▶│ app/api (FastAPI) │
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ app/services/chat.py │ orchestrazione di un turno
└──┬────────┬────────────┬──┘
│ │ │
┌────────────▼──┐ ┌──▼─────────┐ ┌▼───────────────────┐
│ app/memory │ │ app/graph │ │ app/observability │
│ contesto e │ │ orchestr. │ │ tracer, costi, │
│ persistenza │ │ e agenti │ │ analisi │
└───────┬───────┘ └──────┬─────┘ └─────────┬──────────┘
│ │ │
│ ┌────────▼────────┐ │
│ │ app/agents │ │
│ │ app/tools │ │
│ │ app/llm │ │
│ └────────┬────────┘ │
│ │ │
└────────┬────────┴─────────────────┘
▼
app/db (Postgres / Supabase)
| Modulo | Responsabilità | Non fa |
|---|---|---|
app/core | configurazione, logging | logica applicativa |
app/llm | catalogo modelli, client Bedrock, prezzi | prompt |
app/tools | registro dei tool | decidere chi li usa |
app/agents | definizioni e compilazione degli agenti | routing |
app/graph | stato, classifier, orchestratore, subgraph, topologia | persistenza |
app/memory | checkpoint, thread, memoria a lungo termine, riassunti | tracing |
app/observability | span, token, costi, aggregazioni | esecuzione |
app/services | mette insieme i pezzi per un turno di chat | dettagli HTTP |
app/api | trasporto HTTP e schemi | logica |
La direzione delle dipendenze è sempre verso il basso: api → services → {graph, memory, observability} → {agents, tools, llm} → core/db. Nessun modulo in basso
importa uno in alto.
Ciclo di vita di una richiesta
POST /api/chat con {"message": "...", "thread_id": "..."}.
- Thread —
memory.threads.ensure_threadcrea o recupera la conversazione. Al primo messaggio le assegna un titolo. - Contesto —
services.chat.build_contextrecupera il riassunto del thread e le memorie a lungo termine pertinenti al messaggio. Il risultato è un testo che verrà iniettato nei prompt di tutti gli agenti. - Run e tracer — si apre una riga in
runse si crea unRunTracer, passato a LangGraph come callback. - Esecuzione del grafo:
- il classifier etichetta intento, urgenza, categoria e tono;
- l'orchestratore decide se rispondere, quando inviare, goal, sollecito
e a chi delegare (
relationship,respond,ui_agent) oppure chiude; respondproduce la bozza e, se è una trattativa, chiama il sub-agentenegotiate;ui_agentè un grafo proprio che può fareinterrupt()in attesa di approvazione umana (POST /api/chat/resume);- ogni agente torna all'orchestratore finché questo risponde
FINISHo si raggiungeMAX_SUPERVISOR_TURNS.
- Persistenza — messaggio utente e risposta finiscono in
messages, la run viene chiusa con token, costo, durata e agenti coinvolti. - Riassunto — se la conversazione è cresciuta oltre la finestra, il riassunto si aggiorna in background, senza far attendere l'utente.
Esistono anche grafi dedicati, invocati da API proprie e tracciati con
graph distinto: sollecito (bozza a scalata) e onboarding
(intervista di setup cliente).
In streaming (POST /api/chat/stream) i passi sono gli stessi, ma gli eventi
start, node, token, final vengono emessi via SSE mentre il grafo lavora.
Lo stato del grafo
Definito in app/graph/state.py. È ciò che il checkpointer salva ad ogni passo.
| Campo | Significato |
|---|---|
messages | conversazione; il reducer add_messages fonde gli aggiornamenti |
context | riassunto e memorie iniettati all'avvio della run |
classification | esito del Classifier (intento, urgenza, categoria, tono) |
should_respond, send_at, reschedule_at | piano dell'orchestratore |
goal, next_nudge | obiettivo del thread e prossimo sollecito |
draft_response, negotiation_notes | bozza di Respond e brief di Negotiate |
relationship_context | brief prodotto dal Relationship agent |
approval_status, approved_text | esito del grafo UI |
next_agent, instruction | esito dell'ultima delega dell'orchestratore |
turn | numero di deleghe già effettuate (limite di sicurezza) |
route_history | tutte le decisioni prese, con la motivazione: è ciò che la UI mostra |
agents_used | agenti coinvolti nella run |
run_id, thread_id, user_id | identità, propagate a tracing e memoria |
stop_reason | finish, max_turns, no_response, scheduled, awaiting_approval, no_agents |
Scelta importante: nello stato condiviso finisce solo la conclusione di ogni agente, non i suoi passaggi intermedi. I ragionamenti e le chiamate ai tool restano nel tracing (dove servono per il debug) ma non gonfiano il contesto degli altri agenti, che pagherebbero token per informazioni che non useranno.
Perché Classifier fisso + stella sull'orchestratore
Il Classifier è un passo deterministico (output strutturato) che gira sempre: è il pattern custom workflow consigliato da LangGraph quando si mescolano step prevedibili e nodi agentici.
Dopo la classificazione, ogni agente è collegato solo all'orchestratore. Le alternative sarebbero:
- catena fissa (A → B → C): semplice ma rigida, ogni nuovo caso d'uso richiede una nuova catena;
- handoff diretti fra agenti: più espressivo ma difficile da osservare;
- stella con orchestratore: un solo punto in cui si decide se rispondere, quando inviare e a chi delegare.
Respond e UI agent sono subgraph (Negotiate è nidificato in Respond; UI
agent ha stato e nodi propri, con interrupt() per l'approvazione umana).
Modelli e costi
config/models.yaml è l'unico posto in cui compaiono gli identificativi Bedrock
e i prezzi. Il codice usa alias (claude-sonnet-4, nova-lite, ...). Cambiare
modello a un agente è una riga di YAML; cambiare regione o passare a un inference
profile diverso non tocca il codice.
I costi si calcolano dai token realmente riportati da Bedrock
(usage_metadata dei messaggi), non da stime: app/llm/pricing.py.
Sicurezza e limiti
| Rischio | Contromisura |
|---|---|
| Ciclo infinito orchestratore ↔ agente | MAX_SUPERVISOR_TURNS e recursion_limit |
| Agente bloccato in loop di tool | max_iterations per agente |
| Run che non termina | RUN_TIMEOUT_SECONDS |
| Routing verso un agente inesistente | output vincolato a un Literal dei nomi reali |
| Modello che scrive nella memoria di un altro utente | user_id letto dalla config della run, mai dai parametri del tool |
| Chiavi esposte al browser | il frontend parla solo con il backend; la service key non lascia mai il server |
| Tabelle leggibili da chiunque | schema liagraph non esposto dall'API REST, RLS attiva senza policy |