Python & AI Tutorials Logo
LangChain & LangGraph

17. Persistance de l'état et checkpointing

Au chapitre 15, nous avons reconstruit la boucle de l'agent sous forme de StateGraph, et au chapitre 16, nous avons utilisé des composants préconstruits ainsi qu'un routage multi-branches pour construire quelque chose de plus élaboré. Chaque graphe que nous avons écrit jusqu'à présent partage une même limitation : le graphe ne conserve pas son état.

Le graphe détient et gère l'état pendant la durée d'un unique appel à invoke(), et pas une seconde de plus. Lorsque vous l'appelez, LangGraph crée un état neuf, exécute les nœuds, fusionne chaque valeur de retour dans l'état selon les règles du reducer, puis renvoie l'état final à l'appelant. Une fois cet état transmis, le graphe ne s'en souvient plus. L'appel invoke() suivant démarre à partir d'un état tout neuf, sans aucun lien avec l'appel précédent.

Deux problèmes en découlent. Premièrement, messages fait partie de l'état lui aussi, donc l'agent ne peut se souvenir de rien de ce que vous avez dit auparavant. Deuxièmement, si une exécution échoue en cours de route, tout ce qu'elle avait accompli jusqu'à ce point disparaît. Supposons que le troisième nœud lève une exception : les résultats produits par les deux premiers nœuds partent avec elle, et vous devez tout recommencer depuis le début. Au chapitre 15.1, nous avions cité la « récupération après interruptions » comme l'une des raisons de recourir à LangGraph — c'est bien ce problème que nous avions en tête.

LangGraph gère cela au niveau du framework. Attachez un checkpointer à un graphe et LangGraph enregistrera automatiquement un instantané de l'état à chaque étape de l'exécution. Ces instantanés enregistrés survivent à l'appel invoke(), si bien que l'appel suivant peut reprendre là où le précédent s'est arrêté. Cette propriété — un état qui survit au-delà d'une seule exécution — s'appelle la persistance.

Vous avez en fait déjà utilisé un checkpointer. Au chapitre 11, lorsque nous avons doté l'agent RAG conversationnel d'une mémoire multi-tours, nous avons passé create_agent(..., checkpointer=InMemorySaver()) ainsi qu'un thread_id. À l'époque, tout ce que vous aviez besoin de savoir, c'est que le checkpointer conserve l'historique de conversation par thread_id ; nous n'avons jamais expliqué comment. Et au chapitre 16, lorsque nous avons présenté le paramètre checkpointer, nous avions dit « nous verrons comment cela fonctionne au chapitre 17 ». C'est ce chapitre-ci.

Il se compose de trois parties. En 17.1, nous attachons un checkpointer à un graphe et menons des conversations multi-tours à l'aide du thread_id. En 17.2, nous ouvrons les checkpoints enregistrés pour voir ce que l'agent savait à un instant donné — les checkpoints sont votre principal outil pour comprendre pourquoi un agent s'est mal comporté. En 17.3, nous prenons un graphe qui a échoué en cours d'exécution et le reprenons là où il s'est arrêté plutôt que depuis le début.

17.1) Transporter l'état d'un appel à l'autre

17.1.1) Un graphe qui oublie

Nous avons ouvert ce chapitre en disant qu'un graphe ne conserve pas son état. Vérifions-le dans le code.

Le graphe ci-dessous a la même forme que le graphe say_hello du chapitre 15.2. La seule différence est que le nœud renvoie une réponse du LLM au lieu d'une chaîne fixe.

python
from langgraph.graph import StateGraph, MessagesState, START, END
from langchain_openai import ChatOpenAI
 
model = ChatOpenAI(model="gpt-5-mini")
 
def llm_call(state: MessagesState):
    response = model.invoke(state["messages"])
    return {"messages": [response]}
 
builder = StateGraph(MessagesState)
builder.add_node(llm_call)
builder.add_edge(START, "llm_call")
builder.add_edge("llm_call", END)
 
graph = builder.compile()   # pas de checkpointer

Menons maintenant une conversation en deux tours. Nous indiquons notre nom au modèle lors du premier appel, puis lui demandons notre nom lors du second.

python
# Premier appel — nous donnons notre nom
graph.invoke({"messages": [{"role": "user", "content": "Salut, je m'appelle Bob."}]})
 
# Deuxième appel — nous le lui redemandons
result = graph.invoke({"messages": [{"role": "user", "content": "Comment je m'appelle ?"}]})
print(result["messages"][-1].content)

