Matteo
Vincenti
Menu

Agenti con stato: ripartire dopo un crash senza duplicare le azioni

Una prova Docker con LangGraph mostra cosa riparte dopo un crash e come evitare ticket duplicati con checkpoint e chiavi di idempotenza.

Un ticket conservato in una vaschetta, accanto a un erogatore verde con chiave e binario di ritorno interrotto.

Un agente ha appena aperto un ticket, poi il processo si interrompe. Il ticket esiste, ma l'agente non ha ancora salvato l'esito del passaggio. Al riavvio vede un'attività incompleta e la esegue di nuovo. Ora i ticket sono due.

È il punto da cui partirei per valutare un workflow con stato. Recuperare il lavoro serve; capire quali azioni verranno ripetute serve ancora di più. Un checkpoint permette di ritrovare lo stato del processo. L'API che riceve una richiesta deve anche riconoscere quando stiamo chiedendo di completare la stessa operazione.

Ho preparato un laboratorio Docker che rende visibile questa differenza. Il processo viene terminato subito dopo la risposta dell'API fittizia e prima che il nodo abbia restituito il risultato. Prima si ottengono due ticket; aggiungendo un'identità stabile all'operazione, la stessa ripresa restituisce il ticket già creato. Nessun modello a pagamento: qui interessa ciò che succede intorno alla decisione dell'agente.

Che cosa intendiamo per stato e grafo

Un workflow è una procedura composta da passaggi. Lo stato contiene i dati che quei passaggi si scambiano: l'operazione richiesta, i risultati disponibili e le informazioni necessarie per proseguire. Per aprire un ticket possono bastare il titolo, un identificatore dell'operazione e, dopo la creazione, l'identificatore restituito dal servizio.

Un grafo rende esplicito il percorso. I nodi sono funzioni che svolgono un passaggio; gli archi indicano quale funzione eseguire dopo. Un arco condizionale può mandare una proposta all'approvazione, un errore alla gestione del problema o un risultato incompleto a un nuovo tentativo. È questo il senso di graph engineering qui: progettare i passaggi e le transizioni di un processo. Non stiamo parlando di knowledge graph o delle rappresentazioni interne del modello.

Nel laboratorio il percorso è volutamente lineare:

txt
START -> prepare -> create_ticket -> END

prepare stabilisce la chiave dell'operazione; create_ticket chiama il servizio. Uso LangGraph per ispezionare lo stato e la ripresa di un nodo, senza aggiungere rami che nasconderebbero il difetto. Due funzioni in sequenza sarebbero sufficienti per aprire un ticket: il valore del grafo emerge quando i percorsi, le attese e i ritorni diventano parte del problema.

Un nodo può contenere codice normale, una chiamata a un modello o entrambe le cose. La Graph API di LangGraph definisce proprio questi elementi. La distinzione tra framework di orchestrazione e gli altri componenti è approfondita nella mappa di LangChain, LangGraph, OpenRouter e Langfuse.

Il checkpoint arriva dopo il passaggio

Un checkpoint è una fotografia persistita dello stato di esecuzione. In LangGraph lo selezioniamo attraverso un thread_id: riutilizzare quello originale permette di ritrovare il processo; cambiarlo identifica un altro thread.

Il checkpointer del laboratorio scrive su SQLite. Il file sta in un volume Docker, quindi sopravvive alla sostituzione del container. La modalità durability="sync" salva il checkpoint prima di passare al passo successivo. La documentazione dei checkpointer distingue questa modalità da async, che scrive mentre il passo successivo procede, e da exit, che salva lo stato all'uscita gestita del grafo, senza conservare gli stati intermedi necessari a recuperare un crash del processo.

Il punto critico è dentro create_ticket:

py
result = call("/tickets", {"title": state["title"]}, key)
emit("effect_ack", **result)
if args.crash:
    os.kill(os.getpid(), signal.SIGKILL)
return {"ticket_id": result["ticket_id"]}

Il servizio ha già confermato il ticket. Il nodo, invece, muore prima del return. Il checkpoint precedente dice ancora che il prossimo nodo è create_ticket. Alla ripresa viene richiamata la funzione dall'inizio, compresa la richiesta HTTP.

