Engineering the Agentic Stack · Часть 2

Архитектура памяти ИИ-агента: чекпоинты и векторные хранилища

Автоматический перевод Эта статья была автоматически переведена с оригинальной английской версии.

Цикл ризонинга переживает только один запрос, если его состояние не сохраняется за пределами воркера. Без памяти агента агент не может продолжить приостановленный план, восстановиться после сбоя или вспомнить предпочтение из предыдущей сессии. В части 1 был разобран control flow. В этой статье определим, какое состояние требуется на каждом следующем ходе и где его следует хранить.

В качестве опорного примера для обсуждения hot-checkpoint я буду использовать Market Analyst Agent — небольшого агента на LangGraph, который получает рыночные данные и формирует аналитический отчёт. Разделы о cold-vector и raw-Markdown — независимые иллюстративные дизайны, показывающие расширения, пока не реализованные в текущем проекте. Затем разберём, когда имеет смысл использовать PostgreSQL, Redis, Qdrant, key-value-хранилища и обычные Markdown-файлы.

Коротко: разделяйте память по паттерну доступа. Hot memory — состояние чекпоинта отдельного диалога для приостановки и возобновления. Cold memory хранит факты между сессиями в key-value- или векторном хранилище. Document memory содержит знания проекта в файлах, которые можно инспектировать. Начните с ошибки, которую нужно исправить, и только потом выбирайте хранилище. Не помещайте точные факты в систему нечёткого retrieval и не воспринимайте чекпоинт как audit log.

Каждое хранилище ниже читает харнесс — код, который управляет циклом вокруг модели. Харнесс решает, какое содержимое этих хранилищ попадёт в контекстное окно; сами хранилища этого не делают. Статья посвящена тому, где состояние находится до того, как харнесс обратится к нему. В частях 3 и 4 разобрано, что харнесс делает с промптом дальше.


Что такое память ИИ-агента?

Память ИИ-агента — это слой состояния, который позволяет агенту сохранять прогресс задачи, извлекать предыдущие знания и обновлять то, что ему известно, между запусками. В продакшене это не одна векторная база данных, а комбинация hot-чекпоинтов, cold semantic- или structured-хранилищ и человекочитаемой document memory.

ПотребностьЛучший вариант по умолчаниюПочему
Приостановка и возобновление одного запускаPostgreSQL checkpoint storeНадёжное, поддерживает запросы и хорошо работает вместе с данными приложения
Транзиентное состояние с низкой латентностьюRedis checkpoint storeБыстрое возобновление и краткоживущее состояние с компромиссами по персистентности
Семантическое вспоминание между тредамиQdrant или pgvectorИзвлекает воспоминания по смыслу, а не только по точным ключам
Структурированные факты о пользователеPostgreSQL или key-value storeДетерминированные обновления лучше нечёткого retrieval для предпочтений и ID
Конвенции проекта и выученные процедурыMarkdown- или JSON-файлыЧеловекочитаемы, поддерживают diff и легко обновляются агентами
Память о связях между сущностямиKnowledge graphПолезен, когда связи важнее отдельных фактов

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

Ошибки, для которых нужна память

Stateless-агент может ответить на изолированный вопрос, но забывает запрос сразу после завершения вызова. Такой дизайн не подходит, когда продукту нужны следующие сценарии:

  • Приостановка и возобновление: пользователь запускает исследовательскую задачу, закрывает ноутбук и возвращается завтра. Без состояния в чекпоинте агент начинает всё с нуля.
  • Согласованность в многоходовом диалоге: в длинном разговоре агент должен помнить, какие инструменты вызывал, какие данные собрал и какие шаги плана завершил.
  • Персонализация: вернувшийся пользователь ожидает, что агент знает его отношение к риску, предпочитаемую глубину анализа и историю взаимодействий.
  • Human-in-the-loop (HITL): агент собирает доказательства и ждёт одобрения человека для следующего шага. Состояние ожидания должно переживать перезапуск процесса.

В Market Analyst Agent из части 1 запрос «Analyze NVDA» приводит к созданию плана, пяти tool calls, сбору данных и черновику отчёта. Когда пользователь отвечает «выглядит хорошо, но добавь анализ конкурентов», checkpoint store позволяет агенту загрузить состояние последнего завершённого шага и добавить шаг с конкурентами. Без состояния в чекпоинте агент не сможет понять, к чему относится «выглядит хорошо», и будет вынужден начать сначала.

Долгосрочная память нужна для другого сценария. Если пользователь возвращается через неделю и спрашивает: «Обнови мой анализ NVDA», агенту может понадобиться вспомнить предпочтение консервативной оценки рисков и интерес к акциям полупроводниковых компаний. Векторное memory store может извлечь эти факты между сессиями, не запрашивая их повторно.

Примеры реализации ниже используют LangGraph — open-source-библиотеку LangChain для построения агентов в виде явных графов состояний; границы хранения, которые она задаёт, обобщаются на любой фреймворк. LangGraph разделяет память по области видимости. Каждый запуск графа выполняется внутри треда, то есть одного разговора или задачи. Сохранённое состояние внутри этого треда — краткосрочная память. Состояние, общее для разных тредов, — долгосрочная память. Ниже «тред» и «разговор» взаимозаменяемы; я избегаю слова «сессия» для этого диапазона, потому что часть 5 использует его для долговечного лога одного запуска, причём на одном треде может накапливаться несколько таких логов. Текущий контекст модели и переменные процесса образуют слой рабочей памяти над обоими хранилищами.

Шесть типов памяти агента и три уровня хранения, к которым они сводятсяШесть типов памяти агента и три уровня хранения, к которым они сводятся


Таксономия памяти ИИ-агента

Прежде чем переходить к реализации, полезно классифицировать то, что агенту нужно помнить. Фреймворк CoALA — Cognitive Architectures for Language Agents (Sumers, Yao et al., 2023) — широко цитируемая таксономия, опирающаяся на когнитивную науку. В статье о контекст-инжиниринге я ввёл разделение памяти по области видимости; здесь расширю его до шести категорий:

Тип памятиОбластьСрок жизниПримерПаттерн хранения
РабочаяТекущий шагМиллисекундыАргументы tool call, текущий ответ LLMВ процессе (Python dict)
КраткосрочнаяТекущий тредМинуты–часыИстория разговора, прогресс плана, собранные данныеCheckpoint store
ЭпизодическаяМежду тредамиДни–месяцы«На прошлой неделе пользователь спрашивал о доходах NVDA»Vector store / KV store
СемантическаяМежду тредамиМесяцы–постоянно«Пользователь предпочитает консервативные инвестиции»Vector store / KV store
ДокументнаяМежду тредамиДни–постоянноЗаметки проекта, резюме исследований, выученные паттерныFile store (Markdown/JSON)
ПроцедурнаяВся системаПостоянно«При анализе акций всегда проверяй документы SEC»Config / system prompt

Рабочая память — то, с чем LLM активно работает прямо сейчас: переменные Python в текущей функции, содержимое контекстного окна, аргументы tool call во время выполнения. Это самый быстрый и самый эфемерный слой. После текущего шага ничего не сохраняется. Рабочая память ограничена контекстным окном модели, поэтому она становится боттлнеком. Всё, что агент «знает» в момент принятия решения, должно в него поместиться — независимо от того, пришло ли это из checkpoint store, vector query или файла. Остальные уровни существуют, чтобы в нужный момент подавать правильную информацию в рабочую память.

Краткосрочная память — это чекпоинт, который LangGraph записывает после каждой единицы выполнения графа — super-step, определяемого в следующем разделе. Эпизодическая и семантическая память сохраняются между тредами. Document memory хранит заметки проекта, резюме исследований и выученные конвенции в файлах, которые могут инспектировать люди и агенты. Процедурная память живёт в системных инструкциях и определениях инструментов, а не меняется для каждого пользователя.

С точки зрения реализации пять из этих шести типов сводятся к трём уровням хранения. Краткосрочная память становится hot memory — чекпоинтом текущего треда. Эпизодическая и семантическая становятся cold memory — памятью для recall между тредами. Document memory сохраняет накопленные знания проекта в читаемом и напрямую редактируемом виде. Рабочая память в приведённой выше таксономии объединена с hot-уровнем, но фактически никогда не хранится: она существует один шаг, в процессе, и представляет собой контекстное окно, в которое три уровня хранения загружают данные. Процедурная память находится за пределами всех трёх уровней: она живёт в системном промпте и определениях инструментов и поставляется вместе с агентом, а не сохраняется и извлекается.

