Продакшн-разработка ИИ-агентов · Модуль 2 · Урок 2.2

Глава 4. Архитектуры памяти

Цели главы

После главы вы сможете:

  • различать рабочую/краткосрочную (working) и долгосрочную (long-term) память агента и понимать, зачем их разделять
  • классифицировать долгосрочную память на эпизодическую (episodic — что произошло), семантическую (semantic — факты) и процедурную (procedural — как делать)
  • проектировать гибридное хранилище память = вектор + граф
  • реализовать четыре операции памяти: запись (что сохранять, извлечение фактов из диалога), извлечение (retrieval по релевантности и свежести), консолидацию (summarize эпизодов в семантику) и забывание (forgetting/decay, TTL, важность)
  • держать в голове архитектурную модель: память — это отдельный сервис со своим API, а не «свалка в истории сообщений»

Что нового (дельта к базовому курсу)

В базовом курсе память была обзорной дихотомией: «краткосрочная (история чата в окне контекста) vs долгосрочная (что-то сохраняем в БД)». На практике этого недостаточно — «сохраняем всё в одну таблицу и грузим обратно» быстро упирается в окно контекста и деградирует.

Здесь память — это архитектура с типами и операциями, спроектированная как сервис:

  • долгосрочная память делится на типы по природе данных (эпизодическая / семантическая / процедурная) — у каждого свои правила записи и извлечения
  • извлечение учитывает не только релевантность, но и свежесть (recency) и важность
  • память активно управляется: старые эпизоды консолидируются в факты, неважное забывается (decay/TTL)

Главный сдвиг мышления: память — не пассивный лог, а активно обслуживаемое хранилище с операциями Write/Search/Consolidate/Forget.

Working vs long-term: зачем разделять

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

Долгосрочная память живёт во внешнем хранилище и переживает сессии. Её нельзя целиком грузить в контекст — иначе мы вернулись к проблеме переполнения окна. Поэтому из неё извлекают по запросу только релевантное.

Почему так: разделение повторяет иерархию памяти ОС (быстрая дорогая RAM vs медленный объёмный диск). Агент работает в маленьком быстром окне, а большое медленное хранилище подгружает по необходимости — это и есть основная идея MemGPT (LLM как операционная система с управлением «виртуальной памятью»).

Три типа долгосрочной памяти

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

  • Эпизодическая (episodic) — что произошло: конкретные события и взаимодействия с временной меткой. «12 марта пользователь жаловался на медленный экспорт». Извлекается по релевантности + свежести; со временем консолидируется.
  • Семантическая (semantic) — факты и знания: устойчивые утверждения о мире и пользователе. «Пользователь предпочитает формат дат ISO-8601», «компания работает в сфере логистики». Не привязана к одному событию.
  • Процедурная (procedural) — как делать: инструкции, навыки, выученные процедуры. «Чтобы оформить возврат, сначала проверь статус заказа, потом…». Часто хранится как промпт-фрагменты или skill-файлы.

Почему так: разные типы по-разному устаревают и по-разному извлекаются. Семантический факт о предпочтениях нужен почти всегда; конкретный эпизод полугодовой давности — почти никогда. Смешивать их в одно хранилище с одной политикой неэффективно.

Гибрид вектор + граф

Для памяти, как и для RAG, один вектор недостаточен.

  • Вектор хорош для извлечения по смысловой близости: «найди всё похожее на то, что обсуждаем сейчас».
  • Граф хранит связи между сущностями: пользователь → работает в → компания → использует → продукт. Это критично для вопросов про отношения и для multi-hop рассуждения по памяти.

Гибрид: факты и эпизоды эмбеддятся (для семантического поиска) и связываются в граф (сущность–связь–сущность). Извлечение комбинирует оба сигнала. Почему так: память о пользователе — это не плоский список фактов, а сеть сущностей; вопрос «кто из коллег пользователя работал над проектом Z» решается обходом графа, а не косинусной близостью.

Четыре операции: запись, извлечение, консолидация, забывание

