Последние полгода я занимаюсь задачей, которая поначалу казалась тривиальной: научить LLM помнить, с кем она разговаривает.
Задача звучит просто. На практике — нет.
Если вы строили чат-бот или AI-агента, вы знаете ощущение: пользователь написал, что он вегетарианец, а через три сообщения модель предлагает ему стейк-хаус. Или пациент сообщил об аллергии на пенициллин, а ассистент через час забыл и порекомендовал амоксициллин. В рамках одного контекстного окна всё работает. Но стоит начать новую сессию — чистый лист, модель не помнит ничего.
Написал NGT Memory — модуль персистентной памяти для LLM с открытым исходным кодом. REST API, Docker, одна команда для запуска. В этой статье расскажу, как он устроен, какие грабли я собрал и что показали эксперименты.
Проблема, которую чаще обходят костылями
Стандартный подход к «памяти» в LLM-приложениях — засунуть всю историю диалога в контекстное окно. Это работает ровно до момента, пока окно не заполнится. Дальше начинается:
-
Обрезка старых сообщений (и потеря важных фактов)
-
Суммаризация (и искажение деталей)
-
Внешнее векторное хранилище типа Pinecone или Weaviate (и +1 зависимость, +1 сервер, +1 слой абстракции)
Реализовал память прямо в Python-процессе — с тремя механизмами извлечения, которые работают вместе.
Три механизма, одно извлечение
NGT Memory комбинирует:
1. Косинусное сходство — классика. Эмбеддинг запроса сравнивается с эмбеддингами сохранённых фактов. Работает хорошо, когда слова совпадают.
2. Хеббовский ассоциативный граф — вот тут интереснее. Когда пользователь в одном разговоре говорит «вегетарианец», а потом спрашивает про «рестораны», между этими концептами укрепляется связь. В следующий раз при запросе про рестораны граф «подтягивает» связанный концепт «вегетарианец» — даже если в самом запросе нет ни слова про диету.
Это, по сути, правило Хебба: нейроны, которые активируются вместе, связываются вместе. Только вместо нейронов — концепты из текста.
3. Иерархическая консолидация — факты, к которым обращались чаще, «продвигаются» в долгосрочную семантическую память. Редко используемые — постепенно забываются. Как в биологической памяти.
Все три механизма работают за ~2-3 мс на CPU. Основное время тратится на вызов OpenAI API для эмбеддингов (~700 мс) и генерации ответа (~800-1500 мс). Сама память — не узкое место.
Как это выглядит в коде
Запуск:
git clone https://github.com/ngt-memory/ngt-memory
cd ngt-memory
cp .env.example .env # указать OPENAI_API_KEY
docker-compose up -d
Использование:
import httpx
client = httpx.Client(base_url="http://localhost:9190")
# Первый разговор — пользователь представляется
client.post("/chat", json={
"message": "Я вегетарианец и живу в Москве.",
"session_id": "user_42"
})
# Через час, день, неделю — новый вопрос
r = client.post("/chat", json={
"message": "Что мне поесть?",
"session_id": "user_42"
})
print(r.json()["response"])
# → Рекомендует вегетарианские рестораны в Москве
print(r.json()["memories_count"])
# → 2 (извлёк факт про вегетарианство и про Москву)
Весь API — пять эндпоинтов: /chat, /store, /retrieve, /session/reset, /health. Swagger UI из коробки.
Профиль пользователя: не просто текст
Одна из возможностей, которой я горжусь больше всего, — структурированный профиль. Это не просто «сохранить текст и потом найти похожий». Система автоматически извлекает из сообщений конкретные слоты:
Пользователь: "Мне 30 лет, живу в Москве, я вегетарианец"
→ profile.age = 30
→ profile.city = "Москва"
→ profile.diet = "вегетарианец"
И эти данные инжектируются в system prompt перед текстовой памятью, с наивысшим приоритетом:
[USER PROFILE — structured facts, highest priority]
- name: Антон
- age: 30
- city: Москве
- diet: вегетарианец
- allergies: арахис
[END USER PROFILE]
[MEMORY CONTEXT — verified facts about this user]
1. [0.91] Я вегетарианец и живу в Москве.
2. [0.87] У меня аллергия на арахис.
[END MEMORY CONTEXT]
Склейка фрагментов
Пользователи не всегда пишут аккуратными предложениями. Бывает так:
Сообщение 1: "мне"
Сообщение 2: "30"
Сообщение 3: "лет"
Каждое по отдельности — мусор. Но система собирает их в скользящий буфер и склеивает: "мне 30 лет" → проходит фильтр качества → сохраняется → извлекается age=30 с пониженной уверенностью (0.6 вместо 1.0).
Разрешение конфликтов
Если пользователь сначала сказал «мне 30 лет», а потом «мне 28» — возраст не может уменьшиться просто так. Система блокирует изменение, пока пользователь не скажет что-то вроде «я ошибся» или «на самом деле мне 28». Тогда включается режим исправления на 60 секунд, и слот обновляется.
Это мелочь, но именно такие мелочи отличают демку от продукта.
Что показали эксперименты
Я проводил серию экспериментов (все скрипты лежат в experiments/ в репозитории). Вот главные результаты.
Exp 44 — Качество ответов с памятью vs без
Три сценария (медицина, персональный ассистент, техподдержка), оценка GPT-4 как судьи.
|
Режим |
Фактуальная точность (0-3) |
Совпадение ключевых слов |
|---|---|---|
|
С памятью |
2.44 / 3 |
44% |
|
Без памяти |
1.22 / 3 |
27% |
|
Улучшение |
+100% |
+17 п.п. |
Двукратное улучшение фактуальной точности — не потому что модель стала умнее, а потому что она получила нужный контекст в нужный момент.
Exp 48 — Реалистичный A/B-тест
Шесть сценариев из жизни: аллергия на лекарства, диетические ограничения в путешествии, VPN-коды в 1Password, предпочтения по возврату средств, спортивное питание, бронирование перелётов.
Три прогона по 6 сценариев = 18 оценок.
|
Метрика |
Результат |
|---|---|
|
Доля побед памяти |
94% (17/18) |
|
Средняя оценка с памятью |
0.889 |
|
Средняя оценка без памяти |
0.056 |
|
Поражения памяти |
0 |
Ноль поражений. Память не проиграла ни разу за 18 оценок.
Exp 49 — Краевые случаи
Самый жёсткий тест. 14 сценариев, 54 проверки:
-
Извлечение профиля на русском и английском
-
Склейка фрагментов → профиль
-
Фильтрация мусора (10 подряд бессмысленных сообщений)
-
Режим исправления — «я ошибся»
-
Смена города при переезде
-
Смена диеты
-
Команды «запомни:» и «remember:»
-
Кросс-языковое извлечение (факты на русском, вопросы на английском)
-
Сборка полного профиля из разрозненных сообщений
Результат: 51/54 (94%). Три провала — два на граничных случаях regex при извлечении города, один на случайность ответа LLM (модель написала «thirty-one» вместо «31» — профиль корректен, просто текстовый матчер не поймал).
Фильтр качества: не всё стоит запоминать
Одна из ранних проблем: пользователь пишет «ыва», «456», «!!!» — и всё это попадает в память. Через 20 сообщений мусора полезные факты тонут в шуме, качество поиска деградирует.
Я добавил фильтр качества — лёгкую эвристику перед сохранением:
-
Чистые числа, спецсимволы, одно слово → не сохраняем
-
Менее 6 буквенных символов → не сохраняем
-
Если сообщение пользователя — мусор, ответ ассистента на него тоже не сохраняем
Последний пункт неочевидный, но критичный. Ответ LLM на «ыва» — это «Могу я чем-то помочь?». Формально грамотный текст, но нулевая информационная ценность. Если его сохранить, он будет вытеснять полезные факты из выборки лучших результатов.
Архитектура
Запрос пользователя
↓
[POST /chat]
↓
OpenAI Embeddings (text-embedding-3-small) ~700 мс
↓
Извлечение профиля (regex, ~0 мс)
↓
NGT Memory Retrieve (cosine + graph boost) ~2-3 мс
↓
System prompt + [USER PROFILE] + [MEMORY CONTEXT]
↓
OpenAI Chat (gpt-4.1-nano) ~800-1500 мс
↓
Фильтр качества → Сохранить/Пропустить ~1 мс
↓
Ответ
Стек: 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. Пришлось собрать blacklist из 25+ слов для negative lookahead. Неприятный баг, который проявлялся только в определённых комбинациях сообщений.
Производительность
Чистые замеры на CPU (Exp 40, 5000 фактов):
|
Операция |
Пропускная способность |
Задержка (p50) |
|---|---|---|
|
store() |
3 450 / сек |
0.29 мс |
|
retrieve() |
150 запр./сек |
6.3 мс |
|
Память |
— |
~0.8 МБ / 1000 записей |
End-to-end через API (Exp 44, с OpenAI эмбеддингами):
|
Сценарий |
Извлечение |
Эмбеддинг |
|---|---|---|
|
Медицинский ассистент |
3.5 мс |
1 069 мс |
|
Персональный ассистент |
1.8 мс |
867 мс |
|
Техподдержка |
2.3 мс |
357 мс |
|
Среднее |
2.5 мс |
764 мс |
Собственные затраты NGT Memory — 2-3 мс. Остальное — OpenAI API. Память не является узким местом.
Что дальше
Проект в активной разработке. Из ближайших планов:
-
Shared session backend (Redis/PostgreSQL) — для multi-worker production
-
Reranker — приоритизация профильных фактов над эпизодическими
-
Persistence — сохранение памяти между перезапусками контейнера
Вместо заключения
Персистентная память для LLM — это не какая-то магия. Это инженерная задача с кучей краевых случаев, которые проявляются только в реальных диалогах. «Мне» + «30» + «лет» по отдельности — мусор, а вместе — факт. I'm allergic — не имя. Возраст не может уменьшиться. Ответ на мусор — тоже мусор.
Я не утверждаю, что решил задачу полностью. Но 94% успешных проверок на 54 краевых случаях — это уже что-то, с чем можно работать.
Если вам интересно попробовать — всё в открытом доступе:
GitHub: github.com/ngt-memory/ngt-memory
Лицензия BSL 1.1 — бесплатно для личных проектов.
Если есть вопросы по архитектуре, деталям экспериментов или конкретным решениям — спрашивайте в комментариях, отвечу.
Автор: spbmolot