Sortie :

Désolé, mais je ne connais pas votre nom. Pourriez-vous me dire lequel c'est ?

Le seul message que le modèle a reçu lors du deuxième appel était "Comment je m'appelle ?". L'état du premier appel n'est plus dans le graphe, donc les messages précédents — ceux qui portaient le nom — ne sont jamais parvenus au modèle.

Nous pourrions bien sûr corriger cela nous-mêmes. Conserver les messages renvoyés par le premier appel et les transmettre avec le second. C'est exactement ainsi que nous gérions l'historique de conversation au chapitre 8. Mais nous devrions alors écrire notre propre code pour stocker et récupérer l'historique de chaque conversation et de chaque utilisateur. C'est ce travail qu'un checkpointer vous décharge.

17.1.2) Checkpoints et checkpointers

Un checkpointer est un objet dont le rôle est d'enregistrer l'état. Vous créez une instance — InMemorySaver(), par exemple — et la passez à builder.compile(checkpointer=...) pour l'attacher à votre graphe.

Une fois un checkpointer attaché, le graphe copie l'état entier et l'enregistre au fil de l'exécution. Chacune de ces copies enregistrées s'appelle un checkpoint. Voyez cela comme une photographie : l'état entier à cet instant, préservé exactement tel qu'il était.

La sauvegarde automatique dans un jeu vidéo est la bonne image mentale. Le jeu enregistre discrètement votre progression chaque fois que vous franchissez un point significatif, de sorte que vous pouvez quitter et revenir plus tard, ou mourir sans avoir à tout recommencer depuis le début. Un checkpointer fait exactement cela pour un graphe.

Alors, qu'est-ce qu'un « point significatif » ? LangGraph divise l'exécution d'un graphe en étapes, et chaque étape s'appelle un super-step. Un checkpoint est enregistré chaque fois qu'un super-step se termine.

La raison pour laquelle il s'agit d'un super-step et non d'une simple étape est qu'une seule étape peut exécuter plusieurs nœuds à la fois. Dans un graphe comme celui du chapitre 17.1.1, où les nœuds forment une ligne droite, l'exécution d'un nœud constitue un super-step. Mais dans un graphe où plusieurs nœuds s'exécutent en parallèle, tous ces nœuds ensemble constituent un seul super-step.

enregistre l'état

enregistre l'état

invoke appelé

Super-step 1
- node_x

Super-step 2
- node_y
- node_z

Renvoie l'état final

Checkpointer

Ainsi, même un seul appel à invoke() laisse plusieurs checkpoints derrière lui. Nous les extrairons et examinerons précisément ce que chacun contient au chapitre 17.2.

Le InMemorySaver que nous avons utilisé comme exemple est le checkpointer le plus simple qui soit. Comme son nom l'indique, il stocke les checkpoints dans la mémoire du processus (RAM). Il n'y a rien à installer ni rien à configurer, ce qui en fait un bon choix pour l'apprentissage et le développement local. Le compromis est que chaque checkpoint enregistré disparaît lorsque le processus redémarre. Nous examinerons les alternatives pour la production au chapitre 17.1.5.

17.1.3) Ajouter un checkpointer

Attacher un checkpointer ne demande que deux choses.

  1. Créer une instance de checkpointer et la passer à compile().
  2. Passer un config contenant un thread_id chaque fois que vous appelez invoke().

Nous verrons bientôt pourquoi la deuxième est nécessaire. Pour l'instant, sachez simplement qu'elle indique au checkpointer laquelle de ses conversations enregistrées vous souhaitez poursuivre.

Appliquons les deux au graphe du chapitre 17.1.1.

python
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_openai import ChatOpenAI
 
model = ChatOpenAI(model="gpt-5-mini")
 
def llm_call(state: MessagesState):
    response = model.invoke(state["messages"])
    return {"messages": [response]}
 
builder = StateGraph(MessagesState)
builder.add_node(llm_call)
builder.add_edge(START, "llm_call")
builder.add_edge("llm_call", END)
 
# 1. Crée un checkpointer et le passe à compile()
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
 
# 2. Passe un config portant un thread_id à invoke()
config = {"configurable": {"thread_id": "1"}}
 
graph.invoke(
    {"messages": [{"role": "user", "content": "Salut, je m'appelle Bob."}]},
    config,
)
result = graph.invoke(
    {"messages": [{"role": "user", "content": "Comment je m'appelle ?"}]},
    config,
)
print(result["messages"][-1].content)

