Accueil / Articles / À l’intérieur de LangGraph’s InMemorySaver : comment s’insèrent les points de contrôle, les écritures et les blocs de données

À l’intérieur de LangGraph’s InMemorySaver : comment s’insèrent les points de contrôle, les écritures et les blocs de données

Parcourez le stockage, écrivez les dictionnaires de mots-clés et de blobs à l’intérieur de LangGraph’s InMemorySaver, puis suivez comment une seule exécution d’un petit graphe se transforme en trois points de contrôle liés entre eux.

1834 mots

Le InMemorySaver de LangGraph est généralement une configuration en une seule ligne : on le passe à compile(), les conversations se souviennent soudainement de leur état, et personne ne cherche plus à en savoir davantage. Pourtant, la manière dont il organise les données révèle beaucoup sur LangGraph lui-même, y compris le fonctionnement de la reprise, du « voyage dans le temps » et de la tolérance aux pannes, ainsi que les raisons pour lesquelles les outils de sauvegarde persistante ont cette forme. En suivant l’exécution d’un graphe minimal à travers les dictionnaires internes du sauvegardeur, vous pourrez lire un fichier de sauvegarde et comprendre exactement ce que signifie chaque entrée.

Pourquoi les graphes ont besoin de points de sauvegarde

L’exemple ci-dessous crée le graphe le plus simple et utile : un état structuré avec name et address, un seul nœud déterministe qui définit ces deux champs via une Command, ainsi que des arêtes START, puis get_address, et enfin END. Il compile ce graphe à l’aide d’un InMemorySaver et d’un InMemoryStore, l’exécute sur le thread "12345", puis affiche les attributs du checkpointer. Le stockage constitue un composant distinct pour les données à long terme partagées entre threads et n’intervient pas dans les étapes suivantes. Bien que ce fragment soit identifié comme JavaScript, il s’agit en réalité de Python :

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from langgraph.graph import StateGraph
from typing import TypedDict, Literal
from langgraph.types import Command
from langgraph.graph.state import START, END

# we create a checkpointer, for now testing purposes we use inmemory
checkpointer = InMemorySaver()

# we will talk about this in our next blog
store = InMemoryStore()


# how you want to store your graph state which is persisted across chats
class GraphState(TypedDict):
    name: str
    address: str

# this is a determinsitic node that is present as a node
def get_address(state: GraphState) -> Command[Literal[END]]:
    return Command(update={
        "name": "pavaneeshwar",
        "address": "Hyderabad residency"
    })

# intialize graph
graph = StateGraph(GraphState)

# add this node to the graph
graph.add_node("get_address", get_address)

# by default START and END defines the START execution and end execution
graph.add_edge(START, "get_address")
graph.add_edge("get_address", END)

# the above graph we created is START => get_address => END

# we load the entire graph, this returns an object which we can run
app = graph.compile(checkpointer=checkpointer, store=store)

app.invoke({}, config={"configurable": {"thread_id": "12345"}})

# we are interested here how langgraph stores checkpointer
app.checkpointer.__dict__

Les attributs d’InMemorySaver

La liste des clés du dictionnaire du checkpointer montre cinq attributs :

app.checkpointer.__dict__.keys()
# dict_keys(['serde', 'storage', 'writes', 'blobs', 'stack'])

serde : serialization et deserialization

Les données de point d’arrêt ne peuvent pas être stockées sous forme d’objets Python actifs dans une base de données, et même en mémoire, le mécanisme d’enregistrement les conserve sous une forme serialisée. serde est le sérialiseur qui convertit les valeurs en octets et inversement, en annotant chacune d’un type tel que msgpack.

stockage : points d’arrêt par thread

storage contient les points d’arrêt eux-mêmes. Chaque conversation reçoit un ID de thread, et c’est cet ID qui permet à LangGraph de récupérer l’historique d’un thread spécifique. La structure est un dictionnaire imbriqué : l’ID du thread, suivi du nom d’espace du point d’arrêt (une chaîne vide pour le graphe de niveau supérieur ; les sous-graphes disposent de leurs propres noms d’espace), puis de l’ID du point d’arrêt :

{
    "thread_id": {
         "namespace" : {
            "checkpoint_uuid_0": (msgpack, <binary_data>),
            "checkpoint_uuid_1": (msgpack,<binary_data>, checkpoint_uuid_0),
            "checkpoint_uuid_2": (msgpack,<binary_data>, checkpoint_uuid_1),
         }
    }
}

Chaque entrée contient le point de contrôle sérialisé, ses métadonnées sérialisées ainsi que l’ID du point de contrôle parent. Ce pointeur parent transforme les points de contrôle d’un thread en une histoire liée, ce qui permet le rebobinage et la création de branches.

écritures : écritures en attente par point de contrôle

writes enregistre les mises à jour individuelles générées par les tâches. Au lieu d’écraser directement l’état, chaque mise à jour est enregistrée comme une nouvelle entrée identifiée par le thread, l’espace de noms et le point de contrôle à partir duquel la tâche a été exécutée. À l’intérieur, chaque écriture est identifiée par un ID de tâche et un index :

