2.МНЕМОС: трёхуровневая долгосрочная память для AI-ассистента
Разбираю tiered memory system с L1/L2/L3 слоями, тематическими коллекциями в ChromaDB, age decay и интеграцией с evaluator-LLM. Сравниваю с MemGPT/Letta — совпадения есть, но архитектурные решения специфичны для production-случая.
Где мой ассистент в AI-экосистеме: МНЕМОС — это не Memory SDK уровня Mem0/Letta/Zep. Это конкретная реализация tiered memory внутри приложения, дополненная замкнутым циклом самообучения.
Это вторая статья из серии «Как я собрал self-improving AI-ассистента». Первая — общий обзор архитектуры.

Зачем нужна долгосрочная память

Представим, мы каждый раз обращаемся к другому человеку (другу, супругу, коллеге) и он каждый раз не помнит о чем говорили вчера, каждый новый диалог начинается с чистого листа, с нового знакомства. Удобно? Не думаю))  Память человека так не работает. Значит и мой AI-ассистент не должен работать так, подумал я: (в первой статье я описывал, чем меня там бесили дипсики и прочие GPT-чаты). Мне захотелось чтоб мой ассистент работал так:

  • Вчера обсуждали прайс — сегодня ожидаем, что ассистент помнит
  • На прошлой неделе ассистент ошибся в расчёте НДС — на этой неделе не должен повторить
  • Три месяца назад пользователь сказал «обращайся на ты» — это должно работать всегда

Короткая история диалога (последние 20 сообщений) решает только первое. Для остального нужна отдельная система, которая:

  1. Сохраняет успешные ответы как примеры «как надо»
  2. Сохраняет провалы как примеры «как НЕ надо»
  3. Подтягивает релевантные примеры в prompt в зависимости от темы запроса
  4. Обновляется автоматически, без ручной модерации

Я назвал эту систему МНЕМОС (по аналогии с греческой богиней памяти).


Три уровня памяти

Я разделил долгосрочную память на три логических уровня:

flowchart TB
    subgraph L1["L1: Глобальный банк опыта"]

        L1a["Seed-примеры<br/>">15 синтетических<br/>(холодный старт)"]

        L1b["Real Q&A<br/>score ≥ ">4<br/>со всех диалогов"]

        L1c["Gapfill<br/>авто-синтез<br/>по темам"]

    end

subgraph L2["L2: Тематические коллекции"]

        L2finance["expL2_finance"]

        L2business["expL2_business"]

        L2tech["expL2_tech"]

        L2law["expL2_law"]

        L2health["expL2_health"]

        L2science["expL2_science"]

        L2history["expL2_history"]

        L2weather["expL2_weather"]

    end

subgraph L3["L3: Антипаттерны"]

        L3a["score ≤ ">2<br/>с reasoning<br/>для инъекции"]

    end

Query[Запрос пользователя] --> Classify{Классификатор темы}

    Classify -->|finance| L2_finance

    Classify -->|business| L2_business

    Classify -->|...| L2_other[...]

    Classify -->|unknown| L1


L1 — глобальный банк опыта (experience_bank)

Что: Все реальные Q&A с оценкой ≥ 4, плюс seed-примеры для холодного старта.

Где: ChromaDB коллекция experiencebank + SQLite responselog.

Метаданные каждого вектора:

{
    "user_query": "hl-kw">class="hl-kw">$"hl-num">1,

    "response": "hl-kw">class="hl-kw">$"hl-num">1,

    "score": "hl-kw">class="hl-kw">$"hl-num">1,  # "hl-num">1"hl-num">-5

    "theme": "hl-kw">class="hl-kw">$"hl-num">1,  # finance/business/tech/...

    "user_id": "hl-kw">class="hl-kw">$"hl-num">1,

    "is_seed": "hl-kw">class="hl-kw">$"hl-num">1,

    "created_at": datetime,

    "tokens_used": "hl-kw">class="hl-kw">$"hl-num">1,

    "latency_ms": "hl-kw">class="hl-kw">$"hl-num">1,

}

Размерность: 1024 (GigaChat embeddings).

Seed-данные для холодного старта — 14 синтетических примеров:

КатегорияКол-во
Приветствия и шутки2
Погода1
Напоминания1
Календарь1
Финансы (EBITDA, NPV)2
Анализ договоров1
Курсы валют1
Email и планы2
Сообщения1
Возможности ассистента1
Без них при пустой базе few-shot нечего подтягивать, и ассистент галлюцинирует.


 

L2 — тематические коллекции (expL2{theme})

Что: Дубликаты L1-векторов, разложенные по 8 тематическим коллекциям.

Зачем: Приоритетный поиск по теме запроса. Если пользователь спрашивает про кредит — сначала ищем в expL2finance, потом (если мало) — в общем experience_bank.

THEMES = ["finance", "business", "tech", "law",
          "health", "science", "history", "weather"]


"hl-kw">def saveexperience(userquery, response, score, user_id, ...):

    theme = classifyquerytheme(user_query)

    savetosqlite(user_query, response, score, theme)

    savetochroma("experience_bank", ...)

    "hl-kw">if theme "hl-kw">and score >= "hl-num">4:

        savetochroma(f"expL2{theme}", ...)

Тематический классификатор — простой, keyword-based, но взвешенный:

TOPIC_RULES = {
    "finance": ["бюджет", "налог", "прибыль", "кредит",

                "инвестиция", "выручка", ...],

    "weather": ["погода", "температура", "дождь", "снег"],

    "health":  ["врач", "больница", "лекарство", "диагноз"],

    "tech":    ["код", "сервер", "база", "API", "Python", "SQL"],

    "history": ["история", "век", "война", "царь"],

    "science": ["физика", "химия", "биология", "формула"],

    "law":     ["закон", "суд", "иск", "договор", "юрист"],

    "business":["компания", "производство", "продажа",

                "маркетинг", "персонал"],

}


"hl-kw">def classifyquerytheme(query):

    """Возвращает тему с наибольшим score."""

    scores = {theme: "hl-num">0 "hl-kw">for theme "hl-kw">in TOPIC_RULES}

    words = query.lower().split()

    "hl-kw">for word "hl-kw">in words:

        "hl-kw">for theme, keywords "hl-kw">in TOPIC_RULES.items():

            "hl-kw">if word "hl-kw">in keywords:

                # Длинные слова весят больше

                scores[theme] += "hl-num">2 "hl-kw">if "hl-kw">class="hl-kw">$"hl-num">1(word) >= "hl-num">6 "hl-kw">else "hl-num">1

    best = max(scores, key=scores.get)

    "hl-kw">if scores[best] >= "hl-num">4:

        "hl-kw">return best

    "hl-kw">return "general"

Длинные слова (≥ 6 букв) имеют вес × 2. Порог срабатывания — score ≥ 4 (из 8+ тем).



L3 — антипаттерны (experience_fails)

Что: Ответы с оценкой ≤ 2 + reasoning от Sufler.

Где: Отдельная коллекция experience_fails.

Зачем: Few-shot работает не только на «как надо», но и на «как НЕ надо». Если пользователь спросит «как посчитать НДС?», LLM увидит:

⚠️ АНТИПРИМЕРЫ (не делай так):

Вопрос: «Когда применяется НДС?»
Ошибка: «НДС всегда 20% на всё, даже на услуги»
Правильно: НДС не применяется на ряд операций (ст. 149 НК РФ)

flowchart LR
    Response[Ответ LLM] --> Sufler

    Sufler|score ≥ "hl-num">4| L1

    Sufler|score "hl-num">3| L1low[save в L1<br/>без приоритета]

    Sufler|score ≤ "hl-num">2| L3

    Sufler|score ≤ "hl-num">2| FailsLog[(researchsuflerfails)]

    FailsLog -.->|каждые "hl-num">3ч| Likbez[ЛИКБЕЗ]


Как работает поиск: 3 уровня fallback

При каждом запросе ассистент ищет релевантные примеры в три стадии:


Возрастной decay — старые примеры менее ценны:

"hl-kw">def applyagedecay(results):
    """Понижаем score старых примеров."""

    now = datetime.now()

    "hl-kw">for r "hl-kw">in results:

        agedays = (now - r["createdat"]).days

        r["adjustedscore"] = r["score"]  ("hl-num">1 - 0.01  agedays)

    "hl-kw">return "hl-kw">class="hl-kw">$"hl-num">1(results, key="hl-kw">lambda r: r["adjusted_score"], reverse="hl-kw">class="hl-kw">$"hl-num">1)

Через 100 дней пример автоматически «устаревает» (× 0). Это не удаление, а переранжирование — если ничего свежего нет, старый пример всё равно всплывёт.


ChromaDB: как это устроено под капотом

У меня пять типов коллекций:

flowchart TB
    subgraph "ChromaDB"

        EB["experience_bank<br/>1024d<br/>~">1000 векторов"]

        L2["expL2{theme} × ">8<br/>1024d<br/>~">50">-200 каждый"]

        EF["experience_fails<br/>1024d<br/>~">20">-50"]

        Docs["Документы<br/>user-scoped<br/>384d"]

        Likbez["Статьи ЛИКБЕЗ<br/>doc_id ≥ ">100000<br/>1024d"]

    end


    Search[Поиск] --> EB

    Search --> L2

    Search --> EF

    Docs -.->|RAG| Assistant

    Likbez -.->|RAG| Assistant



Почему 1024d у МНЕМОС и 384d у документов?

GigaChat embeddings API — 1024d для основного чата. Документы — старый формат с 384d (GigaChat embeddings v1). Когда-нибудь переиндексирую, но пока работает.

Dimension freeze — важная мелочь:

"hl-kw">class GigaChatEmbeddingFunction:
    embeddingdim = "hl-kw">class="hl-kw">$"hl-num">1  # замораживается при первом вызове


    "hl-kw">def call("hl-kw">class="hl-kw">$"hl-num">1, texts):

        vectors = "hl-kw">class="hl-kw">$"hl-num">1.callapi(texts)

        "hl-kw">if vectors "hl-kw">and "hl-kw">class="hl-kw">$"hl-num">1.embeddingdim "hl-kw">is "hl-kw">class="hl-kw">$"hl-num">1:

            "hl-kw">class="hl-kw">$"hl-num">1.embeddingdim = "hl-kw">class="hl-kw">$"hl-num">1(vectors["hl-num">0])

        # Если GigaChat сменил размерность — pad/truncate

        "hl-kw">return ["hl-kw">class="hl-kw">$"hl-num">1.padtruncate(v, "hl-kw">class="hl-kw">$"hl-num">1.embeddingdim "hl-kw">or "hl-num">1024)

                "hl-kw">for v "hl-kw">in vectors]

Без freeze ChromaDB начнёт ругаться при первом же изменении размерности API.


Как few-shot попадает в промпт

Перед каждым вызовом LLM ассистент инжектит топ-3 примера:

"hl-kw">def injectfewshot(prompt, message, userid, mgtopic="hl-kw">class="hl-kw">$"hl-num">1):
    """Добавляет в промпт топ-">3 примера из МНЕМОС."""

    examples = searchexperiences(message, userid, top_k="hl-num">5)

    examples = examples[:"hl-num">3]  # берём "hl-num">3 лучших


    fails = searchfailexamples(user_id, limit="hl-num">2)

    echoes = searchpersonalecho(message, userid, topk="hl-num">2)


    block = "\n\n📚 ПРИМЕРЫ (схожий вопрос):\n"

    "hl-kw">for ex "hl-kw">in examples:

        block += f"Вопрос: {ex['user_query']}\n"

        block += f"Ответ: {ex['response'][:">500]}\n\n"


    "hl-kw">if fails:

        block += "⚠️ АНТИПРИМЕРЫ (не делай так):\n"

        "hl-kw">for f "hl-kw">in fails:

            block += f"Вопрос: {f['user_query']}\n"

            block += f"Ошибка: {f['response'][:">200]}\n"

            "hl-kw">if f.get('reasoning'):

                block += f"Почему: {f['reasoning'][:">300]}\n"

            block += "\n"


    "hl-kw">if echoes:

        block += "📝 ЭХО-ПАМЯТЬ (ваш прошлый похожий вопрос):\n"

        "hl-kw">for e "hl-kw">in echoes:

            block += f"Вопрос: {e['user_query']}\n"

            block += f"Ответ: {e['response'][:">300]}\n\n"


    "hl-kw">return prompt + block

