Перейти к содержанию

12. Системы Памяти Агента

Зачем это нужно?

Агенту нужна память, чтобы удерживать контекст между итерациями цикла, не забывать решения внутри одной задачи и не начинать каждый разговор с нуля. Без памяти агент тратит токены на повторные объяснения, теряет смысл задачи на 5-й итерации и не помнит, что было неделю назад.

Но память — это не только «куда положить данные». Любая работа с памятью затрагивает контекстное окно LLM, бюджет токенов и prompt cache провайдера. Неаккуратная архитектура памяти превращает дешёвый агент в дорогой и медленный.

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

Реальный кейс

Ситуация: Пользователь спрашивает агента: «Какая была проблема с базой данных, которую мы исправили на прошлой неделе?» Агент отвечает: «У меня нет информации об этом».

Проблема: У агента нет памяти о прошлых разговорах. Каждое взаимодействие начинается с нуля.

Решение: Долговременная память — внешнее хранилище (БД/файлы), в котором сохраняются ключевые факты (решения, артефакты, предпочтения). Агент читает из него по требованию инструментами; устаревшее чистится TTL, чтобы store не превращался в помойку.

Теория простыми словами

Два горизонта памяти

У агента два разных уровня памяти, и их нельзя путать:

Внутри Run (между итерациями цикла):

  • История сообщений текущей задачи: []Message.
  • Растёт от итерации к итерации.
  • Ограничена контекстным окном модели.
  • При завершении Run может быть сохранена — или забыта.

Между Run / сессиями (долговременная):

  • Решения, факты, предпочтения, артефакты.
  • Хранится в БД/файлах.
  • Не лезет в контекст LLM целиком — агент извлекает выборочно, инструментами, когда самому нужно.

Большинство ошибок в дизайне памяти случается из-за того, что эти два уровня смешивают: пытаются хранить весь Run в долговременном хранилище, или наоборот — превращают долговременное хранилище в часть system prompt и тратят на него токены каждой итерации.

Понятийная классификация (как принято в литературе)

Кратковременная (working) память — состояние текущей задачи: что агент уже сделал, какие файлы прочитал, какой план в работе.

Долговременная память — переживает завершение Run: факты, предпочтения, истории решений.

Episodic — конкретные события: «Пользователь спрашивал о месте на диске 2026-01-06».

Semantic — обобщения: «Пользователь предпочитает JSON ответы». Извлекается из эпизодов.

Эти термины полезны для общения, но в коде вам обычно достаточно различать «история текущего Run» и «персистентное хранилище».

Операции с памятью

Память цикла — конкретная структура (линейная история), и операций у неё ровно пять:

  1. Append — дописать сообщения в конец.
  2. Messages — получить копию истории для запроса к LLM.
  3. Replace — полная замена истории (вызывается только condense, гл. 13).
  4. Snapshot — сохранить состояние (чекпоинт, аудит, откат неудачного сжатия).
  5. Restore — восстановить сохранённое.

Store / Retrieve / Forget — это словарь для внешнего store долговременной памяти, а не операции истории цикла. Не смешивайте: внутри Run — история и только история, между Run — внешний store, до которого агент дотягивается инструментами.

Принцип: память — иммутабельная история

Главное правило, на котором держится всё дальнейшее:

История уже отправленных сообщений не переписывается. Она только дополняется (append) или полностью заменяется (replace).

Из этого правила вытекают три практических следствия:

  1. Не переписывай прошлое. Никаких «удалим лишний tool result» или «уплотним assistant-ответ». Это ломает prompt cache на хвосте и иногда заставляет модель имитировать собственный прежний стиль.
  2. System prompt стабилен. Динамические данные (что прочитали, какой план) не вписывай в system prompt — это вычислительный налог на каждой итерации.
  3. Сжатие — это полная замена. Если истории слишком много, мы её осознанно, целиком и редко заменяем на summary + хвост (см. condense ниже). Никаких частичных перезаписей.

Эти правила выглядят строго, но именно они делают разницу между «агент работает быстро и предсказуемо» и «агент стоит дорого, тормозит и теряет контекст».

Как это работает (пошагово)

Шаг 1: History — память цикла

Память цикла конкретна: это линейная история сообщений. Никаких интерфейсов Store/Retrieve/Forget, никаких блоков и слоёв — плоский []Message под мьютексом и пять операций:

type History struct {
    mu   sync.Mutex
    msgs []llm.Message
}

