Самое важное из мира AI — в канале MAX AgentLabОтдельные разборы, инструменты и практические схемы Читайте Agent Lab в Telegram Разборы, кейсы и новости о практических AI-агентах без лишней воды.

Память AI-агента: сравниваем файлы, локальное хранилище и MCP-сервис

Память AI-агента: сравниваем файлы, локальное хранилище и MCP-сервис
Temporary fallback cover; replace in editorial pass.
Уровень: средний · Чтение: до 12 минут ·

Зачем агенту память и чем она опасна

Без памяти AI-агент начинает каждую сессию с нуля: заново выясняет версию Python, ваш стиль ответов и способ деплоя. Пишем всё подряд в «память» — получаем обратную проблему: через месяц агент уверенно советует устаревший ручной деплой, потому что запись о нём так и не удалили, а нормальный поиск по ней находит её быстрее, чем актуальную.

Поэтому хорошая память умеет три вещи: находить нужную запись по запросу, забывать устаревшее и оставаться под вашим контролем. В этой статье мы соберём три минимальных варианта памяти с одинаковым интерфейсом и одним набором тестовых запросов:

  1. Файлы — по одному Markdown-файлу на запись, поиск по пересечению слов.
  2. Локальное хранилище — SQLite с полнотекстовым индексом FTS5 и ранжированием BM25.
  3. MCP-сервис — то же хранилище, но спрятанное за MCP-сервером, к которому агент обращается как к инструменту.

В конце у вас будет скрипт, который считает для каждого варианта долю найденных записей (recall@3), медианную и максимальную задержку и количество «протухших» записей в выдаче. Готовых цифр в статье нет намеренно: результаты сильно зависят от ваших данных и машины, и честное сравнение — то, что вы получите сами.

Что понадобится

  • Python 3.10 или новее.
  • SQLite со встроенным FTS5 (в большинстве сборок Python он есть, ниже проверим).
  • Пакет mcp — официальный Python SDK для Model Context Protocol, для третьего варианта.

Все команды выполняются в отдельном каталоге и ничего не меняют вне него:

mkdir agent-memory-lab && cd agent-memory-lab
python3 -m venv .venv
. .venv/bin/activate
pip install "mcp>=1.2"

# Проверка, что FTS5 доступен
python3 -c "import sqlite3; sqlite3.connect(':memory:').execute('CREATE VIRTUAL TABLE t USING fts5(x)'); print('FTS5 OK')"

Если последняя команда завершилась ошибкой no such module: fts5, ваша сборка Python собрана без FTS5 — вариант с файлами всё равно будет работать, а для двух других понадобится другая сборка Python.

Шаг 1. Общий набор данных и запросов

Чтобы сравнение было честным, все три варианта загружают одни и те же записи. Это учебный пример, а не реальная база: замените его фрагментами, которые ваш агент действительно должен помнить.

Каждая запись имеет поле updated (когда подтверждена) и необязательное expires (после какой даты запись считается устаревшей). Именно expires отделяет память от архива.

cat > memories.jsonl <<'EOF'
{"id": "m1", "text": "Проект использует Python 3.12 и менеджер пакетов uv", "updated": "2026-09-01"}
{"id": "m2", "text": "Пользователь просит отвечать кратко, код без пояснений в комментариях", "updated": "2026-08-15"}
{"id": "m3", "text": "Деплой выполняется через GitHub Actions по тегу v*", "updated": "2026-09-20"}
{"id": "m4", "text": "Деплой выполняется вручную через rsync на сервер", "updated": "2026-03-10", "expires": "2026-06-30"}
{"id": "m5", "text": "Тесты запускаются командой pytest -q из корня репозитория", "updated": "2026-09-05"}
{"id": "m6", "text": "База данных PostgreSQL 16, миграции через alembic", "updated": "2026-07-01"}
EOF

cat > queries.jsonl <<'EOF'
{"query": "как запускать тесты pytest", "expected": "m5"}
{"query": "какая версия python", "expected": "m1"}
{"query": "как устроен деплой", "expected": "m3", "stale": "m4"}
{"query": "миграции базы данных", "expected": "m6"}
{"query": "стиль ответов пользователю", "expected": "m2"}
EOF