Что попадает в промпт пользователю:

📚 ПРИМЕРЫ (схожий вопрос):

Вопрос: «Посчитай рентабельность проекта»
Ответ: «Рентабельность = прибыль / выручка × 100%...»

⚠️ АНТИПРИМЕРЫ (не делай так):
Вопрос: «Сколько стоит открыть ООО?»
Ошибка: «5000₽ — это госпошлина за регистрацию»

📝 ЭХО-ПАМЯТЬ (ваш прошлый похожий вопрос):
Вопрос: «Расскажи про НДС»
Ответ: «НДС — это косвенный налог...»


Meta-Sufler: фильтрация нерелевантных примеров

Проблема: иногда топ-3 примера по косинусу — формально близки, но семантически мимо. Например, запрос про «базу данных сотрудников» находит пример про «SQL-запрос к базе». Векторно близко, по смыслу — нет.

Решение: Meta-Sufler — отдельный LLM-вызов, который оценивает каждый пример от 1 до 5 по релевантности, и отсекает score < 4.

"hl-kw">def metafilterexperiences(experiences, userquery, providercfg):
    """Фильтрует примеры через LLM-оценку релевантности."""

    items = "\n".join(

        f"ID={i}: {ex['user_query'][:">200]}"

        "hl-kw">for i, ex "hl-kw">in "hl-kw">class="hl-kw">$"hl-num">1(experiences)

    )

    prompt = (

        "Ты помогаешь отфильтровать релевантные примеры для few-shot.\n"

        f"Запрос пользователя: {user_query}\n\n"

        f"Примеры:\n{items}\n\n"

        "Оцени релевантность каждого от ">1 до ">5.\n"

        "Если пример НЕ релевантен — напиши 'НЕ ИСПОЛЬЗОВАТЬ'.\n"

        "Формат: ID=X ОЦЕНКА=Y или ID=X НЕ ИСПОЛЬЗОВАТЬ"

    )

    reply = calljudgellm(prompt, provider_cfg)

    keep = []

    "hl-kw">for line "hl-kw">in reply.split("\n"):

        "hl-kw">if "ОЦЕНКА" "hl-kw">in line "hl-kw">and "hl-kw">class="hl-kw">$"hl-num">1(line.split("ОЦЕНКА:")["hl-num">1].strip()["hl-num">0]) >= "hl-num">4:

            idx = "hl-kw">class="hl-kw">$"hl-num">1(line.split("ID=")["hl-num">1].split()["hl-num">0])

            keep.append(experiences[idx])

    "hl-kw">return keep