func NewHistory() *History { return &History{} }

// Append дописывает сообщения в конец. Единственная штатная операция роста.
func (h *History) Append(msgs ...llm.Message) {
    h.mu.Lock()
    defer h.mu.Unlock()
    h.msgs = append(h.msgs, msgs...)
}

// Messages возвращает КОПИЮ истории — её отдаём в запрос к LLM.
func (h *History) Messages() []llm.Message {
    h.mu.Lock()
    defer h.mu.Unlock()
    out := make([]llm.Message, len(h.msgs))
    copy(out, h.msgs)
    return out
}

// Replace — единственный способ изменить уже записанное.
// Вызывается только condense (гл. 13) и тестами.
func (h *History) Replace(msgs []llm.Message) {
    h.mu.Lock()
    defer h.mu.Unlock()
    h.msgs = append(h.msgs[:0], msgs...)
}

// Snapshot/Restore — сохранить и восстановить состояние:
// чекпоинт, аудит, откат неудачного condense.
func (h *History) Snapshot() []llm.Message { return h.Messages() }

func (h *History) Restore(snap []llm.Message) { h.Replace(snap) }

Санитизация встроена в саму историю: пустое assistant-сообщение дропается, orphan tool-result (tool_result без своего tool_call) дропается, пары tool_call ↔ tool_result неразрывны. История, которую возвращает Messages(), всегда валидна для провайдера — отдельный «чистильщик» перед запросом не нужен.

Как это устроено в реальном SDK. Память цикла — линейная History (New/Append/Replace/Snapshot/Restore), без блоков и слоёв. Санитизация (пустые assistant, orphan tool-result) встроена. Кэш-метрики (CacheRead / CacheCreation) парсятся из Usage ответа провайдера.

Шаг 2: Долговременная память — внешний store и инструменты

Долговременная память — это не слой в памяти цикла. Это внешнее хранилище (БД, файлы), из которого агент читает по требованию — обычными инструментами (search_memory, read_note), как читает файлы или ходит в интернет. Внутри Run живёт линейная история; долговременное живёт в store, и память цикла о нём ничего не знает.

Два рабочих паттерна:

  1. Prefetch на старте Run. Релевантные факты выбираются из store до первого запроса и подкладываются в первое user-сообщение Run.
  2. Tool по требованию. Агент сам зовёт search_memory(query), когда понимает, что чего-то не хватает. Tool result дописывается в хвост истории — cache не страдает.
// Паттерн 1: факты — в первое user-сообщение, НЕ в system prompt.
messages := []llm.Message{
    {Role: "system", Content: sysPrompt}, // константа внутри Run
    {Role: "user", Content: userQuery +
        "\n\nРелевантные факты из долговременной памяти:\n- ..."},
}

Обратите внимание: факты из долговременной памяти приходят в первом user-сообщении, не в system prompt. Если вы в начале каждого Run меняете system prompt в зависимости от того, что нашлось в памяти, у вас будет cache miss при каждом запросе.

Что именно класть в store и как организовать поиск (embeddings, гибрид с BM25) — в разделе «Долговременная память» ниже.

Линейная память внутри Run

Дефолтная модель памяти на уровне одного Run — линейная: плоский []Message, в который каждый шаг цикла дописывается новое. Это ровно та History из Шага 1 — здесь разберём, почему ничего хитрее не нужно.

Почему именно так

  • Prompt cache работает. Современные провайдеры (OpenAI, Anthropic, Z.AI и совместимые) кэшируют префикс запроса. Стабильный префикс — почти бесплатные input-токены на повторных итерациях. Любая мутация хвоста сбрасывает кэш дальше точки изменения, мутация префикса — сбрасывает весь кэш.
  • Меньше движущихся частей. Отдельная история = отдельная истина. Нет «оригинала» и «compact-копии», которые могут разъехаться.
  • Совместимость с провайдерами. Все драйверы (OpenAI, Anthropic, Z.AI, vLLM) принимают один и тот же массив сообщений. Любые надстройки — blocks, recall, параллельные summaries — это уже лишний слой поверх (см. «Для любопытных»).

Live state без мутации system prompt

Соблазн положить динамическое состояние в system prompt очень большой:

// ПЛОХО: меняется на каждой итерации → cache miss всего префикса
sysPrompt := basePrompt +
    "\n\nFiles read: " + filesRead.Join(", ") +
    "\nLast actions: " + lastActions.Join(", ") +
    "\nPlan: " + plan.Render()