Поле stale указывает запись, которая не должна попасть в выдачу: она истекла. Последний запрос специально сформулирован в других словоформах («ответов», «пользователю»), чем текст записи, — так вы увидите, как ведут себя лексические методы поиска на русском языке.

Шаг 2. Вариант A: память в файлах

Самый прозрачный подход: каждая запись — Markdown-файл с метаданными во фронтматтере. Файлы можно читать глазами, править в редакторе и хранить в git вместе с проектом.

cat > backend_files.py <<'EOF'
import pathlib
import re

ROOT = pathlib.Path("mem_files")
TOKEN = re.compile(r"\w+")


def tokens(s):
    return {t.lower() for t in TOKEN.findall(s)}


def load(records):
    ROOT.mkdir(exist_ok=True)
    for r in records:
        (ROOT / f"{r['id']}.md").write_text(
            f"---\nid: {r['id']}\nupdated: {r['updated']}\n"
            f"expires: {r.get('expires', '')}\n---\n{r['text']}\n",
            encoding="utf-8",
        )


def search(query, k=3, today="2026-10-20"):
    q = tokens(query)
    scored = []
    for path in ROOT.glob("*.md"):
        _, head, body = path.read_text(encoding="utf-8").split("---\n", 2)
        meta = {}
        for line in head.splitlines():
            key, _, value = line.partition(":")
            meta[key.strip()] = value.strip()
        if meta.get("expires") and meta["expires"] < today:
            continue
        score = len(q & tokens(body))
        if score:
            scored.append((score, meta["id"]))
    scored.sort(reverse=True)
    return [i for _, i in scored[:k]]
EOF

Поиск здесь — простое пересечение множеств слов. Он полностью перечитывает каталог на каждый запрос, поэтому задержка растёт линейно с числом записей. Для десятков записей это не проблема; для тысяч — станет заметно.

Шаг 3. Вариант B: локальное хранилище SQLite FTS5

Тот же интерфейс load/search, но с полнотекстовым индексом и ранжированием BM25. Данные лежат в одном файле memory.db на вашей машине.

cat > backend_sqlite.py <<'EOF'
import re
import sqlite3

DB = "memory.db"


def conn():
    return sqlite3.connect(DB)


def load(records):
    c = conn()
    c.execute("DROP TABLE IF EXISTS mem")
    c.execute("CREATE VIRTUAL TABLE mem USING fts5("
              "id UNINDEXED, text, updated UNINDEXED, expires UNINDEXED)")
    c.executemany("INSERT INTO mem VALUES (?, ?, ?, ?)",
                  [(r["id"], r["text"], r["updated"], r.get("expires", ""))
                   for r in records])
    c.commit()
    c.close()


def add(r):
    c = conn()
    c.execute("INSERT INTO mem VALUES (?, ?, ?, ?)",
              (r["id"], r["text"], r["updated"], r.get("expires", "")))
    c.commit()
    c.close()


def search(query, k=3, today="2026-10-20"):
    terms = re.findall(r"\w+", query.lower())
    if not terms:
        return []
    # Каждое слово в кавычках: пользовательский текст не ломает синтаксис FTS5
    match = " OR ".join(f'"{t}"' for t in terms)
    c = conn()
    rows = c.execute(
        "SELECT id FROM mem WHERE mem MATCH ? "
        "AND (expires = '' OR expires >= ?) "
        "ORDER BY bm25(mem) LIMIT ?",
        (match, today, k),
    ).fetchall()
    c.close()
    return [r[0] for r in rows]
EOF

Два важных момента. Во-первых, запрос собирается из слов в кавычках через OR: если передать в MATCH сырую строку пользователя, символы вроде - или * будут интерпретированы как операторы FTS5 и вызовут ошибку. Во-вторых, фильтр по expires выполняется в том же SQL-запросе, поэтому устаревшие записи не тратят место в топ-k.

Шаг 4. Вариант C: MCP-сервис

