Когда начинаешь нормально работать с 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 строк кода), который совмещает два слоя:

  1. Память агента — persistent storage для знаний, событий, процедур и рабочего контекста.

  2. RAG-индекс — семантический поиск по проектной документации.

Стек:

Компонент

Решение

Язык

Go 1.26

Протокол

MCP 2024-11-05 (stdio + HTTP/JSON-RPC)

Хранилище

SQLite (WAL mode)

Embeddings

Jina AI / OpenAI-совместимые / Ollama

Зависимости

modernc.org/sqlite, google/uuid, uber/zap

Структура проекта:

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-систем — всё валится в одну кучу. Через пару недель это уже не память, а помойка. Я разделил записи на четыре типа, и это не произвольная классификация — она основана на том, как реально выглядят запросы агента.

Тип

Назначение

Пример

episodic

Что произошло (события, эксперименты)

«Пробовали миграцию на v3 — сломалось из-за несовместимых типов»

semantic

Факты и знания (долгосрочные)

«Проект использует chi router, авторизация через JWT»

procedural

Как делать (повторяемые процедуры)

«Деплой:make build && docker push && kubectl apply»

working

Текущий контекст (короткоживущее)

«Сейчас разбираем баг в 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"]
  }
}

Инструмент

Что делает

store_memory

Сохранить запись (с типом, тегами, важностью)

recall_memory

Семантический поиск по памяти

update_memory

Обновить существующую запись

delete_memory

Удалить запись по ID

list_memories

Список с фильтрами (тип, контекст, лимит)

memory_stats

Статистика: количество записей по типам

RAG tools (2 штуки)

Инструмент

Что делает

semantic_search

Семантический поиск по индексированным документам

index_documents

Запустить переиндексацию

File tools (3 штуки)

Инструмент

Что делает

repo_list

Список файлов (с ограничением по allowlist)

repo_read

Чтение файла

repo_search

Текстовый поиск по файлам

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.0

  • GET /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.