Что не так: каждое изменение filesRead или plan инвалидирует кэш на всех ~N тысячах токенов system prompt. На длинной задаче это превращается в стабильный налог: вы платите за input-токены полную цену каждую итерацию, хотя 95% префикса не менялось.

Куда складывать live state:

  1. В результат последнего tool call. Если tool вернул read_file: {content}, к нему естественно дописать заметки агента.
  2. В отдельные Notes структуры, которые рендерятся в последнее user-сообщение или в первое сообщение очередной итерации.
  3. В tool, который агент сам зовёт, чтобы поставить себе чекпоинт (update_plan, set_goal).
// ХОРОШО: live state в результате последнего tool call
result := tool.Result{
    Content: actualOutput,
    Notes: []string{
        "plan: 2/5 done",
        "files touched: a.go, b.go",
    },
}

Стабильный system prompt + дописываемая история = максимум cache hits и минимум поверхности для багов.

Подробнее про сборку контекста, бюджет и динамические секции (когда они всё-таки нужны и как платить за них минимально) — в Главе 13: Context Engineering.

Condense: единственная операция сжатия

Если история доросла до порога — её сжимают, и способ один:

  • Порог один: ~0.85 контекстного окна. Считается по usage.PromptTokens провайдера из последнего ответа; оценка «~3 символа на токен» — только fallback, когда провайдер ещё ничего не ответил.
  • Максимум 1 сжатие за Run.
  • Head суммируется отдельным LLM-вызовом с тем же кэшируемым префиксом (system + tools): суммаризация читает историю из прогретого кэша, а не платит за неё полную цену.
  • Tail (последние сообщения) сохраняется целиком — страховка от потери того, что модели нужно прямо сейчас.
  • Оригиналы не уничтожаются: перед Replace делается Snapshot(). В этом и состоит принцип never destroy originals — не в параллельных «compact-копиях», которые могут разъехаться с истиной, а в снапшоте для recovery и аудита.

Отдельной операции «compact» (структурное переписывание истории, compact-сообщения с тегами вида <prior_tool_use>) нет и не нужно: она сбрасывает prompt cache на хвосте и провоцирует модель имитировать чужой формат как «свой стиль». Если истории много — condense, точка.

Промпт суммаризации и реакция на overflow от провайдера — в Главе 13: Context Engineering.

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

Внутри Run работает линейная история. Между сессиями она теряется — нужно явное внешнее хранилище, из которого агент читает по требованию инструментами (Шаг 2 выше).

Что класть

  • Решения и их обоснования. «Выбрали PostgreSQL, потому что нужна транзакционность» — это вечно полезный факт.
  • Стабильные идентификаторы. Имя пользователя, namespace, окружение, рабочая директория.
  • Установленные предпочтения — с явным типом preference (см. anchoring bias в гл. 13).
  • Артефакты задач. Ссылки на созданные ресурсы, миграции, ID PR-ов.

Что НЕ класть

  • Каждый поворот разговора. Это шум, retrieval начинает возвращать мусор.
  • Гипотезы и временный статус. «Сервис сейчас падает» — устареет к следующей сессии и собьёт диагностику.
  • Полные транскрипты сессий. Если очень нужно — храните отдельно, не в той же таблице, где факты для retrieval.

Связь с лабой

Реализация: запись фактов в файл/БД, retrieval на embeddings, фильтрация по типу — отрабатывается в Lab 11: Memory & Context Engineering.

Checkpoint и Resume

Длинный Run может упасть посередине: процесс убили, кончился rate limit, упала сеть. Чекпоинт сохраняет состояние (включая Snapshot() истории), чтобы при перезапуске не начинать с нуля. Базовая реализация (структура, save/load, интеграция с agent loop) — в Главе 09: Анатомия Агента, продвинутые стратегии (гранулярность, валидация, ротация) — в Главе 11: State Management.

В контексте памяти правило одно: сохраняйте оригинальную историю, а не сжатую. Если в чекпоинт записан результат condense, после resume вы не сможете восстановить детали tool calls.

Shared Memory между агентами

В мульти-агентных системах агенты обмениваются информацией через общее внешнее хранилище — тот же внешний store, только с несколькими писателями. Данные разграничиваются по namespace, чтобы агенты не перезаписывали чужое:

func sharedKey(agentID, key string) string {
    return fmt.Sprintf("shared:%s:%s", agentID, key)
}