Sortie :

Vous vous appelez Bob.

Les deux mêmes appels qu'au chapitre 17.1.1, et un résultat différent. Cette fois, le nom persiste.

Voici pourquoi. Un graphe auquel un checkpointer est attaché charge l'état enregistré avant d'exécuter le nœud llm_call. Cet état contient déjà le premier échange. Le nouveau message que nous avons passé y est ensuite fusionné. Comme nous l'avons vu au chapitre 15.2.2, le champ messages porte le reducer add_messages, donc le nouveau message est ajouté à la liste existante. Le modèle finit par recevoir trois messages : la salutation, sa propre première réponse et la nouvelle question.

Nous n'envoyons que le nouveau message, et LangGraph charge la conversation précédente à partir du dernier checkpoint. L'historique de conversation que nous gérions à la main au chapitre 8 est désormais géré par LangGraph.

17.1.4) thread_id : l'identifiant qui sépare les conversations

Un thread_id est un identifiant qui distingue une conversation d'une autre. C'est vous qui choisissez la valeur. Nous avons utilisé "1" au chapitre 17.1.3, mais n'importe quelle chaîne convient. Appelez le graphe avec le même thread_id et vous poursuivez cette conversation ; appelez-le avec un autre et vous démarrez une conversation distincte.

Vérifions-le. Nous allons faire mener à Alice et Bob des conversations différentes à travers le même graphe.

python
def send(thread_id: str, text: str) -> str:
    config = {"configurable": {"thread_id": thread_id}}
    result = graph.invoke(
        {"messages": [{"role": "user", "content": text}]},
        config,
    )
    return result["messages"][-1].content
 
# Conversation d'Alice
send("alice", "Ma couleur préférée est le turquoise.")
 
# Conversation de Bob — un thread_id différent
send("bob", "Ma couleur préférée est l'orange.")
 
# On redemande à chacun
print("Alice :", send("alice", "Quelle est ma couleur préférée ?"))
print("Bob :  ", send("bob", "Quelle est ma couleur préférée ?"))

Sortie :

Alice : Votre couleur préférée est le turquoise.
Bob :   Votre couleur préférée est l'orange.

Les deux conversations sont passées par le même objet graph et le même checkpointer, et pourtant elles ne se sont jamais mélangées. Le thread_id est la clé primaire que le checkpointer utilise pour stocker et retrouver l'état. Des clés différentes, un stockage totalement distinct.

Alors, que se passe-t-il si vous omettez la valeur ?

python
graph.invoke({"messages": [{"role": "user", "content": "Bonjour"}]})

Sortie :

ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id

Le graphe ne s'exécute pas du tout. Une fois un checkpointer attaché, thread_id n'est pas optionnel — il est obligatoire.

Au vu de ce que nous venons de voir, c'est logique. Avant d'exécuter un nœud, le checkpointer doit charger l'état enregistré — et c'est le thread_id qui lui indique l'état de quelle conversation charger.

Voilà la forme de base d'un service de chatbot : un graphe, un checkpointer et un thread_id par utilisateur ou par salon de discussion.

17.1.5) Les limites d'InMemorySaver et les alternatives pour la production

Nous avons dit précédemment qu'InMemorySaver conserve les checkpoints en mémoire. Deux limitations découlent de ce choix.

Redémarrez le processus et tout disparaît. Redéployez le service ou relancez le serveur, et chaque conversation accumulée jusque-là disparaît.

Des processus distincts ne peuvent pas le partager. Un vrai service répartit les requêtes entrantes sur plusieurs processus. Chaque processus a sa propre mémoire, si bien qu'une conversation enregistrée par le processus A est invisible pour le processus B. Un utilisateur peut envoyer le même thread_id à chaque fois et voir malgré tout la conversation se disloquer, selon le processus qui se trouve prendre en charge la requête.

C'est pourquoi la production utilise des checkpointers qui stockent les checkpoints dans une base de données.

  • SqliteSaver / AsyncSqliteSaver (langgraph-checkpoint-sqlite) — stocke tout dans un seul fichier. Un bon choix pour un petit service tournant sur un seul serveur, ou pour un prototype local.
  • PostgresSaver / AsyncPostgresSaver (langgraph-checkpoint-postgres) — stocke les checkpoints sur un serveur de base de données. Ajoutez d'autres serveurs et chaque processus voit toujours les mêmes checkpoints. C'est le checkpointer que la documentation LangGraph recommande pour la production.

