- BrainTools - https://www.braintools.ru -

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

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

Задача звучит просто. На практике — нет.

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

Написал NGT Memory [1] — модуль персистентной памяти [2] для LLM с открытым исходным кодом. REST API, Docker, одна команда для запуска. В этой статье расскажу, как он устроен, какие грабли я собрал и что показали эксперименты.

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

Стандартный подход к «памяти» в LLM-приложениях — засунуть всю историю диалога в контекстное окно. Это работает ровно до момента, пока окно не заполнится. Дальше начинается:

  • Обрезка старых сообщений (и потеря важных фактов)

  • Суммаризация (и искажение деталей)

  • Внешнее векторное хранилище типа Pinecone или Weaviate (и +1 зависимость, +1 сервер, +1 слой абстракции)

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

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

NGT Memory комбинирует:

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

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

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

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 подряд бессмысленных сообщений)

  • Конфликт [5] возраста — естественный рост vs ошибка [6]

  • Режим исправления — «я ошибся»

  • Смена города при переезде

  • Смена диеты

  • Команды «запомни:» и «remember:»

  • Кросс-языковое извлечение (факты на русском, вопросы на английском)

  • Сборка полного профиля из разрозненных сообщений

Результат: 51/54 (94%). Три провала — два на граничных случаях regex при извлечении города, один на случайность [7] ответа 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 allergicname = 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 [1]

Лицензия BSL 1.1 — бесплатно для личных проектов.


Если есть вопросы по архитектуре, деталям экспериментов или конкретным решениям — спрашивайте в комментариях, отвечу.

Автор: spbmolot

Источник [8]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/27657

URLs in this post:

[1] NGT Memory: https://github.com/ngt-memory/ngt-memory

[2] памяти: http://www.braintools.ru/article/4140

[3] ассоциативный: http://www.braintools.ru/article/621

[4] нейроны: http://www.braintools.ru/article/9161

[5] Конфликт: http://www.braintools.ru/article/7708

[6] ошибка: http://www.braintools.ru/article/4192

[7] случайность: http://www.braintools.ru/article/6560

[8] Источник: https://habr.com/ru/articles/1014366/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1014366

www.BrainTools.ru

Rambler's Top100