Правила те же, что и для долговременной памяти: ничего «общего» в system prompt, чужие артефакты агент читает инструментами, в хвост истории.

Связь: управление состоянием агента — Глава 11: State Management (чекпоинт — частный случай персистенции состояния); обмен и координация между агентами — Глава 07: Multi-Agent.

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

Ошибка 1: Нет TTL

Симптом: Память растёт бесконечно, потребляя хранилище и контекст. Retrieval возвращает устаревшие факты.

Причина: Не забывается устаревшая информация.

Решение: Реализуйте TTL и периодическую очистку. Для разных типов фактов — разный TTL: предпочтения пользователя живут долго, временный статус — часы.

Ошибка 2: Сохранение всего

Симптом: Память заполняется нерелевантной информацией, retrieval становится шумным.

Причина: Нет фильтрации того, что вообще стоит сохранять.

Решение: Сохраняйте только важные факты, не каждый поворот разговора. Хороший фильтр — отдельный лёгкий LLM-вызов в конце Run: «извлеки из этого диалога факты, которые стоит запомнить надолго».

Ошибка 3: Только keyword-поиск

Симптом: Извлечение возвращает нерелевантные результаты или пропускает важную информацию.

Причина: Простое сопоставление подстрок не понимает синонимов и парафразов.

Решение: Используйте embeddings для семантического поиска. Hybrid (BM25 + embeddings) обычно лучше любого из двух по отдельности.

Ошибка 4: Сжатие уничтожает оригиналы

Симптом: После сжатия невозможно восстановить детали tool calls; resume и аудит работают с неполной историей.

Причина: Оригинальные сообщения перезаписаны результатом сжатия (или «оптимизированы» на месте).

Решение: Compact-копий нет — единственная операция над историей это condense. «Never destroy originals» реализуется снапшотом: перед Replace сохраните Snapshot() оригинала — он нужен для recovery, аудита и отката, если LLM-вызов суммаризации упал.

snap := h.Snapshot() // оригинал сохранён
summary, err := summarize(ctx, head(snap))
if err != nil {
    return err // оригинал не тронут, Run продолжается с несжатой историей
}
h.Replace(newHistory(summary, tail))

Ошибка 5: Condense mid-loop без порога

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

Причина: Сжатие вызывается внутри цикла агента, пока задача ещё выполняется.

Решение: Condense — по threshold (~0.85 контекстного окна) или ContextOverflowError, и не чаще одного раза за Run. Иных способов переписать живую историю нет — внутри задачи её не трогайте.

Ошибка 6: Live state в system prompt

Симптом: На каждой итерации prompt-cache hit ≈ 0%, latency растёт линейно с длиной истории, цена ×3-5 от ожидаемой. Метрика cached_tokens от провайдера колеблется около нуля.

Причина: В system prompt вписаны изменяющиеся данные (текущий файл, прочитанные файлы, прогресс плана). Любая мутация делает invalidate всего префикса.

Решение: Держи system prompt стабильным. Стабильные включения (дата, рабочая директория) — константа внутри Run: фиксируются один раз при старте и не меняются, это нормально. Антипаттерн — пересоздавать агента с time.Now() в system prompt на каждый запрос: каждый запрос получает новый префикс → промах кэша на всей длине. Live state кладёт в tool results, в Notes последнего сообщения или в специальные tool-вызовы (update_plan, set_goal), которые модель совершает сама.

Ошибка 7: Оценка токенов через char/3

Симптом: Threshold-condense срабатывает не вовремя — то слишком рано (теряем контекст зря), то слишком поздно (получаем ContextOverflowError). Поведение между моделями сильно расходится.

Причина: «Длина в символах / 3» — приближение, которое систематически промахивается на 30%+. Для русского, кода, и моделей с обновлёнными токенизаторами (например, после смены словаря) промах ещё больше.

Решение: Берите Usage.PromptTokens из ответа провайдера на предыдущую итерацию — это бесплатно и точно. Char-based оценка нужна только для нового пользовательского сообщения, которое ещё не уехало в LLM.

// ПЛОХО
estimate := totalChars / 3
if estimate > threshold { condense() }

// ХОРОШО
budget := lastUsage.PromptTokens + roughEstimate(newUserMessage)
if budget > threshold { condense() }

Для любопытных

Этот раздел — для тех, кому всё-таки хочется глубже. Можно пропустить.

Block Memory: каталог блоков и recall