Spezzare il lavoro in nodi più piccoli riduce quanto dobbiamo ripetere, ma rimane questo intervallo fra due sistemi: l'azione è stata registrata dal destinatario e il chiamante non ne ha ancora salvato il risultato. Anche un task con risultato persistito deve affrontare il caso in cui l'effetto avviene prima del salvataggio di quel risultato.

Preparare il laboratorio

Servono Docker Engine e Docker Compose funzionanti, un terminale e accesso a internet per costruire l'immagine. Non occorrono Python installato sul computer, API key o account di un servizio AI. La prova è stata eseguita su Linux ARM64 in Docker Desktop, con Python 3.13.17, LangGraph 1.2.17 e langgraph-checkpoint-sqlite 3.1.2.

Crea una cartella vuota e apri lì il terminale:

bash
mkdir stateful-agent-lab
cd stateful-agent-lab
docker version
docker compose version

Copia nella cartella i cinque file completi riportati in fondo all'articolo. Questi li scrivi tu; Docker creerà l'immagine, la rete e i due volumi:

txt
stateful-agent-lab/
  Dockerfile
  requirements.txt
  compose.yaml
  api.py
  workflow.py

api è un ricevitore fittizio che registra ticket nel proprio database. workflow esegue i nodi e usa un database separato per i checkpoint. La rete è interna a Docker e non espone porte sul computer. Ogni comando seguente va eseguito da questa stessa cartella.

bash
docker compose -p stateful-lab up -d --build api
docker compose -p stateful-lab run --rm workflow tickets

La prima lista deve essere vuota. Se stai ripetendo una prova precedente e vuoi ripartire da zero, il comando di pulizia finale rimuove anche i dati del laboratorio.

Prova 1: lo stato si recupera, il ticket si duplica

Avvia il percorso senza idempotenza:

bash
docker compose -p stateful-lab run --rm workflow start --thread naive --operation case-naive:open-ticket:v1 --mode naive --crash

Il comando termina con codice 137, dopo effect_ack e crash_before_node_return. È l'interruzione che abbiamo richiesto. Nel Compose, init: true fa eseguire Python come processo figlio: è necessario per ottenere questa terminazione controllata, invece di affidarsi al comportamento speciale del PID 1 nel container.

Ora osserva entrambi i sistemi:

bash
docker compose -p stateful-lab run --rm workflow state --thread naive
docker compose -p stateful-lab run --rm workflow tickets

Lo stato mostra next: ["create_ticket"] e non contiene ticket_id. Il servizio elenca già il ticket 1. Il dato interessante è proprio la distanza fra queste due risposte.

Riprendi lo stesso thread:

bash
docker compose -p stateful-lab run --rm workflow resume --thread naive
docker compose -p stateful-lab run --rm workflow tickets

Il nodo riparte, il servizio crea il ticket 2. prepare non viene rieseguito; l'azione dentro il nodo incompleto sì. Il recupero del checkpoint ha funzionato, ma la seconda richiesta HTTP rappresentava una nuova creazione per il destinatario.

Prova 2: una sola operazione, anche dopo il riavvio

La correzione usa una chiave di idempotenza: un identificatore che resta uguale nei tentativi della stessa operazione. Il destinatario associa atomicamente chiave, contenuto della richiesta e ticket creato. Una richiesta ripetuta restituisce lo stesso ticket.

La chiave case-safe:open-ticket:v1 identifica un'intenzione precisa. Viene salvata nell'input del workflow e inviata nell'header Idempotency-Key. Generare un nuovo UUID dentro il nodo a ogni tentativo annullerebbe questo accordo. Anche usare solo il titolo sarebbe una scelta sbagliata: due richieste legittime possono avere lo stesso testo. È il motivo per cui la Builders' Library di AWS distingue l'identità della richiesta dal semplice confronto dei parametri.

bash
docker compose -p stateful-lab run --rm workflow start --thread safe --operation case-safe:open-ticket:v1 --mode safe --crash
docker compose -p stateful-lab run --rm workflow state --thread safe
docker compose -p stateful-lab run --rm workflow tickets

