Главная / Статьи / Память диалога для агента поддержки на LangGraph с чекпоинтами в SQLite

Память диалога для агента поддержки на LangGraph с чекпоинтами в SQLite

Как агент поддержки на LangGraph сохраняет контекст многоходового диалога с помощью SqliteSaver и thread id и чем краткосрочная память диалога отличается от долгосрочной.

1330 слов

Агент поддержки, который умеет искать по документации LEPA, полезен, но всё равно спотыкается на втором сообщении в чате. Представьте: пользователь пишет одно слово, а агент уточняет, о каком разделе идёт речь.

Turn 1: "help"
Agent: Which area — login, grades, reports…?

Пользователь отвечает, описывая настоящую проблему, и если первый обмен репликами нигде не сохранён, агент начинает с нуля.

Turn 2: "Why aren't grades showing?"
Agent: …starts from zero unless something remembers Turn 1

Поддержка — это диалог, поэтому агенту нужен способ его запоминать. Ниже описана память, которую действительно построили для агента поддержки LEPA, и та, от которой сознательно отказались.

Почему эндпоинт без состояния ломает уточнения

Каждый HTTP-запрос приходит без истории. Для сервера любой вызов выглядит как новый посетитель.

User → Agent → answer → forget
User → Agent → answer → forget
User → Agent → answer → forget

Это ломает любой сценарий, где агент сначала задаёт вопрос. Ответ на «какой раздел LEPA?» имеет смысл, только если система ещё знает, что оба сообщения относятся к одному разговору.

Два вида памяти, построен только один

В начале проекта границу провели так:

  • Краткосрочная память охватывает один разговор, то есть один тред.
  • Долгосрочная память охватывает факты, которые переживают сессию.

В первой версии агента есть первый вид. Второго пока нет. Различие настолько важное, что ниже оно повторяется везде, где может возникнуть путаница.

Состояние внутри запуска и между запусками

Состояние LangGraph уже работает как небольшая память, пока идёт запуск. Объект состояния поддержки хранит четыре вещи.

messages
user_role
issue_category
clarification_needed
retrieved_documents
final_answer
…

Поле messages подключено к редьюсеру add_messages, поэтому каждая новая реплика добавляется в конец и ничего не перезаписывается. В рамках одного вызова invoke через это общее состояние узлы передают друг другу контекст.

Но вызов invoke рано или поздно завершается. Когда приходит следующий HTTP-запрос, всё из предыдущего пропадает, если состояние нигде не сохранили. Сохранение и есть то, что называют чекпоинтингом.

Как работает чекпоинтер

Проще говоря, чекпоинтер сохраняет состояние графа после завершения работы и раскладывает его по идентификатору разговора. Это как закрыть вкладку тикета в службе поддержки и открыть её позже: номер тикета тот же, и история та же.

В проекте используется SqliteSaver из пакета langgraph-checkpoint-sqlite. Всё начинается с идентификатора, который передаётся в конфигурации запуска.

thread_id = "abc-123"
        ↓
invoke graph with config { configurable: { thread_id } }
        ↓
state is saved to SQLite
        ↓
next invoke with same thread_id
        ↓
prior messages / fields load again

На стороне API обработчик берёт thread id из тела запроса или генерирует UUID, если его не передали, и заворачивает в словарь конфигурации.

thread_id = body.thread_id or str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}

Эта конфигурация передаётся графу вместе с новым сообщением пользователя.

result = graph.invoke(
    {"messages": [HumanMessage(content=body.message)]},
    config=config,
)

Один thread id — одна дорожка памяти.

Что сохраняется

Для конкретного треда чекпоинтер сохраняет всё состояние графа. Туда входят растущий список сообщений и поля вроде роли пользователя и категории проблемы, заданные на предыдущих шагах.

На практике первый ход выглядит так: пользователь пишет что-то расплывчатое, агент задаёт уточняющий вопрос, и состояние сохраняется.

