Головна / Статті / Пам'ять діалогу для агента підтримки на 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

Для багатокрокових діалогів підтримки цього досить. До справжньої системи довготривалої пам'яті це не дотягує, і прямо сказати про це — частина уроку. Наступний крок — відійти від зв'язки «один інструмент плюс пам'ять» і розібратися, навіщо робочий процес поділено на етапи приймання, бази знань і підтримки.