Когда начинаешь нормально работать с AI-агентами — не «поиграться», а реально в инженерных задачах — довольно быстро упираешься в одну и ту же проблему:
агент умный, но память — как у золотой рыбки.
Сегодня он с тобой разобрал полпроекта: понял архитектуру, нашёл ограничения, запомнил, что пробовали, где лежат важные доки, почему выбрали именно это решение. Завтра открываешь новую сессию — и снова: «Давай сначала разберёмся, как у вас всё устроено».
Эта статья — не «что такое MCP» и не обзор очередного инструмента. Это инженерный разбор: какая была боль, какие решения я принял в архитектуре, почему именно такие, и что из этого работает, а что — нет.
Репозиторий: github.com/ipiton/agent-memory-mcp
Проблема: зоопарк агентов, ноль памяти
Ключевой момент: я использую не одного агента и не один инструмент. У меня живёт работа сразу в нескольких средах — Cursor, Claude Code, Codex. И я не хотел вот этого зоопарка: тут память отдельно, там отдельно, здесь вообще ничего не помнит.
Мне нужен был один memory-layer, который можно подключить в разные инструменты и получить общий контекст:
в Cursor разобрал проблему — сохранил вывод;
в Claude Code агент это потом нашёл и использовал;
в Codex не пришлось заново объяснять «что тут вообще происходит».
Пошёл смотреть готовые решения. Классика: часть проектов оказались заброшенными, часть не закрывали мой сценарий, где-то не хватало фич, где-то был оверкилл по инфраструктуре. В какой-то момент поймал себя на том, что снова строю костыли вокруг чужих решений — и решил сделать свой инструмент.
Обзор архитектуры
Результат — agent-memory-mcp: MCP-сервер на Go (~7 000 строк кода), который совмещает два слоя:
Память агента — persistent storage для знаний, событий, процедур и рабочего контекста.
RAG-индекс — семантический поиск по проектной документации.
Стек:
Компонент | Решение |
|---|---|
Язык | Go 1.26 |
Протокол | MCP 2024-11-05 (stdio + HTTP/JSON-RPC) |
Хранилище | SQLite (WAL mode) |
Embeddings | Jina AI / OpenAI-совместимые / Ollama |
Зависимости |
|
Структура проекта:
agent-memory-mcp/ ├── cmd/agent-memory-mcp/ # Точка входа: CLI + serve │ ├── main.go │ ├── cli.go # Команды: store, recall, list, search, export... │ └── helpers.go ├── internal/ │ ├── config/ # Конфигурация через env-переменные │ ├── embedder/ # Провайдеры embeddings + fallback │ ├── memory/ # Хранилище памяти агента │ ├── rag/ # Индексирование документов │ ├── vectorstore/ # Векторное хранилище (SQLite + in-memory) │ ├── server/ # MCP-сервер (stdio/HTTP) │ ├── paths/ # Валидация путей (allowlist) │ ├── search/ # Текстовый поиск │ ├── logger/ # Логирование │ └── stats/ # Статистика использования ├── deploy/ │ └── docker/ # Dockerfile + docker-compose └── docs/
Ключевой принцип: zero-ops. Один бинарник, один файл SQLite, никакого Redis/Postgres/Qdrant. Поднимается за секунду, бэкапится cp.
Проектирование схемы памяти
Почему четыре типа, а не «просто заметки»
Одна из первых проблем у memory-систем — всё валится в одну кучу. Через пару недель это уже не память, а помойка. Я разделил записи на четыре типа, и это не произвольная классификация — она основана на том, как реально выглядят запросы агента.
Тип | Назначение | Пример |
|---|---|---|
| Что произошло (события, эксперименты) | «Пробовали миграцию на v3 — сломалось из-за несовместимых типов» |
| Факты и знания (долгосрочные) | «Проект использует chi router, авторизация через JWT» |
| Как делать (повторяемые процедуры) | «Деплой: |
| Текущий контекст (короткоживущее) | «Сейчас разбираем баг в payment service, смотрим retry logic» |
На практике это сильно влияет на качество recall: агент может запросить «что мы уже пробовали?» (episodic), «какие ограничения у системы?» (semantic) или «как деплоить?» (procedural) — и получить точный срез.
Схема таблицы memories
CREATE TABLE memories ( id TEXT PRIMARY KEY, -- UUID content TEXT NOT NULL, -- Основной текст type TEXT NOT NULL, -- episodic|semantic|procedural|working title TEXT, -- Краткий заголовок tags TEXT, -- JSON-массив тегов context TEXT, -- Привязка к задаче/сессии importance REAL DEFAULT 0.5, -- Вес: 0.0–1.0 metadata TEXT, -- Произвольные key-value (JSON) embedding BLOB, -- Вектор: JSON-encoded float32[] created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, accessed_at DATETIME NOT NULL, -- Трекинг обращений access_count INTEGER DEFAULT 0 -- Счётчик обращений ); CREATE INDEX idx_memories_type ON memories(type); CREATE INDEX idx_memories_context ON memories(context); CREATE INDEX idx_memories_importance ON memories(importance); CREATE INDEX idx_memories_created_at ON memories(created_at);
Несколько решений, которые стоит пояснить:
importance (0.0–1.0). Не все воспоминания одинаково ценны. Архитектурное решение с importance 0.9 должно всплывать выше, чем «попробовали и не сработало» с 0.3. При recall это учитывается через формулу:
final_score = cosine_similarity * (0.5 + importance * 0.5)
То есть importance=1.0 даёт множитель 1.0, а importance=0.0 даёт 0.5. Записи с высоким importance получают до 2x буста относительно неважных.
accessed_at и access_count. Трекинг обращений — чтобы в будущем можно было строить decay-функции (реже используемое — менее релевантно) и чистку неактуальных записей.
context. Привязка к задаче, проекту или сессии. Позволяет агенту запросить «вспомни всё, что мы знаем про payment-service» — и получить только релевантный срез.
Embedding-провайдеры: fallback без боли
Embedding'и — это ядро semantic search. Изначально я принципиально не хотел завязываться на одного провайдера: API падают, ключи истекают, хочется иногда работать локально. Поэтому сделал трёхуровневый fallback. Забегая вперёд — у этого подхода нашёлся серьёзный подводный камень, о котором расскажу в разделе про trade-offs. Но сама архитектура fallback'а полезна и в рамках одного провайдера (например, retry + переключение между моделями Ollama).
Цепочка провайдеров
┌─────────────────────────────────────────────────┐ │ Запрос на embedding │ │ │ │ 1. Jina AI (jina-embeddings-v3, 1024d) │ │ ├─ Если ОК → return │ │ └─ Если ошибка → счётчик++ │ │ └─ 3 подряд → отключаем на 1 час │ │ │ │ 2. OpenAI-compatible (text-embedding-3-small) │ │ ├─ Если ОК → return │ │ └─ Если нет ключа или ошибка → далее │ │ │ │ 3. Ollama (bge-m3 → mxbai-embed-large) │ │ ├─ Попытка bge-m3 │ │ ├─ Если пусто → retry (модель загружается) │ │ └─ Fallback на mxbai-embed-large │ │ │ │ 4. Все провайдеры failed → ошибка │ └─────────────────────────────────────────────────┘
Как это выглядит в коде
Ключевой метод — EmbedWithTask. Он принимает текст и тип задачи (retrieval.query для поиска, retrieval.passage для индексирования) и проходит по цепочке:
func (e *Embedder) EmbedWithTask(text string, task string) ([]float32, error) { // 1. Проверяем, не отключён ли Jina (после 3+ последовательных ошибок) // Автоматически включается обратно через 1 час if e.jinaEnabled() && e.jinaKey != "" { vec, err := e.embedJina(text, task) if err == nil && e.validateDimension(vec) { e.resetJinaErrors() return vec, nil } e.incrementJinaErrors() // после 3 ошибок — auto-disable } // 2. OpenAI-compatible (поддержка Matryoshka truncation) if e.openaiKey != "" { vec, err := e.embedOpenAI(text) if err == nil { return vec, nil } } // 3. Ollama — локально, с retry на загрузку модели if e.ollamaURL != "" { vec, err := e.embedOllama(text, "bge-m3:latest") if err == nil && len(vec) > 0 { return vec, nil } // Fallback на вторую модель vec, err = e.embedOllama(text, "mxbai-embed-large:latest") if err == nil && len(vec) > 0 { return vec, nil } } return nil, fmt.Errorf("all embedding providers failed") }
Auto-disable с self-healing
Интересная деталь: если Jina начинает стабильно падать (3 ошибки подряд), провайдер отключается автоматически на 1 час. Это не даёт fallback'у тормозить каждый запрос ожиданием та��маута от мёртвого API. Через час — автоматическая попытка восстановления.
Batch embedding
Для индексирования документов есть BatchEmbed, который работает по-разному для каждого провайдера:
Jina AI — нативный batch-endpoint, отправляем массив текстов одним запросом
OpenAI — один вызов с массивом input'ов
Ollama — суб-батчи по 10 текстов (ограничение контекста), с паузой 50мс между запросами
// Индексирование: 50 чанков за батч, пауза между батчами const batchSize = 50 const batchPause = 50 * time.Millisecond for i := 0; i < len(texts); i += batchSize { end := min(i+batchSize, len(texts)) batch := texts[i:end] embeddings, err := e.embedder.BatchEmbed(batch) if err != nil { return fmt.Errorf("batch %d: %w", i/batchSize, err) } // Upsert в SQLite for j, emb := range embeddings { store.Upsert(chunks[i+j], emb) } time.Sleep(batchPause) }
Semantic search: как ищем по памяти
Почему не keyword search
Если делать текстовый поиск, быстро начинаются проблемы: формулировки разные, термины плавают, агент спрашивает «по-своему». Память может быть записана так:
«После refresh token ломался доступ из-за несовместимого формата»
А запрос прилетит:
«Почему после обновления токена всё отвалилось?»
Keyword search такое пропустит. Semantic — найдёт.
Реализация поиска
Вектора хранятся в SQLite, но сам поиск происходит in-memory. При первом запросе все чанки загружаются в RAM, дальше — O(n) cosine similarity:
type SQLiteStore struct { db *sql.DB chunks map[string]*Chunk // In-memory cache mu sync.RWMutex } func (s *SQLiteStore) Search(queryEmbedding []float32, limit int, minScore float64) []SearchResult { s.mu.RLock() defer s.mu.RUnlock() var results []SearchResult for _, chunk := range s.chunks { score := CosineSimilarity(queryEmbedding, chunk.Embedding) if score >= minScore { results = append(results, SearchResult{ Chunk: chunk, Score: score, }) } } sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) if len(results) > limit { results = results[:limit] } return results }
Cosine similarity — стандартная формула:
func CosineSimilarity(a, b []float32) float64 { var dot, normA, normB float64 for i := range a { dot += float64(a[i]) * float64(b[i]) normA += float64(a[i]) * float64(a[i]) normB += float64(b[i]) * float64(b[i]) } if normA == 0 || normB == 0 { return 0 } return dot / (math.Sqrt(normA) * math.Sqrt(normB)) }
Почему in-memory, а не ANN-индекс
Осознанный trade-off. Для типичного рабочего сценария (сотни-тысячи записей в памяти, тысячи-десятки тысяч чанков документации) brute-force cosine similarity работает за миллисекунды. ANN-индексы (HNSW, IVF) дают выигрыш на масштабах 100k+ векторов, но добавляют сложность, зависимости и вопросы с персистентностью.
Для локального инструмента «я + мои агенты + мои проекты» — in-memory brute-force это правильный баланс простоты и скорости.
Recall с importance-weighted scoring
При поиске по памяти агента (не по документам) используется взвешенная формула:
func (s *Store) Recall(query string, opts RecallOptions) ([]*Memory, error) { // 1. Получаем embedding запроса queryVec, err := s.embedder.EmbedQuery(query) // 2. Ищем по всем записям var scored []scoredMemory for _, mem := range s.memories { // Фильтрация по типу, контексту, тегам if !matchesFilters(mem, opts) { continue } sim := CosineSimilarity(queryVec, mem.Embedding) if sim < 0.05 { continue } // Importance weighting: importance=1.0 → 1.0x, importance=0.0 → 0.5x score := sim * (0.5 + mem.Importance*0.5) scored = append(scored, scoredMemory{mem, score}) } // 3. Сортировка и лимит sort.Slice(scored, func(i, j int) bool { return scored[i].score > scored[j].score }) // 4. Async обновление access stats go s.updateAccessStats(topResults) return topResults, nil }
Обратите внимание: обновление accessed_at и access_count происходит асинхронно — не тормозит поиск.
RAG: индексирование документов
Помимо памяти агента, нужен второй слой — проектная документация. README, RFC, changelog, заметки. Это разные сущности: память — это что агент узнал и решил, документы — это то, что уже существовало.
Архитектура RAG-пайплайна
RAG-движок состоит из трёх компонентов:
┌──────────────────────────────────────────────────┐ │ RAG Engine │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌────────┐ │ │ │ Document │ │ Vector │ │ SQLite │ │ │ │ Service │──│ Service │──│ Store │ │ │ │ │ │ │ │ │ │ │ │ • Сбор файлов│ │ • Batch embed│ │ chunks │ │ │ │ • Чанкинг │ │ • Upsert │ │ meta │ │ │ │ • Метаданные │ │ • Search │ │ files │ │ │ └──────────────┘ └──────────────┘ └────────┘ │ └──────────────────────────────────────────────────┘
Пайплайн обработки документа
Файл (.md, .txt, ...) ↓ processFile() Удаление YAML frontmatter ↓ extractTitle() Извлечение заголовка (из H1 или имени файла) ↓ splitIntoChunks() Нарезка на чанки с overlap ↓ BatchEmbed() Пакетное получение embeddings ↓ Upsert() Сохранение в SQLite + in-memory cache
Стратегия чанкинга
Чанкинг — критический момент для качества поиска. Слишком большие чанки — шум в результатах. Слишком маленькие — потеря контекста.
Параметры: ├─ chunkSize: 2000 символов (настраивается через MCP_CHUNK_SIZE) ├─ overlap: 200 символов (MCP_CHUNK_OVERLAP) └─ step = chunkSize - overlap = 1800
Алгоритм с поиском границы слова:
func splitIntoChunks(text string, chunkSize, overlap int) []string { step := chunkSize - overlap var chunks []string for start := 0; start < len(text); start += step { end := start + chunkSize if end > len(text) { end = len(text) } // Ищем границу слова (до 100 символов назад) if end < len(text) { for i := 0; i < 100 && end > start; i++ { if text[end-1] == ' ' || text[end-1] == '\n' { break } end-- } } chunk := strings.TrimSpace(text[start:end]) if len(chunk) > 0 { chunks = append(chunks, chunk) } } return chunks }
Overlap в 200 символов нужен, чтобы не терять контекст на границах чанков: если важная мысль попала на стык — она окажется в обоих чанках.
Инкрементальное индексирование
Полная переиндексация при каждом запуске — расточительно. Поэтому сделал инкрементальный подход:
func (e *Engine) Index() error { // 1. Собираем все документы из index-директорий docs := e.documentService.CollectDocuments() // 2. Сравниваем с таблицей indexed_files (hash + modtime) for _, doc := range docs { indexed, exists := e.getIndexedFile(doc.Path) if exists && indexed.Hash == doc.Hash { continue // Файл не изменился — пропускаем } if exists { // Файл изменился — удаляем старые чанки e.vectorStore.DeleteByDocPath(doc.Path) } // 3. Индексируем новые/изменённые чанки chunks := e.documentService.ProcessFile(doc) e.vectorService.IndexChunks(chunks) // 4. Обновляем метаданные e.updateIndexedFile(doc.Path, doc.Hash, len(chunks)) } // 5. Удаляем чанки для удалённых файлов e.cleanupDeletedFiles(docs) return nil }
Схема таблицы для трекинга:
CREATE TABLE indexed_files ( file_path TEXT PRIMARY KEY, hash TEXT NOT NULL, -- SHA256 для детекции изменений mod_time DATETIME, size INTEGER, chunk_count INTEGER DEFAULT 0 );
Auto-reindexing
Сервер умеет следить за файлами и переиндексировать при изменениях:
Startup auto-index: при запуске проверяет наличие индекса, перестраивает если нужно
File watcher: poll interval (по умолчанию 5 минут) + debounce (30 секунд)
Debounce: если файлы меняются серией (git checkout, IDE auto-save), переиндексация запускается только после «затишья»
Почему SQLite, а не Postgres / vector DB
Осознанный выбор. Аргументы:
1. Zero-ops. Не нужно поднимать, конфигурировать и мониторить отдельный сервис. Один файл memories.db — и всё.
2. WAL mode. SQLite в WAL-режиме отлично справляется с concurrent reads + occasional writes. Для сценария «один пользователь + несколько агентов» — за глаза.
db.Exec("PRAGMA journal_mode=WAL") db.Exec("PRAGMA synchronous=NORMAL")
3. Портабельность. Бэкап — cp memories.db memories.db.bak. Перенос на другую машину — scp. Нет миграций, нет дампов.
4. Embeddings как JSON в BLOB. Да, это не самый эффективный формат. Но для масштабов персонального инструмента (тысячи, не миллионы записей) — достаточно. А совместимость и простота отладки — на высоте. Можно открыть базу в любом SQLite-клиенте и посмотреть, что внутри.
Что не подойдёт: shared/team-сценарии, high-load, concurrent writes от многих процессов. Для этого нужен уже Postgres + pgvector или отдельный vector DB.
MCP-инструменты: что видит агент
MCP-сервер экспортирует 11 инструментов, разделённых на три группы.
Memory tools (6 штук)
{ "name": "store_memory", "description": "Store a memory in the agent's long-term memory", "inputSchema": { "type": "object", "properties": { "content": { "type": "string", "description": "Memory content" }, "title": { "type": "string" }, "type": { "type": "string", "enum": ["episodic","semantic","procedural","working"] }, "tags": { "type": "array", "items": { "type": "string" } }, "context": { "type": "string" }, "importance": { "type": "number", "minimum": 0, "maximum": 1 } }, "required": ["content"] } }
Инструмент | Что делает |
|---|---|
| Сохранить запись (с типом, тегами, важностью) |
| Семантический поиск по памяти |
| Обновить существующую запись |
| Удалить запись по ID |
| Список с фильтрами (тип, контекст, лимит) |
| Статистика: количество записей по типам |
RAG tools (2 штуки)
Инструмент | Что делает |
|---|---|
| Семантический поиск по индексированным документам |
| Запустить переиндексацию |
File tools (3 штуки)
Инструмент | Что делает |
|---|---|
| Список файлов (с ограничением по allowlist) |
| Чтение файла |
| Текстовый поиск по файлам |
File tools — бонус для удобства. Агент может и файл прочитать, и по документам поискать, не выходя из memory-сервера.
Два режима транспорта: stdio и HTTP
Stdio (по умолчанию)
Стандартный MCP-сценарий для интеграции с IDE и CLI-клиентами:
// Cursor: .cursor/mcp.json { "mcpServers": { "agent-memory": { "command": "agent-memory-mcp", "args": ["serve"], "env": { "JINA_API_KEY": "...", "MCP_INDEX_DIRS": "docs,README.md" } } } }
Поддерживает два формата:
Line mode: одна JSON-строка на сообщение (default)
Content-Length mode: заголовки + тело (для совместимости с LSP-подобными клиентами)
HTTP (JSON-RPC)
Для контейнеризации, удалённого доступа и дебага:
MCP_HTTP_MODE=http MCP_HTTP_PORT=18080 agent-memory-mcp serve
Endpoint'ы:
POST /mcp— JSON-RPC 2.0GET /health— healthcheck
Пример запроса:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "recall_memory", "arguments": { "query": "authentication retry logic", "type": "semantic", "limit": 5 } } }
HTTP-режим оказался полезен не только для деплоя, но и для дебага: через curl проще тестить, чем через stdio-pipe.
CLI: неожиданно полезная штука
Хотя основной сценарий — MCP-сервер, CLI в итоге очень выручил: быстро проверить, что память работает, протестить качество поиска, сделать export/import.
# Сохранить процедурную запись agent-memory-mcp store \ -content "Проект использует chi router для HTTP, авторизация через JWT" \ -type semantic \ -tags "go,chi,jwt,architecture" \ -importance 0.8 # Семантический поиск по памяти agent-memory-mcp recall "как у нас работает авторизация" # Поиск по документам (RAG) agent-memory-mcp search "authentication flow" # Список эпизодических записей agent-memory-mcp list -type episodic -limit 20 # Переиндексация документов agent-memory-mcp index # Экспорт/импорт (для бэкапа или переноса) agent-memory-mcp export -o memories-backup.json agent-memory-mcp import memories-backup.json # JSON-вывод для автоматизации agent-memory-mcp recall "patterns" -json | jq '.[] | .title'
JSON-вывод (-json) — ключевая фича для скриптов. Можно встроить memory в CI/CD, в pre-commit hooks, в shell-пайплайны.
Конфигурация
Всё через environment variables. Основные:
# Ядро MCP_ROOT=. # Корень проекта MCP_ALLOW_DIRS=. # Разрешённые пути # Память MCP_MEMORY_ENABLED=true MCP_MEMORY_DB_PATH=data/memory-store/memories.db # RAG MCP_RAG_ENABLED=true MCP_INDEX_DIRS=docs # Директории для индексирования MCP_CHUNK_SIZE=2000 # Размер чанка MCP_CHUNK_OVERLAP=200 # Overlap между чанками MCP_RAG_AUTO_INDEX=true # Автоиндексация при старте MCP_RAG_FILE_WATCHER=true # Слежение за изменениями MCP_RAG_WATCH_INTERVAL=5m # Интервал проверки MCP_RAG_DEBOUNCE=30s # Debounce перед переиндексацией # Embeddings (нужен хотя бы один) JINA_API_KEY=jina_... # Jina AI OPENAI_API_KEY=sk-... # OpenAI / совместимые OPENAI_BASE_URL=https://api.openai.com OPENAI_EMBEDDING_MODEL=text-embedding-3-small OLLAMA_BASE_URL=http://localhost:11434 # Локальный Ollama MCP_EMBEDDING_DIMENSION=1024 # Размерность вектора
Минимальная конфигурация — один API-ключ (Jina или OpenAI) и всё. Остальное — defaults.
Безопасность: Path Guard
Отдельный модуль paths.Guard ограничивает доступ к файловой системе:
Работает по allowlist: только директории из
MCP_ALLOW_DIRSБлокирует path traversal (
..)Валидирует symlinks (не позволяет выйти за пределы)
Блокирует абсолютные пути, если они не в allowlist
Это важно, потому что MCP-сервер получает запросы от агента, а агент может «захотеть» прочитать что-то за пределами проекта. Guard это предотвращает.
Деплой
Бинарник
go install github.com/ipiton/agent-memory-mcp/cmd/agent-memory-mcp@latest
Homebrew
brew tap ipiton/tap brew install agent-memory-mcp
Docker
# docker-compose.yml services: memory-mcp: build: ./deploy/docker ports: - "18080:8080" volumes: - memory-data:/data - ./docs:/docs:ro environment: MCP_HTTP_MODE: http JINA_API_KEY: ${JINA_API_KEY} MCP_INDEX_DIRS: /docs volumes: memory-data:
Concurrency и отказоустойчивость
Несколько деталей, которые не видны снаружи, но важны для стабильности:
Locking. Memory Store и Vector Store используют sync.RWMutex: множественные параллельные чтения, эксклюзивная запись. RAG Engine имеет дополнительный mutex, предотвращающий одновременное индексирование.
Graceful degradation. Если embeddings недоступны (все провайдеры упали) — память всё ещё работает: можно сохранять, листать, фильтровать. Semantic search деградирует до text matching. Не идеально, но лучше, чем полный отказ.
Async stats. Обновление accessed_at и access_count происходит в горутине — поиск не ждёт записи в БД.
Trade-offs: честный разбор
Semantic search — не магия
Иногда шумит. Иногда приносит «похожее, но не то». Особенно если записи короткие и криво сформулированы. Качество сильно зависит от того, как агент формулирует записи при сохранении. Это не проблема search'а — это проблема «garbage in, garbage out».
Память легко засорить
Если агенту дать сохранять всё подряд — будет свалка. Нужны нормальные tool descriptions (чтобы агент понимал, что стоит сохранять), правила в промптах, и периодическая ручная чистка. Я работаю над автоматической очисткой на основе access patterns, но пока это manual.
O(n) поиск не масштабируется бесконечно
In-memory brute-force отлично работает до десятков тысяч записей. На сотнях тысяч — начнёт заметно тормозить. Если ваш сценарий — team of 50 с общей базой на миллион записей — это решение не для вас. Но для «я и мои 3-5 проектов» — за глаза.
SQLite — один писатель
WAL mode позволяет concurrent reads, но write lock — один на всю базу. При агрессивном параллельном сохранении из нескольких процессов могут быть блокировки. На практике у меня не было проблем, но это стоит иметь в виду.
Embedding model mismatch — баг, который я нашёл у себя
Это тот случай, когда архитектурное решение выглядит элегантно на схеме, а потом ломается в реальности.
Напомню: у меня три embedding-провайдера с fallback. Идея была: если Jina упала — переключаемся на OpenAI, если и он — на Ollama. Звучит отказоустойчиво.
Проблема в том, что вектора от разных моделей живут в разных пространствах. Даже если размерность одинаковая (1024d у всех трёх), это не значит, что вектора совместимы. jina-embeddings-v3 и text-embedding-3-small кодируют смысл по-разному. Cosine similarity между их векторами — по сути случайное число.
Что это значит на практике: если ты накопил 200 записей через Jina, а потом при recall сработал fallback на OpenAI — поиск по старым записям даёт мусор. Не ошибку, не пустой результат, а уверенно неправильные совпадения. Это хуже, чем «не нашёл» — это «нашёл не то и не сказал».
Я наступил на это сам, когда у Jina был даунтайм. Recall стал возвращать странные результаты — не сразу понял почему. Разобрался, и после этого сделал простой вывод:
Сейчас я использую только Ollama с bge-m3 локально. Один провайдер, никакого fallback между разными моделями. Это убирает проблему совместимости полностью: все вектора — из одного пространства, всегда.
Плюсы такого подхода:
нулевая зависимость от внешних API,
нет проблемы model mismatch,
бесплатно,
работает офлайн.
Минус — нужен локальный Ollama-сервер и модель занимает память. Но для моего сценария это не проблема.
Что планирую сделать (и что рекомендую, если делаете похожую систему):
Вариант, к которому я иду — записывать модель в каждую запись + re-embed при смене провайдера:
-- Добавить поле в таблицу memories ALTER TABLE memories ADD COLUMN embedding_model TEXT;
При recall — проверять: если текущий провайдер не совпадает с embedding_model записи, помечать её как «нужна переиндексация» и не учитывать в semantic search (но оставлять доступной по фильтрам и text matching). Фоновая задача проходит по таким записям и пересчитывает вектора текущей моделью.
Для RAG-индекса это уже частично реализовано: в index_metadata хранится embedding_model, и при смене модели можно запустить полную переиндексацию. Для memory store — пока нет, но это следующий шаг.
Мораль: fallback между провайдерами — это хорошо для доступности, но нужно чётко разделять «fallback для индексации/запроса в рамках одного провайдера» и «смена модели навсегда». Первое — ок, второе — требует re-embed.
Как на практике: примеры промптов для агентов
Теория — это хорошо, но memory-система работает настолько хорошо, насколько хорошо агент ей пользуется. Вот реальные примеры, как я настраиваю агентов через system prompt / rules.
Промпт для автоматического сохранения контекста
Добавляю в .cursorrules или system prompt агента:
## Memory У тебя есть доступ к persistent memory через MCP-инструменты. ### Когда сохранять (store_memory): - Найден баг или ограничение системы → type: episodic, importance: 0.7 - Принято архитектурное решение → type: semantic, importance: 0.9 - Выработан рабочий процесс (деплой, тесты, debug) → type: procedural, importance: 0.8 - Текущий контекст задачи (над чем работаем сейчас) → type: working, importance: 0.5 ### Когда НЕ сохранять: - Промежуточные шаги отладки, которые ни к чему не привели - Общеизвестные факты (как работает HTTP, что такое JSON) - Содержимое файлов — для этого есть RAG ### Когда вспоминать (recall_memory): - Перед началом новой задачи: recall "контекст проекта [название]" - При столкновении с багом: recall "похожие ошибки [описание]" - При вопросе "как делать X": recall type:procedural "X" ### Формат записей: Пиши кратко и конкретно. Не "поработали над авторизацией", а "JWT refresh token ломается при одновременных запросах — race condition в middleware, фикс: мьютекс на refresh endpoint".
Промпт для recall перед началом работы
## Начало сессии При каждом новом разговоре: 1. Вызови `recall_memory` с запросом, описывающим текущую задачу 2. Вызови `recall_memory` с type: working, чтобы восстановить рабочий контекст 3. Если есть релевантные результаты — используй их, не спрашивай заново 4. Если результатов нет — работай как обычно и сохрани новый контекст
Промпт для «умного» сохранения с тегами
## Правила тегирования При сохранении всегда добавляй теги: - Имя проекта: "payment-service", "auth-api" - Технологию: "go", "postgres", "redis" - Категорию: "bug", "architecture", "deploy", "performance" - Компонент: "api", "worker", "migrations" Пример: store_memory( content: "Миграция #47 ломает индекс на orders.created_at — откатили, нужно пересоздать индекс CONCURRENTLY", type: "episodic", tags: ["payment-service", "postgres", "migrations", "bug"], context: "payment-v2-migration", importance: 0.8 )
Промпт-минимум (если не хочется настраивать подробно)
Если нет желания писать длинные правила — достаточно одного абзаца:
У тебя есть persistent memory (store_memory / recall_memory). В начале каждой сессии делай recall по текущей задаче. Сохраняй важные решения, найденные баги и рабочие процедуры. Не сохраняй мусор — только то, что пригодится через неделю.
Даже такой минимальный промпт уже даёт заметный эффект: агент начинает «помнить» между сессиями.
Что дальше
Проект уже работает и приносит пользу, но есть план развития:
Embedding model tracking + re-embed: записывать модель в каждую запись, при смене провайдера — фоновая переиндексация (тот самый урок из раздела про model mismatch)
Memory decay: автоматическое снижение relevance для давно неиспользуемых записей
Duplicate detection: поиск и мерж дублирующихся воспоминаний
Memory consolidation: объединение серии episodic записей в одну semantic (как переход из оперативной памяти в долговременную)
Plugin system: расширение провайдеров embeddings и storage
Team mode: опциональный Postgres-бэкенд для шаренной памяти
Заключение
Если у вас тоже есть ощущение, что AI-агент каждый раз «теряет память» между сессиями — эта проблем�� решаема. Мой подход:
отдельный MCP-сервер памяти на Go + SQLite,
semantic search через embeddings с fallback по провайдерам,
RAG по проектной документации,
общий контекст для всех агентов/инструментов,
zero-ops: один бинарник, один файл БД, поднимается за секунду.
Мне это уже дало ощутимый прирост в повседневной работе: агент не начинает с нуля, помнит контекст проекта и может вспомнить, что мы уже пробовали неделю назад.
Репозиторий: github.com/ipiton/agent-memory-mcp
Если будет интересно — готов сделать отдельный пост с практикой: как формулирую записи, как не засорить память, и где memory реально лучше, чем просто RAG.