Open-source персистентная память для LLM

Open-source персистентная память для LLM

Последние полгода я работал над задачей, которая сначала казалась простой: научить LLM помнить, с кем она разговаривает.

На деле это оказалось непросто. Если вы создавали чат-бота или AI-агента, вы сталкивались с ситуацией: пользователь говорит, что он вегетарианец, а через несколько сообщений модель предлагает стейк-хаус. Или пациент сообщает об аллергии на пенициллин, а ассистент позже рекомендует амоксициллин. В рамках одной сессии всё работает. Но при новом запуске — полный сброс. Модель ничего не помнит.

Я создал NGT Memory — open-source модуль персистентной памяти для LLM. Есть REST API, поддержка Docker, запуск одной командой. В этой статье — как это устроено, какие ошибки я допустил и что показали эксперименты.

Проблема, которую чаще решают костылями

Стандартный подход — вставлять всю историю диалога в контекстное окно. Это работает, пока окно не заполнится. Потом начинаются проблемы:

  • Обрезка старых сообщений — и потеря важных фактов
  • Суммаризация — с риском искажения деталей
  • Внешние векторные хранилища вроде Pinecone или Weaviate — ещё одна зависимость, сервер и слой абстракции

Я реализовал память прямо в Python-процессе, с тремя механизмами извлечения, которые работают вместе.

Три механизма, одно извлечение

NGT Memory объединяет:

1. Косинусное сходство — классика. Эмбеддинг запроса сравнивается с эмбеддингами сохранённых фактов. Хорошо работает, когда слова совпадают.

2. Хеббовский ассоциативный граф — здесь интереснее. Когда пользователь в одном диалоге говорит «вегетарианец», а потом спрашивает про «рестораны», между этими концептами формируется связь. В следующий раз при запросе о ресторанах система «подтягивает» тему вегетарианства — даже если в запросе нет слова «диета».

Это реализация правила Хебба: нейроны, которые активируются вместе, укрепляют связь. Только вместо нейронов — текстовые концепты.

3. Иерархическая консолидация — часто используемые факты переходят в долгосрочную память. Редкие — постепенно забываются. Как в биологической памяти.

Все три механизма работают за 2–3 мс на CPU. Основная задержка — вызов OpenAI API для эмбеддингов (~700 мс) и генерации ответа (~800–1500 мс). Сама память — не узкое место.

Как это выглядит в коде

API состоит из пяти эндпоинтов: /chat, /store, /retrieve, /session/reset, /health. Swagger UI доступен из коробки.

Профиль пользователя: не просто текст

Одна из ключевых возможностей — структурированный профиль. Это не просто поиск похожих фраз. Система автоматически извлекает конкретные данные из сообщений:

Эти данные вставляются в system prompt с высочайшим приоритетом — перед всей остальной памятью.

Склейка фрагментов

Пользователи редко пишут полными предложениями. Бывает так:

Каждое сообщение по отдельности — шум. Но система собирает их в буфер и склеивает: «мне 30 лет» → проходит фильтр качества → сохраняется → извлекается как age=30 с пониженной уверенностью (0.6 вместо 1.0).

Разрешение конфликтов

Если пользователь сначала сказал «мне 30 лет», а потом «мне 28» — возраст не может просто так уменьшиться. Система блокирует изменение, пока пользователь не скажет что-то вроде «я ошибся» или «на самом деле мне 28». Тогда включается режим исправления на 60 секунд, и слот обновляется.

Это мелочь, но именно такие детали отличают демонстрацию от продукта.

Что показали эксперименты

Я провёл серию тестов (все скрипты — в папке experiments/ репозитория). Основные результаты:

Exp 44 — Качество ответов с памятью vs без

Три сценария (медицина, персональный ассистент, техподдержка), оценка от GPT-4.

Фактуальная точность (0–3)

Совпадение ключевых слов

Без памяти

Двукратное улучшение фактуальной точности — не потому что модель стала умнее, а потому что она получила нужный контекст в нужный момент.

Exp 48 — Реалистичный A/B-тест

Шесть жизненных сценариев: аллергия на лекарства, диетические ограничения в путешествии, VPN-коды в 1Password, предпочтения по возврату средств, спортивное питание, бронирование перелётов.

Три прогона по 6 сценариев = 18 оценок.

Доля побед памяти

94% (17/18)

Средняя оценка с памятью

Средняя оценка без памяти

Память не проиграла ни разу — 0 поражений за 18 тестов.

