Память диалога для агента поддержки на LangGraph с чекпоинтами в SQLite
Как агент поддержки на LangGraph сохраняет контекст многоходового диалога с помощью SqliteSaver и thread id и чем краткосрочная память диалога отличается от долгосрочной.
Агент поддержки, который умеет искать по документации 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
Для многоходовых диалогов поддержки этого достаточно. До настоящей системы долгосрочной памяти это не дотягивает, и прямо сказать об этом — часть урока. Следующий шаг — отойти от связки «один инструмент плюс память» и разобраться, зачем рабочий процесс разделён на этапы приёма, базы знаний и поддержки.