CoALA классифицирует рабочую, эпизодическую, семантическую и процедурную память. Обзор Memory in the Age of AI Agents уделяет особое внимание vector stores и knowledge graphs, а LangGraph документирует чекпоинты и свой интерфейс Store. Файловые знания проекта находятся за пределами этих таксономий, хотя Claude Code, Cursor и Devin Desktop загружают постоянные файлы проекта.

Тот же паттерн хранения встречается и в других областях. Minecraft-агент Voyager хранит повторно используемые игровые навыки в виде библиотек кода, команды в корпоративном соревновании по document-QA итеративно улучшают процедурные промпт-документы, а web-агенты выводят повторно используемые workflow браузинга из успешных запусков. Позже я вернусь ко всем трём примерам; здесь важно, что файлы делают эти знания инспектируемыми и версионируемыми без отдельного embedding-сервиса.

Память, которой управляет агент, также отличается от фиксированного RAG-пайплайна тем, кто выполняет запись. Агент или его харнесс выбирает, что сохранять, обновлять и удалять, а затем решает, когда это извлекать.

Статья Generative Agents (Park et al., 2023) показала, насколько далеко это можно развить: симулированные агенты сохраняли, переосмысливали и извлекали собственные воспоминания. Его memory stream ранжировал кандидатов по давности, важности и релевантности — дизайн, который до сих пор служит полезной точкой отсчёта для retrieval в памяти агентов.


Краткосрочная память агента: checkpoint store

Каждый раз, когда LangGraph завершает super-step — один узел или пакет параллельно выполнявшихся узлов, — фреймворк сериализует полное состояние графа и записывает его в checkpoint store. Это основа сценариев pause/resume, отладки с перемещением во времени и HITL-workflow.

Hot memory: чекпоинт, записываемый после каждого super-step, и путь восстановления, который его загружаетHot memory: чекпоинт, записываемый после каждого super-step, и путь восстановления, который его загружает

Чекпоинт содержит состояние графа, необходимое для возобновления: AgentState из части 1 — messages, identity, user profile, plan steps, research data, execution mode. Вместе с ним LangGraph хранит собственные служебные данные: ID и timestamp чекпоинта, по одной версии для каждого channel (так LangGraph называет отдельный ключ состояния), а также отдельную запись о версиях каналов, которые уже видел каждый узел. Номер шага находится в metadata чекпоинта, а не в самом чекпоинте. Сопоставляя эти два значения, граф определяет, что запускать дальше. После HITL-interrupt или перезапуска процесса граф загружает чекпоинт, записанный на последней завершённой границе, и входит в следующий узел. Он не продолжает выполнение с произвольной строки Python. Чекпоинт также отличается от append-only event log или trace; часть 5 явно разделяет эти runtime observability-поверхности.

Как работает checkpointing в LangGraph

BaseCheckpointSaver LangGraph — это простой интерфейс: put() записывает чекпоинт, get_tuple() читает последний чекпоинт для треда, list() возвращает историю. Каждый чекпоинт адресуется по (thread_id, checkpoint_ns, checkpoint_id), где thread_id идентифицирует разговор, checkpoint_ns отвечает за namespace подграфа, а checkpoint_id является уникальной версией.

Главное решение — какой бэкенд использовать под этим интерфейсом. PostgreSQL и Redis — два распространённых варианта для продакшена.

PostgreSQL vs Redis

Redis и PostgreSQL как бэкенды чекпоинтов: сравнение латентности, надёжности и модели запросовRedis и PostgreSQL как бэкенды чекпоинтов: сравнение латентности, надёжности и модели запросов

ИзмерениеPostgreSQL (langgraph-checkpoint-postgres)Redis (langgraph-checkpoint-redis)
Модель надёжностиACID-транзакции, WAL и репликацияНастраиваемая персистентность: append-only command log (AOF) или периодические снапшоты (RDB)
История чекпоинтовНадёжная история для resume и отладкиRetention зависит от saver и настроек eviction
Основное ограничениеЛатентность записи в БД и рост таблицИспользование RAM, eviction и настройки персистентности
Операционная совместимостьКоманды, уже использующие реляционные БДКоманды, уже использующие Redis с высоким throughput
Лучший вариант дляНадёжного resume и воспроизводимой отладкиЧувствительного к латентности, восстанавливаемого состояния сессии

Общие бенчмарки баз данных не позволяют предсказать производительность checkpointing. Измеряйте размер сериализованного состояния, частоту записи, настройки персистентности и конкурентность именно вашего графа.

PostgreSQL: надёжный вариант по умолчанию

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

Упрощённая версия настройки чекпоинтов в memory/hot.py. В продакшене задайте LANGGRAPH_STRICT_MSGPACK=true или настройте явный allowlist allowed_msgpack_modules, чтобы десериализация чекпоинтов разрешала только безопасные или объявленные типы; permissive default предупреждает о незарегистрированных типах, но всё равно разрешает их.

import asyncio
from contextlib import asynccontextmanager

from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

@asynccontextmanager
async def postgres_checkpointer(connection_string: str):
    """Yield a PostgreSQL-backed checkpoint store.

    PostgreSQL gives us ACID guarantees — if a checkpoint write succeeds,
    the state is durable even if the process crashes immediately after.
    `from_conn_string` is itself an async context manager: it owns the
    connection and closes it on exit, so the graph has to run inside it.
    """
    async with AsyncPostgresSaver.from_conn_string(connection_string) as checkpointer:
        # Create the checkpoint tables if they don't exist.
        # This is idempotent — safe to call on every startup.
        await checkpointer.setup()
        yield checkpointer

async def main() -> None:
    # The graph lives inside the context manager's scope.
    async with postgres_checkpointer(
        "postgresql://user:pass@localhost:5432/agent_memory"
    ) as checkpointer:
        graph = create_graph(checkpointer=checkpointer)

        # Every invoke/stream call now persists state automatically.
        config = {"configurable": {"thread_id": "user-123-session-1"}}
        result = await graph.ainvoke(
            {"messages": [HumanMessage(content="Analyze NVDA")]}, config
        )

        # Resume later on the same thread_id — loads the latest checkpoint.
        result = await graph.ainvoke(
            {"messages": [HumanMessage(content="approved")]}, config
        )

asyncio.run(main())

AsyncPostgresSaver использует пакет langgraph-checkpoint-postgres, который создаёт четыре таблицы: checkpoints (сериализованное состояние), checkpoint_blobs (большие бинарные данные), checkpoint_writes (ожидающие записи для crash recovery) и checkpoint_migrations (версия схемы). Конкурентные записи разделяются первичным ключом (thread_id, checkpoint_ns, checkpoint_id) и upsert-операциями, а не блокировками: два воркера в одном треде не повредят данные друг друга, но и координироваться между собой не будут.

Redis: когда боттлнеком является латентность

Если боттлнеком становится латентность чекпоинтов, Redis — вариант для восстанавливаемого состояния. Перед выбором Redis вместо PostgreSQL измерьте размер сериализованного состояния, настройки персистентности и конкурентность.

Упрощённая версия настройки чекпоинтов в memory/hot.py:

import asyncio
from contextlib import asynccontextmanager

from langgraph.checkpoint.redis.aio import AsyncRedisSaver

@asynccontextmanager
async def redis_checkpointer(redis_url: str):
    """Yield a Redis-backed checkpoint store.

    Redis keeps checkpoints in memory for low-latency access.
    Trade-off: less durable than PostgreSQL unless you enable AOF,
    Redis's append-only log — snapshots alone lose recent writes on a crash.
    """
    async with AsyncRedisSaver.from_conn_string(redis_url) as checkpointer:
        # Initialize Redis data structures
        await checkpointer.asetup()
        yield checkpointer

async def main() -> None:
    # Same graph API, different backend.
    async with redis_checkpointer("redis://localhost:6379") as checkpointer:
        graph = create_graph(checkpointer=checkpointer)

asyncio.run(main())