{
    ('thread_id', 'namespace', 'checkpoint_uuid_1') : {
        ('operation_uuid_1', 0) : ('operation_uuid_1', 'channel_name', ('msgpack', '<binary data>')),
        ('operation_uuid_2', 1) : ('operation_uuid_2', 'channel_name', ('msgpack', '<binary data>'))
    }
}

Le channel_name dans ce schéma est un espace réservé. Lorsqu’un nœud met à jour name, le canal devient name ; lorsqu’il met à jour address, le canal devient address. Un nœud qui met à jour les deux en même temps crée deux entrées sous le même point de contrôle. Comme les écritures sont enregistrées dès que une tâche est terminée, un exécution qui échoue en cours de step n’a pas besoin de réexécuter les tâches qui ont déjà réussi.

blobs : valeurs de canal versionnées

blobs stocke la valeur réelle de chaque canal à chaque version. La clé combine le thread, l’espace de noms, le canal et la version, permettant ainsi à un point de contrôle de faire référence à une valeur de canal par version au lieu d’incorporer une copie :

{
    ('thread_id', 'namespace', 'channel_name', 'version') : ('mssgpack', '<binary data>')
}

stack : gestion de contexte

L’attribut stack est parfois décrit comme une file d’attente de tâches en attente, mais dans l’implémentation du sauvegardeur il s’agit d’une pile de gestionnaires de contexte (ExitStack) utilisée pour gérer les ressources lors de l’entrée et de la sortie du sauvegardeur en tant que gestionnaire de contexte. Il ne conserve pas l’état d’exécution du graphe. Il s’agit de fonctionnalités internes privées, il convient donc de les vérifier en fonction de votre version installée.

Suivre l’exécution étape par étape

Une invocation du graphe produit trois points de contrôle.

Point de contrôle 1 : l’entrée des données

Le premier point de contrôle, dont l’ID est 1f1b054e-b2a5-660a-bfff-7484776ebce0, contient deux en-têtes msgpack : le point de contrôle lui-même et ses métadonnées.

// First Message pack
{
  "v": 4,
  "ts": "2026-09-14T15:56:59.435773+00:00",
  "id": "1f1b054e-b2a5-660a-bfff-7484776ebce0",
  "channel_versions": {
    "__start__": "00000000000000000000000000000001.0.267464090313665"
  },
  "versions_seen": {
    "__input__": {}
  },
  "updated_channels": [
    "__start__"
  ]
}

// Second Message Pack, this is just meta data

{
  "source": "input",
  "step": -1,
  "parents": {}
}

À ce stade, seul le canal __start__ existe. Il a reçu sa première version, qui est listée dans updated_channels, et les métadonnées indiquent que la source est de type input avec un step fixé à -1, ce qui signifie que c’est l’état avant l’exécution de toute étape du graphe. Les chaînes de version suivent un schéma simple : un compteur incrémental aligné à zéro suivi d’une fraction aléatoire qui assure l’unicité des versions.

Le point de contrôle fait référence à la valeur du canal via sa version, et le blob correspondant contient les données. Ici, l’entrée était un dictionnaire vide, que msgpack encode sous la forme du seul octet \x80:

// this msgpack basically {}
('12345', '', '__start__', '00000000000000000000000000000001.0.267464090313665'): ('msgpack', b'\x80')

Point de contrôle 2 : routage vers le nœud

Le deuxième point de contrôle, 1f1b054e-b2a6-6294-8000-96e3a3cb81ac, enregistre le lien allant de START à get_address. Il s’agit de la routage, et non pas encore du lancement du nœud :

// first message pack
{
  "v": 4,
  "ts": "2026-09-14T15:56:59.436094+00:00",
  "id": "1f1b054e-b2a6-6294-8000-96e3a3cb81ac",
  "channel_versions": {
    "__start__": "00000000000000000000000000000002.0.27282425125643517",
    "branch:to:get_address": "00000000000000000000000000000002.0.27282425125643517"
  },
  "versions_seen": {
    "__input__": {},
    "__start__": {
      "__start__": "00000000000000000000000000000001.0.267464090313665"
    }
  },
  "updated_channels": [
    "branch:to:get_address"
  ]
}

// second message pack
{
  "source": "loop",
  "step": 0,
  "parents": {}
}

Deux canaux transmettent désormais la version 2. __start__ passe à une nouvelle version car son entrée a été consommée, et un nouveau canal, branch:to:get_address, indique que get_address doit être exécuté en suivant. versions_seen montre que la tâche __start__ a déjà pris connaissance de la version 1 du canal __start__ ; c’est grâce à ce suivi que LangGraph détermine quels nœuds doivent encore être exécutés. Les métadonnées passent alors au loop source avec un step de 0.

L’écriture qui a déclenché ce changement est stockée sous l’ID du point de contrôle précédent, car elle a été générée par la tâche qui s’est exécutée à partir de ce point de contrôle :

