Files
agentik/docs/MEMORY-DESIGN.md
subochev 65365da89c Phase 5: :memory-vector (JVector + SQLite + LLM-эмбеддинги), memory-abstraction, compaction, MessageContext
- :memory-api — общий контракт MemoryStore/Prefetcher/Reviewer/Tools/MemorySystem
- :memory-md (KMP, kotlinx-io) — Hermes-style §-файлы, keyword overlap
- :memory-vector (JVM-only) — JVector ANN + SQLite + HttpEmbeddingClient
- :standalone — AGENTIK_MEMORY_BACKEND={md,vector,off}, выбор в Main.kt
- :standalone — compaction рабочего контекста (LiteLlmContextCompactor + reviewPreCompaction)
- :proto — MessageContext (origin: user/system/event) на send и в Message
- :server — backward-compat dual-format для POST /messages
- README — env-vars, vector-бэкенд docs
2026-09-15 03:44:15 +03:00

27 KiB
Raw Permalink Blame History

Memory — дизайн (draft)

Обсуждение долговременной памяти агента. Начат 2026-09-13. Цель — выбрать модель хранения и поиска до написания кода.

Update 2026-09-14: пивот хранения. Поднимаем не один monolithic backend, а абстракцию (MemoryStore/MemoryPrefetcher/MemoryReviewer/ MemoryTools в :memory-api) и подменяемые реализации. v1 идёт на Hermes-style §-файлах в ~/.agentik/memory/{USER,WORLD,PREFERENCES}.md (модуль :memory-md). SQLite+vector sidecar из §3 ниже переезжает в следующий бэкенд (:memory-vector, фаза 2+) — обоснование там же, отличий в API не будет. §3.1 («почему не файлы») остаётся аргументом против единого источника истины вне основной БД, но под абстракцией это уже не та проблема: факты в MD живут отдельно, а всё остальное state диалогов по-прежнему в agentik.db.

1. Требования

Что память должна делать в agentik:

  1. Хранить факты между сессиями: о пользователе (USER), о мире/проектах (WORLD), о предпочтениях (PREFERENCE).
  2. Быстро находить релевантное перед каждым ходом (prefetch, top-K).
  3. Быть перезаписываемой — пользователь и сам агент могут удалять/править/архивировать.
  4. Переживать рестарты — данные не теряются.
  5. Работать на native таргетах (:server уже KMP, :standalone linuxX64 в плане).
  6. Single-binary — никаких внешних сервисов типа Qdrant.

Чего не обязательно в v1:

  • миллионы заметок;
  • мультипользовательские tenant'ы;
  • sub-ms ANN на >100K векторов.

2. Варианты хранения

A. Текстовые файлы (Hermes-style)

  • ~/.hermes/memories/MEMORY.md и USER.md, разделитель записей §.
  • Поиск: substring / FTS5 / LLM-ранжирование.
  • + человекочитаемо, легко бэкапить (cp), редактировать руками.
  • − не семантический: «как мы деплоим» не найдёт «systemctl + k3s».
  • − параллельно с SQLite (у нас всё остальное в agentik.db) — два места правды.

B. SQLite + FTS5

  • Всё в agentik.db: memory_note + memory_note_fts (виртуальная FTS5-таблица).
  • Поиск: FTS5 BM25 + trigram (для русского).
  • + один процесс, знакомый API, никаких новых зависимостей.
  • − keyword-only: «k8s» не матчится с «kubernetes», «Go» не находится в «статически типизированном языке с горутинами».

C. SQLite (canonical) + векторный sidecar

  • Канонический store: memory_note(id, category, content, created_at, last_used_at, use_count, conversation_id?, source, embedding_model, embedding_dim) — обычные столбцы.
  • Векторный индекс: либо BLOB-столбец с packed Float32Array + brute-force cosine, либо отдельная embedded-БД (sqlite-vss, LanceDB).
  • + семантический поиск «по смыслу»; canonical store остаётся SQL-инспектируемым (можно grep, sqlite3 agentik.db "SELECT …").
  • + гибридный ranking: similarity × recency × use_count.
  • − зависимость на embedding-модель.

D. Чисто векторная БД (Qdrant / Milvus / Weaviate / Chroma)

  • + production-grade ANN.
  • − отдельный процесс, ломает single-binary философию agentik.

3. Рекомендация — гибрид C

Канонический store — SQLite (как у нас всё остальное). Векторный индекс — sidecar.