AsyncRedisSaver из langgraph-checkpoint-redis хранит каждый чекпоинт как отдельный документ RedisJSON под тем же ключом (thread_id, checkpoint_ns, checkpoint_id), что и Postgres saver. Редизайн v0.1.0 заменил несколько поисковых операций одним вызовом JSON.GET, заметно снизив латентность. В Redis 8.0+ RedisJSON и RediSearch включены по умолчанию — устанавливать дополнительные модули не нужно.

Для деплоев с ограниченной памятью ShallowRedisSaver хранит только последний чекпоинт для каждого треда: истории нет, зато минимальное использование RAM. Используйте этот вариант, если нужны pause/resume, но не нужна отладка с перемещением во времени.

Когда что использовать

Используйте PostgreSQL, если:

  • Нужна полная история чекпоинтов для отладки с перемещением во времени или воспроизводимого resume
  • Надёжность не подлежит обсуждению (финансовые сервисы, здравоохранение)
  • PostgreSQL уже используется в вашем стеке
  • Агент выполняет долгие задачи, и потеря состояния означает часы повторных вычислений
  • Нужно унифицированное хранилище данных — PostgreSQL с pgvector может быть единым бэкендом для чекпоинтов, долгосрочной памяти и vector search

Используйте Redis, если:

  • Латентность чекпоинтов — ваш боттлнек (real-time chat, streaming UX)
  • Вы создаёте voice bots или streaming-сценарии, где доступ к чекпоинту находится на измеряемом критическом по латентности пути
  • Нужно горизонтальное масштабирование для большого числа параллельных тредов
  • Используются высококонкурентные fan-out-паттерны, в которых несколько агентов разделяют состояние
  • Сессии короткоживущие, а потеря чекпоинта допускает восстановление
  • Вы хотите semantic caching для сокращения лишних вызовов LLM (Redis LangCache кэширует семантически похожие запросы и избегает повторных вызовов LLM)

Другие варианты: langgraph-checkpoint-sqlite подходит для локальной разработки и однопроцессных деплоев. Для AWS-native-стеков langgraph-checkpoint-aws предоставляет DynamoDBSaver с автоматическим выносом payload: небольшие чекпоинты (<350 KB) остаются в DynamoDB, а большие выгружаются в S3. Serverless-ценообразование и отсутствие инфраструктуры, которой нужно управлять, делают этот вариант привлекательным для деплоев с переменной нагрузкой.


Долгосрочная память: вспоминание между сессиями

Hot memory обслуживает текущий разговор. Долгосрочная память относится к пользователю, который вернётся на следующей неделе: она хранит факты, предпочтения и историю взаимодействий, сохраняющиеся между тредами.

LangGraph предоставляет интерфейс Store для памяти между тредами через класс BaseStore. Каждый элемент памяти — это пара (namespace, key) с JSON-значением и необязательным векторным эмбеддингом. Namespace обычно кодирует пользователя или организацию: ("user", "user-123", "preferences").

Путь retrieval из cold memory: эмбеддинг запроса, поиск в Qdrant с фильтром по пользователю, пересчёт оценки и добавление в контекстПуть retrieval из cold memory: эмбеддинг запроса, поиск в Qdrant с фильтром по пользователю, пересчёт оценки и добавление в контекст

Векторное хранение: семантический recall с Qdrant

Когда агенту нужно вспомнить неструктурированные факты («Что пользователь говорил о сроках своих инвестиций?»), vector search обеспечивает семантический recall. Вместо поиска по точному ключу агент делает запрос по смыслу.

Qdrant — специализированная векторная БД на Rust, которая поддерживает хранение эмбеддингов, индексацию (Hierarchical Navigable Small World, или HNSW) и поиск с фильтрами. HNSW и его компромиссы подробно разобраны в моей статье о ранжировании поиска. Qdrant также предлагает MCP server, который работает как слой семантической памяти, если ваш агентный фреймворк поддерживает Model Context Protocol.

Ниже приведён независимый иллюстративный дизайн на Qdrant. Это не упрощённая версия текущего memory/long.py. В текущем проекте профили пользователей хранятся с точной фильтрацией по user_id и placeholder zero-vector. Реальная интеграция эмбеддингов остаётся задачей на будущее. Request handler должен аутентифицировать запрос и сформировать principal на основе подтверждённой identity; клиент не передаёт его. Фильтр Qdrant задаёт область retrieval, а не авторизацию.

from qdrant_client import QdrantClient
from qdrant_client.models import (
    PointStruct, Distance, VectorParams, Filter, FieldCondition, MatchValue,
)
import hashlib
from dataclasses import dataclass

@dataclass(frozen=True)
class AuthenticatedPrincipal:
    """Created by the server after authentication, never from request JSON."""
    user_id: str

class UserMemoryStore:
    """Long-term memory backed by Qdrant vector search.

    Stores user facts as embedded vectors for semantic retrieval.
    Each fact is a short natural-language statement about the user.
    """

    def __init__(self, qdrant_url: str, collection_name: str = "user_memory"):
        self.client = QdrantClient(url=qdrant_url)
        self.collection_name = collection_name
        self._ensure_collection()

    def _ensure_collection(self):
        """Create the collection if it doesn't exist."""
        collections = [c.name for c in self.client.get_collections().collections]
        if self.collection_name not in collections:
            self.client.create_collection(
                collection_name=self.collection_name,
                vectors_config=VectorParams(
                    size=1536,  # text-embedding-3-small dimensions
                    distance=Distance.COSINE,
                ),
            )

    def store_fact(
        self, principal: AuthenticatedPrincipal, fact: str, embedding: list[float]
    ):
        """Store a user fact with its embedding."""
        point_id = hashlib.md5(f"{principal.user_id}:{fact}".encode()).hexdigest()
        self.client.upsert(
            collection_name=self.collection_name,
            points=[PointStruct(
                id=point_id,
                vector=embedding,
                payload={"user_id": principal.user_id, "fact": fact},
            )],
        )

    def recall(
        self,
        principal: AuthenticatedPrincipal,
        query_embedding: list[float],
        top_k: int = 5,
    ):
        """Retrieve the most relevant facts for a user given a query."""
        results = self.client.query_points(
            collection_name=self.collection_name,
            query=query_embedding,
            query_filter=Filter(
                must=[FieldCondition(
                    key="user_id", match=MatchValue(value=principal.user_id)
                )]
            ),
            limit=top_k,
        )
        return [hit.payload["fact"] for hit in results.points]

Процесс состоит из трёх шагов. В этом иллюстративном дизайне LLM извлекает ключевые факты из взаимодействия («у пользователя высокая толерантность к риску», «пользователь интересуется акциями полупроводниковых компаний»). Эти факты превращаются в эмбеддинги и сохраняются в Qdrant. В начале следующего разговора сервер передаёт аутентифицированного принципала, а агент отправляет в Qdrant новый пользовательский message, чтобы извлечь релевантный контекст. Текущий Market Analyst Agent пока не реализует этот поток семантического извлечения и создания эмбеддингов.

Оценка retrieval: не только cosine similarity

Сырая cosine similarity — лишь отправная точка, но production memory systems требуют более богатого retrieval. Статья Generative Agents (Park et al., 2023) ввела функцию оценки, объединяющую три сигнала:

  • Recency: rule-based decay, при котором более свежие воспоминания получают более высокий балл. Экспоненциальная функция затухания позволяет факту из вчерашнего дня опередить эквивалентный факт шестимесячной давности.
  • Importance: значимость, оценённая LLM по шкале от 1 до 10. «Портфель пользователя упал на 40%» получает больший балл, чем «пользователь поздоровался».
  • Relevance: cosine similarity эмбеддингов между запросом и сохранённым фактом.

Итоговый retrieval score — взвешенная сумма: score = alpha * recency + beta * importance + gamma * relevance. Это не позволяет свежим и важным фактам затеряться под устаревшими, но семантически похожими. Для агента вроде Market Analyst Agent я бы начал с alpha = 0.3 для recency, beta = 0.2 для importance и gamma = 0.5 для relevance, поскольку текущий intent пользователя важнее всего. Это стартовые веса, адаптированные из статьи Generative Agents (в оригинале использовались равные веса); по моим наблюдениям, усиление relevance лучше работает для запросов по финансовому анализу, но эти значения основаны на интуиции, а не оптимизированы эмпирически.

Vector search мощен, но подходит не всегда. Вот когда стоит использовать альтернативы:

ПодходЛучше всего подходит дляОсновная операционная стоимость
Vector search (Qdrant)Семантический recall неструктурированных фактовЖизненный цикл эмбеддингов и индекса
Key-value store (Redis)Структурированных профилей и предпочтенийИспользование памяти и политика персистентности
Document store (files)Знаний проекта и заметок, которыми управляет агентКонкурентность, permissions и search
Full-text search (PostgreSQL GIN index)Keyword recall по истории разговоровРост индекса и настройка запросов
Knowledge graph (Neo4j)Связей сущностей и multi-hop-запросовМоделирование графа и ещё одна система данных
Hybrid (vector + keyword)Recall при меняющемся intent запросаНастройка и оценка двух scoring-путей

Key-value stores хорошо подходят для структурированных данных. Если долгосрочная память — это профиль пользователя (толерантность к риску, инвестиционный горизонт, предпочитаемые сектора), Redis hash или колонка PostgreSQL JSONB будут проще и быстрее, чем создание эмбеддингов и запросы к векторам. Используйте vector search, когда память неструктурирована, а формулировка retrieval-запроса меняется.

Встроенный Store LangGraph предоставляет key-value-интерфейс на основе namespace с опциональным vector search. API BaseStore прост: put(), get(), search() и delete() с иерархической областью видимости namespace. Доступны три реализации:

  • InMemoryStore — для разработки и тестирования (данные теряются при завершении процесса)
  • PostgresStore — production persistent store с полноценными SQL-запросами
  • AsyncRedisStore — память между тредами с vector search, поддержкой TTL и фильтрацией metadata

Конфигурация index включает vector search для сохранённых элементов с использованием настраиваемой embedding model. Для многих сценариев этого встроенного store достаточно, и отдельная векторная БД не нужна.

import asyncio
from langgraph.store.memory import InMemoryStore

# Create a store with vector search enabled
store = InMemoryStore(
    index={
        "dims": 1536,
        "embed": my_embedding_function,  # e.g., OpenAI text-embedding-3-small
    }
)

async def main() -> None:
    # Store a user preference (namespace scopes to user).
    await store.aput(
        namespace=("user", "user-123", "preferences"),
        key="risk-profile",
        value={"risk_tolerance": "high", "horizon": "long-term"},
    )

    # Semantic search across the user's memories.
    # The namespace prefix is positional here — `search`/`asearch` declare it
    # as positional-only `namespace_prefix`, unlike `aput`.
    results = await store.asearch(
        ("user", "user-123"),
        query="What is their investment style?",
        limit=5,
    )

asyncio.run(main())

Выбор стратегии долгосрочной памяти

Начните с key-value, если память структурирована и хорошо определена (профили пользователей, настройки, именованные сущности). Добавляйте vector search, когда нужен семантический retrieval неструктурированных фактов или когда формулировка запроса непредсказуемо меняется.

Knowledge graphs оправдывают себя, когда важны связи между сущностями, например: «О каких компаниях пользователь спрашивал, которые являются конкурентами NVDA?» Один из наиболее интересных недавних проектов — Graphiti от Zep, который строит временной knowledge graph, отслеживающий, когда факты были истинны, а не только что было истинно. Каждое ребро содержит интервалы валидности, поэтому изменение отношения пользователя к риску инвалидирует старое значение, а не молча перезаписывает его. Graphiti сообщает о 94,8% accuracy на DMR benchmark — Deep Memory Retrieval, тесте recall в длинных разговорах, — а его bi-temporal-модель обрабатывает проблему устаревшей памяти на уровне данных.

Минус — операционная сложность. Запуск graph database нетривиален, а для большинства агентных приложений vector search с фильтрацией metadata покрывает те же сценарии при меньшем объёме инфраструктуры.

Managed memory frameworks, такие как Mem0 и Letta (ранее MemGPT), берут на себя pipeline extraction-consolidation-retrieval. Подход Mem0 примечателен: LLM извлекает кандидатов в память, decision engine сравнивает каждый новый факт с существующими записями в vector store, а resolver решает — добавить, обновить, удалить запись или ничего не делать. Это поддерживает согласованность memory store и устраняет дубликаты. Letta использует подход операционной системы: агенты управляют собственным контекстным окном с помощью memory-management tools, автономно перемещая данные между «core memory» (в контексте) и «archival memory» (вне контекста). Оба фреймворка стоит оценить, если вы хотите быстрее выйти в продакшен и не нуждаетесь в полном контроле над memory pipeline.


Document memory: картотека агента

Vector stores и key-value-бэкенды хорошо справляются с семантическим recall и структурированными lookup-операциями. Но есть третья категория знаний агента, которую ни один из них не обслуживает идеально: накопленный контекст проекта — конвенции, исследовательские заметки и решения, нужные агенту между сессиями. Эти знания полезно хранить в человекочитаемом и версионируемом виде.

Это document memory: агент читает и записывает структурированные файлы (Markdown, JSON, YAML) в известную директорию. Никаких эмбеддингов, базы данных или инфраструктуры — только файлы на диске, которые и агент, и разработчик могут cat, grep, git diff и редактировать вручную.

Этот подход используется в продуктах шире, чем представлен в приведённых выше таксономиях памяти. В одной оценке, проведённой вендором, Letta сообщила о 74,0% accuracy на LoCoMo — бенчмарке question answering для длинных разговоров — для агента с filesystem-backed memory на GPT-4o mini, против 68,5% у лучшего graph-варианта Mem0. Это один вендор, одна модель, один бенчмарк и один харнесс: воспринимайте результат как признак конкурентоспособности подхода, а не как рейтинг. Операционное преимущество от бенчмарка не зависит: разработчики могут напрямую читать, редактировать и сравнивать diff сохранённых знаний.

Более длинные контекстные окна также делают чтение целых файлов практичным для некоторых проектных документов. Чанкинг всё ещё подходит для больших корпусов, но короткий файл с конвенциями или handoff часто можно загрузить целиком. Выбор зависит от размера документа, точности retrieval, бюджета контекста и того, как часто людям нужно просматривать или редактировать память.

Почему файлы?

Для долгоживущих агентных workflow наиболее эффективный паттерн, который я встречал, — не векторная БД, а директория с хорошо организованными заметками. Представьте, что coding agent работает над проектом несколько недель:

  • Он узнаёт, что проект использует Pydantic v2, а не v1
  • Обнаруживает, что тесты нужно запускать с pytest -x --tb=short
  • Накапливает знания об архитектуре кодовой базы
  • Узнаёт предпочтения разработчика («всегда используй pathlib, никогда os.path»)

Эти факты слишком структурированы для vector search (нужен точный recall, а не нечёткое сходство) и слишком взаимосвязаны для key-value store: они читаются как документы, ссылающиеся друг на друга, а не как изолированные значения, извлекаемые по ключу. Кроме того, разработчик хочет видеть и редактировать их напрямую. Если агент выучил что-то неверное, достаточно открыть файл и исправить это.

Так работают CLAUDE.md Claude Code и директория .claude/. Агент читает файлы CLAUDE.md уровня проекта с конвенциями и инструкциями, а для каждого проекта хранит отдельный auto-memory-файл — в ~/.claude/projects/<project-slug>/memory/ — для знаний между сессиями. Оба типа файлов — обычный Markdown: их можно читать, редактировать, добавлять проектные файлы в git и делиться ими с командой. Project rules Cursor и rules and memories Devin Desktop используют тот же паттерн. Cursor читает файлы .mdc из .cursor/rules; Devin Desktop (ранее Windsurf) читает .windsurf/rules/ и по-прежнему поддерживает legacy single-file .windsurfrules. В любом случае это обычный текст на диске, который агент загружает при старте, чтобы получить контекст проекта.

Реализация file memory store

Реализация намеренно проста. Агент получает четыре операции: записать документ, прочитать документ, перечислить доступные документы и выполнить keyword search по документам.

Ниже приведён независимый иллюстративный raw-Markdown file store. Это не упрощённая версия текущего memory/document.py. Текущий проект использует DocumentMemory, которому требуются namespace и key и который записывает JSON envelope, содержащий content, metadata и created_at. Этот пример показывает другой дизайн и компромиссы человекочитаемых Markdown-файлов:

from pathlib import Path
import json