Tous implémentent la même interface qu'InMemorySaver. Le code de votre graphe, vos nœuds et la façon dont vous utilisez thread_id restent exactement les mêmes. La seule chose qui change, c'est la façon dont vous créez le checkpointer.

create_agent, que vous avez rencontré au chapitre 16, utilise les checkpointers de la même manière. Passez un checkpointer à son paramètre checkpointer, et passez un config portant un thread_id à invoke(). C'est ce qui a permis à l'agent RAG conversationnel du chapitre 11 de se souvenir des tours précédents.

Nous continuerons d'utiliser InMemorySaver pour le reste de ce chapitre. Où que les checkpoints se trouvent, la manière de travailler avec eux est la même.

17.2) Inspecter l'état et déboguer

Au chapitre 17.1.2, nous avons dit que le checkpointer enregistre l'état à chaque super-step. Extrayons ces checkpoints et examinons-les.

Ouvrir les checkpoints enregistrés n'est pas une simple curiosité. Les agents se comportent mal. Ils appellent des outils dans une boucle qui ne se termine jamais, ils perdent le fil des tours précédents, ils empruntent une branche que vous n'aviez jamais anticipée. Pour découvrir pourquoi, vous devez savoir à quoi ressemblait l'état à ce moment précis — et les checkpoints ont la réponse.

LangGraph vous donne deux méthodes.

  • graph.get_state(config) — renvoie le checkpoint le plus récent pour cette conversation.
  • graph.get_state_history(config) — renvoie chaque checkpoint de cette conversation, du plus récent au plus ancien.

Toutes deux nécessitent un thread_id dans le config, puisqu'elles doivent savoir de quels checkpoints vous parlez.

Les checkpoints que ces deux méthodes renvoient sont représentés par des objets StateSnapshot.

17.2.1) StateSnapshot : ce que contient un checkpoint

Voyons ce que contient réellement un checkpoint. Nous n'utiliserons pas de LLM pour cet exemple. Un LLM renvoie quelque chose de différent à chaque fois, ce qui n'aide pas lorsque nous voulons parcourir chaque champ un par un. Nous allons plutôt construire un petit graphe qui renvoie des valeurs fixes.

L'état aura deux types de champs. foo n'a pas de reducer, donc il est écrasé ; bar en a un, donc il accumule. C'est le même arrangement que le reducer add_messages que nous avons attaché à messages au chapitre 15.2.2 — ici nous utilisons operator.add de Python comme reducer pour concaténer des listes.

python
from operator import add
from typing_extensions import TypedDict, Annotated
 
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
 
class State(TypedDict):
    foo: str                        # pas de reducer → écrasement
    bar: Annotated[list[str], add]  # reducer add → accumulation
 
def node_a(state: State):
    return {"foo": "a", "bar": ["a"]}
 
def node_b(state: State):
    return {"foo": "b", "bar": ["b"]}
 
builder = StateGraph(State)
builder.add_node(node_a)
builder.add_node(node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)
 
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "1"}}
graph.invoke({"foo": "", "bar": []}, config)
 
snapshot = graph.get_state(config)
print(snapshot)

Sortie :

StateSnapshot(
    values={'foo': 'b', 'bar': ['a', 'b']},
    next=(),
    config={'configurable': {'thread_id': '1', 'checkpoint_ns': '',
                             'checkpoint_id': '1f17da03-9654-65d8-8002-9e59231bb481'}},
    metadata={'source': 'loop', 'step': 2, 'parents': {}},
    created_at='2026-07-12T03:17:33.637368+00:00',
    parent_config={'configurable': {'thread_id': '1', 'checkpoint_ns': '',
                                    'checkpoint_id': '1f17da03-9653-6ca0-8001-7c24c8ec66f2'}},
    tasks=(),
    interrupts=()
)

