Agent 长期记忆治理:四个参数背后的真实权衡
🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,leetcode
✨那些你一个人走过的夜路,终将化作照亮未来的光



上一篇讲"怎么写进去",这一篇讲"怎么不让它烂掉"。
四个参数:
0.80 / 0.95 / 0.995 / 30+0.3。所有数值和算法均来自代码,文中会纠正两个流传很广但错误的说法。
0. 一句话结论
记忆治理不是四道独立的开关,而是一条有严格顺序的流水线:
衰减(让时间生效)
↓
去重 / 合并(收敛重复)
↓
淘汰(清理垃圾)
↓
保护(撤销误删)
顺序不能换——先衰减再淘汰,否则淘汰判定用的是"去年的重要性"。
1. 记忆会烂掉的三种方式
| 病症 | 表现 | 后果 |
|---|---|---|
| 重复 | "我叫张三"说了五遍,存了五条 | 召回时同一事实挤占多个 TopK 名额 |
| 碎片 | "喜欢美式" + "每天早上一杯美式" | 单条信息量低,任何一条都不足以支撑回答 |
| 变旧 | 三个月前说"想学吉他",早就放弃了 | 模型拿着过时信息做判断 |
四个参数正好对应:
-
0.95→ 治重复 -
0.80→ 治碎片 -
0.995→ 治变旧 -
30 + 0.3→ 决定哪些该彻底扔掉
2. 参数总表
SimilarityThreshold: 0.80 // 合并阈值
DedupThreshold: 0.95 // 去重阈值
TTLDays: 30 // 过期天数
DecayRate: 0.995 // 每日衰减系数
MinImportance: 0.3 // 淘汰最低重要度
TriggerInterval: 5 // 每写入 N 条触发一次合并
注意最后那个 TriggerInterval: 5——治理不是定时的,是攒够 5 条新记忆才跑一次。这是为了把治理成本摊薄,避免每写一条就全库扫一遍。
3. 0.95 去重:不是"覆盖",是"吸收"
3.1 先纠正一个说法
常见说法是"0.95 以上直接去重,物理丢弃新来的,或者覆盖旧的"。
都不对。
真实行为是:新条目不插入,旧条目被"吸收"了新条目的元数据。
if sim >= m.consolidationCfg.DedupThreshold {
if importance > m.Items[i].Importance {
m.Items[i].Importance = importance // 只升不降
}
m.Items[i].LastAccessed = time.Now() // 刷新访问时间
if category != "" && (m.Items[i].Category == "" || m.Items[i].Category == "general") {
m.Items[i].Category = category // 分类更具体则覆盖
}
if slotHint != "" && m.Items[i].SlotHint == "" {
m.Items[i].SlotHint = slotHint // 槽位提示只补空
}
if len(tags) > 0 {
m.Items[i].Tags = mergeTags(m.Items[i].Tags, tags) // 标签求并集
}
return false // 不插入新条目
}
3.2 为什么这样设计
① importance 取 max,不取新值
因为重复陈述不应该降低原有条目的价值。用户又说了一遍"我对花生过敏",不会让这条变得"更不重要"。
② 分类"更具体则覆盖",general 视为最弱
if category != "" && (m.Items[i].Category == "" || m.Items[i].Category == "general") {
先存进来的可能是 general(兜底分类),后来抽到明确的 identity,就应该升级。反过来,已经标成 identity 的不会被后到的 general 降级。
这是"信息密度只增不减"的原则。
③ 标签求并集,槽位提示只补空
同一件事可能带不同标签——先说是"偏好",后说是"饮食禁忌",并集之后召回时两条路都能命中。
④ content 保持不变
这是最容易忽略的一点:命中 0.95 时不合并文本,也不重算 embedding。
代价是:如果新表述更清晰,旧表述更模糊,你保留的仍然是旧的。这是"低成本优先"的取舍——重算 embedding 要调 API,高频去重时成本不可接受。
4. 0.80 ~ 0.95 合并:不是 LLM 语义合并
4.1 第二个需要纠正的说法
很多讲解会说:"0.80~0.95 的碎片,调 LLM 合并成一句更丰满的话。"
代码里没有任何 LLM 调用。 合并是纯规则拼接:
func mergeItemsPure(a, b Item, now time.Time) Item {
base, other := a, b
if b.Importance > a.Importance {
base, other = b, a // importance 高的当主干
}
merged := base
merged.Importance = math.Max(base.Importance, other.Importance)
merged.LastAccessed = now
// 文本合并:互不包含就拼接,一方包含另一方就取长的
if !strings.Contains(base.Content, other.Content) &&
!strings.Contains(other.Content, base.Content) {
merged.Content = base.Content + ";" + other.Content
} else if len(other.Content) > len(base.Content) {
merged.Content = other.Content
}
// embedding 按 importance 加权平均
total := base.Importance + other.Importance
for i := range base.Embedding {
merged.Embedding[i] = (base.Embedding[i]*base.Importance +
other.Embedding[i]*other.Importance) / total
}
merged.Tags = mergeTags(base.Tags, other.Tags)
merged.Supersedes = appendUniqueInt(merged.Supersedes, other.ID)
return merged
}
4.2 为什么不用 LLM 合并
三个理由,一个比一个实际:
① 成本
合并在热路径上(TriggerInterval: 5,每 5 条触发一次)。一次全库两两比对可能产生几十次合并。每次合并调一次 LLM,写入延迟和费用都会失控。
② 可回溯
规则拼接保留了原文:
用户偏爱美式;用户习惯每天早上喝一杯美式
两条原文都还在,Supersedes 里还登记了被合并条目的 ID。出问题能查清"这句话是怎么来的"。
LLM 合并会把原文改写成新句子——一旦改写错了,原始信息就永久丢失了。
③ 确定性
同样的两条记忆,规则合并永远产出同样的结果。LLM 合并每次可能不一样,无法做回归测试。
4.3 拼接的边界处理
strings.Contains 那个分支值得单独说:
| 情况 | 处理 | 理由 |
|---|---|---|
| 互不包含 |
| 信息互补,都保留 |
| A 包含 B | 取 A | B 是 A 的子串,拼接会重复 |
| 长度相同且互相包含 | 取 base | 等价内容,任取一个 |
这比"无脑拼接"多做了一步去冗余。 如果两条记忆只是详略差异,取长的即可,不必拼成一句啰嗦的话。
4.4 embedding 加权平均
merged.Embedding[i] = (a[i]*a.Importance + b[i]*b.Importance) / (a.Importance + b.Importance)
为什么按 importance 加权,不直接取平均?
因为 importance 高的那条通常是主干信息。直接平均会让退化的向量偏向"信息量低的那条",导致合并后的条目在召回时反而更难命中。
加权平均保证:重要的那条决定了合并后向量的大方向。
5. 0.995 衰减:时间是怎么"腐蚀"记忆的
5.1 公式
items[i].Importance = oldImportance * math.Pow(cfg.DecayRate, days)
即:
importance(t) = importance(0) × 0.995^天
5.2 三个关键时间点
| 经过天数 | 0.995^n | 从 1.0 衰减到 |
|---|---|---|
| 30 天 | 0.860 | 0.86 |
| 100 天 | 0.606 | 0.61 |
| 240 天 | ≈0.300 | 0.30 |
| 365 天 | 0.161 | 0.16 |
一条初始 importance = 1.0 的记忆,要 240 天才会衰减到 0.3 的淘汰线。
不同来源的记忆到达淘汰线的时间:
| 来源 | 初始 importance | 降到 0.3 需要 |
|---|---|---|
| 用户主动陈述 | 0.7 | 约 71 天 |
| 问答对派生 | 0.5 | 约 138 天 |
| 高价值事实 | 1.0 | 约 240 天 |
这个结论很重要:用户亲口说的事实,大约两个多月后就会进入淘汰候选区。
5.3 一个容易被忽略的写库优化
const minDecayDelta = 0.01
if oldImportance - items[i].Importance >= minDecayDelta {
updates[items[i].ID] = ...
}
衰减变化量不到 0.01 就不写库。
为什么?因为数值很小的时候(比如 0.31 → 0.3085),变化幅度 0.0015,写进数据库毫无意义,但会产生一次 UPDATE。
这是典型的"精度换 IO":肉眼不可见的精度损失,换来数量级的写入减少。
5.4 这里有个真实的设计缺陷
衰减和淘汰用的都是 CreatedAt:
days := now.Sub(items[i].CreatedAt).Hours() / 24
而 LastAccessed 虽然被更新了(去重命中时会刷新),却不参与衰减和淘汰计算。
后果:
-
一条用户每周都提到、每次都被召回的偏好记忆,
LastAccessed很新,但CreatedAt很旧 → 照样衰减,照样被淘汰 -
更糟的是:去重命中时 importance 只在"新值更高"时才提升,而重复陈述的 importance 通常是同一个常数(用户陈述恒为 0.7),永远不会累加
所以:反复提及并不会让记忆更长寿。
这和你稿子里"没被用户反复提及,重要度掉到 0.3 以下"的说法正好相反——"反复提及"在这个实现里是无效的。
正确做法应该是:
refTime := max(items[i].CreatedAt, items[i].LastAccessed)
或者引入"提及次数"参与 importance 计算。这是一个明确可改进项。
6. 30 天 + 0.3:为什么必须是联合条件
6.1 两个反例
只看时间会怎样?
用户三年前说"我对花生过敏",importance 也衰减到 0.29 了——但它仍然是对的。按时间删,这条安全信息就没了。
只看重要度会怎样?
用户昨天随口说"今天天气不错",importance 本来就只有 0.1。按重要度删,昨天的临时信息立刻消失——但它可能还在当前语境里有用。
6.2 联合判定的真实含义
if cfg.TTLDays > 0 &&
days > float64(cfg.TTLDays) &&
items[i].Importance < cfg.MinImportance {
removed[i] = true
reasons[i] = "expired"
}
两个条件必须同时成立:
够旧(> 30 天) AND 够低(< 0.3)
这保证了:
-
重要的事实(importance 衰减得慢、或初始值高)→ 永远不会被时间淘汰
-
新的记忆(哪怕 importance 很低)→ 至少能活 30 天
6.3 但"过敏史永不被删"这个承诺要打折扣
很多讲解会说"这样过敏史永远不会被删"。
在这个实现里不成立。
因为 importance 会持续衰减,240 天后 1.0 也会掉到 0.3 以下,第 241 天就会被淘汰——除非它被某个机制持续"续命"。
真实续命途径只有两个:
-
图中心度保护(见下一节)
-
有新的写入命中它,把 importance 提升(但只有"新值更高"时才提升)
所以这套系统的记忆是有"自然寿命上限"的(约 240 天 / importance 1.0)。 这是设计选择,不是 bug,但讲的时候不能说成"永不过期"。
7. 保护机制:为什么淘汰后还能"复活"
if cfg.ProtectFn != nil {
candidates := 收集所有待删除的 ID
protected := cfg.ProtectFn(candidates)
for _, id := range protected {
removed[id] = false
delete(reasons, id)
}
}
这是淘汰之后的最后一道闸:先按规则算出"该删谁",再让保护函数从候选里捞人。
真实实现保护的是图记忆里入度 Top3 的节点——被别的记忆指向最多的条目,通常是"用户本人""核心偏好"这类中心事实。
而且这个判定和物理删除在同一个临界区里,避免"判定完保护、删之前被另一个写入改了入度"的竞态。
设计上的价值:规则负责"差不多该删了",保护负责"但这些不能删"。
8. 冲突 → superseded:软删的真实现状
8.1 触发条件比想象中窄
var conflictDetectableCategories = map[string]bool{
"identity": true, "preference": true, "fact": true,
}
const ( conflictMinSim = 0.75; conflictMaxSim = 0.95 )
只有三类事实参与冲突检测,且相似度必须落在 [0.75, 0.95):
-
低于 0.75:根本不是同一条记忆,谈不上矛盾
-
高于 0.95:已经被去重吸收,不会走到冲突判定
候选最多取 3 条(成本控制),交给 LLM 判定是否矛盾,默认判定"不矛盾"。
保守是刻意的:宁可漏判,也不误伤。 误判一条重要记忆为"已被取代",比漏判一条旧信息的代价大得多。
8.2 软删做了什么
func (m *LongTerm) MarkSuperseded(oldIDs []int, newID int) []int {
for i := range m.Items {
if oldSet[m.Items[i].ID] && !m.Items[i].Superseded {
m.Items[i].Superseded = true
m.Items[i].SupersededAt = now
marked = append(marked, m.Items[i].ID)
}
}
if newID > 0 && len(marked) > 0 {
// 在新条目上登记 supersedes 链
m.Items[i].Supersedes = appendUniqueInt(..., marked...)
}
}
两个动作:
-
旧条目
Superseded = true+ 记录SupersededAt -
新条目登记
Supersedes(替代链)
召回时默认过滤 superseded = true,所以旧公司不会再被当成现公司。
8.3 "可以瞬间改回来"是夸大
你稿子里说:
"万一用户明天说'我其实没跳槽,那是开玩笑的',系统可以瞬间把 superseded 改回 active,实现完美回溯。"
代码里没有这个能力。
全库搜索 Restore / Unsupersede / Reinstate,只在 RAG chunk 恢复里命中,没有任何"取消 superseded"的接口。
真实情况是:
-
数据还在库里(软删不物理删除)
-
有
SupersededAt和Supersedes链,可审计 -
但没有产品化的恢复路径,只能人工改库
"可回溯"指的是数据留存 + 审计链,不是"一键恢复"。 面试时必须这么讲,否则一追问"你怎么恢复"就断了。
9. 四个参数怎么联动:完整执行顺序
Consolidate() 被触发(每写入 5 条)
│
├─ Phase 1:衰减
│ 遍历全部条目,importance *= 0.995^days(CreatedAt)
│ 变化 ≥ 0.01 才进 updates(省 IO)
│
├─ Phase 2:两两比对(同用户内)
│ 跨用户直接 skip(多租户隔离)
│ sim ≥ 0.95 → 保留 importance 高的,吸收元数据,删另一条
│ 0.80 ≤ sim < 0.95 → 规则合并,删另一条
│
├─ Phase 3:淘汰
│ days > 30 AND importance < 0.3 → 标记 expired
│
├─ Phase 4:保护
│ ProtectFn(图中心度 Top3)→ 撤销删除
│
└─ 产出 ConsolidationPlan
updates(衰减 + 合并修改)
deletes(去重 / 合并 / 淘汰)
由 memorytx 在同一事务里落库
顺序为什么不能变:
-
衰减必须在淘汰之前 → 否则淘汰用的是未衰减的旧值,永远达不到阈值
-
淘汰必须在保护之前 → 否则保护函数没有"待删候选"可捞
-
合并必须在淘汰之前 → 否则先删了碎片,合并就没对象了
这是一条有数据依赖的流水线,不是四个并行开关。
10. 调参时的现实建议
| 参数 | 想调大 | 想调小 | 注意 |
|---|---|---|---|
|
| 更保守,宁可留重复 | 更激进,容易误删相似事实 | 换 embedding 模型后必须重调 |
|
| 合并更少,碎片更多 | 合并更激进,可能把不同事实拼一起 | 中文短句的余弦分布比英文更集中 |
|
| 记忆更持久,但旧信息更难淘汰 | 淘汰快,长期事实保不住 | 0.99 的话 60 天就到 0.3 |
|
| 垃圾留得久 | 新记忆活不长 | 与 importance 联动 |
|
| 淘汰更保守,库会膨胀 | 淘汰激进,容易误删 | 需配合图保护 |
最容易出问题的是 0.80 和 0.95 的间距。
0.80~0.95 只有 0.15 的窗口。如果 embedding 分布集中(中文短句常见),大量"语义相关但不同"的事实会落进这个区间被合并,产生语义漂移。
这两个阈值必须用真实数据校准,不能拍脑袋。
11. 这套设计的短板(如实说)
| # | 问题 | 影响 |
|---|---|---|
| 1 | 衰减/淘汰用 | 高频使用的记忆也会"自然死亡",反复提及无效 |
| 2 | importance 不会累加 | 用户说十遍"我对花生过敏"和说一遍,寿命完全一样 |
| 3 | 合并不重算 embedding,去重不改 content | 合并后向量可能不够准;旧表述占位 |
| 4 | 无 superseded 恢复接口 | "可回溯"仅限数据留存,不是可操作能力 |
| 5 | 两两比对是 O(n²) | 单用户记忆过千后,一次 Consolidate 会显著变慢 |
| 6 | 每 5 条触发一次全量扫描 | 写入路径上带了一次性延迟,量级随库增长 |
| 7 | 无治理效果评测 | 合并/淘汰后召回质量如何变化,无法量化 |
第 1 条和第 2 条是同一个根因:系统只用时间衡量价值,没有用"使用频率"衡量价值。
12. 面试口径(可直接背)
"记忆治理我设计成一条有顺序的四阶段流水线:衰减、去重合并、淘汰、保护。
先说两个阈值:0.95 以上我判定为重复,但不是简单丢弃新的——我保留旧条目,把新条目的 importance、分类、标签、槽位提示吸收进去,importance 只升不降,分类只从 general 往具体升,这叫信息密度只增不减。0.80 到 0.95 之间做合并,这里是纯规则合并,不调 LLM:importance 高的当主干,互不包含就拼接,一方包含另一方就取长的,embedding 按 importance 加权平均,同时在新条目上登记 supersedes 链。不用 LLM 的理由是成本、可回溯和确定性。
衰减是每天 0.995,但有个写库优化:变化不到 0.01 就不落库,用精度换 IO。
淘汰是 30 天和 importance 0.3 的联合条件,必须同时满足。这能保证重要的东西活得久、新的东西至少活 30 天。但我要补一句实话:它不是永不过期,importance 1.0 大约 240 天也会衰到 0.3,所以是有自然寿命上限的。
淘汰之后还有一道保护:图记忆里入度 Top3 的节点会被撤销删除,而且判定和删除在同一临界区,避免竞态。
冲突处理是软删。相似度落在 0.75 到 0.95、且只有 identity/preference/fact 三类才送 LLM 判定,候选最多 3 条,默认判不矛盾,宁可漏判不误伤。命中的旧条目标 superseded,数据留着、有 supersedes 链可以审计,但我没有做一键恢复接口,这块是缺口。
最后说个我自己发现的缺陷:衰减用的是 CreatedAt,不是 LastAccessed,所以用户反复提到的记忆也会按创建时间衰减,重复提及并不会延长寿命。这个应该改成取两者较新的那个。"
——最后这段主动暴露缺陷,是整段回答里最值钱的部分。
13. 结语
四个参数看似简单,真正的工程设计藏在这几个判断里:
-
去重不是删除,是吸收 —— 信息的密度只增不减
-
合并不用 LLM,用规则 —— 确定性比智能更重要,尤其在热路径
-
淘汰必须联合判定 —— 单一维度一定会误伤
-
算完"该删谁"之后还要能"捞回来" —— 保护机制是规则系统的安全阀
而最能体现工程成熟度的,不是这四个参数本身,而是知道它们的边界在哪:
-
衰减只用时间 → 高频记忆会冤死
-
importance 不累加 → 反复陈述没有回报
-
软删无恢复接口 → "可回溯"要打引号
能把"我做了什么"和"我没做到什么"讲得一样清楚,才是这套治理真正被吃透的标志。
更多推荐

所有评论(0)