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» и «персистентное хранилище».
Операции с памятью¶
Память цикла — конкретная структура (линейная история), и операций у неё ровно пять:
- Append — дописать сообщения в конец.
- Messages — получить копию истории для запроса к LLM.
- Replace — полная замена истории (вызывается только condense, гл. 13).
- Snapshot — сохранить состояние (чекпоинт, аудит, откат неудачного сжатия).
- Restore — восстановить сохранённое.
Store / Retrieve / Forget — это словарь для внешнего store долговременной памяти, а не операции истории цикла. Не смешивайте: внутри Run — история и только история, между Run — внешний store, до которого агент дотягивается инструментами.
Принцип: память — иммутабельная история¶
Главное правило, на котором держится всё дальнейшее:
История уже отправленных сообщений не переписывается. Она только дополняется (append) или полностью заменяется (replace).
Из этого правила вытекают три практических следствия:
- Не переписывай прошлое. Никаких «удалим лишний tool result» или «уплотним assistant-ответ». Это ломает prompt cache на хвосте и иногда заставляет модель имитировать собственный прежний стиль.
- System prompt стабилен. Динамические данные (что прочитали, какой план) не вписывай в system prompt — это вычислительный налог на каждой итерации.
- Сжатие — это полная замена. Если истории слишком много, мы её осознанно, целиком и редко заменяем на 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, и память цикла о нём ничего не знает.
Два рабочих паттерна:
- Prefetch на старте Run. Релевантные факты выбираются из store до первого запроса и подкладываются в первое user-сообщение Run.
- 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:
- В результат последнего tool call. Если tool вернул
read_file: {content}, к нему естественно дописать заметки агента. - В отдельные
Notesструктуры, которые рендерятся в последнееuser-сообщение или в первое сообщение очередной итерации. - В 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, чтобы агенты не перезаписывали чужое:
Правила те же, что и для долговременной памяти: ничего «общего» в 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)) в основном пути цикла — это путь «для любопытных», а не дефолт.
Связь с другими главами¶
- Глава 09: Анатомия Агента — память как один из ключевых компонентов агента, связь с runtime.
- Глава 11: State Management — чекпоинты, идемпотентность, persist state.
- Глава 13: Context Engineering — сборка контекста из памяти, бюджеты, condense, динамические секции system prompt.
- Глава 20: Cost & Latency Engineering — почему prompt cache настолько важен и как его не сломать.
Граница: эта глава отвечает за хранение и извлечение информации. Управление тем, как эта информация складывается в контекст LLM, — в Context Engineering.
Что дальше?¶
После понимания систем памяти переходите к:
- 13. Context Engineering — научитесь собирать контекст из памяти, состояния и retrieval, не ломая prompt cache.