Последние полгода я занимаюсь задачей, которая поначалу казалась тривиальной: научить 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 подряд бессмысленных сообщений)
Конфликт возраста — естественный рост vs ошибка
Режим исправления — «я ошибся»
Смена города при переезде
Смена диеты
Команды «запомни:» и «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 — бесплатно для личных проектов.
Если есть вопросы по архитектуре, деталям экспериментов или конкретным решениям — спрашивайте в комментариях, отвечу.
Комментарии (9)

alexeyw
24.03.2026 18:03Мне кажется вот этот вариант сохранения ИИ личности лучше, так как не требует для своей работы LLM, да и данные после перезапуска контейнера или даже смены агента не теряются.

spbmolot Автор
24.03.2026 18:03Согласен, hippograph — крутой проект. Но это немного разные задачи:
hippograph — сохраняет личность ИИ (характер, стиль, предпочтения самого агента).
NGT — помнит факты о пользователе (вегетарианец, аллергия, город).Представь: hippograph делает так, чтобы бот всегда был вежливым и любил анекдоты.
NGT делает так, чтобы бот не предлагал стейк вегетарианцу.Можно использовать вместе — hippograph задаёт характер, NGT подкидывает контекст про конкретного собеседника.

Tassdesu
24.03.2026 18:03Спасибо большое за статью! Очень круто, что поделились реальными тестами
Один момент, который было бы здорово уточнить: в тестах без памяти какой именно был сетап? (что-то конкретное по промптам/окну/инструкциям?)
И ещё — какую модель вы в итоге использовали? Было бы супер узнать детали, очень интересно, как это повлияло на результаты.
Жду продолжения, пишите ещё такие материалы!
spbmolot Автор
24.03.2026 18:03Спасибо!
Без памяти — тот же промпт, но без блоков [USER PROFILE] и [MEMORY CONTEXT]. Всё остальное идентично.
Модель — gpt-4.1-nano. Выбирал баланс цена/качество: она дешевле, но достаточно умная, чтобы понимать контекст.

ogregor
24.03.2026 18:03То же активно интересуюсь темой. Из последнего что нашел это темпоральные графы. Про ту же временную инвалидацию.
И трёх уровневую систему памяти к примеру cortex-mem. В гитхаб есть.
Систему многоуровневого доступа (L0/L1/L2), которая включает краткие абстракты, структурированные обзоры и полные версии данных для оптимизации контекста.

TedBeer
24.03.2026 18:03“мне 30 лет”
А через 5 лет мне все еще будет 30? Есть механизм определения таких плавающих данных? Если я в чате сказал - другу исполнилось вчера 30 лет, то я смогу в другой момент/месяц/год узнать когда у него ДР?
Triton5
При рестарте рестарте контейнера вся память теряется, это ИМХО главная проблема.
Именно по теме RAG для телеграм бота я читал статью:
https://habr.com/ru/articles/988358/
Вот вкратце разница:
Критерий // NGT Memory //«Гриша»
Тип памяти // Векторная + графовая + профиль // Ключевые слова + паттерны + профиль
Хранение // In-memory (RAM одного процесса) // JSON-файлы на диске
Поиск // Косинусное сходство эмбеддингов + Хеббовский граф // Инвертированный индекс по ключевым словам
Что запоминает // Факты о пользователе (извлечение через regex + эвристики) // Успешные Q&A-паттерны + имя пользователя
Обучение // Консолидация частых фактов // Сохранение удачных ответов + счётчик использования
Зависимости // OpenAI API для эмбеддингов // Локальная модель (Qwen), без внешних API
Масштабируемость // Низкая (in-memory, single worker) // Средняя (файлы, но нет индексов для больших объёмов)
Задержка // ~2-3 мс память + ~700 мс на эмбеддинги // ~50-200 мс поиск по ключевым словам (локально)
spbmolot Автор
Согласен, рестарт = потеря — больно. Это MVP, проверял гипотезу про retrieval, а не инфраструктуру.
Разница с «Гриша» простая: я за качество поиска (эмбеддинги понимают смысл, а не только точное слово), «Гриша» за скорость и автономность (локальная модель, файлы на диске).
Redis под капотом уже в планах — там абстракция SessionStore есть, осталось реализовать.