Open-source персистентная память для LLM. fastapi.. fastapi. llm.. fastapi. llm. memory.. fastapi. llm. memory. Open source.. fastapi. llm. memory. Open source. openai.. fastapi. llm. memory. Open source. openai. python.. fastapi. llm. memory. Open source. openai. python. retrieval.. fastapi. llm. memory. Open source. openai. python. retrieval. искусственный интеллект.. fastapi. llm. memory. Open source. openai. python. retrieval. искусственный интеллект. Машинное обучение.

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

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


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

Автор: spbmolot

Источник

Rambler's Top100