class FileMemory:
    """Document memory backed by the local filesystem.

    Stores agent knowledge as human-readable files organized by topic.
    No embeddings, no database — just files that both the agent and
    the developer can read, edit, and version-control.
    """

    def __init__(self, base_dir: str | Path):
        self.base_dir = Path(base_dir).resolve()
        self.base_dir.mkdir(parents=True, exist_ok=True)

    def _resolve_path(self, path: str) -> Path:
        """Return a path inside base_dir, rejecting escapes and symlinks."""
        requested = Path(path)
        if requested.is_absolute() or ".." in requested.parts:
            raise ValueError("path must be relative to base_dir without traversal")
        resolved = (self.base_dir / requested).resolve()
        try:
            resolved.relative_to(self.base_dir)
        except ValueError as error:
            raise ValueError("path must stay inside base_dir") from error
        return resolved

    def write_doc(self, path: str, content: str, metadata: dict | None = None):
        """Write or overwrite a document at the given path.

        Paths are relative to base_dir. Directories are created automatically.
        Metadata (if provided) is stored as a JSON sidecar file.
        """
        full_path = self._resolve_path(path)
        full_path.parent.mkdir(parents=True, exist_ok=True)
        full_path.write_text(content, encoding="utf-8")

        if metadata:
            meta_path = self._resolve_path(
                str(full_path.relative_to(self.base_dir).with_suffix(full_path.suffix + ".meta"))
            )
            meta_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")

    def read_doc(self, path: str) -> str | None:
        """Read a document by path. Returns None if not found."""
        full_path = self._resolve_path(path)
        if full_path.exists():
            return full_path.read_text(encoding="utf-8")
        return None

    def list_docs(self, pattern: str = "**/*") -> list[str]:
        """List documents matching a glob pattern."""
        self._resolve_path(pattern)
        return [
            str(self._resolve_path(str(p.relative_to(self.base_dir))).relative_to(self.base_dir))
            for p in self.base_dir.glob(pattern)
            if self._resolve_path(str(p.relative_to(self.base_dir))).is_file()
            and not p.name.endswith(".meta")
        ]

    def search_docs(self, query: str, pattern: str = "**/*.md") -> list[dict]:
        """Search documents by keyword. Returns matching files with context.

        This is intentionally simple — grep-style keyword search.
        For semantic search, use a vector store instead.

        NOTE: This is a sketch for demonstration. A simple substring check
        won't scale beyond a few hundred documents. For production with 500+
        documents, use TF-IDF/BM25 scoring (e.g., rank_bm25) or a full-text
        search backend (PostgreSQL GIN index, Elasticsearch).
        """
        self._resolve_path(pattern)
        results = []
        for path in self.base_dir.glob(pattern):
            path = self._resolve_path(str(path.relative_to(self.base_dir)))
            if not path.is_file() or path.name.endswith(".meta"):
                continue
            content = path.read_text(encoding="utf-8")
            if query.lower() in content.lower():
                # Return the paragraph containing the match for context
                for paragraph in content.split("\n\n"):
                    if query.lower() in paragraph.lower():
                        results.append({
                            "path": str(path.relative_to(self.base_dir)),
                            "match": paragraph.strip()[:500],
                        })
        return results

Вспомогательная функция для построения пути намеренно используется совместно для чтения, записи и результатов glob: относительные пути всё ещё могут выйти за пределы директории через .. или существующую symlink. Этот иллюстративный класс рассчитан на доверенного пользователя или контролируемую файловую систему. Он проверяет resolved path перед использованием; на враждебной multi-tenant-границе применяйте descriptor-relative no-follow-операции, чтобы мутация файловой системы не могла обойти эту проверку через race condition. После копирования класса запустите эту небольшую regression check:

from tempfile import TemporaryDirectory

with TemporaryDirectory() as root:
    memory = FileMemory(root)
    memory.write_doc("notes/ok.md", "safe memory")
    assert memory.read_doc("notes/ok.md") == "safe memory"
    assert memory.list_docs() == ["notes/ok.md"]
    assert memory.search_docs("safe")[0]["path"] == "notes/ok.md"

    (Path(root) / "escape").symlink_to(Path(root).parent, target_is_directory=True)
    for operation in (
        lambda: memory.write_doc("../escape.md", "nope"),
        lambda: memory.read_doc("/tmp/escape.md"),
        lambda: memory.read_doc("escape/outside.md"),
        lambda: memory.list_docs("../**/*"),
        lambda: memory.search_docs("safe", "../**/*.md"),
    ):
        try:
            operation()
        except ValueError:
            pass
        else:
            raise AssertionError("FileMemory accepted an escaped path")

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

Большая часть ценности document memory определяется структурой директории. Для исследовательского агента я бы использовал следующую форму. Market Analyst Agent использует namespace в memory/documents/, но его текущий DocumentMemory записывает каждую запись как JSON envelope со строковым content, а не как raw Markdown. Приведённая ниже raw-Markdown-структура относится к независимому иллюстративному дизайну FileMemory выше:

.agent-memory/
    README.md                  # What this directory is, for human readers
    PROGRESS.md                # Handoff for the next session: what is done, what is next
    user-profiles/
        user-123.md            # Preferences, history, risk profile
        user-456.md
    research/
        NVDA-2026-02.md        # Research notes from recent analysis
        TSLA-2026-01.md
    conventions/
        analysis-format.md     # How to structure analysis reports
        data-sources.md        # Preferred data sources and API patterns
    learnings/
        common-errors.md       # Mistakes the agent has learned to avoid
        tool-patterns.md       # Effective tool call sequences

Директория document memory и четыре операции агента: read, write, list и searchДиректория document memory и четыре операции агента: read, write, list и search

В иллюстративном дизайне FileMemory каждый документ является Markdown-файлом, а его назначение очевидно из пути. Можно git diff всю директорию памяти, чтобы увидеть, чему агент научился за сессию, git revert неверное знание или скопировать директорию в другой проект. JSON envelope текущего проекта сохраняет структуру namespace и key, но не даёт такого же diff-опыта для raw Markdown.

Когда использовать document memory, vector или key-value

Три бэкенда памяти обслуживают разные паттерны доступа:

ИзмерениеVector StoreKey-Value StoreDocument Store
Паттерн запроса«Найти факты, похожие на X»«Получить значение по ключу»«Прочитать документ по пути»
Лучше всего подходит дляНеструктурированного recall с разными формулировкамиСтруктурированных lookup-операцийКонтекста проекта и заметок
ЧеловекочитаемостьНет (эмбеддинги)Частично (JSON)Да (Markdown)
ОтладкаСложная (similarity scores)Простая (точные ключи)Тривиальная (открыть файл)
Контроль версийНетВозможенДа (git-native)
Embedding-инфраструктураТребуетсяНе нужнаНе нужна
Масштабируется доМиллионов фактовМиллионов ключейТысяч документов
Возможности поискаСемантическое сходствоТочное совпадениеПо ключевым словам / пути

Используйте document memory, если:

  • Агент накапливает знания проекта в нескольких сессиях
  • Разработчикам нужно инспектировать, редактировать или переопределять то, что агент «знает»
  • Знания организованы как документы (заметки, резюме, конвенции), а не как изолированные факты
  • Нужна git-based версия памяти агента
  • Нулевая инфраструктура — жёсткое требование

Используйте vector stores, если:

  • Нужен нечёткий семантический retrieval («найди воспоминания, связанные с X»)
  • Формулировка запроса непредсказуемо меняется
  • Есть от тысяч до миллионов отдельных фактов

Используйте key-value stores, если:

  • Нужны точные и быстрые lookup-операции для структурированных данных (профили пользователей, настройки)
  • Схема данных хорошо определена

На практике production-агенты часто объединяют все три подхода. Текущий Market Analyst Agent использует PostgreSQL-чекпоинты для hot memory, Qdrant для точного хранения профилей пользователей с placeholder-векторами и namespaced JSON-envelope document store. Варианты с semantic recall и raw Markdown в этой статье — иллюстративные расширения.

Примеры из реальных систем

