Un agent sans persistance est un agent jetable. Dès que le processus s’arrête — crash, redéploiement, simple fin de requête HTTP — tout son état disparaît. Pour un démonstrateur, peu importe. Pour un système qui doit reprendre une conversation, attendre la validation d’un humain pendant trois heures, ou se remettre d’une panne sans rejouer dix appels LLM facturés, c’est rédhibitoire.
Le checkpointer de LangGraph répond à tout cela avec une seule idée : après chaque étape du graphe, on sauvegarde l’état complet. Cette sauvegarde débloque quatre capacités qui semblent distinctes mais partagent le même mécanisme — mémoire conversationnelle, reprise sur panne, retour en arrière (time-travel) et human-in-the-loop.
Le modèle mental : super-steps et checkpoints
LangGraph exécute le graphe par super-steps : à chaque tour, l’ensemble des nœuds prêts s’exécute, l’état est mis à jour via les reducers, puis un checkpoint est écrit. Un checkpoint contient l’état du graphe, le prochain nœud à exécuter, et les éventuelles tâches en attente.
Ces checkpoints sont regroupés par thread (thread_id). Un thread est une
ligne de vie : une suite ordonnée de checkpoints qui constitue l’historique
complet d’une exécution. Deux conversations distinctes = deux threads = deux
historiques isolés.
from langgraph.checkpoint.memory import InMemorySaver
agent = graphe.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "conversation-42"}}
agent.invoke({"messages": [msg_utilisateur]}, config)
Le thread_id passé dans config est la clé de voûte : c’est lui qui dit au
checkpointer où ranger et où relire l’état. L’oublier, c’est repartir d’une
ardoise vierge à chaque appel.
Mémoire court-terme : la conversation qui persiste
La première conséquence, presque gratuite, est la mémoire conversationnelle.
Relancez l’agent avec le même thread_id : le checkpointer recharge
l’historique avant d’exécuter le moindre nœud.
agent.invoke({"messages": [{"role": "user", "content": "Je m'appelle Léa."}]}, config)
# Plus tard, même thread_id :
rep = agent.invoke({"messages": [{"role": "user", "content": "Quel est mon prénom ?"}]}, config)
# → "Léa" : l'historique a été rechargé automatiquement.
On ne renvoie que le nouveau message : le reducer add_messages le fusionne
avec l’historique persisté. C’est la mémoire court-terme — celle d’un thread.
La mémoire long-terme, qui traverse les threads, relève du Store et fera
l’objet d’un autre deep-dive.
Passer à un backend durable
En production, on remplace InMemorySaver par un checkpointer Postgres. L’API du
graphe ne change pas d’une ligne — seule l’instanciation diffère.
from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://user:pass@localhost:5432/agents?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup() # crée les tables au premier lancement
agent = graphe.compile(checkpointer=checkpointer)
agent.invoke({"messages": [msg]}, config)
Human-in-the-loop : suspendre avant d’agir
Voici la capacité qui justifie à elle seule la persistance. Un agent qui peut envoyer un e-mail, émettre un remboursement ou supprimer des données ne doit pas le faire sans garde-fou. On veut suspendre l’exécution, montrer à un humain ce que l’agent s’apprête à faire, et ne reprendre qu’après son feu vert.
La fonction interrupt() fait exactement cela : appelée dans un nœud, elle gèle
le graphe, persiste l’état et rend la main à l’appelant.
from langgraph.types import interrupt, Command
def validation_humaine(etat: Etat) -> dict:
action = etat["action_proposee"]
# Gèle l'exécution et expose la proposition à l'humain.
decision = interrupt({
"type": "validation",
"action": action,
"question": "Confirmer cette action ?",
})
if decision == "approuver":
return {"messages": [executer(action)]}
return {"messages": [{"role": "assistant", "content": "Action annulée."}]}
Côté appelant, l’interruption se détecte dans le résultat, puis se lève avec un
Command(resume=...) :
resultat = agent.invoke({"messages": [msg]}, config)
if "__interrupt__" in resultat:
# … on présente resultat["__interrupt__"] à un humain, on attend sa réponse …
agent.invoke(Command(resume="approuver"), config) # reprise exacte
Comme l’état interrompu est persisté, l’humain peut répondre une minute ou un
jour plus tard, depuis un autre processus. Le HITL n’est pas un mode bloquant en
mémoire : c’est un état gelé sur disque qu’on réveille avec le bon thread_id.
Inspecter et réécrire l’état
La persistance rend l’agent introspectable. À tout moment, on peut lire l’état courant d’un thread :
snapshot = agent.get_state(config)
print(snapshot.values["messages"]) # l'état complet
print(snapshot.next) # le(s) prochain(s) nœud(s) à exécuter
On peut même corriger l’état avant de reprendre — par exemple injecter une réponse d’outil rectifiée par un opérateur :
agent.update_state(config, {"messages": [correction]})
agent.invoke(None, config) # reprend avec l'état corrigé
invoke(None, …) signifie « ne fournis pas de nouvelle entrée, reprends depuis
le checkpoint ». C’est le geste de base du time-travel et de la reprise sur panne.
Time-travel : rembobiner le graphe
Chaque checkpoint a un identifiant. En relisant l’historique d’un thread, on peut repartir d’un point antérieur — pour déboguer, ou pour explorer une autre branche de décision.
historique = list(agent.get_state_history(config))
# Reprendre depuis un checkpoint précis :
config_passe = historique[2].config
agent.invoke(None, config_passe)
Reprendre depuis un ancien checkpoint fork l’exécution : on rejoue la suite à partir de cet instant, sans détruire la ligne d’origine. Précieux pour comparer deux trajectoires d’un même agent face à la même situation.
Penser la persistance dès la conception
Bonnes pratiques & pièges
- Mappez le thread_id sur un identifiant métier (ticket, session, user), pas un UUID anonyme.
- Backend durable (Postgres ou SQLite) en production ; InMemorySaver réservé aux tests.
- Placez tout effet de bord irréversible APRÈS la barrière interrupt().
- Surveillez le checkpointer comme votre base principale : sa panne rend l'agent indisponible.
- Réutilisez un pool de connexions ; n'appelez setup() qu'une fois, en migration.
- Prévoyez une stratégie de fenêtre de contexte (résumé, troncature) pour les longs threads.
- InMemorySaver en production : tout l'historique s'évapore au moindre redémarrage.
- Un effet de bord placé avant interrupt() : il sera rejoué à la reprise (double envoi).
- Oublier le thread_id : l'agent repart d'une ardoise vierge à chaque appel.
- Croire que resume reprend « à la ligne » : c'est le nœud entier qui est ré-exécuté.
- Laisser l'historique grossir sans limite : coût et latence dérivent silencieusement.
Ce qu’il faut retenir
- Le checkpointer persiste l’état à chaque super-step, indexé par thread.
- Cette unique primitive débloque mémoire, reprise sur panne, time-travel et HITL.
interrupt()+Command(resume=…)réalisent un human-in-the-loop non bloquant, parce que l’état attend sur disque, pas en RAM.- En production : backend durable (Postgres), effets de bord après validation, et supervision du checkpointer.
La persistance vous donne un agent fiable. Reste à le rendre observable : savoir, en production, pourquoi il a pris telle décision, ce qu’elle a coûté et si elle était bonne. C’est tout l’objet du cas pratique de support tracé avec LangFuse.