3.1 Почему не A (только файлы)

  • В agentik всё состояние уже в SQLite: разговоры, working memory, summary, ошибки. Раздваивать на файлы = доп. синхронизация при каждом save/delete + новая категория бэкапов.
  • Файлы не масштабируются на >100 заметок без индекса: grep -F это O(N) по байтам.
  • Семантический поиск всё равно хочется — придётся добавлять векторы позже, тогда MD превращается в sidecar с теми же проблемами синхронизации, но без преимуществ.

3.2 Почему не B (только FTS5)

  • Keyword-поиск быстро упирается в перефразирование: пользователь пишет «на чём билдим?», заметка говорит «building with Gradle 9.4.1» — без морфологии и/или эмбеддингов не находится.
  • Мультиязычность (русские заметки, английские запросы) — FTS5 с trigram работает, но семантика всё равно точнее.
  • Цена эмбеддингов на нашем масштабе ($0.004 на 1000 заметок единоразово + копейки на save) практически нулевая.

3.3 Почему не sqlite-vss / LanceDB embedded

  • sqlite-vss — расширение SQLite, надо пересобирать под каждый native таргет (linuxX64, iosArm64, iosSimulatorArm64, mingwX64). Блокирует наш linuxX64-чек (NATIVE-COMPATIBILITY.md).
  • LanceDB Java SDK — есть (Apache 2.0), но JVM-only (JNI к нативной .so); на ios/iosSimulator без отдельного билда не работает.
  • На нашем масштабе (<10K заметок на агента) brute-force cosine по BLOB-столбцу — правильный порядок сложности:
    • 1 эмбеддинг = 1536 floats × 4 байта ≈ 6 KB (OpenAI small) или 384 × 4 ≈ 1.5 KB (MiniLM).
    • 10K × 6 KB = 60 MB в RAM. Дешево.
    • Поиск: 10K cosine-similarity = ~0.5 ms на JVM, <0.1 ms нативно. Не нужен HNSW.

Если когда-нибудь выйдем за 50K заметок — переедем на sqlite-vss или LanceDB без поломки API: MemoryStore.search(query, k) остаётся прежним.

3.4 Схема канонического store (предложение)

CREATE TABLE memory_note (
    id              TEXT PRIMARY KEY,           -- "mem-<uuid>"
    category        TEXT NOT NULL,              -- 'user' | 'world' | 'preference'
    content         TEXT NOT NULL,              -- полный текст заметки
    conversation_id TEXT,                       -- NULL = global, иначе привязана к диалогу
    source          TEXT NOT NULL,              -- 'agent_save' | 'user_explicit' | 'auto_review'
    created_at      INTEGER NOT NULL,           -- epoch ms
    last_used_at    INTEGER NOT NULL,           -- когда последний раз матчилась в prefetch
    use_count       INTEGER NOT NULL DEFAULT 0, -- сколько раз выдавалась в prefetch
    embedding_model TEXT NOT NULL,              -- 'openai/text-embedding-3-small' (для миграций)
    embedding_dim   INTEGER NOT NULL,
    embedding       BLOB NOT NULL               -- packed Float32Array, little-endian
);

CREATE INDEX memory_note_category_idx ON memory_note(category);
CREATE INDEX memory_note_last_used_idx ON memory_note(last_used_at DESC);
CREATE INDEX memory_note_conversation_idx ON memory_note(conversation_id);

Размер: на 10K заметок ≈ 60 MB (OpenAI small) или 15 MB (MiniLM). Дешевле, чем agentik.db-кеш.

4. Embedding: где считать

Вариант + −
Удалённо через litellm (text-embedding-3-small, 1536-dim, $0.02/1M tokens) 0 локальных деплов, высокое качество, мультиязычный +1 HTTP на save/query; нужен ключ OpenAI
Локально через ONNX Runtime (all-MiniLM-L6-v2, 384-dim, $0) оффлайн, native-совместимо, без задержек +25 MB к бинарю; хуже качество; инициализация ~500 ms
Через GOOGLE backend (Gemini embedding) уже работающее API привязывает embedding к backend; GOOGLE может не иметь OpenAI embedding-API
Гибрид (по умолчанию OpenAI, fallback на локальный если ключа нет) resilience +сложность

Рекомендация: на v1 — отдельный embedding-конфиг, дефолт OpenAI через litellm, фоллбэк на локальный MiniLM через ONNX (если выйдет 0.3+ java SDK и мы соберём native-билды под все таргеты — отложим в v2).

Стоимость на реальном использовании

  • text-embedding-3-small, 1000 заметок × 200 токенов = 200K токенов = $0.004 единоразово.
  • memory_save ≈ $0.00001 (200 токенов на индексацию).
  • prefetch (1 query embedding) ≈ $0.000003.
  • 100 заметок/месяц, 50 ходов/день = <$0.01/месяц. Ничтожно.