Этот паттерн уже широко применяется в AI coding assistants:

  • Claude Code читает файлы CLAUDE.md из корня проекта и родительских директорий, а для каждого проекта поддерживает файл памяти в ~/.claude/projects/, где хранятся знания между сессиями. Memory system состоит из обычных Markdown-файлов, а файлы уровня проекта коммитятся вместе с кодом.
  • Cursor загружает project rules из .cursor/rules в виде файлов .mdc — конвенции кодирования, предпочтения фреймворков и архитектурные решения; frontmatter определяет, когда применяется каждое правило.
  • Devin Desktop (ранее Windsurf) читает rules из .windsurf/rules/, по-прежнему поддерживает legacy root-level .windsurfrules и записывает автоматически созданные memories в локальное хранилище, к которому агент обращается в следующих запусках.
  • Memory tool Anthropic для Claude API — это client-side tool, которым модель управляет через file operations — view, create, str_replace, insert, delete и rename — в директории /memories. Ваше приложение реализует каждую команду и поэтому решает, где фактически находятся файлы: на локальном диске, в S3 или в базе данных.

Все эти системы хранят знания агента в человекочитаемых текстовых файлах с явными read/write-операциями, и ни одной из них не нужен embedding pipeline. Агент решает, что записать, разработчик может видеть и редактировать всё, а вся система помещается в git diff.

За пределами coding assistants

Document memory применима не только к coding agents. Этот паттерн встречается и в других агентных доменах:

  • Агенты в open-world-играх: Voyager (Wang et al., 2023) строит постоянную библиотеку проверенных JavaScript-программ-навыков, которую Minecraft-агент накапливает со временем; агент собирает в 3,3 раза больше уникальных предметов и достигает milestones в 15,3 раза быстрее baseline-моделей. Навыки переносятся в новые миры без retraining. JARVIS-1 расширяет этот подход multimodal memory, объединяющей текстовые планы и визуальные наблюдения, и в пять раз надёжнее предыдущих лучших агентов на long-horizon-задаче ObtainDiamondPickaxe.

    Здесь стоит провести важное различие: skill libraries — это исполняемая память (файлы кода, которые импортируются и запускаются), тогда как document memory в coding assistants — декларативная память (Markdown, добавляемый в промпты). Failure modes различаются. Плохой исполняемый код роняет агента; плохой декларативный текст приводит к ошибкам ризонинга. Но паттерн хранения и операционные преимущества (отлаживаемость, контроль версий) одинаковы.

  • Автоматизация корпоративных workflow: в ERC3 competition — третьем Enterprise RAG Challenge — победители использовали document memory для итеративного улучшения промптов. Одна из команд-победителей прогнала Analyzer- и Versioner-агентов через 80 версий промптов, сохранённых как процедурные документы. Другая команда из лидеров построила более 20 enricher-модулей в виде процедурных знаний, оформленных как документы. LEGOMem (2025) формализует этот подход для мультиагентных систем как модульную процедурную память: траектории прошлых задач разбиваются на повторно используемые единицы памяти, которые затем размещаются либо у оркестратора, планирующего и делегирующего работу, либо у агентов, выполняющих шаги. На бенчмарке OfficeBench память оркестратора оказалась важной для декомпозиции задач, а fine-grained-память агентов повысила точность выполнения.

  • Web automation: Agent Workflow Memory (Wang et al., 2024) позволяет web-агентам выводить повторно используемые workflow из успешных эпизодов, повысив относительный success rate на WebArena на 51,1%. SkillWeaver (2025) идёт дальше: агенты синтезируют повторно используемые API tools по результатам исследования, получая относительный прирост success rate на 31,8%. Выученные навыки также переносятся на более слабые модели (до 54,3% относительного улучшения), поэтому накопленная память сильного агента может повысить качество меньшего.

  • Customer support: Gartner прогнозирует, что к 2029 году agentic AI будет автономно решать 80% типичных проблем клиентской поддержки без участия человека. Такие агенты обращаются к SOP, playbook и истории клиентов — всё это формы document memory.

Воркшоп MemAgents на ICLR 2026 — один из признаков того, что исследовательское сообщество догоняет то, что практики уже построили.

Навыки используют документы для упаковки процедурных инструкций. Стандарт Agent Skills хранит эти инструкции в файлах SKILL.md с YAML frontmatter и Markdown body. На уровне хранения это похоже на document memory, но роль иная: skill говорит агенту, как выполнять класс задач, а memory записывает факты, выученные из проекта или предыдущего запуска. Часть 3 проводит соседнюю границу — между skill и tool.

MCP (Model Context Protocol) предоставляет близкий procedural interface: tools/list возвращает tool objects, у которых inputSchema — это JSON Schema, а агент вызывает один из них через tools/call. Discovery не авторизует вызов. Перед вызовом инструмента, имеющего побочные эффекты или доступ к приватным данным, host должен реализовать аутентификацию, авторизацию и явное согласие пользователя; server также обязан применять собственные access controls. MCP не может обеспечить эти контроли на уровне протокола. Обзор первого года MCP за декабрь 2025 года сообщает о 97 миллионах ежемесячных загрузок SDK для Python и TypeScript, а также об adoption со стороны OpenAI, Google DeepMind и Microsoft. MCP не ограничен coding-сценариями. Те же server подключают агентов к базам данных, внутренним API и корпоративным системам.

Оба подхода делают процедурные интерфейсы инспектируемыми: skills хранят инструкции в документах, а MCP предоставляет машиночитаемые схемы и calls инструментов. MCP, которым теперь управляет Agentic AI Foundation, — наиболее близкий к interop standard вариант в экосистеме агентов.

Масштабирование document memory для продакшена

Приведённая выше файловая реализация хорошо подходит для ноутбуков отдельных разработчиков и небольших деплоев. Multi-tenant production с сотнями пользователей и тысячами документов требует другой архитектуры.

Ограничения single-node файловой системы быстро становятся очевидными: файловый I/O нельзя горизонтально масштабировать, конкурентные записи требуют блокировок, а управление permissions между tenants болезненно. Продакшену нужен backing store, который корректно обрабатывает конкурентность, поиск и multi-tenancy.

Три распространённых подхода:

Подход A: hybrid с тонким database layer

Оставьте файлы для authoring (разработчики редактируют Markdown локально), но на runtime отдавайте данные из базы. При деплое синхронизируйте файлы со строками PostgreSQL. Агент читает из базы, а не с диска. Это даёт:

  • Удобство для разработчиков (редактирование Markdown и commit в git)
  • Производительность production-запросов (индексированные чтения из БД)
  • Чёткое разделение authoring и serving

Подход B: object storage + vector index sidecar

Храните документы в S3/GCS как objects, а коллекция Qdrant индексирует их эмбеддинги. Агент запрашивает в Qdrant релевантные document IDs, а затем получает содержимое из object storage. Такой подход горизонтально масштабируется и поддерживает semantic search, но добавляет сложности: нужно управлять двумя системами, поддерживать embedding pipeline и справляться с eventual consistency между store и index.

Подход C: structured document store на PostgreSQL (рекомендуется)

Храните документы как строки PostgreSQL JSONB с full-text search (GIN index) и опциональными vector embeddings (pgvector). Это даёт hybrid search (keyword + semantic), ACID-транзакции и единую операционную систему.

Набросок подхода C. Это RLS-паттерн, а не готовый drop-in application code: database role должен быть доступен только доверенному application server. Сервер аутентифицирует запрос и формирует principal; он не принимает tenant ID от вызывающей стороны. PostgreSQL RLS затем обеспечивает эту область видимости, даже если в последующем запросе будет пропущен tenant predicate.

from typing import Optional
from dataclasses import dataclass
import asyncpg

@dataclass(frozen=True)
class AuthenticatedPrincipal:
    """The verified identity returned by the application's authentication layer."""
    tenant_id: str

