Продакшн-разработка ИИ-агентов · Модуль 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 по времени, понижение важности, удаление неподтверждённого. Забывание — это фича, а не баг: оно держит память релевантной и недорогой.
Почему так: без консолидации и забывания память монотонно растёт и зашумляется; извлечение по одной лишь релевантности начинает возвращать протухшие факты. Активное обслуживание памяти — то, что отличает архитектуру от «свалки в истории».
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 сессий: убедитесь, что между сессиями подтягиваются нужные факты, протухшие забываются, а эпизоды консолидируются в семантику.
Проверка знаний
Агент поддержки сохраняет в долгосрочную память каждую реплику диалога целиком. Через месяц извлечение по запросу возвращает кучу нерелевантных кусков переписки, ответы стали хуже, а стоимость контекста выросла.
Что является корнем проблемы и как её исправить?
Верный ответ: B
Корень — запись сырого диалога вместо извлечённых фактов: память растёт и зашумляется. Правильная операция Write извлекает структурированные факты (LLM-экстракция) и дедуплицирует, а не дампит реплики. Увеличение окна (A) и top-k (C) лишь усугубят шум; граф (D) решает другую задачу (связи), а не зашумлённость записи.
Пользователь полгода назад был на тарифе Free и часто его упоминал, а недавно перешёл на Pro. На вопрос про его тариф агент извлекает из памяти старые, многочисленные упоминания Free и отвечает неверно.
Какая комбинация механизмов памяти устранит ошибку?
Верный ответ: A
Проблема в том, что извлечение по одной релевантности достаёт устаревшие, но многочисленные упоминания Free. Нужны свежесть (новый факт Pro ранжируется выше) и забывание (старые упоминания Free затухают по decay/TTL). Усиление близости (B) только закрепит ошибку; загрузка всей истории (C) вернёт шум и переполнение; процедурная память (D) — для инструкций «как делать», не про факты-предпочтения.
За месяц у агента накопились сотни эпизодических записей вида «пользователь снова спрашивал про функцию экспорта в разных формулировках». Память раздулась, а полезного сигнала из этих записей по отдельности мало.
Какая операция памяти здесь нужна?
Верный ответ: B
Это задача консолидации: множество мелких эпизодов сворачиваются в один устойчивый семантический факт — память сжимается, а сигнал усиливается. Простое удаление (A) потеряло бы важный вывод о потребности пользователя; повышение importance (C) лишь раздует шум; переэмбеддинг (D) не меняет того, что данные фрагментированы.