- :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
27 KiB
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:
- Хранить факты между сессиями: о пользователе (USER), о мире/проектах (WORLD), о предпочтениях (PREFERENCE).
- Быстро находить релевантное перед каждым ходом (prefetch, top-K).
- Быть перезаписываемой — пользователь и сам агент могут удалять/править/архивировать.
- Переживать рестарты — данные не теряются.
- Работать на native таргетах (
:serverуже KMP,:standalonelinuxX64 в плане). - 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 тестов)
Цель: агент запоминает между сессиями.
:memoryKMP-модуль: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: один-shotLiteLlm.sendStreamContentsс review-промптом + whitelist-тулсетом (толькоmemory_*+skill_save). Результат — 0+ записей вmemory_note.SystemGuidanceконстанты:MEMORY_GUIDANCE,SKILLS_GUIDANCE,TOOL_USE_ENFORCEMENT(из Hermesprompt_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-заметку, архивирует исходные. Для скиллов — то же самое.
- Через
:serverendpointGET /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_idnullable, по умолчанию NULL (глобальная). - Ответить на вопросы 1–10 из раздела 7.
После закрытия чеклиста — открываем Фазу 1.