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