Идея выглядит привлекательно: историю группируем в блоки «один запрос → один цикл агента → результат», держим каталог блоков с короткими summary, а полное содержимое блока модель подтягивает по запросу через tool recall(block_id). В REPL-агенте, где каждая команда — закрытый сюжет, это соблазнительный способ дать модели адресовать прошлое («глянь, что было в команде #3»).

Почему это не основной путь:

  • Два источника правды. Каталог/summary и оригиналы блоков неизбежно расходятся; отладка «что именно видела модель» усложняется.
  • Двойная цена. LLM-вызовы на summary каждого блока + промахи кэша, когда большой блок вставляется в середину истории.
  • Лишний слой. Те же 90% случаев закрываются condense (см. выше) + внешним store с инструментом поиска.

Если очень нужно адресуемое прошлое — сначала попробуйте внешний store + tool поиска: это тот же recall, но без второго представления истории.

Мини-упражнения

Упражнение 1: File-backed store

Реализуйте внешнее хранилище долговременной памяти, переживающее перезапуск процесса:

type FileStore struct {
    filepath string
    // ...
}

func (s *FileStore) Save(ctx context.Context, key string, value any) error {
    // Сохраните в JSON-файл (atomic write через temp + rename)
}

Ожидаемый результат:

  • Store переживает перезапуск.
  • Запись атомарна (нет частично записанных файлов при крахе).
  • Есть отдельная команда Cleanup() для пробежки по TTL.

Упражнение 2: Семантический поиск по store

Реализуйте retrieval через embeddings поверх внешнего хранилища:

func (s *FileStore) SearchSemantic(ctx context.Context, query string, limit int) ([]Item, error) {
    // Закодируйте query, посчитайте cosine similarity к items, верните top-K
}

Ожидаемый результат:

  • Находит релевантные элементы без точного совпадения слов.
  • Возвращает наиболее похожие первыми.
  • Падение модели embeddings не должно крашить поиск (ошибки логируются, метод возвращает то, что смог).

Упражнение 3: History + threshold-condense

Реализуйте History (Append / Messages / Replace / Snapshot / Restore) и функцию-сторож:

func shouldCondense(usage llm.Usage, ctxWindow int, threshold float64) bool {
    // true, если usage.PromptTokens / ctxWindow >= threshold (типовое значение ~0.85)
}

func condense(ctx context.Context, ep llm.Endpoint, msgs []llm.Message) ([]llm.Message, error) {
    // 1. Разделить на head (старое) и tail (последние 2 user-шага)
    // 2. Попросить ep собрать summary head по промпту из гл. 13;
    //    запрос суммаризации несёт тот же префикс system + tools
    // 3. Вернуть [summary as user message] + tail
}

Ожидаемый результат:

  • История append-only, никаких мутаций кроме Replace.
  • Threshold срабатывает по реальным PromptTokens, не по char/3.
  • Оригинал-снапшот сохраняется до успешного condense (если LLM-вызов упал, история не теряется).
  • Condense не вызывается чаще одного раза за Run.

Критерии сдачи / Чек-лист

✅ Сдано:

  • Понимаете разделение «внутри Run» vs «между сессиями»: линейная история vs внешний store с инструментами.
  • Память цикла — History (Append / Messages / Replace / Snapshot / Restore), санитизация встроена.
  • Не мутируете system prompt live-состоянием; дата и рабочая директория — константа внутри Run.
  • Condense — единственная операция сжатия: один порог ~0.85 по Usage.PromptTokens, максимум 1/Run, оригинал — через снапшот.
  • Считаете токены через Usage.PromptTokens, не через char/3.
  • Реализуете TTL и фильтрацию для долговременного store.

❌ Не сдано:

  • Condense вызывается чаще одного раза за Run.
  • Live state живёт в system prompt.
  • Char/3 — единственный счётчик токенов.
  • Сжатие выполняется без снапшота оригинала (оригиналы уничтожены).
  • Долговременная память без TTL и без фильтра, что туда класть.
  • Block memory (каталог блоков + recall(block_id)) в основном пути цикла — это путь «для любопытных», а не дефолт.

Связь с другими главами

Граница: эта глава отвечает за хранение и извлечение информации. Управление тем, как эта информация складывается в контекст LLM, — в Context Engineering.

Что дальше?

После понимания систем памяти переходите к:

  • 13. Context Engineering — научитесь собирать контекст из памяти, состояния и retrieval, не ломая prompt cache.