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

Зачем агенту память и чем она опасна
Без памяти AI-агент начинает каждую сессию с нуля: заново выясняет версию Python, ваш стиль ответов и способ деплоя. Пишем всё подряд в «память» — получаем обратную проблему: через месяц агент уверенно советует устаревший ручной деплой, потому что запись о нём так и не удалили, а нормальный поиск по ней находит её быстрее, чем актуальную.
Поэтому хорошая память умеет три вещи: находить нужную запись по запросу, забывать устаревшее и оставаться под вашим контролем. В этой статье мы соберём три минимальных варианта памяти с одинаковым интерфейсом и одним набором тестовых запросов:
- Файлы — по одному Markdown-файлу на запись, поиск по пересечению слов.
- Локальное хранилище — SQLite с полнотекстовым индексом FTS5 и ранжированием BM25.
- 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, эмбеддинги — собраны в Глоссарии.