Разбираю Long-Term Memory (LTM) — отдельный модуль app/ltmemory.py, который архитектурно входит в МНЕМОС. Пока МНЕМОС (L1/L2/L3) помнит, как отвечать, LTM помнит, кого обслуживает. Два независимых процесса: фоновое извлечение фактов по крону раз в 6 часов и realtime-инъекция в промпт при старте сессии. Сравниваю с «core memory» MemGPT и Mem0.
Это пятая статья из серии «Как я собрал self-improving AI-ассистента». Предыдущие:
- Общий обзор архитектуры
- МНЕМОС: трёхуровневая долгосрочная память
- STM + МНЕМОС: двойная память
- ИНТУИТИВНЫЙ КОНТУР — как ассистент «чувствует» запрос до того, как подумает
Проблема: ассистент каждый раз знакомился заново
В статье про МНЕМОС я писал: «Память человека так не работает — каждый новый диалог начинается не с чистого листа». МНЕМОС решил половину проблемы: ассистент запомнил, как отвечать — успешные примеры, тематические коллекции, антипаттерны.
Но оставалась вторая половина. И она бесила сильнее:
Пользователь: «Привет, я Руслан, мы вчера обсуждали прайс»
Ассистент: «Приятно познакомиться, Руслан! О каком прайсе речь?» 🤦
Каждый новый диалог — заново знакомимся. Ассистент не помнил, кто перед ним:
- Как зовут собеседника и как к нему обращаться
- Кто он, какая профессия, из какого города
- Что с ним уже было: три месяца назад он сказал «обращайся на ты» — и это должно работать всегда
- Как он предпочитает получать ответы: коротко или развёрнуто, с эмодзи или без, на языке профи или «по-человечески»
- Его стиль общения
МНЕМОС это не закрывал: там лежит опыт (Q&A с оценками Sufler), а не досье на пользователя. Хранилище было общим, а персональный слой — отсутствовал.
История диалога (STM/БРЭЙН) решала только вопрос «о чём мы говорили в этом разговоре». Вчерашний диалог — уже не в контексте, там 12000 символов лимит.
Нужна была отдельная система, которая собирает факты о пользователе и помнит их между сессиями. Я назвал её LTM — Long-Term Memory.
Решение: отдельный модуль, который знает тебя
LTM — это модуль app/ltmemory.py (230 строк). Он делает ровно две вещи:
1. Извлекает факты о пользователе из диалогов (LLM, фоновый крон раз в 6ч)
Инжектит их в промпт при старте сессии (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 | «сдержан» |
Сохранение (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) + ЛИКБЕЗ + Gapfill | LLM-извлечение из диалогов (крон 6ч) |
| Ключ | вектор в ChromaDB (2560-dim) | строка в userfacts |
| Хранилище | ChromaDB + responselog | PostgreSQL userfacts |
| Per-user изоляция | коллекции userN / глобальные | строго по user_id |
| Обновление | каждый ответ + крон | крон раз в 6ч |
| Жизненный цикл | навсегда | навсегда (макс 40 фактов) |
| Стиль-адаптация | few-shot (похожие примеры) | Style Profile (характеристики) |
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] [ЖАНР: ЗАКАЗ] |
sequenceDiagramparticipant 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: «Доброе утро, Руслан! В Пензе сегодня немного дождливо, ты не забыл зонтик?»
Где берет первичные данные и изоляция между пользователями
Пользователь регистрируется в системе ассистента, указывает имя и пол - ассистент берет изначально данные из этих данных. Позже, в процессе диалогов накапливает постепенно досье, но стартовую информацию для самого первого знакомства возьмет именно из профиля пользователя.
Сравнение с аналогами
| Фича | MemGPT/Letta | Mem0 | Мой LTM |
|---|---|---|---|
| Core memory (факты о пользователе) | да, но ручной ввод в memory | да (add/delete) | да, автоизвлечение LLM |
| Автономное извлечение из диалогов | нет | нет | да (крон 6ч) |
| Отдельный блок для стиля общения | нет | нет | да (Style Profile) |
| Per-user изоляция | да | да | да |
| UPSERT без дублей | да | да | да |
| Лимит «что влезет в промпт» | да (жёсткий) | нет | да (15 фактов) |
| Часть долгосрочной памяти (МНЕМОС) | отдельно | отдельно | встроен в МНЕМОС |
Границы и ограничения
Честно о том, где пока LTM не идеальна и над чем еще работать:
- Задержка до 6 часов. Только что сказанный факт мгновенно попадает в историю (STM), но в блок
[ПАМЯТЬ]— только после ближайшего LTM-прохода. Если пользователь сообщил «у меня теперь свой бизнес» и тут же спросил про бизнес — в этом же диалоге ассистент может этого не «знать» как факта.
- Только явные факты. Промпт извлечения запрещает додумывать. «Похоже, пользователь интроверт» — не извлечётся. Это осознанный выбор: лучше недобрать, чем наврать в досье.
- Конфликт с «запомни». Есть два пути записи факта: явная команда «запомни: правило X» (→
memory.py, мгновенно) и автоизвлечение LTM (→ фон). Оба пишут вuserfacts, но с разнымиcontexttype. Нужно следить, чтобы автоизвлечение не затирало явно введённые правила.
- Ограничение 40 фактов. Для очень активных пользователей старые факты отваливаются. Пока на практике это не наступает (5-10 фактов на человека), но предел есть.
- Ошибка извлечения. Известный некритичный баг
Fact extraction error: 'dict' has no attribute 'strip'— падает отдельное извлечение, система продолжает работать с прошлыми фактами. Логируется и не роняет сервис.
Что дальше
В следующих статьях:
- Замкнутый цикл обучения — Sufler → fail → ЛИКБЕЗ → знание (как LTM и МНЕМОС кормят друг друга)
- Matematicus — 3-level matching для точных расчётов
- Противодействие противоречиям — что делать, когда факты конфликтуют (Contradiction Resolver)
Если есть вопросы по конкретному компоненту — пишите в комментариях.
Комментарии
Для комментирования авторизуйтесь на сайте