Exp 49 — Краевые случаи

Самый строгий тест: 14 сценариев, 54 проверки:

  • Извлечение профиля на русском и английском
  • Склейка фрагментов → профиль
  • Фильтрация мусора (10 бессмысленных сообщений подряд)
  • Конфликт возраста — естественный рост vs ошибка
  • Режим исправления — «я ошибся»
  • Смена города при переезде
  • Смена диеты
  • Команды «запомни:» и «remember:»
  • Кросс-языковое извлечение (факты на русском, вопросы на английском)
  • Сборка полного профиля из разрозненных сообщений

Результат: 51 из 54 (94%). Три сбоя — два связаны с граничными случаями регулярных выражений при извлечении города, один — с тем, что LLM ответила «thirty-one» вместо «31» (профиль корректен, но текстовый матчер не сработал).

Фильтр качества: не всё стоит запоминать

Ранняя проблема: сообщения вроде «ыва», «456», «!!!» попадали в память. Через 20 таких сообщений полезные факты тонули в шуме, качество поиска падало.

Я добавил фильтр качества — лёгкую эвристику перед сохранением:

  • Чистые числа, спецсимволы, одно слово — не сохраняем
  • Менее 6 буквенных символов — не сохраняем
  • Если сообщение пользователя — мусор, ответ ассистента на него тоже не сохраняем

Последний пункт критичен. Ответ LLM на «ыва» — «Могу я чем-то помочь?» — формально грамотный, но без информационной ценности. Если его сохранить, он будет вытеснять полезные факты из результатов поиска.

Архитектура

Стек: FastAPI, AsyncOpenAI, Pydantic Settings. Никаких внешних баз данных. Всё хранится в оперативной памяти одного процесса.

Да, при перезапуске контейнера память теряется. Это осознанный компромисс текущей версии. Для продакшена следующий шаг — Redis или PostgreSQL как хранилище сессий.

Грабли, на которые я наступил

1. Разделение сессий между воркерами. Запустил Docker с --workers 4, обрадовался производительности, но в 75% случаев память была пуста. Оказалось, каждый воркер создаёт свой SessionStore в памяти. Сохранение попадает в воркер 1, а извлечение — в воркер 3. Решение на текущем этапе — --workers 1. Для масштабирования нужно общее хранилище сессий.

2. System prompt был слишком мягким. Первая версия: «When relevant memories are provided, use them to give accurate responses». Модель интерпретировала это как «можно использовать, а можно и нет». Пользователь пишет «я вегетарианец», потом спрашивает «могу ли я есть мясо?» — модель отвечает «конечно, если хотите».

Пришлось ужесточить: «Treat every fact in MEMORY CONTEXT as absolute truth about the user. NEVER contradict or ignore these facts.» + пример прямо в промпте. После этого модель отвечает: «Вы вегетарианец, мясо вам не подходит».

3. I'm allergic → name = allergic. Regex для извлечения имени по шаблону I'm + [Name] радостно матчил I'm allergic, I'm also, I'm sorry. Пришлось создать чёрный список из 25+ слов с negative lookahead. Неприятный баг, проявлявшийся только в определённых комбинациях сообщений.

Производительность

Чистые замеры на CPU (Exp 40, 5000 фактов):

Пропускная способность

Задержка (p50)

3 450 / сек

retrieve()

150 запросов/сек

~0,8 МБ на 1000 записей

End-to-end через API (Exp 44, с эмбеддингами от OpenAI):

Извлечение

Медицинский ассистент

Персональный ассистент

Техподдержка

Собственные затраты NGT Memory — 2–3 мс. Остальное — задержки от OpenAI API. Память не является узким местом.

Что дальше

Проект в активной разработке. Ближайшие планы:

  • Общее хранилище сессий (Redis/PostgreSQL) — для multi-worker окружения
  • Reranker — приоритизация профильных фактов над эпизодическими
  • Персистентность — сохранение памяти между перезапусками контейнера

Вместо заключения

Персистентная память для LLM — это не магия. Это инженерная задача с множеством краевых случаев, которые проявляются только в реальных диалогах. «Мне» + «30» + «лет» по отдельности — мусор, а вместе — факт. I'm allergic — не имя. Возраст не может уменьшиться. Ответ на мусор — тоже мусор.

Я не утверждаю, что решил задачу полностью. Но 94% успешных проверок на 54 краевых случаях — это уже серьёзная основа.

Читать оригинал