5.LTM: AI-ассистент помнит, КТО ТЫ! Долгосрочная память о пользователе внутри МНЕМОС
Разбираю Long-Term Memory (LTM) — отдельный модуль app/ltmemory.py, который архитектурно входит в МНЕМОС. Пока МНЕМОС (L1/L2/L3) помнит, как отвечать, LTM помнит, кого обслуживает. Два независимых процесса: фоновое извлечение фактов по крону раз в 6 часов и realtime-инъекция в промпт при старте сессии. Сравниваю с «core memory» MemGPT и Mem0.

Это пятая статья из серии «Как я собрал self-improving AI-ассистента». Предыдущие:

  1. Общий обзор архитектуры
  2. МНЕМОС: трёхуровневая долгосрочная память
  3. STM + МНЕМОС: двойная память
  4. ИНТУИТИВНЫЙ КОНТУР — как ассистент «чувствует» запрос до того, как подумает


Проблема: ассистент каждый раз знакомился заново

В статье про МНЕМОС я писал: «Память человека так не работает — каждый новый диалог начинается не с чистого листа». МНЕМОС решил половину проблемы: ассистент запомнил, как отвечать — успешные примеры, тематические коллекции, антипаттерны.

Но оставалась вторая половина. И она бесила сильнее:

Пользователь: «Привет, я Руслан, мы вчера обсуждали прайс»

Ассистент: «Приятно познакомиться, Руслан! О каком прайсе речь?» 🤦

Каждый новый диалог — заново знакомимся. Ассистент не помнил, кто перед ним:

  • Как зовут собеседника и как к нему обращаться
  • Кто он, какая профессия, из какого города
  • Что с ним уже было: три месяца назад он сказал «обращайся на ты» — и это должно работать всегда
  • Как он предпочитает получать ответы: коротко или развёрнуто, с эмодзи или без, на языке профи или «по-человечески»
  • Его стиль общения

МНЕМОС это не закрывал: там лежит опыт (Q&A с оценками Sufler), а не досье на пользователя. Хранилище было общим, а персональный слой — отсутствовал.

История диалога (STM/БРЭЙН) решала только вопрос «о чём мы говорили в этом разговоре». Вчерашний диалог — уже не в контексте, там 12000 символов лимит.

Нужна была отдельная система, которая собирает факты о пользователе и помнит их между сессиями. Я назвал её LTM — Long-Term Memory.


Решение: отдельный модуль, который знает тебя

LTM — это модуль app/ltmemory.py (230 строк). Он делает ровно две вещи:

1. Извлекает факты о пользователе из диалогов (LLM, фоновый крон раз в 6ч)
  1. Инжектит их в промпт при старте сессии (realtime)

Ключевой момент, чтобы не путаться: LTM — отдельный модуль, но входит в МНЕМОС.

В моей архитектуре МНЕМОС — это не один файл, а вся долгосрочная память: experiencebank/, rag.py, research.py, chutye.py + таблицы responselog, research*, userfacts. LTM — ещё один компонент этой системы. Он использует её хранилище (user_facts), живёт по её правилам (per-user изоляция, накопление навсегда) и кормит тот же промпт.

Если МНЕМОС-L1/L2/L3 — это «архив знаний ассистента», то LTM — «папка с досье на пользователя» внутри того же архива.