Память — это сервис с чётким API, а не таблица, куда мы складываем историю.

  • Запись (Write): не сохранять весь диалог дословно. Из реплик извлекают факты (LLM-экстракция: «пользователь сменил тариф на Pro») и записывают как структурированные элементы с метками времени и важности. Дедуплицируйте, обновляйте существующее, а не плодите дубли.
  • Извлечение (Search): ранжируйте не только по релевантности, но и по свежести (recency) и важности. Свежее и важное — выше. Это спасает от выдачи устаревших фактов («любит тариф Free», когда пользователь уже на Pro).
  • Консолидация (Consolidate): периодически суммируйте старые эпизоды в семантические факты. Десять эпизодов «спрашивал про экспорт» → один факт «пользователю важна функция экспорта». Это сжимает память и поднимает сигнал.
  • Забывание (Forget): не всё стоит хранить вечно. Decay/TTL по времени, понижение важности, удаление неподтверждённого. Забывание — это фича, а не баг: оно держит память релевантной и недорогой.

Почему так: без консолидации и забывания память монотонно растёт и зашумляется; извлечение по одной лишь релевантности начинает возвращать протухшие факты. Активное обслуживание памяти — то, что отличает архитектуру от «свалки в истории».

Интерфейс MemoryStore: Write / Search / Consolidate / Forget
package memory

import (
	"context"
	"time"
)

// MemoryKind — тип долгосрочной памяти. Разные типы по-разному устаревают
// и по-разному извлекаются.
type MemoryKind int

const (
	Episodic   MemoryKind = iota // что произошло (события с меткой времени)
	Semantic                     // факты и знания
	Procedural                   // как делать (инструкции, навыки)
)

// MemoryItem — единица памяти.
// (json-теги опущены: бэктики недопустимы в этом raw-литерале; в реальном
//  коде добавьте теги, напр. для CreatedAt — json:"created_at".)
type MemoryItem struct {
	ID         string
	Kind       MemoryKind
	Text       string
	Embed      []float32
	Importance float64   // 0..1 — насколько факт важен; влияет на ранжирование и забывание
	CreatedAt  time.Time // для свежести (recency) и TTL/decay
	LastUsed   time.Time
}

// MemoryStore — память как СЕРВИС со своим API, а не «таблица с историей».
type MemoryStore interface {
	// Write извлекает факты и записывает их (дедуплицируя), а не дамп диалога.
	Write(ctx context.Context, item MemoryItem) error

	// Search ранжирует по релевантности + свежести + важности, не только по близости.
	Search(ctx context.Context, query string, qEmbed []float32, topK int) ([]MemoryItem, error)

	// Consolidate суммирует старые эпизоды в семантические факты.
	Consolidate(ctx context.Context) error

	// Forget применяет decay/TTL и удаляет протухшее/неважное.
	Forget(ctx context.Context, now time.Time) error
}
Извлечение с учётом релевантности, свежести и важности
package memory

import (
	"math"
	"sort"
	"time"
)

// cosine — косинусная близость двух эмбеддингов (релевантность).
func cosine(a, b []float32) float64 {
	var dot, na, nb float64
	for i := range a {
		dot += float64(a[i]) * float64(b[i])
		na += float64(a[i]) * float64(a[i])
		nb += float64(b[i]) * float64(b[i])
	}
	if na == 0 || nb == 0 {
		return 0
	}
	return dot / (math.Sqrt(na) * math.Sqrt(nb))
}

// recencyScore — экспоненциальное затухание свежести.
// halfLife — период полураспада: через него вклад свежести падает вдвое.
func recencyScore(createdAt, now time.Time, halfLife time.Duration) float64 {
	age := now.Sub(createdAt)
	return math.Exp(-math.Ln2 * float64(age) / float64(halfLife))
}

// rankMemories ранжирует элементы памяти по СУММЕ трёх сигналов, а не по
// одной близости: иначе извлечение возвращает протухшие, но «похожие» факты.
func rankMemories(
	items []MemoryItem,
	qEmbed []float32,
	now time.Time,
	halfLife time.Duration,
	topK int,
) []MemoryItem {
	type scored struct {
		item  MemoryItem
		score float64
	}
	ranked := make([]scored, len(items))
	for i, it := range items {
		relevance := cosine(qEmbed, it.Embed)
		recency := recencyScore(it.CreatedAt, now, halfLife)
		// Веса подбираются под задачу; важность даёт «липкость» ключевым фактам.
		score := 0.6*relevance + 0.25*recency + 0.15*it.Importance
		ranked[i] = scored{item: it, score: score}
	}
	sort.Slice(ranked, func(i, j int) bool {
		return ranked[i].score > ranked[j].score
	})

	if topK > len(ranked) {
		topK = len(ranked)
	}
	out := make([]MemoryItem, topK)
	for i := 0; i < topK; i++ {
		out[i] = ranked[i].item
	}
	return out
}