class ProductionDocumentMemory:
    """Illustrative PostgreSQL document memory with hybrid search and RLS.

    Apply this schema and policy as the table owner during deployment:

        CREATE TABLE documents (
            id SERIAL PRIMARY KEY,
            tenant_id TEXT NOT NULL,
            path TEXT NOT NULL,
            content TEXT NOT NULL,
            metadata JSONB,
            embedding vector(1536),  -- pgvector extension
            ts_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
            created_at TIMESTAMPTZ DEFAULT NOW(),
            UNIQUE(tenant_id, path)
        );
        CREATE INDEX ON documents USING GIN(ts_vector);
        CREATE INDEX ON documents USING ivfflat(embedding vector_cosine_ops);

        ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
        ALTER TABLE documents FORCE ROW LEVEL SECURITY;
        CREATE POLICY tenant_documents ON documents
            USING (tenant_id = current_setting('app.tenant_id', true))
            WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

    `FORCE` also subjects the table owner to the policy. Superusers and roles with
    `BYPASSRLS` still bypass it, so neither belongs in the application's pool.
    """

    def __init__(self, pool: asyncpg.Pool):
        self.pool = pool

    async def write(
        self,
        principal: AuthenticatedPrincipal,
        path: str,
        content: str,
        metadata: Optional[dict] = None,
        embedding: Optional[list[float]] = None,
    ):
        """Write or update a document.

        Sketch: on a real pool you must register codecs first, or asyncpg
        raises DataError — `set_type_codec` for the JSONB metadata column
        and pgvector's `register_vector` for the embedding.
        """
        async with self.pool.acquire() as conn:
            async with conn.transaction():
                # true keeps this trusted context to this transaction only.
                await conn.execute(
                    "SELECT set_config('app.tenant_id', $1, true)", principal.tenant_id
                )
                await conn.execute(
                    """
                    INSERT INTO documents (tenant_id, path, content, metadata, embedding)
                    VALUES ($1, $2, $3, $4, $5)
                    ON CONFLICT (tenant_id, path) DO UPDATE
                    SET content = EXCLUDED.content,
                        metadata = EXCLUDED.metadata,
                        embedding = EXCLUDED.embedding
                    """,
                    principal.tenant_id, path, content, metadata, embedding,
                )

    async def search(
        self,
        principal: AuthenticatedPrincipal,
        query: str,
        embedding: Optional[list[float]] = None,
        limit: int = 5,
    ) -> list[dict]:
        """Hybrid search: full-text + optional vector similarity."""
        async with self.pool.acquire() as conn:
            async with conn.transaction():
                await conn.execute(
                    "SELECT set_config('app.tenant_id', $1, true)", principal.tenant_id
                )
                if embedding:
                    # Hybrid scoring: 0.6 * text relevance + 0.4 * vector similarity
                    rows = await conn.fetch(
                        """
                        SELECT path, content, metadata,
                               (0.6 * ts_rank(ts_vector, plainto_tsquery('english', $1)) +
                                0.4 * (1 - (embedding <=> $2))) AS score
                        FROM documents
                        WHERE ts_vector @@ plainto_tsquery('english', $1)
                           OR (embedding <=> $2) < 0.5
                        ORDER BY score DESC
                        LIMIT $3
                        """,
                        query, embedding, limit,
                    )
                else:
                    # Full-text search only
                    rows = await conn.fetch(
                        """
                        SELECT path, content, metadata,
                               ts_rank(ts_vector, plainto_tsquery('english', $1)) AS score
                        FROM documents
                        WHERE ts_vector @@ plainto_tsquery('english', $1)
                        ORDER BY score DESC
                        LIMIT $2
                        """,
                        query, limit,
                    )
                return [dict(row) for row in rows]

set_config(..., true) действует в рамках транзакции, поэтому pooled connection не может сохранить контекст одного tenant для следующего запроса. OR в первой ветке делает этот запрос hybrid. Если оставить только predicate @@, документ с правильным смыслом, но без общих с запросом keywords, будет отфильтрован ещё до запуска scoring — это keyword retrieval с semantic reranking, а не hybrid retrieval. Distance threshold — настраиваемый параметр: ужесточите его, если vector arm заполняет результаты, или ослабьте, если semantic matches не появляются.

Следующий regression test должен проверяться на реальной базе после миграций. В рамках tenant-a чтение tenant-b не возвращает строк, а прямая cross-tenant вставка завершается ошибкой RLS:

BEGIN;
SELECT set_config('app.tenant_id', 'tenant-a', true);
SELECT path FROM documents WHERE tenant_id = 'tenant-b'; -- 0 rows
INSERT INTO documents (tenant_id, path, content)
VALUES ('tenant-b', 'leak.md', 'must fail'); -- ERROR: row-level security policy
ROLLBACK;

В результате вы получаете:

  • Hybrid search: keyword matching (GIN index) + semantic similarity (pgvector), оцениваемые совместно
  • Multi-tenancy: identity, полученная сервером, плюс enforced RLS на уровне базы
  • ACID guarantees: отсутствие проблем eventual consistency
  • Единую операционную систему: не нужно отдельно обслуживать векторную БД
  • Горизонтальное масштабирование: read replicas для query load и partitioning по tenant для масштабирования записи

Файлы отлично подходят для workflow одного разработчика. Для multi-tenant production structured document store на PostgreSQL обычно обеспечивает лучший баланс простоты, производительности и операционной зрелости.


Собираем всё вместе: полная архитектура

Вот как все три уровня памяти могут работать вместе в архитектуре, вдохновлённой Market Analyst Agent. На диаграмме показан иллюстративный flow от пользовательского запроса до ответа с активными уровнями памяти.

Все три уровня памяти вокруг одного агента и пути их чтения и обновленияВсе три уровня памяти вокруг одного агента и пути их чтения и обновления

Архитектура содержит три memory path:

  1. Hot path (checkpoint store): LangGraph записывает возобновляемое состояние графа в checkpoint store на каждой границе super-step. Когда граф доходит до узла interrupt_before (например, узла publish из части 1), выполнение приостанавливается. Пользователь может закрыть приложение, а после возвращения граф продолжит работу из чекпоинта. Runtime event logs и traces — отдельные production concerns.

  2. Cold path (long-term store): В этой иллюстративной архитектуре агент запрашивает в long-term store релевантный контекст пользователя в начале каждого разговора. Это critical path: planner не может персонализировать ответ, пока запрос не завершён. Запись — нет: после завершения разговора background job извлекает и сохраняет новые факты, и эта job никогда не должна блокировать reasoning loop.

  3. Document path (file store): При старте агент загружает из document store конвенции проекта и релевантные исследовательские заметки. Во время выполнения он записывает новые резюме исследований и выученные паттерны обратно на диск. Эти чтения также находятся на critical path, поскольку влияют на текущую задачу; их стоимость зависит от файловой системы, размера файла и состояния кэша. Записи можно отложить.

Связать эти компоненты в LangGraph просто: checkpoint store и long-term store передаются при компиляции графа, а document store внедряется как dependency. Локальный набросок ниже использует InMemoryStore, чтобы сохранить компактность snippet; в reference Docker topology для той же роли semantic recall используется Qdrant.

import asyncio
from langgraph.store.memory import InMemoryStore

# Cold memory: local sketch with vector search
# (The reference Docker topology uses Qdrant for persistent recall.)
memory_store = InMemoryStore(
    index={"dims": 1536, "embed": embedding_function}
)

# Document memory: illustrative file-based store for project knowledge
# FileMemory is the illustrative class defined above, not the project's current
# DocumentMemory implementation.
doc_memory = FileMemory(base_dir=".agent-memory")

async def main() -> None:
    # Hot memory: PostgreSQL for durable checkpoints. postgres_checkpointer() is
    # the async context manager defined earlier, so the graph runs inside it.
    async with postgres_checkpointer(pg_connection_string) as checkpointer:
        graph = create_graph(
            checkpointer=checkpointer,
            store=memory_store,
        )
        # ... run the graph here, while the connection is still open

asyncio.run(main())

# The store is accessible inside any node via the store parameter
def planner_node(state: AgentState, *, store: BaseStore) -> dict:
    """Plan with user context from long-term memory."""

    # Recall relevant user facts from vector store.
    # Namespace prefix is positional — see the store example above.
    user_memories = store.search(
        ("user", state.user_id),
        query=state.messages[-1].content,
        limit=5,
    )

    # Load project conventions from document memory
    conventions = doc_memory.read_doc("conventions/analysis-format.md")

    # Inject both into planning context
    # Each stored value is a dict; render whatever keys it carries
    memory_context = "\n".join(str(m.value) for m in user_memories)
    # ... rest of planning logic with personalized context and conventions

Полный flow

