Головна / Статті / Усередині InMemorySaver від LangGraph: як працюють чекпоїнти, записи та блоки даних

Усередині InMemorySaver від LangGraph: як працюють чекпоїнти, записи та блоки даних

Пройдіться по сховищу, додайте словники записів та блобів у InMemorySaver LangGraph та простежте, як один маленький графовий запуск перетворюється на три взаємопов’язані контрольні точки.

1834 слів

InMemorySaver від LangGraph зазвичай є деталлю налаштування, яка описується одним рядком: ви передаєте його функції compile(), розмови раптово пам’ятають свій стан, і більше ніхто не цікавиться цим. Проте спосіб, яким він організовує дані, пояснює багато аспектів самого LangGraph, включаючи принципи відновлення роботи, „подорожей у часі“ та стійкості до збоїв, а також причини того, чому інструменти для створення контрольних точок мають саме такий вигляд. Простежуючи процес обробки мінімальної графи через внутрішні словники цього засобу, ви зможете прочитати дані контрольної точки та точно зрозуміти значення кожного запису.

Чому графам потрібні контрольні точки

Чекпоїнтер виконує функцію короткострокової пам’яті для графа: він фіксує моментальний знімок стану графа під час виконання. Уявіть собі точки збереження у грі в режимі історії: без них, щоб знову пройти другий рівень, доводиться спочатку пройти перший. Точка збереження записує прогрес гравця, щоб можна було продовжити з цього моменту, навіть після завершення гри. LangGraph робить те саме після кожного кроку, тож потік може продовжити роботу або переграти сценарій з попередньої точки.

Мінімальний граф для перевірки

У наведеному нижче прикладі створюється найменша корисна граф: типовий стан із полями name та address, єдиний детерміністичний вузол, який встановлює обидва поля за допомогою Command, та ребра START, потім get_address, а потім END. Граф компілюється за допомогою InMemorySaver та InMemoryStore, викликається на потоці "12345", а наприкінці виводяться атрибути чекпоїнтера. Сховище є окремим компонентом для довгострокових даних, які обмінюються між потоками, і не відіграє жодної ролі у подальших діях. Хоча цей фрагмент позначений як JavaScript, насправді це 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__

Атрибути InMemorySaver

Список ключів словника чекпоїнтера показує п’ять атрибутів:

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

serde: серіалізація та десеріалізація

Дані контрольних точок не можна зберігати як живі об’єкти Python у базі даних, а навіть у пам’яті зберігач зберігає їх у серіалізованому вигляді. serde — це серіалізатор, який перетворює значення в байти та назад, позначаючи кожне з них типом, наприклад msgpack.

Зберігання: контрольні точки за потоками

storage містить самі контрольні точки. Кожна розмова отримує ідентифікатор потоку, і саме за цим ідентифікатором LangGraph отримує історію конкретного потоку. Структура — це вкладена словникова структура: ідентифікатор потоку, потім простір імен контрольної точки (порожня стрічка для верхнього рівня графа; підграфи мають власні простори імен), а потім ідентифікатор контрольної точки:

{
    "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),
         }
    }
}

Кожен елемент містить серіалізований стан перевірки, його серіалізовані метадані та ідентифікатор батьківської перевірки. Цей вказівник на батька перетворює стани перевірок потоку на ланцюгову історію, що дозволяє здійснювати повернення назад та розгалуження.

Записи: кількість запланованих записів на одну перевірку

writes фіксує окремі оновлення, які створюють завдання. Замість того, щоб безпосередньо перезаписувати стан, кожне оновлення записується як новий елемент, ідентифікований потоком, простором імен та перевіркою, з якої виконувалося завдання. Усередині кожен запис ідентифікується за допомогою ідентифікатора завдання та індексу:

{
    ('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>'))
    }
}

channel_name у цьому скетчі є місцем для заміни. Коли вузол оновлює name, канал дорівнює name; коли він оновлює address, канал дорівнює address. Вузол, який оновлює обидва параметри одночасно, створює два записи під одним же чекпоїнтом. Оскільки дані зберігаються відразу після завершення завдання, запуск, який зазнає невдачі на певному етапі, не потребує повторної обробки завдань, які вже були успішно виконані.

blobs: версійовані значення каналів

blobs зберігає фактичне значення кожного каналу на кожній версії. Ключ поєднує поток, простір імен, канал та версію, тож чекпоїнт може посилатися на значення каналу за версією, замість того щоб зберігати його копію:

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

stack: керування контекстом

