12. Системы Памяти Агента¶
Зачем это нужно?¶
Агентам нужна память, чтобы удерживать контекст между разговорами, учиться на прошлом опыте и не повторять одни и те же ошибки. Без этого агент быстро забывает важное и тратит токены на повторные объяснения.
В этой главе разберём системы памяти, которые помогают агентам запоминать, извлекать и забывать информацию эффективно.
Реальный кейс¶
Ситуация: Пользователь спрашивает агента: "Какая была проблема с базой данных, которую мы исправили на прошлой неделе?" Агент отвечает: "У меня нет информации об этом."
Проблема: У агента нет памяти о прошлых разговорах. Каждое взаимодействие начинается с нуля, тратя контекст и время пользователя.
Решение: Система памяти сохраняет важные факты, достаёт их при необходимости и забывает устаревшее, чтобы не вылезать за лимиты контекста.
Теория простыми словами¶
Типы памяти¶
Кратковременная память:
- История текущего разговора (хранится в runtime, не в долговременном хранилище)
- Ограничена контекстным окном LLM
- Теряется при завершении разговора
- Примечание: Управление кратковременной памятью (саммаризация, отбор) описано в Context Engineering. Термин "рабочая память" используется в Context Engineering для обозначения недавних поворотов разговора в контексте.
Долговременная память (постоянное хранилище):
- Факты, предпочтения, прошлые решения
- Хранится в базе данных/файлах
- Сохраняется между разговорами
Episodic память:
- Конкретные события: "Пользователь спрашивал о месте на диске 2026-01-06"
- Полезна для отладки и обучения
Semantic память:
- Общие знания: "Пользователь предпочитает JSON ответы"
- Извлекается из эпизодов
Операции с памятью¶
- Store — Сохранить информацию для будущего
- Retrieve — Найти релевантную информацию
- Forget — Удалить устаревшую информацию
- Update — Изменить существующую информацию
Как это работает (пошагово)¶
Шаг 1: Интерфейс памяти¶
type Memory interface {
Store(key string, value any, metadata map[string]any) error
Retrieve(query string, limit int) ([]MemoryItem, error)
Forget(key string) error
Update(key string, value any) error
}
type MemoryItem struct {
Key string
Value any
Metadata map[string]any
Created time.Time
Accessed time.Time
TTL time.Duration // Время жизни
}
Шаг 2: Сохранение информации¶
type SimpleMemory struct {
store map[string]MemoryItem
mu sync.RWMutex
}
func (m *SimpleMemory) Store(key string, value any, metadata map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key] = MemoryItem{
Key: key,
Value: value,
Metadata: metadata,
Created: time.Now(),
Accessed: time.Now(),
TTL: 24 * time.Hour, // Дефолтный TTL
}
return nil
}
Шаг 3: Извлечение с поиском¶
func (m *SimpleMemory) Retrieve(query string, limit int) ([]MemoryItem, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// Простой поиск по ключевым словам (в проде используйте embeddings)
results := make([]MemoryItem, 0, len(m.store))
queryLower := strings.ToLower(query)
for _, item := range m.store {
// Проверяем, не истёк ли срок
if item.TTL > 0 && time.Since(item.Created) > item.TTL {
continue
}
// Простое сопоставление ключевых слов
valueStr := fmt.Sprintf("%v", item.Value)
if strings.Contains(strings.ToLower(valueStr), queryLower) {
item.Accessed = time.Now() // Обновляем время доступа
results = append(results, item)
}
}
// Сортируем по времени доступа (самые свежие первыми)
sort.Slice(results, func(i, j int) bool {
return results[i].Accessed.After(results[j].Accessed)
})
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
Шаг 4: Забывание истёкших элементов¶
func (m *SimpleMemory) Cleanup() error {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
for key, item := range m.store {
if item.TTL > 0 && now.Sub(item.Created) > item.TTL {
delete(m.store, key)
}
}
return nil
}
Шаг 5: Интеграция с агентом¶
func runAgentWithMemory(ctx context.Context, client *openai.Client, memory Memory, userInput string) (string, error) {
// Извлекаем релевантные воспоминания
memories, _ := memory.Retrieve(userInput, 5)
// Строим контекст с воспоминаниями
messages := []openai.ChatCompletionMessage{
{Role: "system", Content: "Ты полезный ассистент с доступом к памяти."},
}
// Добавляем релевантные воспоминания как контекст
if len(memories) > 0 {
memoryContext := "Релевантная прошлая информация:\n"
for _, mem := range memories {
memoryContext += fmt.Sprintf("- %s: %v\n", mem.Key, mem.Value)
}
messages = append(messages, openai.ChatCompletionMessage{
Role: "system",
Content: memoryContext,
})
}
messages = append(messages, openai.ChatCompletionMessage{
Role: "user",
Content: userInput,
})
resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: "gpt-4o-mini",
Messages: messages,
})
if err != nil {
return "", err
}
answer := resp.Choices[0].Message.Content
// Сохраняем важную информацию из разговора
if shouldStore(userInput, answer) {
key := generateKey(userInput)
memory.Store(key, answer, map[string]any{
"user_input": userInput,
"timestamp": time.Now(),
})
}
return answer, nil
}
Блочная память (Block Memory)¶
Простой SimpleMemory на map подходит для учебных целей. В production-агенте память устроена сложнее. Ключевая идея: блочная архитектура.
Блок (Block) — это единица взаимодействия. Один запрос пользователя, цепочка tool calls, финальный ответ — всё это один блок. Блок хранит оригинальные сообщения и их компактную версию.
Жизненный цикл блока¶
flowchart LR
A[Prepare] --> B[Append]
B --> C[BuildContext]
C --> D[Close]
D --> E["Compact + Summary"]
- Prepare — создаём новый блок, привязываем к хранилищу
- Append — добавляем сообщения (user, assistant, tool results)
- BuildContext — собираем историю из текущего и прошлых блоков
- Close — финализируем: создаём compact-версию, считаем токены, генерируем summary
Compact: 91% сжатие tool chains¶
При закрытии блока цепочки tool calls схлопываются в компактную форму:
type Block struct {
ID int
Query string
Messages []Message // оригинал
Compact []Message // сжатая версия
Summary string
Tokens int
}
func (b *Block) Close() {
b.Query = extractQuery(b.Messages) // первые 120 символов user-сообщения
b.Compact = compactMessages(b.Messages)
b.Tokens = estimateTokens(b.Compact) // chars / 3
b.Summary = buildSummary(b.Query, b.Messages, b.Compact)
}
Функция compactMessages схлопывает цепочки assistant(tool_calls) → tool_result в одно сообщение с тегами <prior_tool_use>:
func compactMessages(msgs []Message) []Message {
var result []Message
var toolBuf strings.Builder
for _, msg := range msgs {
if msg.HasToolCalls() {
for _, tc := range msg.ToolCalls {
args := truncate(tc.Arguments, 80)
res := truncate(findToolResult(msgs, tc.ID), 200)
line := fmt.Sprintf("%s %s -- %s", tc.Name, args, res)
if !isDuplicate(toolBuf.String(), line) {
toolBuf.WriteString(line + "\n")
}
}
continue
}
if msg.Role == "tool" {
continue // уже обработано выше
}
if toolBuf.Len() > 0 {
result = append(result, Message{
Role: "user", // не assistant — модель не имитирует свой контент
Content: "<prior_tool_use>\n" + toolBuf.String() + "</prior_tool_use>",
})
toolBuf.Reset()
}
result = append(result, msg)
}
return result
}
Compact-сообщения получают роль user, а не assistant. Это предотвращает ситуацию, когда модель воспринимает сжатый контент как свои предыдущие слова и начинает их имитировать.
BuildContext: бюджетирование блоков¶
При сборке контекста мы берём compact-версии прошлых блоков (от новых к старым), пока хватает бюджета:
func (m *Memory) BuildContext(currentBlock *Block, contextSize int) []Message {
budget := contextSize - currentBlock.Tokens - 4096 // резерв
var history []Message
// от новых блоков к старым
for i := len(m.blocks) - 1; i >= 0 && budget > 0; i-- {
block := m.blocks[i]
if block.Tokens > budget {
break
}
history = append(block.Compact, history...)
budget -= block.Tokens
}
return append(history, currentBlock.Messages...)
}
Каталог блоков и Recall¶
Модель не видит содержимое прошлых блоков напрямую. Вместо этого в system prompt рендерится каталог — список блоков с краткими описаниями:
[CONTEXT BLOCKS]
Blocks from prior interactions (use recall tool to get full details):
#0: check disk usage — exec x3, read x1 → "Disk /data at 92%, cleaned logs" (~5K tokens)
#1: deploy service — edit x2, exec x4 → "Deployed v2.3.1 to staging" (~8K tokens)
#2: fix nginx config — read x2, edit x1 → "Updated proxy_pass for /api" (~3K tokens)
Когда модели нужны подробности, она вызывает инструмент recall:
type RecallTool struct {
memory *Memory
}
func (t *RecallTool) Execute(ctx context.Context, args RecallArgs) (string, error) {
msgs := t.memory.BlockMessages(args.BlockID)
if msgs == nil {
return "Block not found", nil
}
return formatMessages(msgs), nil // полные оригинальные сообщения
}
Recall возвращает оригинальные сообщения блока, а не compact-версию. Это критично: compact теряет детали, а для глубокого анализа нужна полная информация.
Working Memory (Рабочая память)¶
Working Memory — это динамическая секция system prompt, которая содержит контекст текущей задачи. В отличие от block memory (прошлые взаимодействия), Working Memory — это "что агент знает прямо сейчас".
Компоненты Working Memory¶
| Компонент | Назначение |
|---|---|
| TaskContext | Текущая задача (первые 50 слов), прочитанные/изменённые файлы, последние действия |
| LivePlan | Цель, шаги с статусами (pending/in_progress/completed/cancelled) |
| Budget | Предупреждения о заполнении контекстного окна |
| ContextBlocks | Каталог прошлых блоков для recall |
Render в system prompt¶
type WorkingMemory struct {
Task string
FilesRead *Ring[string] // кольцевой буфер, MRU-семантика
FilesModified *Ring[string]
LastActions *Ring[string]
Plan *LivePlan
Budget *BudgetTracker
Blocks []BlockSummary
maxChars int // ~6000 символов
}
func (wm *WorkingMemory) Render() string {
var sb strings.Builder
sb.WriteString("[TASK CONTEXT]\n")
sb.WriteString("Task: " + wm.Task + "\n")
sb.WriteString("Files read: " + wm.FilesRead.Join(", ") + "\n")
sb.WriteString("Files modified: " + wm.FilesModified.Join(", ") + "\n")
sb.WriteString("Recent: " + wm.LastActions.Join(", ") + "\n")
if wm.Plan != nil {
sb.WriteString("\n" + wm.Plan.Render() + "\n")
}
if wm.Budget.ShouldWarn() {
sb.WriteString("\n[BUDGET]\n" + wm.Budget.Warning() + "\n")
}
if len(wm.Blocks) > 0 {
sb.WriteString("\n[CONTEXT BLOCKS]\n")
for _, b := range wm.Blocks {
sb.WriteString(fmt.Sprintf("#%d: %s (~%dK tokens)\n", b.ID, b.Summary, b.Tokens/1000))
}
}
// Trimming при превышении бюджета
if sb.Len() > wm.maxChars {
wm.LastActions.Trim(3)
wm.FilesRead.Trim(5)
}
return sb.String()
}
Проблема: Working Memory не персистентна¶
Working Memory живёт в оперативной памяти. Между сессиями она теряется: агент забывает план, перечитывает файлы, начинает с нуля.
Решение: передавать Working Memory как параметр в Run(), чтобы она переживала между REPL-циклами. Для персистенции между сессиями — реализовать Export/Import:
func (wm *WorkingMemory) Export() WorkingMemorySnapshot {
return WorkingMemorySnapshot{
Task: wm.Task,
FilesRead: wm.FilesRead.Items(),
FilesModified: wm.FilesModified.Items(),
Plan: wm.Plan.Export(),
}
}
func (wm *WorkingMemory) Restore(snap WorkingMemorySnapshot) {
wm.Task = snap.Task
for _, f := range snap.FilesRead {
wm.FilesRead.Push(f)
}
// ...
}
4 уровня памяти в production-агенте:
- Working Memory — задача, план, бюджет, файлы (в system prompt)
- Block Memory — завершённые взаимодействия (original + compact)
- Recall — model-driven retrieval полных данных блока
- Condense — emergency LLM-сжатие при overflow (см. Context Engineering)
Checkpoint и Resume¶
Агент может работать часами над сложной задачей. Если процесс упадёт посередине, потеряется весь прогресс. Checkpoint (чекпоинт) сохраняет состояние разговора периодически. При сбое агент возобновляет работу с последней сохранённой точки.
Базовая реализация Checkpoint (структура, сохранение/загрузка, интеграция с agent loop) описана в Главе 09: Анатомия Агента. Продвинутые стратегии (гранулярность, валидация, ротация) — в Главе 11: State Management.
Здесь мы рассматриваем, как Checkpoint связан с памятью агента:
- Что сохранять: историю сообщений (
messages[]), содержимое памяти, состояние инструментов, текущий шаг выполнения. - Когда сохранять: после каждого значимого шага (вызов инструмента, ответ пользователю). Для коротких задач (2-3 итерации) Checkpoint избыточен. Для длинных задач (10+ итераций) — обязателен.
- TTL: устанавливайте TTL на Checkpoint (например, 24 часа), чтобы устаревшие снимки не накапливались.
Shared Memory между агентами¶
В мульти-агентных системах агенты обмениваются информацией через общее хранилище памяти. Каждый агент читает и пишет в общий store, разграничивая данные по namespace:
type SharedMemoryStore struct {
store CheckpointStore
}
// Запись с namespace агента
func (s *SharedMemoryStore) Put(ctx context.Context, agentID, key string, value any) error {
fullKey := fmt.Sprintf("shared:%s:%s", agentID, key)
data, _ := json.Marshal(value)
return s.store.Set(ctx, fullKey, data, 0)
}
// Чтение данных другого агента
func (s *SharedMemoryStore) Get(ctx context.Context, agentID, key string) (any, error) {
fullKey := fmt.Sprintf("shared:%s:%s", agentID, key)
data, err := s.store.Get(ctx, fullKey)
if err != nil {
return nil, err
}
var result any
return result, json.Unmarshal(data, &result)
}
// Получить все записи всех агентов (для supervisor)
func (s *SharedMemoryStore) ListAll(ctx context.Context) (map[string]any, error) {
keys, _ := s.store.Keys(ctx, "shared:*")
result := make(map[string]any)
for _, key := range keys {
val, _ := s.store.Get(ctx, key)
var parsed any
json.Unmarshal(val, &parsed)
result[key] = parsed
}
return result, nil
}
Связь: Подробнее об управлении состоянием агента — в Главе 11: State Management. Checkpoint — это частный случай персистенции состояния.
Типовые ошибки¶
Ошибка 1: Нет TTL (Time To Live)¶
Симптом: Память растёт бесконечно, потребляя хранилище и контекст.
Причина: Не забывается устаревшая информация.
Решение: Реализуйте TTL и периодическую очистку.
Ошибка 2: Сохранение всего¶
Симптом: Память заполняется нерелевантной информацией, делая извлечение шумным.
Причина: Нет фильтрации того, что сохранять.
Решение: Сохраняйте только важные факты, не каждый поворот разговора.
Ошибка 3: Нет оптимизации извлечения¶
Симптом: Извлечение возвращает нерелевантные результаты или пропускает важную информацию.
Причина: Простое сопоставление ключевых слов вместо семантического поиска.
Решение: Используйте embeddings для семантического поиска по сходству.
Ошибка 4: Compact уничтожает оригиналы¶
Симптом: После компакции невозможно восстановить детали tool calls. Recall возвращает сжатую версию.
Причина: Оригинальные сообщения удалены при создании compact-версии.
Решение:
// ПЛОХО: перезаписываем оригинал
block.Messages = compactMessages(block.Messages)
// ХОРОШО: храним оба варианта
block.Compact = compactMessages(block.Messages)
// block.Messages остаётся без изменений
Принцип Never destroy originals: condensation — это view, не мутация. Оригиналы нужны для recall по полной истории.
Ошибка 5: Программная компакция mid-loop¶
Симптом: Агент теряет контекст посередине задачи, начинает заново или повторяет действия.
Причина: Компакция вызывается внутри цикла агента, пока задача ещё выполняется.
Решение: Compact — только при Block.Close(), когда взаимодействие завершено. Внутри цикла используйте LLM-конденсацию (condense), которая создаёт осмысленное резюме вместо механического сжатия.
Мини-упражнения¶
Упражнение 1: Реализуйте Memory Store¶
Создайте хранилище памяти, которое сохраняется на диск:
type FileMemory struct {
filepath string
// Ваша реализация
}
func (m *FileMemory) Store(key string, value any, metadata map[string]any) error {
// Сохранить в файл
}
Ожидаемый результат:
- Память сохраняется между перезапусками
- Можно загрузить из файла при старте
Упражнение 2: Семантический поиск¶
Реализуйте извлечение с использованием embeddings:
func (m *Memory) RetrieveSemantic(query string, limit int) ([]MemoryItem, error) {
// Используйте embeddings для поиска семантически похожих элементов
}
Ожидаемый результат:
- Находит релевантные элементы даже без точного совпадения ключевых слов
- Возвращает самые похожие элементы первыми
Упражнение 3: Реализуйте Block Memory с Compact¶
Реализуйте блочную память с компакцией tool chains:
type BlockMemory struct {
blocks []*Block
current *Block
}
func (m *BlockMemory) NewBlock() *Block {
// Создать новый блок
}
func (m *BlockMemory) CloseBlock() {
// Закрыть текущий блок: compact + summary
}
func (m *BlockMemory) BuildContext(contextSize int) []Message {
// Собрать историю из compact-версий прошлых блоков + текущий блок
}
Ожидаемый результат:
- Compact сжимает цепочку из 10 tool calls в 1-2 сообщения
- BuildContext укладывается в бюджет токенов
- Оригинальные сообщения сохраняются для recall
Критерии сдачи / Чек-лист¶
Сдано:
- Понимаете разные типы памяти (включая блочную и рабочую)
- Можете сохранять и извлекать информацию
- Реализуете TTL и очистку
- Интегрируете память с агентом
- Понимаете разницу между compact и оригиналом
- Знаете, зачем нужна Working Memory
Не сдано:
- Нет TTL, память растёт бесконечно
- Сохранение всего без фильтрации
- Только простой поиск по ключевым словам
- Compact уничтожает оригиналы
- Нет блочной структуры — вся история в одном массиве
Связь с другими главами¶
- Глава 09: Анатомия Агента — Память — ключевой компонент агента
- Глава 13: Context Engineering — Память питает управление контекстом (факты из памяти используются при сборке контекста)
- Глава 08: Evals и Надежность — Память влияет на консистентность агента
ВАЖНО: Память (эта глава) отвечает за хранение и извлечение информации. Управление тем, как эта информация включается в контекст LLM, описано в Context Engineering.
Что дальше?¶
После понимания систем памяти переходите к:
- 13. Context Engineering — Узнайте, как эффективно управлять контекстом из памяти, состояния и retrieval