Turn 1: "help"
  → clarification question
  → state saved

Второй ход идёт уже с доступом к предыдущим сообщениям, поэтому классификатор читает последний текст пользователя в контексте, а шаги базы знаний и поддержки могут ответить как следует.

Turn 2: "Why aren't grades showing?"
  → prior messages still there
  → classify sees the latest user text
  → knowledge + support can answer

Тесты подтверждают это поведение. Они отправляют расплывчатое первое сообщение, затем вопрос про оценки с тем же thread id и проверяют, что во втором результате история сообщений длиннее, чем в первом.

Что здесь означает «краткосрочная память»

Когда говорят, что у агента есть краткосрочная память, имеется в виду только этот стек.

SQLite checkpoint
  + thread_id
  + SupportState.messages (append)

Это память одного треда поддержки. Она позволяет:

  • сначала уточнить, а потом ответить
  • помнить предыдущие реплики внутри одного чата
  • сделать так, чтобы чат-эндпоинт вёл себя как сессия, а не как поток не связанных между собой вопросов

Автоматически она не даёт:

  • постоянного профиля каждого учителя
  • знаний о школе сверх документов в Markdown
  • утверждений вроде «агент знает, что этот человек во всех приложениях предпочитает тёмную тему»

Всё это относится к проектированию долгосрочной памяти.

Какой была бы долгосрочная память

Долгосрочная память — это информация, которую намеренно хранят за пределами одного треда и достают позже. Для LEPA кандидатами могли бы быть такие факты: пользователь всегда учитель, школа называет свои учебные периоды «Semester A», а проблему с публикацией оценок решили на прошлой неделе.

Поможет грубая аналогия, и это только аналогия.

Short-term ≈ RAM for this conversation
Long-term  ≈ saved notes you can look up next month

Сохранённая переписка треда abc-123 всё равно остаётся памятью треда. История разговора сама по себе не является долгосрочной памятью; для неё понадобились бы отдельное хранилище и политика извлечения. В объекте состояния даже предусмотрено поле для краткого содержания разговора на будущее. Сейчас его не заполняет ни один узел, так что это задел на будущее, а не функция.

Ответ чата и контракт thread id

Чат-эндпоинт возвращает JSON, в котором среди прочих полей есть ответ, категория и флаг памяти.

{
  "answer": "...",
  "category": "grades",
  "memory_updated": true,
  "thread_id": "...",
  "clarification_needed": false
}

Ожидается, что клиент на следующем ходе отправит thread id обратно. Если не отправит, API создаст новый UUID, и начнётся новый разговор. Одно это правило делает память явной, а не волшебной.

Одна оговорка насчёт ответа: в текущем API флаг памяти всегда равен true. Он не означает, что было записано долгосрочное краткое содержание. Он лишь говорит, что запрос прошёл через граф с чекпоинтами.

Небольшая эксплуатационная ловушка

SqliteSaver.from_conn_string — это контекстный менеджер, поэтому он закрывает базу, как только выполнение покидает блок with. Для скрипта это работает, а для долгоживущего процесса FastAPI — нет. Поэтому вспомогательная функция get_sqlite_checkpointer открывает соединение, вызывает setup и держит соединение всё время жизни приложения.

Деталь крошечная, её легко упустить, но она становится важной, как только память должна жить за пределами ноутбука.

Стек памяти в одной строке

Память агента — это состояние во время запуска плюс чекпоинты в SQLite между запусками.

State during a run
  +
SQLite checkpoints across runs
  +
thread_id to find the right conversation

Для многоходовых диалогов поддержки этого достаточно. До настоящей системы долгосрочной памяти это не дотягивает, и прямо сказать об этом — часть урока. Следующий шаг — отойти от связки «один инструмент плюс память» и разобраться, зачем рабочий процесс разделён на этапы приёма, базы знаний и поддержки.