3.STM + МНЕМОС: Двойная память AI - ассистента

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.pyexperiencebank/, research.py
Хранилищеchathistory (SQL), generalsettingsChromaDB, 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 LIKE1–5ms (<10K строк)0✅ Достаточно
PG tsvector0.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)


Принцип: STM — то, что нужно для текущего диалога. МНЕМОС — то, что пригодится в любом будущем диалоге.

Когда что срабатывает

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 (история), PreLLMfew-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/LettaSTM + МНЕМОС
Tiered memorycore / archival / recallSTM (3 слоя) + МНЕМОС (3 слоя)
Always-in-contextcore memoryякоря STM + few-shot МНЕМОС
Searchable archivearchival (vector)МНЕМОС L1/L2 + chathistory (SQL)
Tool-call для поискаrecallSEARCHHISTORY (BРЭЙН L3)
Memory lifecycleручной edit / agent decidesauto-anchor + auto-save
Personal data isolationдада (по userid)
Failure storageнетexperiencefails (L3)
Cross-dialog learningдада + ЛИКБЕЗ-цикл
Anchor auto-placementручнойкаждые ~3000 симв + topic shift



Главное отличие: у меня STM очищается при смене диалога. У MemGPT core memory persists across sessions. Это осознанное решение — мне не нужно помнить тему диалога месячной давности в новом чате.

Что в дальше:




Вопросы:

  • Делали dual-memory в проде? Какие проблемы с очисткой state?
  • Anchor по длине диалога — хорошо работает или нужны другие триггеры?
  • SQL LIKE для истории диалога — на каком объёме начнёт тормозить?

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



← Рынок труда в РФ глазами соискателя на 2026 год.
«МойОфис» закрывает офисы: российский «убийца» MS →
Автору на кофе ☕🥐

Комментарии

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