Dopo l'uscita 137, il checkpoint indica ancora create_ticket; il servizio contiene il nuovo ticket 3. Riavvia anche il destinatario, poi riprendi:

bash
docker compose -p stateful-lab restart api
docker compose -p stateful-lab run --rm workflow resume --thread safe
docker compose -p stateful-lab run --rm workflow state --thread safe
docker compose -p stateful-lab run --rm workflow tickets

La risposta dell'azione è created: false, ticket_id: 3. Il nodo può finalmente salvare quel risultato; next diventa una lista vuota. Nel servizio rimangono tre ticket totali: due della prova precedente e uno di questa.

Nel ricevitore il vincolo UNIQUE sulla chiave e una transazione BEGIN IMMEDIATE proteggono la verifica e la creazione. Se scrivessimo soltanto un log locale dopo la risposta HTTP, un crash fra la risposta e il log riproporrebbe lo stesso problema.

I controlli che distinguono un retry da una nuova richiesta

Con la sola prova positiva potremmo costruire un servizio che rifiuta ogni richiesta successiva. Aggiungiamo due controlli:

bash
docker compose -p stateful-lab run --rm workflow post --operation case-safe:open-ticket:v1 --title "Different payload"
docker compose -p stateful-lab run --rm workflow post --operation case-new:open-ticket:v1

Il primo restituisce HTTP 409, con uscita 1: la chiave esiste ma il contenuto è diverso. Il secondo crea il ticket 4, perché identifica un'altra operazione. Il titolo può essere identico a quello precedente senza impedire una nuova creazione.

Puoi anche invocare nuovamente resume --thread safe: il workflow è già terminato e non compare un altro effect_ack.

I controlli che distinguono un retry da una nuova richiesta
PassaggioNodo ancora da eseguireTicket della singola operazione
Crash senza chiavecreate_ticket1
Ripresa senza chiavenessuno2
Crash con chiavecreate_ticket1
Riavvio del ricevitore e ripresa con chiavenessuno1

Questi sono gli esiti osservati nel laboratorio. La deduplicazione dipende dal contratto implementato nel ricevitore e dalla conservazione del suo database. Per un'API che non offre idempotenza serve una verifica dell'operazione tramite un identificatore consultabile oppure una gestione esplicita dell'esito incerto, prima di ritentare. Aggiungere un registro nel chiamante, da solo, non rende atomica una scrittura su un altro sistema.

Ripresa, replay e approvazione hanno conseguenze diverse

La prova recupera l'ultimo stato disponibile. Il replay da un checkpoint storico chiede invece di eseguire nuovamente i nodi successivi a quel punto; può ripetere chiamate API e chiamate al modello. Anche la cronologia richiede quindi una decisione sugli effetti che vogliamo riprodurre. In grafi con nodi paralleli, LangGraph può recuperare le scritture già salvate dei nodi completati; il nostro nodo è morto prima di completare quella scrittura.

Un'approvazione umana è un altro passaggio ancora. LangGraph offre interrupt e ripresa con Command(resume=...). L'approvazione deve riferirsi all'azione che la persona ha esaminato: cambiare destinatario o payload richiede di rivederla. Qui il riavvio usa lo stesso codice e gli stessi dati; migrazioni di workflow, retry concorrenti su più host e riconnessione degli stream restano fuori dalla prova.

Per un servizio reale definirei soprattutto quanto a lungo conservare le chiavi rispetto ai retry ammessi e come riconciliare un risultato incerto. Cancellare troppo presto la ricevuta dell'operazione riapre la possibilità di creare duplicati.

Quando sceglierei qualcosa di più semplice

Per leggere dati, produrre una proposta e restituirla subito può bastare una procedura lineare. Se il requisito è eseguire lavori indipendenti, ritentarli e distribuirli fra worker, una coda con stato nel database evita di modellare un grafo applicativo più ampio.

Il grafo diventa utile quando devo capire e controllare percorsi diversi: una proposta respinta torna alla revisione, una verifica fallita cambia strategia, una persona approva giorni dopo. Aiuta a esprimere queste transizioni; comporta anche uno schema di stato, regole per le migrazioni e manutenzione dei checkpoint.

