STM + МНЕМОС: как оперативная и долгосрочная память работают в паре
В данной статье разбираю dual-memory архитектуру: STM (Short-term Memory) решает проблему character limit внутри диалога (якоря + история + поиск), МНЕМОС — долгосрочное обучение между диалогами. Вместе они дают «человеческую» память ассистента. Сравниваю с MemGPT — концептуально близко, но реализация другая.
> Где SATI в AI-экосистеме: STM + МНЕМОС — это не Memory SDK. Это конкретная dual-memory реализация внутри production-приложения, дополненная замкнутым циклом самообучения.
Это третья статья из серии. Предыдущие:
Проблема, которую не решает один МНЕМОС
МНЕМОС отлично работает «между» диалогами — хранит опыт, тематические примеры, антипаттерны. Но внутри одного длинного диалога у него другая проблема:
Диалог из 50 сообщений:
Хранится в БД: [сообщение 1] [сообщение 2] ... [сообщение 47] [сообщение 48] [сообщение 49] [сообщение 50]
↑ ↑
прайс-лист последнее
(85 строк) «переведи»
│ │
Загружается в LLM: │ character limit 20000 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ сообщение 20 ... сообщение 50 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
❌ прайс НЕ влез ✅ последнее влезло
Сообщения 1–19 лежат в БД, но LLM их НЕ видит. Мёртвый груз. Отсюда галлюцинации: «не вижу сумму», «какой прайс?», «опишите подробнее».
МНЕМОС про это не знает — он оперирует Q&A-парами, а не непрерывной историей диалога. Для оперативной памяти внутри треда нужна отдельная система. Я назвал её STM.
Две независимые системы памяти
flowchart TB
subgraph STM["⚡ STM (Short-term Memory) — оперативная память диалога"]
B1["Слой ">1: ЯКОРЯ<br/>~">3000 симв между<br/>тема + данные<br/>всегда в промпте"]
B2["Слой ">2: ИСТОРИЯ<br/>последние N сообщений<br/>character limit ">20000"]
B3["Слой ">3: ПОИСК<br/>ToolCall от LLM<br/>SQL LIKE по chat_history"]
end
subgraph MNEMOS["🧠 МНЕМОС — долгосрочная память"]
M1["L1: experience_bank<br/>~">1000 векторов"]
M2["L2: expL2{theme}<br/>">8 тематических"]
M3["L3: experience_fails<br/>антипаттерны"]
end
B1 -.->|тема запроса| M2
B2 -.->|user_query| M1
B3 -.->|ничего| MNEMOS
M1 -.->|few-shot| B2
STM живёт один диалог (при F5 / смене треда очищается).
МНЕМОС живёт вечно (накапливается между всеми диалогами).
Таблица сравнения
| Параметр | STM | МНЕМОС |
|---|---|---|
| Назначение | Не забыть данные внутри диалога | Учиться на всех диалогах |
| Файлы | microgoal.py, chathistory.py | experiencebank/, research.py |
| Хранилище | chathistory (SQL), generalsettings | ChromaDB, responselog, research_* |
| Жизненный цикл | Очищается при смене треда | Накапливается вечно |
| Размер | ~100–500 сообщений на диалог | ~1 000+ записей, 27 MB ChromaDB |
| Активация | Каждое сообщение | После каждого ответа + крон |
Слой 1 STMа: якоря
Якорь — короткая заметка, которая всегда в промпте. Содержит тему диалога и ключевые данные.
[ТЕМЫ ДИАЛОГА: прайс-лист → расчёт себестоимости → конвертация в USD]
[ДАННЫЕ: ЛДСП Серый графит 2.57м² × 327.39₽ = 841.39₽ | ДВП Белый 0.67 × 91₽ = 60.61₽ | Итого: 1 050.19₽]
Правила постановки якорей:
"hl-kw">def shouldanchor(mgstack, charcount, currenttopic):
"""Решает, нужен ли новый якорь."""
last = mg_stack[-"hl-num">1]
# Триггер "hl-num">1: накопилось достаточно символов
"hl-kw">if charcount - last.charpos >= "hl-num">3000:
"hl-kw">return "NEW_ANCHOR"
# Триггер "hl-num">2: тема сменилась резко
"hl-kw">if current_topic != last.topic:
# Проверяем — может, это продолжение?
"hl-kw">if findsimilaranchor(mgstack, currenttopic):
"hl-kw">return "MERGE_ANCHOR"
"hl-kw">return "NEW_ANCHOR"
# Триггер "hl-num">3: данные обновились (Matematicus / SearXNG)
"hl-kw">if currentdatachanged:
"hl-kw">return "UPDATEANCHORDATA"
"hl-kw">return "NO_ANCHOR"
flowchart TD
Msg[Сообщение] --> Char{Накоплено<br/>≥"hl-num">3000 симв?}
Char -->|Да| New[Новый якорь]
Char -->|Нет| Topic{Тема<br/>изменилась?}
Topic -->|Да| Similar{Похожа на<br/>предыдущую?}
Similar -->|Да| Merge[Merge якорь]
Similar -->|Нет| New
Topic -->|Нет| Data{Данные<br/>обновились?}
Data -->|Да| Upd[Обновить data]
Data -->|Нет| Nothing[Без изменений]Где хранится: таблица microgoals + дубликат в generalsettings под ключом microgoals{userid}{thread_id}.
Лимит: ~1500 симв на data-поле якоря. Если Matematicus выдал расчёт на 5 страниц — это в якорь не влезет. Поэтому data — это выжимка: только итог и ключевые позиции.
Якоря как save points: открытие из реальных логов
Якоря в STM — это автосохранения, как save points в RPG-играх. Если контекст потеряется (character limit, смена треда), система найдёт ближайший якорь и «загрузит» его обратно.
Реальный пример из лога (диалог про ВНЖ Казахстана):
# Тема Позиция
1 Утреннее приветствие Сати 21 881
2 Правила ВНЖ Казахстана 22 004
3 Запрет ВНЖ ...
...
15 Открытые API контрагентов 45 165
16 SABY Сбис API 45 840
17 API ФНС России 46 264
18 выражение благодарности 49 194
18 якорей за один диалог. Ни один из них не сработал по 3000-символьному порогу — все сработали на смене темы.
Почему это не баг, а фича
Изначально я проектировал якоря срабатывать либо каждые ~3000 символов, либо при смене темы. На практике:
extracttopicllmхорошо различает темы → на каждом реальном повороте диалога срабатывает «смена темы»- 3000-символьный порог почти никогда не достигается — это страховка, а не основной триггер
- Больше якорей = меньше потеря контекста — если контекст потеряется, у STM всегда есть свежий якорь с темой и ключевыми цифрами
В играх это очевидно: чем чаще save — тем меньше теряешь при смерти. То же самое в STM. Частые якоря — это фича, а не баг.
Если бы я проектировал систему заново и хотел «оптимизировать», я бы не трогал extracttopicllm. Якоря каждые ~1600 символов лучше, чем якоря каждые ~3000 символов.
Слой 2 STMа: история диалога
Линейная загрузка последних N сообщений из БД с обрезкой по character limit.
"hl-kw">def loadhistorymessages(chatrows, maxchars="hl-num">20000): """Берёт последние N сообщений, обрезает по символам.""" result = [] total_chars = "hl-num">0 "hl-kw">for row "hl-kw">in "hl-kw">class="hl-kw">$"hl-num">1(chat_rows): msg = format_message(row) "hl-kw">if totalchars + "hl-kw">class="hl-kw">$"hl-num">1(msg) > maxchars: "hl-kw">break result.insert("hl-num">0, msg) total_chars += "hl-kw">class="hl-kw">$"hl-num">1(msg) "hl-kw">return result flowchart LR DB[(chat_history)] -->|SELECT last "hl-num">100| All[Все сообщения] All -->|"hl-kw">class="hl-kw">$"hl-num">1| Loop[Итерация назад] Loop -->|"hl-kw">class="hl-kw">$"hl-num">1 sum| Check{< "hl-num">20000<br/>символов?} Check -->|Да| Add[Добавить] Add --> Loop Check -->|Нет| Stop[Стоп] Add --> Result[История для промпта] Stop --> Result
Почему SQL, а не векторный поиск?
Для истории диалога важна последовательность, а не семантика. Vector DB теряет порядок. SQL — нет.
Слой 3 STMа: ToolCall поиск по истории
LLM сама решает, когда ей нужно искать в истории, и пишет в ответе специальный маркер:
SATI отвечает: «SEARCH_HISTORY: ЛДСП цена прайс»
Парсер eventstream ловит маркер, делает SQL LIKE поиск по chathistory, инжектит результат обратно в промпт:
"hl-kw">def searchchathistory(conn, userid, threadid, query):
"""SQL LIKE по всей истории диалога."""
keywords = query.lower().split()
conditions = " ">OR ".join(
f"content LIKE '%{kw}%'" "hl-kw">for kw "hl-kw">in keywords "hl-kw">if "hl-kw">class="hl-kw">$"hl-num">1(kw) >= "hl-num">3
)
cursor = conn.execute(f"""
SELECT content, createdat ">FROM chathistory
WHERE userid = ? ">AND threadid = ? ">AND ({conditions})
ORDER BY id DESC LIMIT ">5
""", (userid, threadid))
"hl-kw">return cursor.fetchall()
sequenceDiagram
participant LLM
participant ES "hl-kw">as event_stream
participant SQL
participant Prompt
LLM->>ES: "SEARCH_HISTORY: ЛДСП прайс"
ES->>SQL: SELECT content LIKE
SQL-->>ES: ["ЛДСП Серый графит ">327.39₽", "ДВП Белый ">91₽", ...]
ES->>Prompt: inject "[НАЙДЕНО В ИСТОРИИ: ...]"
Prompt->>LLM: "hl-num">2-й проход с найденными данными
LLM-->>User: "">327.39 / ">74.62 = $">4.39/м²"Почему SQL LIKE, а не ChromaDB?
| Метод | Скорость | Сложность | Для наших данных |
|---|---|---|---|
| SQL LIKE | 1–5ms (<10K строк) | 0 | ✅ Достаточно |
| PG tsvector | 0.1ms | +5 строк SQL + индекс | ⚠️ Избыточно |
| ChromaDB векторный | 10–50ms | Уже есть | ❌ Избыточно |
chat_history (100–500 строк на тред) SQL LIKE мгновенный. Не нужен ни полнотекстовый индекс, ни отдельная поисковая система.
Полный цикл одного сообщения: STM + МНЕМОС вместе
flowchart TB
User[Пользователь пишет] --> STM1[STM L1: extract_topic]
STM1 --> STM2[STM L1: should_anchor?]
STM2 -->|нужен| STM3[pushmicrogoal]
STM2 -->|не нужен| STM4[продолжаем]
STM3 --> STM5[STM L2: load_history]
STM4 --> STM5
STM5 --> Mnemos1[МНЕМОС: search_experiences]
Mnemos1 --> Assembler[Prompt Assembler]
STM5 --> Assembler
STM3 -->|якорь| Assembler
Assembler --> LLM[LLM отвечает]
LLM --> Decision{LLM пишет<br/>SEARCH_HISTORY?}
Decision -->|Да| STM6[STM L3: SQL поиск]
STM6 --> LLM2["hl-num">2-й проход LLM]
LLM2 --> User2[Ответ пользователю]
Decision -->|Нет| User2
User2 --> Sufler[Sufler оценивает]
Sufler -->|score ≥ "hl-num">4| Mnemos2[МНЕМОС L1]
Sufler -->|score ≤ "hl-num">2| Mnemos3[МНЕМОС L3]
Sufler -->|score ≤ "hl-num">2| Log[researchsuflerfails]Шаг за шагом:
"hl-num">1. Пользователь: «Переведи итог в доллары»
↓
STM L1: extracttopicllm → «конвертация валют»
↓
STM L1: should_anchor? → тема не изменилась → БЕЗ якоря
↓
STM L2: loadhistorymessages → последние "hl-num">20 сообщений
↓
МНЕМОС: search_experiences → топ-"hl-num">3 примера по теме
↓
Prompt Assembler: собирает system_prompt
↓
LLM видит якорь: «прайс → расчёт себестоимости»
Видит в промпте: «[ТЕМЫ: ...] [ДАННЫЕ: "hl-num">1 050₽]»
Прайс выпал из истории, но LLM это понимает
↓
LLM решает: «SEARCH_HISTORY: прайс ЛДСП»
↓
STM L3: SQL LIKE → «ЛДСП "hl-num">327.39₽ | ДВП "hl-num">91₽ | ...»
↓
LLM "hl-num">2-й проход: «"hl-num">1 050 / "hl-num">74.62 = $"hl-num">14.07»
↓
Ответ пользователю
↓
Sufler: score="hl-num">5 → МНЕМОС L1 (сохранить как успех)
Пример: диалог про НДС
Полный сценарий, где обе системы работают в паре:
ПОЛЬЗОВАТЕЛЬ: «Но не всегда же нужно считать с НДС!»
↓
STM:
extracttopicllm → «Обязательность учёта НДС»
pushmicrogoal → новый микро-гол
should_anchor → merge с Anchor #"hl-num">3
injectfewshot → "hl-num">5→"hl-num">3 примера по теме НДС
↓
LLM отвечает: «НДС обязателен на все операции по реализации...»
↓
Sufler: score="hl-num">2 (галлюцинация — НДС не на всё)
↓
МНЕМОС:
savefailexample → ChromaDB experience_fails
savesuflerfail → researchsuflerfails (id="hl-num">154)
autoaddresearchtopic → «Когда НДС не учитывается»
↓
Через "hl-num">3 часа (ЛИКБЕЗ):
collectresearchtopics() → sufler_fail «Когда НДС не учитывается»
SearXNG → "hl-num">8 статей
Сохраняет в research_articles
Индексирует в ChromaDB МНЕМОС L2
↓
Следующий диалог:
STM (few-shot) находит статью из МНЕМОС L2
→ SATI знает когда НДС применять
→ Больше не галлюцинируетГраница между STM и МНЕМОС
Ключевой вопрос: что где хранить?
| Данные | STM | МНЕМОС |
|---|---|---|
| Последние сообщения диалога | ✅ | ❌ |
| Тема текущего диалога | ✅ (якорь) | ❌ |
| Ключевые цифры из текущего расчёта | ✅ (data якоря) | ❌ |
| Все прошлые Q&A с оценкой | ❌ | ✅ |
| Успешные few-shot примеры | ❌ | ✅ |
| Антипаттерны | ❌ | ✅ |
| Тематические коллекции | ❌ | ✅ |
| Статьи ЛИКБЕЗ | ❌ | ✅ |
| Загруженные документы | ❌ | ✅ (RAG) |
| Курсы валют | ❌ | ❌ (CBR per-message) |
Когда что срабатывает
flowchart LR
User[Пользователь пишет] --> STM[STM: extracttopic<br/>shouldanchor<br/>load_history]
STM -->LLM[LLM генерирует]
LLM --> STMSearch[STM L3:<br/>SEARCH_HISTORY]
STMSearch --> LLM
LLM --> User2[Ответ]
User2 --> Sufler[Sufler оценивает]
Sufler --> Mnemos[МНЕМОС:<br/>saveexperience / savefail]
Mnemos -.->|крон "hl-num">3ч| Likbez[ЛИКБЕЗ]
Likbez -.-> Mnemos| Момент | STM | МНЕМОС |
|---|---|---|
| Пользователь пишет | L1 (тема), L3 (pending action) | — |
| Сборка промпта | L1 (якоря), L2 (история), PreLLM | few-shot, userfacts |
| LLM ответил | L3 (SEARCHHISTORY / MATEMATICUS) | — |
| После ответа | — | Sufler, Chutye, сохранение |
| Крон (3ч/6ч/Вс) | — | ЛИКБЕЗ, Gapfill, Selfcal |
Что это решает
| Проблема | Без системы | С STMом + МНЕМОС |
|---|---|---|
| «договор» | Few-shot галлюцинация | SEARCHHISTORY находит реальный прайс |
| «не вижу сумму» | Character limit обрезал | Якорь + SQL поиск достают из БД |
| «какой курс?» | Берёт из таблиц | CBR всегда в контексте (per-message) |
| Смена темы | Микро-гол потерян | Новый якорь + старые данные через поиск |
| Слияние тем | Путаница | mergeanchor() объединяет похожие якоря |
| Ошибки повторяются | Те же галлюцинации | Sufler → ЛИКБЕЗ → ChromaDB → знание |
| Не знает новое | Только предобучение | ЛИКБЕЗ исследует интернет каждые 3 часа |
Сравнение с MemGPT / Letta
MemGPT оперирует одной моделью памяти (core/archival/recall). У меня — две независимые, каждая со своей логикой жизненного цикла.
| Фича | MemGPT/Letta | STM + МНЕМОС |
|---|---|---|
| Tiered memory | core / archival / recall | STM (3 слоя) + МНЕМОС (3 слоя) |
| Always-in-context | core memory | якоря STM + few-shot МНЕМОС |
| Searchable archive | archival (vector) | МНЕМОС L1/L2 + chathistory (SQL) |
| Tool-call для поиска | recall | SEARCHHISTORY (BРЭЙН L3) |
| Memory lifecycle | ручной edit / agent decides | auto-anchor + auto-save |
| Personal data isolation | да | да (по userid) |
| Failure storage | нет | experiencefails (L3) |
| Cross-dialog learning | да | да + ЛИКБЕЗ-цикл |
| Anchor auto-placement | ручной | каждые ~3000 симв + topic shift |
Что в дальше:
- Пару слов о интуитивном контуре: Contour - как фундаментальная надстройка над памятью
- LTM: долгосрочная память о пользователе внутри МНЕМОС
- Замкнутый цикл обучения: Sufler оценивает → fail → research_sufler_fails → ЛИКБЕЗ → статьи → ChromaDB → few-shot. Полная production-цепочка self-improvement.
- Matematicus: 3-х уровневый matching для точных расчётов без галлюцинаций
Вопросы:
- Делали dual-memory в проде? Какие проблемы с очисткой state?
- Anchor по длине диалога — хорошо работает или нужны другие триггеры?
- SQL LIKE для истории диалога — на каком объёме начнёт тормозить?
Если есть вопросы по конкретному компоненту — пишите в комментариях или в личку.
Комментарии
Для комментирования авторизуйтесь на сайте