Последние полгода я работал над задачей, которая сначала казалась простой: научить 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 краевых случаях — это уже серьёзная основа.