5. Жизненный цикл заметки

            review-loop (post-turn)
                    │
                    ▼
        memory_save(category, content) ──► write to memory_note
                    │                            │
                    │                            ├──► embedding = embed(content)
                    │                            ├──► use_count = 0
                    │                            └──► last_used_at = created_at
                    │
                    ▼
              prefetch(user_message, topK=10) ──► vector top-K + recency rerank
                    │                                       │
                    │                                       ▼
                    │                          bump use_count, last_used_at
                    │                                       │
                    │                                       ▼
                    │                          inject into user message prefix:
                    │                              "[Контекст — то, что я помню]
                    │                               - факт 1
                    │                               - факт 2
                    │                               [/Контекст]"
                    │
                    ▼
            (eventually)
                    │
       manual memory_delete(id)  ◄──  пользовательская команда
                    │
       curator: last_used_at < 90 days ago  ──►  status = 'archived' (не видна в prefetch)

status в схеме нет — архивация = use_count == 0 AND last_used_at < archive_threshold. Чисто по таймстампам, без LLM. Curator (Фаза 4) делает это в фоне раз в сутки.

6. Review-loop (как у Hermes, адаптированный)

Не fork-agent. Один-shot LLM-вызов на LiteLlm.sendStreamContents с урезанным туловым whitelist'ом (memory_save, memory_search, memory_list, skill_save). Тот же backend, что у основного агента.

Триггер: каждые N ходов (default 10, настраивается AGENTIK_MEMORY_NUDGE_INTERVAL). Условие: только после успешно завершённого хода (не прерванного, не упавшего).

Промпт (рус/англ по языку разговора):

Ты — фоновый аналитик. Посмотри на последний разговор и реши, есть ли
что запомнить в долговременную память агента. Сохраняй только если:
1. Пользователь рассказал о себе: persona, привычки, предпочтения.
2. Пользователь рассказал о проекте/окружении: стек, инструменты, сроки.
3. Пользователь выразил ожидания к тому, как агент должен работать.

Если ничего нет — просто ответь "nothing to save" и не вызывай тулы.
Если есть — вызови memory_save(category, content) для каждого факта.

category: одна из user / world / preference. Агент решает сам.

7. Открытые вопросы (нужны решения)

# Вопрос Предложение
1 Переносимость embeddings между моделями. Поменяем модель → переиндексировать всё? Хранить embedding_model в строке; на старте проверять, что у всех строк он одинаковый; иначе фоновый reindex. Дёшево ($0.004 на 1000 заметок).
2 Мультиязычность. Русские заметки + английские запросы. text-embedding-3-small мультиязычен (тестировал на рус+англ — ок); MiniLM — частично. На v1 OpenAI хватает.
3 TTL заметок. Hermes без TTL. У нас возможны устаревшие факты. Curator (Фаза 4): use_count == 0 AND last_used_at < 90d → архив. Без жёсткого удаления.
4 Персональные данные / секреты. Можем сохранить API-ключ из разговора. Guard rail в memory_save: regex-detect на токеноподобные паттерны (sk-…, Bearer …, JWT); агент не должен сохранять. Финальный review — пользователь. Шифрование at-rest — отдельная тема (см. общий security pass).
5 Когда НЕ писать. Review должен решить «не сохранять». Встроено в промпт выше. Если агент сомневается — не пишет.
6 Каталог и формат SKILL-аналога для памяти. Hermes делает это как USER.md vs MEMORY.md. У нас одна таблица с category. Нужно ли разделение? Одной таблицы достаточно (категория в строке). Преимущество: один запрос, одна транзакция.
7 Привязка к conversation_id. Глобальная память vs per-conversation. Колонка nullable. По умолчанию глобальная (NULL). Привязка — только когда факт явно про «этот диалог» (например, «в этом диалоге используем минимальный API»).
8 Prefetch size. Сколько фактов впрыскивать в user message? Default 10, настраивается AGENTIK_MEMORY_PREFETCH_TOPK.
9 Где считать query embedding — клиент или «агент»? На стороне агента (там же, где LiteLlm). Один HTTP-вызов на turn.
10 Что делать с дубликатами? «Пользователь — DevOps» vs «Пользователь работает с k8s» — это две заметки или одна с тегами? v1: две отдельные заметки. Curator (Фаза 4) объединяет похожие через LLM.