Un orchestratore durevole come Temporal è un'alternativa per coordinare attività, attese e recovery. La sua documentazione sulle Activity mantiene la richiesta di idempotenza: un'attività può aver eseguito l'effetto e morire prima di registrarne il completamento.

Per scegliere partirei da una domanda molto concreta: dopo un'interruzione, quale operazione posso ripetere e quale devo prima cercare nel sistema destinatario? Il grafo viene dopo questa risposta.

Sorgenti completi e pulizia

I cinque file seguenti appartengono al laboratorio didattico, non a un servizio esposto al pubblico. L'immagine Python è fissata per digest, le due dipendenze dirette per versione. Le dipendenze transitive restano soggette alla risoluzione di pip.

Dopo le prove, questo comando elimina soltanto container, rete e volumi del progetto stateful-lab. Cancella quindi anche i ticket e i checkpoint creati qui:

bash
docker compose -p stateful-lab down -v

L'immagine locale stateful-agent-lab:local può essere rimossa con docker image rm stateful-agent-lab:local quando nessun altro laboratorio la usa. La base Python potrebbe essere condivisa con altri progetti.

Dockerfile

docker
FROM python:3.13-slim@sha256:57b29a7c050c4e679e4bea572e14b3965cf4b5839de2da49d55e49ffc4024f39
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 LANGGRAPH_STRICT_MSGPACK=true
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY api.py workflow.py ./
CMD ["python", "workflow.py", "--help"]

requirements.txt

txt
langgraph==1.2.17
langgraph-checkpoint-sqlite==3.1.2

compose.yaml

yaml
name: stateful-agent-lab
services:
  api:
    image: stateful-agent-lab:local
    build: .
    command: ["python", "api.py"]
    volumes:
      - receiver-data:/data
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)"]
      interval: 2s
      timeout: 3s
      retries: 15
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    networks: [lab]
  workflow:
    init: true
    image: stateful-agent-lab:local
    entrypoint: ["python", "workflow.py"]
    environment:
      API_URL: http://api:8000
    volumes:
      - checkpoint-data:/data
    depends_on:
      api:
        condition: service_healthy
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    networks: [lab]
volumes:
  receiver-data:
  checkpoint-data:
networks:
  lab:
    internal: true

api.py

py
"""Local teaching receiver. No external service calls or credentials."""
import json
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

DB = "/data/receiver.sqlite"
with sqlite3.connect(DB) as db:
    db.execute("""CREATE TABLE IF NOT EXISTS tickets (
        id INTEGER PRIMARY KEY,
        operation_key TEXT UNIQUE,
        payload TEXT NOT NULL
    )""")

class Handler(BaseHTTPRequestHandler):
    def respond(self, status, value):
        data = json.dumps(value, sort_keys=True).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        if self.path == "/health":
            return self.respond(200, {"ok": True})
        if self.path != "/tickets":
            return self.respond(404, {"error": "not found"})
        with sqlite3.connect(DB) as db:
            rows = db.execute(
                "SELECT id, operation_key, payload FROM tickets ORDER BY id"
            ).fetchall()
        return self.respond(200, {"tickets": [
            {"ticket_id": row[0], "key": row[1],
             "payload": json.loads(row[2])} for row in rows
        ]})

    def do_POST(self):
        if self.path != "/tickets":
            return self.respond(404, {"error": "not found"})
        try:
            length = int(self.headers.get("Content-Length", "0"))
            if not 0 < length <= 4096:
                return self.respond(400, {"error": "invalid size"})
            payload = json.loads(self.rfile.read(length))
            if (not isinstance(payload, dict)
                or set(payload) != {"title"}
                or not isinstance(payload["title"], str)
                or not 1 <= len(payload["title"]) <= 200):
                return self.respond(400, {"error": "invalid payload"})
            canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
            key = self.headers.get("Idempotency-Key")
            if key is not None and not 1 <= len(key) <= 200:
                return self.respond(400, {"error": "invalid key"})
            with sqlite3.connect(DB, timeout=10) as db:
                db.execute("BEGIN IMMEDIATE")
                prior = (db.execute(
                    "SELECT id, payload FROM tickets WHERE operation_key = ?",
                    (key,)
                ).fetchone() if key else None)
                if prior:
                    if prior[1] != canonical:
                        return self.respond(409, {"error": "key reused with different payload"})
                    ticket_id, created = prior[0], False
                else:
                    cursor = db.execute(
                        "INSERT INTO tickets(operation_key, payload) VALUES (?, ?)",
                        (key, canonical)
                    )
                    ticket_id, created = cursor.lastrowid, True
            # The database transaction commits BEFORE the HTTP response.
            return self.respond(201 if created else 200, {
                "ticket_id": ticket_id, "created": created
            })
        except (ValueError, json.JSONDecodeError):
            return self.respond(400, {"error": "invalid JSON"})

ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