('12345', '', '1f1b054e-b2a5-660a-bfff-7484776ebce0'): {
        ('4efa087d-283c-eb5c-478a-97c592eb3802', 0): ('4efa087d-283c-eb5c-478a-97c592eb3802', 'branch:to:get_address', ('null', b''), '~__pregel_pull, __start__')
 }

Deux nouveaux blobs sont également créés. Le blob __start__ est marqué comme empty, ce qui indique que le canal a été vidé après avoir été consommé, tandis que le canal de branche stocke une valeur null car il ne sert qu’à déclencher quelque chose :

// one created for progressing start
('12345', '', '__start__', '00000000000000000000000000000002.0.27282425125643517'): ('empty', b''),

// one for creating branch
('12345', '', 'branch:to:get_address', '00000000000000000000000000000002.0.27282425125643517'): ('null', b'')

Point de contrôle 3 : la mise à jour de l’état du nœud

Le troisième point de contrôle, 1f1b054e-b2a6-6d66-8001-d006da4d6d19, enregistre l’exécution de get_address ainsi que ses mises à jour des valeurs name et address :

// first message pack
{
  "v": 4,
  "ts": "2026-09-14T15:56:59.436372+00:00",
  "id": "1f1b054e-b2a6-6d66-8001-d006da4d6d19",
  "channel_versions": {
    "__start__": "00000000000000000000000000000002.0.27282425125643517",
    "branch:to:get_address": "00000000000000000000000000000003.0.07103778333502464",
    "name": "00000000000000000000000000000003.0.07103778333502464",
    "address": "00000000000000000000000000000003.0.07103778333502464"
  },
  "versions_seen": {
    "__input__": {},
    "__start__": {
      "__start__": "00000000000000000000000000000001.0.267464090313665"
    },
    "get_address": {
      "branch:to:get_address": "00000000000000000000000000000002.0.27282425125643517"
    }
  },
  "updated_channels": [
    "address",
    "name"
  ]
}

// second message pack
{
  "source": "loop",
  "step": 1,
  "parents": {}
}

channel_versions contient toujours la dernière version de chaque canal, tandis que versions_seen enregistre ce que chaque nœud a vu lors de son exécution. __start__ reste à la version 2 car rien ne le modifie à nouveau. Le canal de branche et les deux canaux d’état passent à la version 3, updated_channels liste address et name, et le compteur d’étapes atteint 1.

Le nœud a écrit deux valeurs, ce qui se reflète par deux enregistrements sous l’ID du deuxième point de contrôle, un pour chaque canal, partageant le même ID de tâche :

('12345', '', '1f1b054e-b2a6-6294-8000-96e3a3cb81ac'): {
        ('a6b6f3e8-32e4-88a4-559d-cd6d409c7910', 0): ('a6b6f3e8-32e4-88a4-559d-cd6d409c7910', 'name', ('msgpack', b'\xacpavaneeshwar'), '~__pregel_pull, get_address'),
        ('a6b6f3e8-32e4-88a4-559d-cd6d409c7910', 1): ('a6b6f3e8-32e4-88a4-559d-cd6d409c7910', 'address', ('msgpack', b'\xb3Hyderabad residency'), '~__pregel_pull, get_address')
}

Finalement, de nouveaux blobs contiennent les chaînes codées en msgpack pour les deux champs d’état :

('12345', '', 'name', '00000000000000000000000000000003.0.07103778333502464'): ('msgpack', b'\xacpavaneeshwar'),
('12345', '', 'address', '00000000000000000000000000000003.0.07103778333502464'): ('msgpack', b'\xb3Hyderabad residency')

Pourquoi la structure est conçue de cette manière

Trois dictionnaires pour un graphe à un seul nœud semblent excessifs, mais chaque élément a sa raison d’être :

  • Points de contrôle liés aux parents fournissent à chaque thread un historique complet. Vous pouvez examiner n’importe quel état antérieur, reprendre à partir de celui-ci ou créer une nouvelle branche à partir de lui.
  • Blocs versionnés stockent chaque valeur de canal une seule fois par modification, ce qui permet aux points de contrôle de rester petits même lorsque l’état est volumineux et globalement inchangé.
  • Écritures en attente permettent de reprendre les étapes. Si une tâche au sein d’une étape échoue, les écritures des tâches réussies ont déjà été sauvegardées et n’ont pas besoin d’être exécutées à nouveau.

Les outils de création de points de contrôle persistants tels que celui de Postgres conservent les points de contrôle, les blocs et les écritures dans des tables distinctes qui reflètent ces dictionnaires, de sorte que le même modèle mental s’applique à votre base de données.

Points clés

  • InMemorySaver est conçu pour le développement et les tests ; ses données disparaissent lorsque le processus s’arrête.
  • storage contient des points de contrôle et des métadonnées par thread et espace de noms, reliés par des IDs parent.
  • writes contient les mises à jour par tâche, identifiées par le point de contrôle d’où elles proviennent.
  • blobs stocke les valeurs des canaux par version, de sorte que les canaux inchangés ne sont jamais copiés.
  • Lectures complémentaires