8. Что фиксируем до кода

  • Канонический store: SQLite-таблица memory_note в общей agentik.db.
  • Поиск: семантический (embedding) + recency/use_count rerank.
  • Embedding backend: openai/text-embedding-3-small через litellm; в v2 — локальный MiniLM через ONNX.
  • Vector index: brute-force cosine по BLOB-столбцу (v1) → sqlite-vss / LanceDB (v2 если нужно).
  • Single-binary: всё в нашем процессе, без внешних сервисов.
  • Prefetch: топ-K (default 10) заметок в user-message-префиксе.
  • Review-loop: один-shot LiteLlm-вызов с whitelist-тулсетом, каждые N ходов.
  • Curator (отложен в Фазу 4): архивация по use_count + last_used_at.

9. Что НЕ делаем в v1

  • /learn и автоматическое создание скиллов (отдельная фаза).
  • Vector compression / quantization (нужно только при >100K заметок).
  • Multi-agent shared memory (один пользователь — один агент — один набор заметок).
  • Encryption at rest (общий security pass).
  • Embedding-кэш для текстов, которые уже были заэмбеджены (можно LRU в RAM).
  • Memory.conflict resolution (агент сохранил «k8s», потом «nomad» — это конфликт или эволюция? v1 не разрешает, v2 — curator).

10. Пофазный план

Фаза 1 — Память (v2.1, ~600 строк кода + ~300 тестов)

Цель: агент запоминает между сессиями.

  • :memory KMP-модуль: MemoryNote, MemoryCategory (USER | WORLD | PREFERENCE), MemoryStore (commonMain интерфейс) + SQLite jvm-impl.
  • Тулы: MemoryReadTool (поиск), MemorySaveTool, MemoryListTool, MemoryDeleteTool.
  • MemoryPrefetcher.prefetch(query, topK): List<MemoryNote> — embed query, brute-force cosine, rerank по recency × use_count.
  • EmbeddingClient — обёртка над litellm /v1/embeddings (один HTTP-вызов, retry, кэш in-RAM LRU на 256 текстов).
  • ChatAgent.memory: MemoryStore + EmbeddingClient параметры.
  • ChatConversation.send(): перед каждым sendStreamContents инжектит top-K заметок в префикс user-сообщения.
  • ChatAgent.afterTurn() hook: после успешного хода инкрементит _turnsSinceReview; если ≥ memoryNudgeInterval — запускает фоновую корутину MemoryReviewAgent.
  • MemoryReviewAgent: один-shot LiteLlm.sendStreamContents с review-промптом + whitelist-тулсетом (только memory_* + skill_save). Результат — 0+ записей в memory_note.
  • SystemGuidance константы: MEMORY_GUIDANCE, SKILLS_GUIDANCE, TOOL_USE_ENFORCEMENT (из Hermes prompt_builder.py).
  • Конфиг: AGENTIK_MEMORY_BACKEND (openai/local/none), AGENTIK_MEMORY_NUDGE_INTERVAL (10), AGENTIK_MEMORY_PREFETCH_TOPK (10), AGENTIK_EMBEDDING_MODEL (text-embedding-3-small).
  • Тесты: MemoryStore round-trip, prefetch ranking, review-loop пишет 0–N заметок на фейковом LLM, защита от секретов (regex), embedding-кэш работает, миграция схемы идемпотентна.

Фаза 2 — Контекстная компрессия (v2.2, ~500 строк)

Цель: диалог переживает 50+ ходов без 400 от LLM.

  • TokenEstimator — грубая оценка (chars / 4) по working memory.
  • Compressor.shouldCompress() — триггер: tokens > threshold_percent * contextLimit (default 75%). Anti-thrashing: ≤5 неудачных попыток подряд → пауза 10 минут.
  • WorkingMemoryStore.compact(dropFromOrderIdx, summaryEntry: MessageRecord.Summary) расширяется: drop + insert в одной транзакции. Уже сигнатура, нужно тело.
  • ChatConversation.afterTurn() (после review-loop): если shouldCompress — head + summary + tail:
    • head = system + первые 3 не-system записи;
    • middle = всё что не head/tail; уходит в aux-вызов;
    • tail = последние ~20K токенов (или 8 записей, что больше).
  • Aux summarizer prompt — портированный из Hermes context_compressor.py (структура с Historical Task Snapshot / Goal / Active State / Blocked / Resolved / Remaining Work).
  • Резюме пишется как MessageRecord.Summary(text, createdAt) в working memory, заменяет middle в getOrCreateLiteConversation.
  • Тесты: каждая граничная ситуация (head+1, всё в tail, пустой middle, ошибка aux-LLM → static fallback).

Фаза 3 — Skill self-improvement (v2.3, ~400 строк)