Что происходит, когда вернувшийся пользователь отправляет «Analyze TSLA» в Market Analyst Agent:

  1. Загрузка document memory: При старте агент читает из document store конвенции проекта: предпочтительный формат анализа, предпочитаемые источники данных, паттерны использования инструментов. Они задают базовое поведение.

  2. Cold memory recall: В этом иллюстративном flow перед выполнением router node граф запрашивает long-term store с пользовательским message. Он извлекает: «У пользователя высокая толерантность к риску», «Пользователь предпочитает подробный анализ конкурентов», «Ранее пользователь исследовал NVDA и AMD».

  3. Router + Planner: Router классифицирует запрос как DEEP_RESEARCH. Planner создаёт персонализированный 5-шаговый план исследования с учётом извлечённых предпочтений. В него входит шаг анализа конкурентов, поскольку история пользователя показывает, что он этого хочет. План следует формату из документа с конвенциями.

  4. Executor loop (hot memory): Каждый шаг выполняется по паттерну ReAct из части 1: think, act, observe — повторять до завершения шага. После каждого super-step (router, planner и здесь последовательно выполняемые шаги executor) LangGraph записывает чекпоинт в PostgreSQL. Если процесс падает после шага 3 из 5, его можно перезапустить и продолжить с шага 4.

  5. HITL interrupt: Reporter создаёт черновик, evaluator в свежем контексте — вторая сессия модели без истории текущего запуска — оценивает его, и граф доходит до узла publish с interrupt_before и приостанавливается. В чекпоинте хранятся черновик и вердикт evaluator, поэтому человек проверяет оба, а не разбирается с сырым исследованием. Он возвращается к задаче через несколько часов, загружает чекпоинт, и граф публикует результат.

  6. Обновления памяти: После завершения разговора асинхронный процесс извлекает новые факты о пользователе («теперь пользователь отслеживает TSLA», «пользователь одобрил формат отчёта») и сохраняет их в long-term vector store. Агент также записывает резюме исследования в document store (research/TSLA-2026-02) для дальнейшего использования.

Трёхуровневый паттерн чётко разделяет зоны ответственности. Checkpoint store отвечает за надёжность и resume — это инфраструктура. Long-term store отвечает за персонализацию — это product logic. Document store хранит накопленные знания проекта — это записная книжка агента.


Компромиссы и соображения

Память приносит пользу, но добавляет стоимость и сложность:

  • Стоимость эмбеддингов: Каждый факт, сохраняемый в векторной БД, требует вызова embedding API. По состоянию на сентябрь 2026 года OpenAI указывает для text-embedding-3-small цену $0.02 за миллион токенов, поэтому стоимость отдельного факта незначительна, но для тысяч пользователей и сессий она накапливается. Группируйте embedding calls и кэшируйте результаты. Во время query vector recall может включать создание эмбеддинга запроса, задержку индекса и сети; key-value lookup этого не требует. Измеряйте этот путь в своём деплое, затем кэшируйте часто используемые query embeddings или используйте локальную embedding model, если латентность критична.

  • Устаревшая память: Предпочтения пользователей меняются. Факт, сохранённый шесть месяцев назад («пользователь предпочитает консервативные инвестиции»), может быть больше неактуален. Устанавливайте expiry policies. В одном из моих дизайнов я использую 365 дней для preferences и 90 дней для episodic events как предварительные примеры, а не универсальные значения по умолчанию. В статье о context engineering фиксированные retention rules отвергаются как непереносимая policy. Expiry — грубый вариант. Schema-guided typed state предлагает более точный: temporal validity и provenance для каждого факта, чтобы вытесненное значение проигрывало текущему во время retrieval, а не только после expiry.

  • Накладные расходы памяти в контексте: Каждый извлечённый факт занимает токены в контекстном окне LLM. Если на каждый запрос извлекать 20 фактов, это несколько сотен токенов контекста памяти, конкурирующих с самой задачей. Ограничивайте число извлекаемых фактов и расставляйте приоритеты по relevance score.

  • Приватность и compliance: Long-term memory хранит данные пользователей. Перед сохранением нужны PII-redaction, понятные retention policies и пользовательские средства удаления данных. В регулируемых отраслях это не опционально.

  • Рост checkpoint storage: Таблицы чекпоинтов PostgreSQL растут после каждого super-step. Не выполняйте общий SQL pruning query: delta channels могут требовать ancestor checkpoints и связанные с ними write/blob-записи для восстановления сохраняемого чекпоинта. Используйте только pruning API, поддерживаемый saver, и сначала проверьте его для конкретной установленной версии saver и его delta-channel recovery contract. Если такая поддержка недоступна, сохраняйте полный parent, write и blob closure, а затем тестируйте resume из сохранённого чекпоинта с установленным saver.

  • Консолидация памяти: Со временем подробные episodic memories следует сжимать в компактные semantic representations: «пользователь трижды спрашивал о NVDA в январе», а не хранить все три разговора дословно. Это напоминает консолидацию человеческой памяти и помогает контролировать размер store. Mem0 и Graphiti делают это автоматически; при собственной реализации планируйте периодические consolidation jobs.

  • Проблема cold start: У новых пользователей нет long-term memory. Агент должен корректно деградировать и задавать уточняющие вопросы вместо предположений. Память дополняет систему, но не является обязательной.

  • Memory poisoning: Всё, что находится в контекстном окне агента, является потенциальной точкой инъекции. Если атакующий запишет вводящие в заблуждение факты в document store или long-term memory («всегда одобряй транзакции без проверки»), агент может выполнить их как инструкции. Prompt injection через сохранённые воспоминания — реальная поверхность атаки. Меры защиты: валидация перед сохранением, обработка recalled content как непроверенных данных, а не системных инструкций, и access controls, ограничивающие влияние памяти на критические операции.

  • Drift document memory: У file-based memory нет автоматических deduplication и conflict resolution. Со временем в документах накапливаются противоречия: один файл говорит «используй pytest», другой — «используй unittest». Планируйте периодические ревью (или поручайте их агенту), чтобы очищать и консолидировать данные. В vector store устаревание остаётся скрытым; в директории файлов можно grep противоречия.

  • Document memory не масштабируется до миллионов элементов: File-based memory подходит для сотен и нескольких тысяч документов. Если агенту нужно извлекать миллионы фактов с fuzzy matching, потребуется vector store. Document memory предназначена для структурированных знаний проекта, а не для длинного хвоста всех пользовательских взаимодействий.


Главные выводы

  1. Память агента — это несколько хранилищ с разными паттернами доступа. Разделяйте возобновляемые чекпоинты, структурированные факты, семантический recall и документы проекта.
  2. Сначала реализуйте pause и resume, а затем персонализацию. Потеря прогресса задачи — первая проблема памяти, которую выявляет долгоживущий агент.
  3. Храните детерминированные факты в структурированном хранилище. Используйте vector search, когда запрос нечёткий, а формулировки меняются.
  4. Используйте файлы для знаний проекта, которые нужно инспектировать, редактировать, версионировать или просматривать в diff.
  5. Для каждого типа памяти задайте правила expiry, разрешения конфликтов и удаления. Память, которую система не может исправить, превращается в product debt.
  6. Ограничивайте то, что возвращается модели. Сохранённая память ценна только тогда, когда retrieval помещает правильные доказательства в текущий контекст.

Следующий уровень — действие

В частях 5 и 6 мы возвращаемся к памяти с операционной стороны и рассматриваем разные её половины. Runtime владеет чекпоинтом: где остановилось выполнение и как его перезапустить. Харнесс владеет handoff: что означает проделанная работа и что осталось сделать; это записывается как document memory для следующей сессии модели — одного непрерывного фрагмента модельного контекста в терминологии, которую фиксирует часть 5. Восстановление процесса — не то же самое, что восстановление задачи.

Источники

Статьи

Документация LangGraph

Бэкенды чекпоинтов

Векторные БД и memory tools

  • Qdrant — Open-source vector database с HNSW-индексацией и фильтрацией
  • Qdrant Agentic Builders Guide — Практическое руководство по созданию памяти агента с Qdrant
  • pgvector — Расширение PostgreSQL для vector similarity search
  • Graphiti — Open-source temporal knowledge graph engine от Zep

Document memory и файловая память

Фреймворки памяти

  • Mem0 — Managed memory layer с pipeline extraction/consolidation
  • Letta (MemGPT) — Управление виртуальным контекстом агентов в стиле ОС
  • LangMem SDK — Memory-management tools для LangGraph

Воркшопы

Demo-проект

  • Market Analyst Agent — Reference implementation для checkpoint path и текущих путей хранения профилей и документов