Стоимость: +"hl-num">1 LLM-вызов перед каждым сообщением. Это ощутимо, поэтому используется только в assistantaction (не-streaming путь), а не в chatstream.

flowchart LR
    Stream["chatstream<br/>(онлайн)"] --> InjectSync["injectfew_shot<br/>только косинус"]

    "hl-kw">Async["assistantaction<br/>(оффлайн)"] --> InjectAS["injectfewshotasync<br/>косинус + Meta-Sufler"]



Что в МНЕМОС лежит сейчас

КоллекцияОбъёмСкорость поиска
experiencebank~1000 векторов<50ms
expL2finance~150<20ms
expL2business~80<20ms
expL2tech~200<20ms
expL2law~30<15ms
expL2health~15<15ms
expL2science~25<15ms
expL2weather~20<15ms
experiencefails~30<20ms
Итого~1 550 векторов~50ms max
Для self-hosted ChromaDB на даже на 2 ГБ RAM этого хватает с запасом. Проблемы начинаются после ~50K векторов — поэтому лучше сразу озаботиться большим объемом памяти.

Сравнение с MemGPT / Letta

Архитектурно совпадение сильное:

flowchart LR
    subgraph "MemGPT"

        MG_Core[Core Memory]

        MG_Arch[Archival Memory]

        MG_Recall[Recall Tool]

    end


    subgraph "Мой МНЕМОС"

        MyL1[L1 experiencebank]

        My_L2[L2 тематические]

        My_L3[L3 антипаттерны]

    end


    MGCore ---|не соответствует| MyL1

    MGCore ---|частично| MyL2

    MGArch --- MyL1

    MGRecall ---|нет аналога| MyL3
ФичаMemGPT/LettaМой МНЕМОС
Tiered memorycore / archival / recallL1 / L2 / L3
Vector DBлюбой (PGVector, Chroma, Qdrant)ChromaDB
Self-editingданет (только через Sufler)
External evaluatorнетSufler
Тематический приоритетнетда (8 тем)
Антипаттерны в few-shotнетда
Personal echo (по user_id)частичнода
Age decayнетда (1% в день)
Автосинтез пробелов (Gapfill)нетда
Weekly self-calibrationнетда (Selfcal)
Главные отличия:
  1. Антипаттерны как часть few-shot — я не видел в академических работах. Reflexion хранит рефлексии, но не инжектит их как «негативные примеры».

  1. Тематические коллекции — MemGPT ищет по всей archival memory. Я сначала ищу в тематической. На малом объёме это быстрее, на большом — не факт.

  1. Age decay — простая, но эффективная эвристика.

  1. Полный цикл: MНЕМОС сам по себе бесполезен без Sufler (наполнение) и ЛИКБЕЗ (расширение). Это часть larger system — об этом в следующей статье.


Что дальше

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


Вопросы к читателям:

  • Делали tiered memory в проде? Какие пороги для min_score работают?
  • Кто использовал Meta-Sufler / LLM-based фильтрацию few-shot? Ощутимая разница?
  • ChromaDB vs PGVector vs Qdrant — кто что использует на self-hosted?

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

← Осмысление проекта AI-ассистента и самого себя
Рынок труда в РФ глазами соискателя на 2026 год. →
Автору на кофе ☕🥐

Комментарии

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