Huit champs. Prenons-les un à la fois.

  • values — l'état tel qu'il était à ce checkpoint. bar vaut ['a', 'b'] parce que le reducer add a accumulé ce que les deux nœuds ont renvoyé ; foo vaut 'b' parce qu'il n'a pas de reducer, donc la dernière écriture l'emporte. C'est le champ qui répond à la question « à quoi ressemblait l'état à ce moment-là ? »
  • next — un tuple de noms de nœuds à exécuter après ce checkpoint. Un () vide signifie qu'il ne reste rien à exécuter, c'est-à-dire que le graphe s'est terminé. ('node_b',) signifie que node_b est encore à venir.
  • config — l'adresse de ce checkpoint. thread_id identifie la conversation, et checkpoint_id identifie quel moment à l'intérieur de celle-ci. LangGraph attribue le checkpoint_id automatiquement chaque fois qu'il enregistre un checkpoint.
  • metadata — des informations comptables sur l'exécution. source vous indique d'où provient le checkpoint : "input" signifie qu'il a été construit à partir de l'entrée que vous avez transmise à invoke(), et "loop" signifie qu'il a été produit pendant l'exécution du graphe. step est le numéro du super-step.
  • created_at — le moment où le checkpoint a été enregistré. Pratique lorsque vous voulez le mettre en correspondance avec vos logs.
  • parent_config — le config du checkpoint immédiatement antérieur à celui-ci. Suivez-le et vous pouvez remonter l'exécution en arrière. Il vaut None pour le tout premier checkpoint.
  • tasks — le registre d'exécution des nœuds listés dans next. Au moment où un checkpoint est enregistré, ces nœuds n'ont pas encore été exécutés ; une fois qu'ils le sont, leur résultat est attaché à ce checkpoint. Un nœud qui a réussi laisse sa valeur de retour dans result, et un nœud qui a échoué laisse son exception dans error.
  • interrupts — l'endroit où le graphe s'est mis en pause pour rendre le contrôle à une personne. LangGraph peut s'arrêter en cours d'exécution et attendre que quelqu'un approuve une étape ou fournisse une valeur, et ce champ enregistre ces pauses.

Vous lisez les champs comme des attributs. metadata est un dictionnaire, donc vous en extrayez les valeurs à l'aide d'une clé.

python
snapshot = graph.get_state(config)
 
print(snapshot.values)            # {'foo': 'b', 'bar': ['a', 'b']}
print(snapshot.next)              # ()
print(snapshot.metadata["step"])  # 2

Parmi ceux-ci, celui que vous consulterez le plus souvent pendant le débogage est next. Si next n'est pas vide, c'est que le graphe n'est pas arrivé jusqu'au bout — il s'est arrêté quelque part au milieu. Et lorsque nous reprendrons un graphe ayant échoué au chapitre 17.3, c'est de ce champ que nous partirons.

17.2.2) Parcourir l'historique des checkpoints

get_state() ne vous montre que le checkpoint le plus récent. Mais déboguer revient souvent à se demander « comment en sommes-nous arrivés là ? » — et pour cela, vous avez besoin de toute la trajectoire de l'exécution. get_state_history() vous la donne.

python
for snap in graph.get_state_history(config):
    print(f"step={snap.metadata['step']:>2}  "
          f"next={str(snap.next):<16}  values={snap.values}")

Sortie :

step= 2  next=()                values={'foo': 'b', 'bar': ['a', 'b']}
step= 1  next=('node_b',)       values={'foo': 'a', 'bar': ['a']}
step= 0  next=('node_a',)       values={'foo': '', 'bar': []}
step=-1  next=('__start__',)    values={'bar': []}

Le checkpoint le plus récent apparaît en premier, alors travaillez de bas en haut pour suivre l'exécution dans l'ordre.

  • step -1 — juste après qu'invoke() a reçu l'entrée. Remarquez que le {"foo": "", "bar": []} que nous avons transmis n'apparaît pas dans values. Faire entrer l'entrée dans l'état est en soi une étape, et cette étape n'a pas encore été exécutée. Le __start__ dans next est le nœud interne qui s'en charge.

    bar apparaît comme [], mais ce n'est pas la valeur que nous avons transmise. Un champ avec un reducer démarre avec une valeur vide dans laquelle les écritures viennent s'accumuler. foo n'a pas de reducer, donc il n'a aucune valeur de départ — c'est pourquoi il n'apparaît pas ici.

  • step 0__start__ a été exécuté et l'entrée est maintenant dans l'état. foo='' et bar=[] sont les valeurs que nous avons transmises. node_a est le prochain à venir.
  • step 1 — le résultat de l'exécution de node_a. foo vaut maintenant 'a' et bar vaut ['a'], avec node_b ensuite.
  • step 2 — le résultat de l'exécution de node_b. next est vide, donc le graphe est terminé.