// Forget — забывание как фича: удаляем то, у чьей «живучести» истёк ресурс.
// Живучесть = свежесть * важность; ниже порога — элемент протух.
func filterForget(
	items []MemoryItem,
	now time.Time,
	halfLife time.Duration,
	threshold float64,
) []MemoryItem {
	kept := items[:0]
	for _, it := range items {
		vitality := recencyScore(it.CreatedAt, now, halfLife) * it.Importance
		if vitality >= threshold {
			kept = append(kept, it)
		}
	}
	return kept
}

Anti-patterns

Грабли архитектур памяти
ГрабляПочему плохоКак избегать
Сохранять весь диалог дословно в долгосрочную памятьПамять монотонно растёт, зашумляется; извлечение тонет в нерелевантных репликахНа записи извлекать факты (LLM-экстракция) и хранить структурированные элементы, а не сырой лог
Грузить всю долгосрочную память в контекстВозврат к проблеме переполнения окна, рост стоимости и context rotИзвлекать по запросу top-k релевантного; держать working и long-term раздельно (модель MemGPT)
Извлечение только по косинусной близостиВозвращаются устаревшие, но «похожие» факты (тариф Free, когда пользователь уже на Pro)Ранжировать по сумме релевантность + свежесть (recency) + важность
Никогда не забывать (хранить всё вечно)Протухшие факты вытесняют актуальные, хранилище раздувается, ответы деградируютDecay/TTL + порог по важности; забывание — это фича, поддерживающая релевантность
Не консолидировать эпизодыСотни мелких событий «спрашивал про X» вместо одного факта; сигнал размыт, токены тратятся зряПериодическая консолидация: суммировать старые эпизоды в семантические факты
Плоское хранилище без связей между сущностямиВопросы про отношения и multi-hop по памяти не решаются косинусной близостьюГибрид вектор + граф: эмбеддинги для смысла, граф сущность–связь–сущность для отношений

Практическое задание (PRO-M2-MEM)

  • Определите MemoryItem с полями Kind/Importance/CreatedAt и интерфейс MemoryStore (Write/Search/Consolidate/Forget); сделайте in-memory реализацию.
  • Write: на каждой реплике вызывайте LLM для извлечения фактов (а не сохраняйте сырой текст), дедуплицируйте по смыслу перед вставкой.
  • Search: реализуйте rankMemories со взвешенной суммой релевантность+свежесть+важность; покажите на примере, что свежий факт обгоняет старый «более похожий».
  • Consolidate: соберите эпизоды старше N дней, суммируйте их LLM в семантические факты, пометьте исходные эпизоды как сконсолидированные.
  • Forget: реализуйте filterForget с порогом живучести (свежесть*важность) и прогоняйте по расписанию; проверьте, что важные факты переживают decay.
  • Прогоните сценарий из 3 сессий: убедитесь, что между сессиями подтягиваются нужные факты, протухшие забываются, а эпизоды консолидируются в семантику.

Проверка знаний

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

Что является корнем проблемы и как её исправить?

  • A Память переполнена; нужно увеличить размер окна контекста модели
  • B Сохраняется сырой диалог вместо извлечённых фактов; на записи нужно извлекать факты и дедуплицировать
  • C Слишком маленький top-k; нужно извлекать больше элементов
  • D Нужно перейти с вектора на граф для всех данных

Пользователь полгода назад был на тарифе Free и часто его упоминал, а недавно перешёл на Pro. На вопрос про его тариф агент извлекает из памяти старые, многочисленные упоминания Free и отвечает неверно.

Какая комбинация механизмов памяти устранит ошибку?

  • A Ранжирование с учётом свежести (recency) + забывание устаревшего по decay/TTL
  • B Увеличить вес косинусной близости при извлечении
  • C Хранить всю историю и грузить её целиком в контекст
  • D Перевести все факты в процедурную память

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

Какая операция памяти здесь нужна?

  • A Forget — просто удалить все эти эпизоды
  • B Consolidate — суммировать эпизоды в семантический факт «пользователю важна функция экспорта»
  • C Увеличить importance каждому эпизоду
  • D Переэмбеддить все эпизоды более крупной моделью