Третий вариант не меняет хранилище — он меняет способ доступа. Память становится отдельным процессом с инструментами recall и remember, и любой MCP-совместимый клиент (IDE-агент, десктоп-ассистент, ваш собственный оркестратор) может подключить её без изменения кода агента.

cat > mcp_memory.py <<'EOF'
import json

from mcp.server.fastmcp import FastMCP

import backend_sqlite as store

mcp = FastMCP("agent-memory")


@mcp.tool()
def recall(query: str, k: int = 3) -> str:
    """Найти до k актуальных записей памяти по запросу. Возвращает JSON-список id."""
    return json.dumps(store.search(query, k))


@mcp.tool()
def remember(id: str, text: str, updated: str, expires: str = "") -> str:
    """Сохранить новую запись. expires в формате YYYY-MM-DD, пусто — бессрочно."""
    store.add({"id": id, "text": text, "updated": updated, "expires": expires})
    return "ok"


if __name__ == "__main__":
    mcp.run()  # транспорт stdio по умолчанию
EOF

Сервер работает через stdio: клиент запускает его как дочерний процесс и обменивается сообщениями через stdin/stdout. Отсюда главное правило — ничего не печатать в stdout из кода сервера: любой лишний print испортит протокол. Отладочный вывод отправляйте в stderr.

В этом примере recall возвращает только идентификаторы — так проще сравнивать с другими вариантами. В рабочей версии агенту нужен и текст записи, и дата updated, чтобы модель могла сама оценить свежесть.

Шаг 5. Единый замер

Скрипт загружает данные во все хранилища, прогоняет запросы и печатает по строке на вариант. Для MCP задержка измеряется через настоящий клиент, то есть включает сериализацию и обмен с процессом — ровно то, что увидит агент.

cat > bench.py <<'EOF'
import asyncio
import json
import statistics
import sys
import time

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

import backend_files
import backend_sqlite


def read_jsonl(path):
    with open(path, encoding="utf-8") as f:
        return [json.loads(line) for line in f if line.strip()]


records = read_jsonl("memories.jsonl")
queries = read_jsonl("queries.jsonl")


async def evaluate(name, search):
    hits, stale, lat = 0, 0, []
    for q in queries:
        t = time.perf_counter()
        ids = await search(q["query"])
        lat.append((time.perf_counter() - t) * 1000)
        hits += q["expected"] in ids
        stale += q.get("stale") in ids if q.get("stale") else 0
    lat.sort()
    print(f"{name:7} recall@3={hits / len(queries):.2f} stale={stale} "
          f"p50={statistics.median(lat):.2f}ms max={lat[-1]:.2f}ms")


def wrap(fn):
    async def search(q):
        return fn(q)
    return search