Атрибут stack іноді описується як черга завдань, що очікують виконання, але у реалізації зберігача це є стек менеджера контексту ( ExitStack ), який використовується для керування ресурсами під час входу та виходу зберігача як менеджера контексту. Він не зберігає стан виконання графу. Це приватні внутрішні елементи, тому перевірте їх у згодності з вашою встановленою версією.

Покрокове відстеження виконання

Одне викликання графу створює три контрольні точки.

Контрольна точка 1: надходження вхідних даних

Перша контрольна точка з ідентифікатором 1f1b054e-b2a5-660a-bfff-7484776ebce0 містить два пакети даних у форматі msgpack: саму контрольну точку та її метадані.

// 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": {}
}

На цьому етапі існує лише канал __start__. Він отримав свою першу версію, яка вказана у списку updated_channels, а метадані позначають джерело як input із значенням step, дорівнюваним -1, що означає стан до виконання будь-якого кроку графа. Рядки версій слідують простій схемі: заповнений нулями, монотонно зростаючий лічильник, за яким йде випадкова дробова частина, що забезпечує унікальність версій.

Чекпоїнт посилається на значення каналу через його версію, а відповідний блоб зберігає дані. Тут вхідним даним був порожній словник, який msgpack кодує як один байт \x80:

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

Чекпоїнт 2: маршрутизація до вузла

Другий контрольний пункт, 1f1b054e-b2a6-6294-8000-96e3a3cb81ac, фіксує шлях від START до get_address. Йдеться про маршрутизацію, а не про виконання вузла:

// 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": {}
}

Зараз два канали передають версію 2. __start__ переміщується до нової версії, оскільки його вхідні дані були використані, а новий канал branch:to:get_address сигналізує про те, що наступним слід виконати get_address. Перемінна versions_seen вказує, що завдання __start__ вже бачило версію 1 каналу __start__; саме цей облік допомагає LangGraph визначати, які вузли ще потрібно виконати. Метадані перемикаються на джерельний loop із значенням step 0.

Запис, який спричинив цю зміну, зберігається під ID попереднього контрольного пункту, оскільки він був створений завданням, яке виконувалося з того контрольного пункту:

('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__')
 }

Також створюються два нові блоби. Блоб __start__ позначений як empty, що відображає те, що канал було очищено після його використання, а канал гілки зберігає значення null, оскільки він слугує лише тригером:

// 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'')

Контрольна точка 3: вузол оновлює стан

Третя контрольна точка, 1f1b054e-b2a6-6d66-8001-d006da4d6d19, фіксує виконання функції get_address та її оновлення значень name та 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 завжди містить найновішу версію кожного каналу, тоді як versions_seen фіксує, яку версію бачив кожен вузол під час своєї роботи. __start__ залишається на версії 2, оскільки до нього більше ніхто не звертається. Канал гілки та два канали стану переходять на версію 3, updated_channels містить список address та name, а лічильник кроків досягає значення 1.

Вузол записав два значення, тому під ID другого контрольного пункту з’являються два записи — по одному для кожного каналу, які мають спільний ID завдання:

('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')
}

Нарешті, нові блоки містять рядки у форматі msgpack для двох полів стану:

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

Чому схема розроблена саме так

Три словники для графа з одним вузлом можуть здатися зайвими, але кожен елемент має своє призначення:

  • Чекпоїнти, пов’язані з батьківським елементом надають кожному потоку повну історію змін. Ви можете переглянути будь-який попередній стан, продовжити роботу з нього або створити нову гілку.
  • Версіоновані блоки даних зберігають кожне значення каналу лише один раз після зміни, тож чекпоїнти залишаються компактними навіть тоді, коли стан великий та майже не змінюється.
  • Чекаючі записи дозволяють продовжувати виконання кроків. Якщо одне завдання в кроці зазнає невдачі, записи успішних завдань вже збережені та не потребують повторного виконання.

Постійні інструменти створення чекпоїнтів, такі як той, що використовується в Postgres, зберігають чекпоїнти, блоки даних та записи у окремих таблицях, які відображають ці структури, тож той самий підхід застосовується до вашої бази даних.

Основні висновки

  • InMemorySaver призначений для розробки та тестування; його дані зникають після завершення процесу.
  • storage зберігає точки контролю та метадані для кожної нитки та простору імен, пов’язані ID батьківських елементів.
  • writes зберігає оновлення для кожного завдання, ключовані точкою контролю, з якої вони були створені.
  • blobs зберігає значення каналів за версіями, тож незмінені канали ніколи не копіюються.
  • Пов’язана література