Un next non vide au step 0 ou au step 1 ne signifie pas que le graphe s'est arrêté là. Un checkpoint pris alors que le graphe est encore en cours d'exécution a naturellement un nœud à venir. Lorsque le chapitre 17.2.1 disait qu'« un next non vide signifie que le graphe s'est arrêté », il parlait du checkpoint au point où quelque chose a mal tourné. Au milieu de l'historique, next vous montre simplement quel chemin le graphe a emprunté.

17.3) Reprendre là où l'exécution a échoué

Parce que l'état est enregistré à la fin de chaque super-step, un échec en cours d'exécution n'emporte pas avec lui le travail déjà achevé — il est toujours là, dans les checkpoints. Il n'y a aucune raison de tout recommencer depuis le début. Vous reprenez là où l'exécution s'est arrêtée.

17.3.1) Reprendre avec invoke(None, config)

Reprendre est simple : passez None là où va l'entrée.

python
graph.invoke(None, config)

Cela signifie « il n'y a pas de nouvelle entrée ; continue à partir de l'état enregistré ». Le config a bien sûr toujours besoin d'un thread_id, puisque LangGraph doit savoir quelle conversation poursuivre.

Mettons en scène un échec et reprenons à partir de là. Nous allons construire un graphe à deux nœuds dont le second échoue uniquement lors de sa première exécution. Il doit réussir à la reprise, sinon nous ne verrions jamais la reprise fonctionner réellement.

python
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
 
class State(TypedDict):
    step_1_done: bool
    step_2_done: bool
 
first_try = True   # drapeau pour faire échouer la première exécution
 
def step_1(state: State):
    print("step_1 en cours d'exécution (travail coûteux)")
    return {"step_1_done": True}
 
def step_2(state: State):
    global first_try
    if first_try:
        first_try = False
        print("step_2 a échoué (délai d'attente de l'API dépassé)")
        raise RuntimeError("L'API externe a dépassé son délai d'attente")
    print("step_2 en cours d'exécution")
    return {"step_2_done": True}
 
builder = StateGraph(State)
builder.add_node(step_1)
builder.add_node(step_2)
builder.add_edge(START, "step_1")
builder.add_edge("step_1", "step_2")
builder.add_edge("step_2", END)
 
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "job-42"}}
 
try:
    graph.invoke({"step_1_done": False, "step_2_done": False}, config)
except RuntimeError as e:
    print("Échec :", e)

Sortie :

step_1 en cours d'exécution (travail coûteux)
step_2 a échoué (délai d'attente de l'API dépassé)
Échec : L'API externe a dépassé son délai d'attente

step_1 a réussi et step_2 a levé une exception. Découvrons où le graphe s'est arrêté, en utilisant le get_state() que nous avons appris au chapitre 17.2.

python
snapshot = graph.get_state(config)
print("next   =", snapshot.next)
print("values =", snapshot.values)

Sortie :

next   = ('step_2',)
values = {'step_1_done': True, 'step_2_done': False}

next vaut ('step_2',), ce qui nous indique que le graphe s'est arrêté au milieu de l'exécution de step_2. Et dans values, step_1_done vaut True — le résultat de step_1 est toujours là, dans le checkpoint.

Maintenant, nous reprenons avec None.

python
result = graph.invoke(None, config)
print("Final =", result)

Sortie :

step_2 en cours d'exécution
Final = {'step_1_done': True, 'step_2_done': True}

step_1 en cours d'exécution (travail coûteux) ne s'est jamais affiché. step_1 n'a pas été exécuté une seconde fois. LangGraph a chargé l'état enregistré et a repris à step_2. Nous n'avons pas payé deux fois cette coûteuse première étape.

17.3.2) Ce à quoi il faut faire attention lors d'une reprise

Lorsque vous reprenez, le nœud qui a échoué est réexécuté. Si ce nœud appelle un LLM ou sollicite une API externe, ces appels se produisent à nouveau eux aussi — et ils peuvent renvoyer quelque chose de différent.

C'est là que se cache le piège. Si step_2 a envoyé un e-mail puis a échoué, la reprise envoie un second e-mail. LangGraph garantit uniquement qu'il ne réexécutera pas les nœuds qui ont réussi.

Ainsi, tout nœud susceptible d'être réexécuté doit être idempotent : faire la même chose deux fois doit vous laisser au même point. Vérifiez si l'e-mail a déjà été envoyé avant de l'envoyer ; mettez une clé unique sur la table de la base de données pour qu'une insertion en double ne puisse pas se produire.