系列「企业级 AI Agent 实现拆解」第十篇。上一篇讲了知识库检索,这篇看记忆系统怎么让 Agent 越用越聪明。
为什么需要记忆
对话结束,进程可能重启,下次对话是全新的 session。如果没有记忆,用户每次都要重新告诉 Agent:"我是开发者,用中文回答,不要用太多术语"------这很烦。
企业场景里问题更大:某个工程师问了 100 次数据库性能问题,Agent 应该知道这个人关注什么方向,而不是每次都从零开始。
记忆系统(Memory BC)把有价值的信息从对话中提炼出来,持久化,下次对话时召回,注入 system prompt。就像你有个笔记本,每次聊天结束把关键信息记下来,下次见面时翻一翻。
命名空间:层级化的记忆分区
记忆不是一锅粥,需要按用途分区。DeepFlux 用 namespace 元组(层级路径)来组织:
go
// domain/model/memory.go
type Memory struct {
namespace []string // 层级路径
// ...
}
四层命名空间:
| namespace 路径 | 含义 | 举例 |
|---|---|---|
["user", "uid123"] |
用户全局偏好 | "用中文回答"、"我是开发者" |
["user", "uid123", "project", "pid456"] |
项目级事实 | "这个项目用 PostgreSQL" |
["agent", "erp-assistant"] |
Agent 共享知识 | "本 Agent 专注 ERP 系统" |
["session", "sid789"] |
会话临时记忆 | 本轮对话的摘要 |
用元组而不是字符串枚举的好处是天然层级 ------不需要改 schema 就能加新层级。比如将来加 "team" 层级,只要写 ["user", uid, "team", tid] 即可。
你可以把它想象成文件路径:
/user/张三/是张三的私人文件夹,/user/张三/project/ERP/是他在 ERP 项目里的专属信息,/agent/客服机器人/是所有用户共享的机器人知识。
四种记忆类型
每条记忆都有一个 kind 标签,告诉系统这条记忆的"性质":
| 类型 | 说明 | 举例 |
|---|---|---|
fact |
客观事实 | "我住北京"、"团队有 5 个人" |
preference |
个人偏好 | "回答用中文"、"少用术语" |
skill |
学到的做事方法 | "排查 OOM 先看 limit 设置" |
summary |
长会话压缩 | "讨论了 K8s 集群迁移方案" |
类型不同,存储的 namespace 也不同------preference 和 skill 跨项目有效,存在用户全局命名空间;fact 如果带 projectID 则收窄到项目 scope,防止跨项目污染。
衰减:自动淘汰低价值记忆
不能无限积累记忆。存储有限,注入 prompt 的空间也有限。衰减机制决定哪些记忆该留。
域模型公式(用于淘汰排序):
go
// domain/model/memory.go
func (m *Memory) DecayScore(now time.Time) float64 {
if m.pinned {
return 1e9 // 钉住的记忆永远不淘汰
}
ageDays := now.Sub(m.createdAt).Hours() / 24
if ageDays < 1 { ageDays = 1 }
return float64(m.confidence) * float64(m.hitCount+1) / ageDays
}
三个因子:
- confidence(置信度):自动提炼的默认 0.7,用户手动编辑后升为 1.0
- hitCount(命中次数):被检索命中越多,说明越有用
- age_in_days(年龄):越旧的记忆 score 越低
Cron 衰减任务(独立于域模型公式):
go
// infrastructure/decay/job.go --- 每天 0.95 系数衰减
const defaultThreshold = 30 * 24 * time.Hour // 30 天未访问才触发
func applyDecay(ctx context.Context, db *sql.DB, threshold time.Duration) {
db.ExecContext(ctx, `
UPDATE memories
SET importance = GREATEST(1, ROUND(importance * 0.95)),
updated_at = now() -- 衰减后刷新,避免反复命中
WHERE pinned = false
AND source = 'auto' -- 只衰减自动提炼的
AND updated_at < now() - make_interval(secs => $1) -- 30 天没被碰过
AND deleted_at IS NULL -- 跳过已软删
`)
}
两套机制配合:Cron 每天把长期不活跃的记忆 importance 打九五折(最低降到 1);当需要淘汰时,EvictByPolicy 按 importance * (hit_count + 1) / ageDays 排序,删掉尾部------这正是域模型 DecayScore 公式的 SQL 对应(importance = confidence × 100,仅差常数倍,不影响排序)。pinned 的记忆永远不参与衰减和淘汰。
就像你的书架:长期不翻的书慢慢积灰(0.95 衰减),积灰太久的书会被清理掉(EvictByPolicy),但"钉"在架子上的参考书永远不会被清理。
用户画像:结构化的 Profile
除了 Memory(自然语言短句),还有 Profile(结构化画像):
go
// domain/model/memory.go
type Profile struct {
TenantID string
UserID string
Fields map[string]string // {"language": "zh-CN", "role": "developer"}
UpdatedAt time.Time
}
两者互补:
| 特性 | Profile | Memory |
|---|---|---|
| 数据形式 | key-value 键值对 | 自然语言短句 |
| 注入方式 | 总是注入 system prompt 头部 | 向量检索 topK 后注入 |
| 检索方式 | 按 userID 直接查 | 语义相似度匹配 |
| 适用场景 | 稳定的结构化信息 | 动态的上下文相关内容 |
Profile 存的是"用户说中文、是开发者"这种稳定信息;Memory 存的是"上次讨论了数据库迁移方案"这种动态信息。
记忆提炼:会话结束后异步进行
每次 session 结束后,memory BC 异步从对话历史里提炼新记忆。整个过程是 fire-and-forget------失败不影响主流程。
javascript
会话消息 → LLM 提炼 → 8 类 JSON → 向量去重 → 写库
第一步:LLM 提炼
把对话历史发给专门的"记忆提炼 prompt",LLM 返回 8 个分类的结构化 JSON:
json
{
"profile": ["用户是后端开发者"],
"preferences": ["偏好中文回答"],
"entities": ["项目用 PostgreSQL"],
"events": ["上周做了数据库迁移"],
"cases": ["排查过 OOM 问题"],
"patterns": ["习惯先看日志再查指标"],
"tools": ["常用 kubectl 和 psql"],
"skills": ["会用 EXPLAIN ANALYZE 优化查询"]
}
每个分类映射到对应的 MemoryKind:profile/entities/events/tools → fact,preferences → preference,cases/patterns/skills → skill。
第二步:向量去重
每条候选记忆先调 Embedder 生成向量,然后在该用户的 namespace 里查相似记忆。如果 cosine 相似度 ≥ 0.85,说明已有差不多的记忆了,跳过------防止重复积累。
go
// application/command/extract_session.go
const dupSimilarityThreshold = float32(0.85)
func (h *ExtractSessionHandler) saveIfUnique(ctx context.Context, m *model.Memory) error {
vecs, _ := h.emb.Embed(ctx, []string{m.Content()})
similar, _ := h.repo.SearchSimilar(ctx, m.TenantID(), scopes, vecs[0], 3, dupSimilarityThreshold)
if len(similar) > 0 {
return nil // 已有足够相似的记忆 → 跳过
}
return h.repo.Save(ctx, m)
}
你可以把去重想象成"笔记查重":记新笔记前先翻翻旧笔记,如果已经记过差不多的事情(相似度 ≥ 85%),就不重复记了。
Recall:怎么把记忆注入给 Agent
Agent 每次对话时,通过 Recall 查询把相关记忆注入 system prompt:
go
// application/query/recall.go
func (h *RecallHandler) Handle(ctx context.Context, in RecallInput) (*RecallResult, error) {
// 1. Profile 总是拿(不需要检索)
prof, _ := h.profile.Load(ctx, in.TenantID, in.UserID)
// 2. 多命名空间并集检索
scopes := []domain.NSQuery{
{Namespace: []string{"user", in.UserID}}, // 用户全局
}
if in.ProjectID != "" {
scopes = append(scopes, domain.NSQuery{
Namespace: []string{"user", in.UserID, "project", in.ProjectID}, // 项目级
})
}
if in.AgentConfig != "" {
scopes = append(scopes, domain.NSQuery{
Namespace: []string{"agent", in.AgentConfig}, // Agent 共享知识
})
}
if in.SessionID != "" {
scopes = append(scopes, domain.NSQuery{
Namespace: []string{"session", in.SessionID}, // 会话临时记忆
})
}
// 3. 把用户问题 embed 成向量,在各 scope 里做语义检索
vecs, _ := h.emb.Embed(ctx, []string{in.Query})
mems, _ := h.mems.Recall(ctx, in.TenantID, scopes, vecs[0], in.TopK)
// 4. 命中的记忆异步 Touch + 落库(fire-and-forget,不阻塞响应)
go func() {
for _, m := range mems {
m.Touch() // hitCount + 1
_ = h.mems.Save(ctx, m) // 持久化热度 → 下次淘汰更不易被清
}
}()
return &RecallResult{Memories: mems, Profile: prof}, nil
}
检索同时从多个命名空间查(用户 + 项目 + Agent + 会话),取并集。命中的记忆在后台 goroutine 里异步 Touch(hitCount + 1)并落库------不阻塞本次响应;hitCount 写回 memories.hit_count 列后,淘汰排序 importance * (hit_count + 1) / age 会让常用记忆分数更高,不容易被淘汰------形成正反馈循环。
小结
记忆系统的关键设计:
- 层级命名空间 :用元组
["user",uid]/["user",uid,"project",pid]/["agent",cfg]/["session",sid]分区,天然层级可扩展 - 四种记忆类型:fact / preference / skill / summary,不同类型存不同 namespace
- 双套衰减:Cron 每天 0.95 打折 + EvictByPolicy 按 score 淘汰尾部,pinned 永不淘汰
- Profile + Memory 互补:结构化键值对(总是注入)+ 自然语言短句(语义检索 topK 注入)
- 提炼带去重:LLM 8 分类提取 + 向量相似度 ≥ 0.85 跳过,防止重复积累
- 正反馈循环:命中的记忆 hitCount 增长 → 衰减分变高 → 更不容易被淘汰
下一篇:多租户隔离 ------ 一条 RLS 策略怎么防数据串