Цель: агент сам создаёт/обновляет скиллы.

  • SkillSaveTool(action=create|update, name, description, body) (LiteTool).
  • Расширить SkillCatalog в :skills: lastUsedAt, useCount, archivedAt. Сохранение в SQLite (skill_usage таблица) — нужно решить, отдельная БД или в agentik.db. Рекомендую в agentik.db (та же причина, что и для memory).
  • SkillLoader.loadDirectory теперь читает timestamp + useCount из SQLite при наличии, иначе — из filesystem mtime.
  • MemoryReviewAgent дополняется skill-частью (combined prompt) или запускается параллельно вторым вызовом.
  • Тулы в whitelist review: memory_* + skill_save + skill_delete.

Фаза 4 — Curator (v2.4, ~300 строк)

Цель: фоновая уборка устаревших заметок и скиллов.

  • Корутина CuratorJob запускается в Main.kt, тик раз в сутки (настраивается).
  • Без LLM: сканирует memory_note и skills. Если last_used_at < 90d и use_count == 0 → archivedAt = now(). Не удалять.
  • С LLM (опц., выкл по умолчанию): consolidation pass — fork-agent (один-shot) ищет похожие заметки, объединяет в umbrella-заметку, архивирует исходные. Для скиллов — то же самое.
  • Через :server endpoint GET /memory / GET /memory/archived для пользовательского контроля.

Фаза 5 — Умный prefetch (v2.5, отложено)

  • Переход brute-force → sqlite-vss при >50K заметок.
  • Локальный embedding через ONNX Runtime (all-MiniLM-L6-v2) как fallback при отсутствии OpenAI-ключа.
  • Embedding-кэш на диске (LRU).
  • Vector quantization (int8) для экономии RAM.

11. Что меняется в коде

Новые модули

:memory                                       (новый KMP модуль, commonMain + jvmMain)
  commonMain/.../MemoryNote.kt                -- sealed: id, category, content, ...
  commonMain/.../MemoryCategory.kt            -- USER | WORLD | PREFERENCE
  commonMain/.../MemoryStore.kt               -- интерфейс (insert, list, search, delete)
  commonMain/.../EmbeddingClient.kt           -- интерфейс (embed: String -> FloatArray)
  commonTest/.../MemoryStoreTest.kt
  jvmMain/.../sqlite/SqliteMemoryStore.kt     -- INSERT/SELECT + brute-force cosine
  jvmMain/.../embedding/LitertlmEmbedding.kt  -- HTTP-вызов /v1/embeddings через ktor-client
  jvmMain/.../embedding/InMemoryEmbedding.kt  -- для тестов
  jvmTest/.../SqliteMemoryStoreTest.kt
  jvmTest/.../LitertlmEmbeddingTest.kt        -- через WireMock или httptestserver

Изменения в :standalone

agent/MemoryReadTool.kt / MemorySaveTool.kt / MemoryListTool.kt / MemoryDeleteTool.kt
agent/MemoryReviewAgent.kt                    -- один-shot review, fire-and-forget coroutine
agent/SkillSaveTool.kt                        -- в Фазе 3
llm/SystemGuidance.kt                         -- MEMORY_GUIDANCE / SKILLS_GUIDANCE константы
llm/CompressionPrompts.kt                     -- в Фазе 2
persistence/WorkingMemoryStore.kt             -- расширение compact() в Фазе 2
agent/ChatAgent.kt                            -- +memory, +embedding, +afterTurn() hook, +review coroutine
agent/ChatConversation.kt                     -- +userMessagePrefix(memory snapshot), +afterTurn compress trigger (Фаза 2)
config/AgentikConfig.kt                       -- +memory config block
Main.kt                                       -- +memory init, +curator coroutine (Фаза 4)

Изменения в :proto

Не нужны. Память — внутренняя фича standalone, не часть протокола. (Если захотим управлять памятью через IRC/CLI — добавим Memory в :proto, как Agent в Фазе 4.)

Изменения в :server / :client

Не нужны. Память не идёт через HTTP API в v1. (Только чтение дампа GET /memory — это Фаза 4.)

12. Чеклист перед кодом

  • Подтвердить: канонический store в agentik.db (а не отдельный файл).
  • Подтвердить: embedding через litellm text-embedding-3-small (а не локальный ONNX).
  • Подтвердить: brute-force cosine в BLOB (а не sqlite-vss / LanceDB на старте).
  • Подтвердить: review-loop как один-shot, не полноценный fork-agent.
  • Подтвердить: prefetch инжектится в user-message-префикс (не system_instruction).
  • Подтвердить: MemoryNote.conversation_id nullable, по умолчанию NULL (глобальная).
  • Ответить на вопросы 1–10 из раздела 7.

После закрытия чеклиста — открываем Фазу 1.