async def main():
    backend_files.load(records)
    backend_sqlite.load(records)
    await evaluate("files", wrap(backend_files.search))
    await evaluate("sqlite", wrap(backend_sqlite.search))

    params = StdioServerParameters(command=sys.executable, args=["mcp_memory.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            async def mcp_search(q):
                res = await session.call_tool("recall", {"query": q, "k": 3})
                return json.loads(res.content[0].text)

            await evaluate("mcp", mcp_search)


asyncio.run(main())
EOF

python3 bench.py

Запустите скрипт несколько раз: первый прогон часто медленнее из-за холодного кэша файловой системы и импорта модулей. Для устойчивой оценки задержки увеличьте число запросов — пяти учебных хватает только для проверки, что всё работает.

Как проверить результат

  • Скрипт отработал без исключений и напечатал три строки: files, sqlite, mcp.
  • stale=0 во всех строках. Запись m4 истекла 30 июня 2026 года и не должна появляться. Чтобы убедиться, что фильтр действительно работает, временно передайте в search дату today="2026-01-01" — устаревшая запись должна вернуться в выдачу.
  • Строки sqlite и mcp дают одинаковый recall: под MCP-сервером то же хранилище, поэтому различаться должна только задержка. Если recall расходится, сервер читает другой файл базы — проверьте рабочий каталог.
  • Запрос про стиль ответов. Посмотрите, нашёл ли его хоть один вариант. Из кода видно, что оба метода сопоставляют слова буквально, а слова «ответов»/«отвечать» и «пользователю»/«Пользователь» для них разные. Это ожидаемое ограничение, а не ошибка в настройке.

Заполните сводную таблицу своими числами. Колонки «Переносимость» и «Контроль данных» — это свойства самих подходов, их не нужно измерять:

Вариант recall@3 p50, мс Переносимость Контроль данных
Файлы ваш замер ваш замер Максимальная: обычный текст, работает в git, читается без кода Полный: всё видно глазами, правится в редакторе, есть diff
SQLite FTS5 ваш замер ваш замер Высокая: один файл, но нужен SQLite с FTS5 Полный, но для просмотра нужен SQL-клиент или скрипт
MCP-сервис ваш замер ваш замер Между агентами: любой MCP-клиент подключается без изменения кода Зависит от размещения: локальный stdio — у вас; удалённый сервис — у владельца сервера

Типовые ошибки

  • Память без срока годности. Если у записей нет expires или хотя бы updated, вы не сможете отличить актуальный факт от устаревшего. Минимум — хранить дату последнего подтверждения и показывать её модели.
  • Сырая строка в MATCH. Запрос «как работает CI/CD?» без экранирования вызывает синтаксическую ошибку FTS5. Слова в кавычках, как в примере, решают проблему.
  • print в MCP-сервере. Клиент получает мусор вместо JSON-RPC и обрывает соединение с невнятной ошибкой. Логируйте только в stderr.
  • Относительный путь к базе. memory.db открывается относительно рабочего каталога процесса. Если MCP-клиент запускает сервер из другой папки, сервер создаст пустую базу и молча будет возвращать пустые списки. В рабочей версии используйте абсолютный путь.
  • Сравнение задержки в разных условиях. Вызов внутри процесса и вызов через MCP измеряют разные вещи. Сравнивайте варианты по тому пути, которым их реально будет вызывать агент.
  • Агент пишет в память всё. Если отдать модели инструмент remember без правил, память быстро заполнится одноразовыми деталями. Сформулируйте в системной инструкции, что стоит запоминать (решения, предпочтения, договорённости), и проверяйте записи вручную.

Ограничения

  • Лексический поиск и русская морфология. Оба метода сравнивают слова буквально: токенизатор FTS5 unicode61 приводит регистр, но не выполняет стемминг русского языка. Для устойчивого поиска нужны лемматизация при записи и запросе либо векторный поиск по эмбеддингам — это отдельная тема, в эту статью она не входит.
  • Пять запросов — это проверка работоспособности, а не бенчмарк. Чтобы делать выводы о точности, соберите хотя бы несколько десятков запросов из реальных сессий вашего агента с заранее размеченным ожидаемым ответом.
  • Дата «сегодня» зашита в код для воспроизводимости. В рабочей версии берите текущую дату.
  • Нет конкурентной записи. Файловый вариант не защищён от одновременной записи двумя агентами; SQLite выдержит, но без настройки WAL-режима и таймаутов возможны блокировки.
  • Нет разграничения доступа. Локальный MCP-сервер по stdio доступен только запустившему его клиенту. Если вы выносите память в сетевой сервис, понадобятся аутентификация, шифрование и явная политика, какие данные туда попадают.

Что выбрать

Для одного агента и одного проекта начните с файлов: они прозрачны, их удобно ревьюить в git, а нехватка производительности проявится не скоро. Когда записей становится много или нужен ранжированный поиск — переходите на SQLite FTS5, сохранив тот же интерфейс search. MCP-сервис имеет смысл, когда одну и ту же память должны использовать несколько агентов или клиентов: вы платите задержкой на межпроцессный вызов, но получаете единый источник фактов. Какой вариант лучше по точности и задержке именно у вас, покажет замер из шага 5 на ваших данных.

Что дальше

  • Другие практические разборы про агентов, инструменты и оценку — в разделе Руководства.
  • Термины из статьи — MCP, BM25, recall@k, эмбеддинги — собраны в Глоссарии.