workflow.py

py
"""Reproducible checkpoint/effect gap, without an LLM or paid API."""
import argparse
import json
import os
import signal
import urllib.error
import urllib.request
from typing import NotRequired, TypedDict

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph

API = os.environ.get("API_URL", "http://api:8000")

def emit(event, **values):
    print(json.dumps({"event": event, **values}, sort_keys=True), flush=True)

def call(path, payload=None, key=None):
    headers = {"Content-Type": "application/json"}
    if key is not None:
        headers["Idempotency-Key"] = key
    data = None if payload is None else json.dumps(payload).encode()
    request = urllib.request.Request(API + path, data=data, headers=headers)
    with urllib.request.urlopen(request, timeout=5) as response:
        return json.load(response)

class State(TypedDict):
    operation_id: str
    mode: str
    title: str
    key: NotRequired[str]
    ticket_id: NotRequired[int]

parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["start", "resume", "state", "tickets", "post"])
parser.add_argument("--thread", default="demo")
parser.add_argument("--operation", default="case-001:open-ticket:v1")
parser.add_argument("--mode", choices=["naive", "safe"], default="safe")
parser.add_argument("--title", default="Check supplier configuration")
parser.add_argument("--crash", action="store_true")
args = parser.parse_args()

if args.action == "tickets":
    emit("tickets", **call("/tickets"))
elif args.action == "post":
    try:
        emit("post", **call("/tickets", {"title": args.title}, args.operation))
    except urllib.error.HTTPError as error:
        emit("http_error", status=error.code, body=json.loads(error.read()))
        raise SystemExit(1)
else:
    def prepare(state: State):
        emit("prepare", operation=state["operation_id"])
        return {"key": state["operation_id"]}

    def create_ticket(state: State):
        key = state["key"] if state["mode"] == "safe" else None
        result = call("/tickets", {"title": state["title"]}, key)
        emit("effect_ack", **result)
        if args.crash:
            emit("crash_before_node_return")
            os.kill(os.getpid(), signal.SIGKILL)
        return {"ticket_id": result["ticket_id"]}

    builder = StateGraph(State)
    builder.add_node("prepare", prepare)
    builder.add_node("create_ticket", create_ticket)
    builder.add_edge(START, "prepare")
    builder.add_edge("prepare", "create_ticket")
    builder.add_edge("create_ticket", END)
    config = {"configurable": {"thread_id": args.thread}}
    with SqliteSaver.from_conn_string("/data/checkpoints.sqlite") as saver:
        graph = builder.compile(checkpointer=saver)
        saved = graph.get_state(config)
        if args.action == "state":
            emit("state", values=saved.values, next=list(saved.next))
        elif args.action == "start":
            if saved.values:
                parser.error("thread already exists; resume it or choose a new thread")
            result = graph.invoke({
                "operation_id": args.operation, "mode": args.mode,
                "title": args.title
            }, config, durability="sync")
            emit("done", **result)
        else:
            if not saved.values:
                parser.error("thread not found; use the original thread id")
            result = graph.invoke(None, config, durability="sync")
            emit("done", **result)

Condividi l’articolo

Hai una domanda o un’osservazione sull’articolo?

Scrivimi su LinkedIn
Tutti gli articoli