flowchart TB
    subgraph MNEMOS["МНЕМОС — долгосрочная память (живёт навсегда)"]

        direction TB

        L1["L1: experience_bank<br/>успешные Q&A (score ≥ ">4)"]

        L2["L2: expL2_{theme} × ">8<br/>тематические коллекции"]

        L3["L3: experience_fails<br/>антипаттерны (score ≤ ">2)"]

        LIKBEZ["Статьи ЛИКБЕЗ<br/>user0documents_v2"]

        LTM["LTM (app/ltmemory.py)<br/>ФАКТЫ О ПОЛЬЗОВАТЕЛЕ"]

        UF[("user_facts<br/>таблица фактов")]

    end


    subgraph BRAIN["БРЭЙН / STM — оперативная память (один диалог)"]

        MG["micro_goals + якоря"]

        HIST["chat_history (история треда)"]

    end


    PROMPT["system_prompt"]


    LTM --> UF

    UF -->|"[ПАМЯТЬ О ПОЛЬЗОВАТЕЛЕ] + [СТИЛЬ ОБЩЕНИЯ]"| PROMPT

    L1 -->|few-shot| PROMPT

    L2 -->|few-shot| PROMPT

    L3 -->|"⚠️ АНТИПРИМЕРЫ"| PROMPT

    LIKBEZ -->|"📊 ИНФОРМАЦИЯ ПО ТЕМЕ"| PROMPT

    MG -->|"[ТЕМЫ ДИАЛОГА]"| PROMPT

    HIST -->|"история треда"| PROMPT

Смотрите: LTM стоит внутри рамки МНЕМОС (справа), но это самостоятельный файл, самостоятельный крон-процесс и самостоятельная таблица в том же хранилище. Оба кормят один и тот же промпт — просто разными блоками.


Два процесса, как у LTM устроена жизнь

У LTM два независимых процесса — загрузка (realtime) и извлечение (cron). Один читает, другой пишет. Никогда не пересекаются.

flowchart LR
    subgraph Extraction["Извлечение — фон, крон каждые ">6ч"]

        A[scripts/runltmextract.py] --> B[Берёт всех активных пользователей]

        B --> C[LLM анализирует последние сообщения]

        C --> D[extract_facts → JSON-массив]

        D --> E[savefacts → userfacts]

    end


    subgraph Loading["Загрузка — realtime, при старте сессии"]

        F[Пользователь открывает чат] --> G[load_facts]

        G --> H[SELECT из user_facts<br/>LIMIT "hl-num">15]

        H --> I[Блок в system_prompt]

    end


    E -.->|пишет| UF[(user_facts)]

    UF -.->|читает| H


Извлечение (Extraction) — LLM-анализатор диалогов

Раз в 6 часов крон (0 /6 → runltmextract.py) берёт каждого активного пользователя и просит LLM вытащить из последних сообщений факты:

# runltmextract.py — упрощённо

for uid in active_users:
facts = await extract_facts(uid, 0, cfg) # LLM-анализ диалога
if facts:
savefacts(conn, uid, facts) # → userfacts

Что анализируется:

  • если есть тред — последние 60 сообщений этого треда
  • без треда — последние 30 сообщений вообще
  • фильтр: сообщения короче 20 символов отбрасываются (это «ок», «спасибо», «привет» — мусор для извлечения)
  • минимум 2 «осмысленных» сообщения, иначе возвращаем пусто

Сам промпт — жёстко регламентированный:

Извлеки из диалога факты о пользователе.
  • ТОЛЬКО явно упомянутые факты. Не додумывай.
  • НЕ извлекай то, что ассистент сказала о себе — только факты о ПОЛЬЗОВАТЕЛЕ.
  • Максимум 10 фактов. Если нечего извлекать — верни пустой массив.

Формат: [{"key": "ltm_name", "value": "Иван"}, ...]

temperature=0.1 — чтобы LLM не фантазировала, а вытаскивала только сказанное.

Категории фактов (ключ всегда начинается с ltm_):

КатегорияКлючПример значения
Имяltmname«Руслан»
Родной языкltmlang«русский»


Профессияltmrole«программист»
Город/странаltmlocation«Пенза»
Интересыltminterest«ИТ, мебель»
Важные заметкиltmnote«создатель Сати»
Исправленияltmcorrection«не называй меня на Вы»
Тонltmstyletone«прямой, без воды»
Формат ответовltmstylelength«кратко»
Обращениеltmstylepronoun«на ты»
Эмодзиltmstyleemoji«умеренно»
Тех.уровеньltmstyletech«senior»
Терпеливостьltmstylepatience«сразу к делу»
Эмоциональностьltmstyleemotion«сдержан»

Первые 8 — это «кто ты», последние 7 — «как ты общаешься». Вторую группу я вынес отдельно — про неё ниже (Style Profile).

 

Сохранение (save_facts) — UPSERT без дублей

INSERT INTO userfacts (userid, factkey, factvalue, contexttype, extractedat)

VALUES (?, ?, ?, 'long_term', ?)
ON CONFLICT(userid, factkey)
DO UPDATE SET factvalue = excluded.factvalue, extractedat = excluded.extractedat

Никаких дублей: если факт уже есть и значение не изменилось — пропускаем. Изменилось (поменял работу, переехал) — перезаписываем. Старое не накапливается.

Защита от распухания:

  • значение обрезается до 500 символов, ключ до 100
  • в БД максимум 40 фактов на пользователя (MAXFACTSIN_DB) — самые старые удаляются
  • в промпт попадают только 15 последних (MAXFACTSIN_PROMPT)

Загрузка (Loading) — realtime

В main.py при сборке промпта:

# main.py:1565 — LTM: инжекция фактов о пользователе

from .ltmemory import loadfacts as ltm_load
ltmtext = ltmload(conn, userid)
if ltm_text:
streamprompt = ltmtext + "\n" + stream_prompt


load_facts читает последние 15 фактов и собирает два блока:

[ПАМЯТЬ О ПОЛЬЗОВАТЕЛЕ]

Зовут: Руслан
Профессия: программист
Город: Пенза
Заметка: создатель Сати

[СТИЛЬ ОБЩЕНИЯ]



Тон: прямой, без воды
Обращение: на ты
Формат: кратко


Обратите внимание на вложенную привязку: блок LTM ставится в начало промпта — раньше правил, инструментов и few-shot. Потому что «кого обслуживаешь» — это самый базовый контекст, на котором должны строиться все остальные инструкции.


Style Profile — отдельный блок про то, как общаться

Отдельная фишка LTM — Style Profile. Это не факты о жизни, а модель того, как пользователь ведёт диалог. Семь характеристик: тон, формат, обращение, эмодзи, тех.уровень, терпеливость, эмоциональность.


pie title Style Profile — из чего собирается

"Тон (прямой/формальный/тёплый)" : 1
"Формат (кратко/развёрнуто)" : 1
"Обращение (на ты/на Вы)" : 1
"Эмодзи (активно/без)" : 1
"Тех.уровень (начинающий/senior)" : 1
"Терпеливость (уточняет/сразу к делу)" : 1
"Эмоциональность (сдержан/эмоционален)" : 1


Зачем это отдельно? Потому что «что сказать» и «как сказать» — разные задачи. Первое покрывают факты, второе — стиль. Если пользователь — senior программист, который пишет «кратко, на ты, без эмодзи» — ассистент адаптирует ответы: без воды, без сюсюканья, по делу. Если другой пользователь эмоционален и любит эмодзи — ассистент это считывает и подстраивается.

Это и есть персонализация — не через тонну правил в промпте, а через факты, которые собрала сама система.



Как это выглядит в бою

Пример 1: сессия через месяц.

Пользователь: «Сати, доброе утро»

Ассистент: «Доброе утро, Руслан! Как настроение? В Пензе сейчас немного дождливо»

Раньше ассистент ответил бы «Доброе утро! Чем могу помочь?» — формально и безлико.



Пример 2: тех.разговор с адаптацией стиля.

Пользователь (senior, на ты, кратко): «Разведи юнит-тесты и интеграционные в CI»

Без LTM: ассистент объясняет, ЧТО такое юнит-тесты, по шагам, на «вы»
С LTM: «Сделаю. Юнит — на каждый push, интеграционные — на тег. Ок?»


Пример 3: исправление, которое должно работать всегда.

(в каком-то диалоге пользователь написал «не называй меня на Вы»)

→ LTM извлёк: ltm_correction = «не называй меня на Вы»

(все последующие сессии):
[ПАМЯТЬ О ПОЛЬЗОВАТЕЛЕ]

Исправление: не называй меня на Вы


Ровно тот кейс из вступления статьи про МНЕМОС — «три месяца назад сказал „обращайся на ты" — должно работать всегда». Там я его заявил как требование, здесь — как реализацию.



LTM vs МНЕМОС (L1/L2/L3) — что где

Самое частое недоразумение — путать LTM с МНЕМОС. Развожу:

ПараметрМНЕМОС (L1/L2/L3)LTM
Что хранитОпыт: Q&A, примеры «как надо/не надо», статьиФакты о пользователе: имя, дети, стиль
Чей это вопрос«Что я знаю?»«Кого я обслуживаю?»
Вопрос, на который отвечает«как посчитать НДС»«как зовут собеседника»
НаполнениеSufler (score 1-5) + ЛИКБЕЗ + GapfillLLM-извлечение из диалогов (крон 6ч)
Ключвектор в ChromaDB (2560-dim)строка в userfacts
ХранилищеChromaDB + responselogPostgreSQL userfacts
Per-user изоляцияколлекции userN / глобальныестрого по user_id
Обновлениекаждый ответ + кронкрон раз в 6ч
Жизненный циклнавсегданавсегда (макс 40 фактов)
Стиль-адаптацияfew-shot (похожие примеры)Style Profile (характеристики)

И главное: LTM — это часть МНЕМОС. МНЕМОС — зонтик долгосрочной памяти: архив знаний (L1/L2/L3) + досье на пользователя (LTM). Отдельный модуль — да, но он лежит в том же подсистемном слое, использует ту же БД и работает по тем же правилам.

flowchart LR
    subgraph Mnemos["МНЕМОС"]

        subgraph Knowledge["Знание (чему научилась)"]

            direction LR

            E1[L1 experiencebank] --> E2[L2 expL2theme] --> E3[L3 experience_fails]

        end

        subgraph Person["Досье (кого обслуживает)"]

            LTM1[LTM — факты о пользователе]

        end

    end

    subgraph Brayn["БРЭЙН / STM"]

        MG1[микро-цели и якоря]

    end

    subgraph Contour["Контур"]

        C1[UserModel trust/frust]

    end

    LTM1 --> Person

    E1 --> Knowledge


Три «памяти» и Контур — где LTM

Чтобы не запутаться во всех пяти статьях серии:

СистемаЧто помнитЖивётПример
STM тему и цифры этого диалогаодин диалогякорь «шкаф 1800×400, 2 шт»
МНЕМОС L1-L3как отвечать (опыт, знания)вечно«в НДС была ошибка», статьи ЛИКБЕЗ
LTM (внутри МНЕМОС)кого обслуживаю (факты о человеке)вечно«зовут Руслан, на ты, сдержан»
Контурв каком состоянии пользовательпереживает рестарт[УВЕР: HIGH] [ЖАНР: ЗАКАЗ]

Заметьте симметрию: МНЕМОС-L1/L2/L3 и LTM — обе долгосрочные и живут вечно. STM — оперативная. Контур — не память, а «Система 1», которая читает все слои и превращает их в метки.

 
sequenceDiagram

participant U as Пользователь
participant M as main.py (chat_stream)
participant LTM as ltmemory.py
participant UF as user_facts (PG)
participant CRON as Крон (6ч)
participant LLM as LLM-анализатор

Note over CRON,LLM: Извлечение (фоновое, раз в 6ч)
CRON->>LLM: «вот диалог — вытащи факты»
LLM-->>CRON: [{"key":"ltm_name","value":"Руслан"}, ...]
CRON->>UF: save_facts (UPSERT, максимум 40)

Note over U,M: Загрузка (realtime, новая сессия)
U->>M: «Сати, доброе утро»
M->>LTM: loadfacts(userid)
LTM->>UF: SELECT ... LIMIT 15
UF-->>LTM: 5 фактов
LTM-->>M: "[ПАМЯТЬ О ПОЛЬЗОВАТЕЛЕ]..."
M->>M: streamprompt = ltmtext + stream_prompt
M-->>U: «Доброе утро, Руслан! В Пензе сегодня немного дождливо, ты не забыл зонтик?»


Где берет первичные данные и изоляция между пользователями


Пользователь регистрируется в системе ассистента, указывает имя и пол - ассистент берет изначально данные из этих данных. Позже, в процессе диалогов накапливает постепенно досье, но стартовую информацию для самого первого знакомства возьмет именно из профиля пользователя. 

И да, собранное досье пользователя хранится в db в зашифрованном виде - даже я, как разработчик, не смогу прочитать это, так как у каждого юзера свой ключ шифрования. 

Также, что логично, у ассистента изолированы пользовательские данные, следовательно, ассистент, при общении, с Русланом "помнит" только факты о Руслане. Поэтому, при общении в профиле Антона, ему не будут инжектиться факты об Руслане. У Антона собирается свой собственный архив. В общем, МНЕМОС, как таковой, обучается в диалогах и инжектит свои знания независимо от пользователя, то LTM - это персонализированная и изолированная память об пользователе.


Сравнение с аналогами

ФичаMemGPT/LettaMem0Мой LTM
Core memory (факты о пользователе)да, но ручной ввод в memoryда (add/delete)да, автоизвлечение LLM
Автономное извлечение из диалоговнетнетда (крон 6ч)
Отдельный блок для стиля общениянетнетда (Style Profile)
Per-user изоляциядадада
UPSERT без дублейдадада
Лимит «что влезет в промпт»да (жёсткий)нетда (15 фактов)
Часть долгосрочной памяти (МНЕМОС)отдельноотдельновстроен в МНЕМОС

Главное отличие: у Mem0/Letta факты добавляет разработчик или пользователь (API-вызовом или вручную). У меня факты собирает сам ассистент — LLM-анализатор раз в 6 часов читает диалоги и догадывается, что «Руслан» — это имя, а «не называй меня на Вы» — исправление. Zero-кода, zero-ручной модерации.

 

Границы и ограничения

Честно о том, где пока LTM не идеальна и над чем еще работать:

  1. Задержка до 6 часов. Только что сказанный факт мгновенно попадает в историю (STM), но в блок [ПАМЯТЬ] — только после ближайшего LTM-прохода. Если пользователь сообщил «у меня теперь свой бизнес» и тут же спросил про бизнес — в этом же диалоге ассистент может этого не «знать» как факта.

  1. Только явные факты. Промпт извлечения запрещает додумывать. «Похоже, пользователь интроверт» — не извлечётся. Это осознанный выбор: лучше недобрать, чем наврать в досье.

  1. Конфликт с «запомни». Есть два пути записи факта: явная команда «запомни: правило X» (→ memory.py, мгновенно) и автоизвлечение LTM (→ фон). Оба пишут в userfacts, но с разными contexttype. Нужно следить, чтобы автоизвлечение не затирало явно введённые правила.

  1. Ограничение 40 фактов. Для очень активных пользователей старые факты отваливаются. Пока на практике это не наступает (5-10 фактов на человека), но предел есть.

  1. Ошибка извлечения. Известный некритичный баг Fact extraction error: 'dict' has no attribute 'strip' — падает отдельное извлечение, система продолжает работать с прошлыми фактами. Логируется и не роняет сервис.

Что дальше

В следующих статьях:

  • Замкнутый цикл обучения — Sufler → fail → ЛИКБЕЗ → знание (как LTM и МНЕМОС кормят друг друга)
  • Matematicus — 3-level matching для точных расчётов
  • Противодействие противоречиям — что делать, когда факты конфликтуют (Contradiction Resolver)

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

← Нейросеть стала стандартом: но так ли хорошо ли эт
Эксперимент. ИИ-ассистент, как автор статей и блог →
Автору на кофе ☕🥐

Комментарии

Для комментирования авторизуйтесь на сайте