遗忘机制的工程化:让 Agent 学会"忘记"
上一篇我们用 LLM 把零散的对话提炼成了结构化的长期记忆。整个系统现在能存、能读、能合并、能重排。
但有个问题我一直压着没说——记忆只进不出。
一个正在膨胀的列表
我在本地跑了一个小实验。让 Agent 和模拟用户对话 200 轮,每轮都触发 consolidate。一周后,InMemoryLongTermMemory 里攒了 1200 条记忆。
1200 条什么概念?
检索一条 query,要遍历 1200 个 Map entry,算 1200 次 Jaccard 相似度。每次 0.3ms,单次检索 360ms。如果 Agent 一次对话跑 10 轮 Tool Calling,每轮都注入记忆,光检索就花了 3.6 秒。
更关键的是质量。1200 条记忆里,大量是"好的""嗯嗯""明白了"这种被 SimpleConsolidationStrategy 误提取的废话。真正的核心信息——用户的技术栈、偏好、正在做的项目——淹没在噪声里。
这不是"记忆不够好"的问题,是记忆太多的问题。
人脑也有同样的问题。艾宾浩斯遗忘曲线说的不是"记忆力差",而是"遗忘是记忆系统正常运作的一部分"。没有遗忘的大脑会像没有删除键的文件系统—— eventually Everything becomes important, which means nothing is.
遗忘不是删除,是"清理"
很多人对遗忘的第一反应是:这不就是删数据吗?低重要性直接删掉不就完了。
没那么简单。
删什么、留什么、什么时候删,这三个问题比"存什么"更难。
"存什么"的判定标准相对明确:LLM 提取,置信度过滤,够了。但"删什么"涉及到时间维度的价值衰减——一条上周的重要决策,和一条三天前的天气吐槽,在存储时重要性可能差不多(都是 0.8),但现在的价值完全不同。
所以我需要一个机制,让遗忘本身也有"策略"。
设计决策一:遗忘的触发时机
有三个时机可以选。
选项 A:每次 consolidate 时顺便遗忘。
好处是简单,一次调用搞定存和清。坏处是 consolidate 的延迟会叠加。用户说一句话,Agent 要等 LLM 提取 + 合并 + 遗忘全部跑完才继续。一次对话多等好几秒。
选项 B:定期异步遗忘。
比如每天凌晨跑一次。好处是不影响主流程。坏处是记忆可能在一天内膨胀到影响检索质量。
选项 C:按条件触发。
比如记忆数量超过阈值、或者距离上次遗忘超过 N 小时,才触发。平衡了及时性和性能。
我选了选项 C,封装成一个 ForgetExecutor。外部调用方(DefaultMemorySystem)决定什么时候跑,Executor 只负责"怎么删"。
这个设计的好处是 Executor 不持有时间逻辑。它接收一个 ForgettingContext,里面包含当前时间和容量信息,策略根据这些信息计算分数。Executor 本身是无状态的,可以随时调用、随时重试。
设计决策二:遗忘的粒度
是删单条记忆,还是批量清理?
单条删除更精细,但 1200 条记忆每条都算一遍分数,再决定删不删,开销不小。批量清理快,但可能误伤。
我的方案是先算分,后批量删:
- 策略为所有记忆计算可遗忘分数
- 按分数排序,取超过阈值的前 N 条
- 一次性删除
这里有个关键设计:分数和删除是两个阶段。分数计算是策略的职责,删除决策是 Executor 的职责。策略只回答"这条记忆该不该忘",Executor 回答"现在删哪些"。
这样策略可以纯函数化——输入是记忆列表和上下文,输出是分数列表。不涉及任何副作用,测试极其干净。
设计决策三:怎么衡量"该不该忘"
这是整个机制的核心。
我定义了三种内置策略,从不同维度评估:
重要性遗忘
最直观的。score = 1 - importance。
importance 是 LLM 在提取时打的分数(0-1)。低重要性 = 高可遗忘分数。
但有个陷阱:SimpleConsolidationStrategy 给所有提取的事实打 0.3 的基础分。如果阈值设在 0.3,那就全删了。所以重要性遗忘更适合配合 LLM 驱动的合并使用——LLM 打的分数分布更合理。
时效遗忘
记忆越老,越该忘。
用指数衰减的反向计算:
age = now - timestamp
recency = exp(-age / (halfLife / ln(2)))
score = 1 - recency
半衰期的概念沿用第二篇。7 天半衰期意味着:7 天前的记忆,新鲜度衰减到 0.5,score = 0.5。
超过 maxAge 的条目直接 score = 1,必忘。这个安全阀防止某些永远不会触发阈值的老条目永远占着位置。
容量遗忘
记忆数量超过上限时,保留最重要的,遗忘最不重要的。
按重要性排序,超出 capacity 的部分按排名线性增长 score。排名越靠后,score 越高。
这类似于数据库的"容量驱逐",但用重要性代替 LRU 的访问时间。对于 Agent 记忆,重要性比"最近访问过"更能反映保留价值。
复合遗忘
三种策略单独用都有盲区:
- 只看重要性:一条很久前的低重要性记忆可能永远不触发阈值
- 只看时效:一条刚刚存的重要记忆可能因为时效维度分数低而幸存,但一条三天前的低重要性记忆也可能因为时效分数高而幸存
- 只看容量:不区分"为什么超出",只是机械地按排名删
复合策略把三个维度加权叠加:
finalScore = 0.5 * importanceScore + 0.3 * ageScore + 0.2 * capacityScore
权重怎么定?重要性占一半——它是最直接的"该不该留"信号。时效占 30%——时间价值是次要但不可忽略的。容量占 20%——只在溢出时才起作用,是最后的兜底。
这些是经验值,不是理论最优。实际使用时可以根据场景微调。
权重可以按场景调整。如果是高频对话的 Agent,时效权重可以调高;如果是专业领域助手,重要性权重应该更高。
实现:ForgetExecutor
执行器本身很薄,核心逻辑是:
async execute(memory): Promise<ForgetResult> {
const items = memory.getAllItems();
const scores = await this.strategy.score(items, context);
const toDelete = scores
.filter(s => s.score >= threshold)
.sort((a, b) => b.score - a.score) // 分数高的先删
.slice(0, maxDeletionsPerRun);
const toDeleteIds = new Set(toDelete.map(item => item.itemId));
// 执行删除
let deletedCount = 0;
const details = scores.map(s => ({
itemId: s.itemId,
score: s.score,
deleted: toDeleteIds.has(s.itemId) && !this.config.dryRun,
reason: s.reason,
}));
if (!this.config.dryRun) {
for (const item of toDelete) {
await memory.delete(item.itemId);
deletedCount++;
}
}
return {
deletedCount: this.config.dryRun ? 0 : deletedCount,
keptCount: items.length - (this.config.dryRun ? 0 : deletedCount),
details,
};
}
有几个值得注意的细节:
安全阀 maxDeletionsPerRun:防止策略误判时一次性删太多。默认 50 条。如果系统真的需要大规模清理,分多次执行即可。
Dry-run 模式:dryRun: true 时只算分数不删除,返回完整的评估报告。用于上线前验证策略行为,或者监控系统定期"体检"。
详细日志:每条记忆的分数和删除原因都被记录下来。上线后可以分析"为什么这条被删了",调参时有据可依。
集成到 DefaultMemorySystem
DefaultMemorySystem 新增了一个 forget() 方法:
async forget(strategy?: ForgettingStrategy): Promise<number> {
const executor = new ForgetExecutor({
strategy: strategy ?? defaultComposite,
scoreThreshold: 0.6,
maxDeletionsPerRun: 50,
});
const result = await executor.execute(this.longTerm as any);
return result.deletedCount;
}
调用方可以在任何时候手动触发遗忘。也可以在 consolidate() 末尾自动调用一次——但要注意 async 化:
// 在 consolidate() 末尾添加
if (this.shouldForget()) {
this.forget().catch(err =>
console.warn('Auto-forget failed:', err)
);
}
为什么是 catch 而不是 await?因为遗忘不是主流程。consolidate 的核心职责是把短期记忆提炼到长期,遗忘是"顺便清一下垃圾"。如果遗忘失败(比如策略计算出错),不应该影响 consolidate 的完成。
这里的设计哲学和第三篇的"design for failure"一脉相承:任何非核心步骤的失败,都不能阻塞主链路。
底层原理:为什么是指数衰减
遗忘曲线用指数衰减,不是因为我随便选的。
人脑的遗忘曲线(Ebbinghaus, 1885)本质上就是指数型的:遗忘速度与当前记忆强度成正比。刚学完忘得快,越久远忘得越慢。
数学表达就是:
dM/dt = -k * M(t)
解出来:
M(t) = M0 * exp(-k * t)
这就是指数衰减。半衰期 T = ln(2) / k。
用半衰期做参数的好处是可解释性。"7 天半衰期"比"λ = 0.099"好懂一百倍。调参时不需要理解公式,只需要回答一个问题:"我希望一周前的记忆重要性变成多少?"
一个完整的运行示例
假设 Agent 运行了两周,长期记忆里有 300 条。触发复合遗忘:
| 记忆 | 重要性 | 年龄 | importanceScore | ageScore | finalScore | 结果 |
|---|---|---|---|---|---|---|
| "用户用 TypeScript" | 0.9 | 2天 | 0.1 | 0.18 | 0.10 | 保留 |
| "用户喜欢吃辣" | 0.8 | 10天 | 0.2 | 0.63 | 0.29 | 保留 |
| "今天天气不错" | 0.3 | 1天 | 0.7 | 0.09 | 0.38 | 保留 |
| "用户说'嗯嗯'" | 0.2 | 25天 | 0.8 | 0.92 | 0.68 | 删除 |
| "用户确认收到" | 0.25 | 60天 | 0.75 | 1.00 | 0.65 | 删除 |
阈值 0.6 下,后两条被删。第一条 TypeScript 虽然年龄小,但重要性高,score 只有 0.10,稳稳保留。
第三条"天气不错"虽然重要性低(0.3),但年龄只有 1 天,时效分数把它拉回 0.38,低于阈值,幸存。再过一周,它的 ageScore 会涨到 0.5 左右,最终可能超过 0.6 被清理。
测试策略
遗忘策略的测试重点不是"删了什么",而是分数的可解释性和稳定性。
// 验证:超龄记忆 score = 1(maxAge=0 表示任何正年龄都算超龄)
const strategy = new AgeBasedForgetting(7 * 24 * 60 * 60 * 1000, 0);
const now = Date.now();
const items = [{
id: 'old',
content: '很久前的记忆',
metadata: { timestamp: now - 100, type: 'fact' },
}];
const scores = await strategy.score(items, { now, capacity: 0, currentSize: 1 });
expect(scores[0].score).toBeCloseTo(1.0);
// 验证:容量溢出时分数线性增长
const capStrategy = new CapacityForgetting(2);
const manyItems = Array.from({ length: 5 }, (_, i) => ({
id: `mem_${i}`,
content: `记忆 ${i}`,
metadata: { timestamp: now, type: 'fact', importance: 1 - i * 0.1 },
}));
const capScores = await capStrategy.score(manyItems, { now, capacity: 2, currentSize: 5 });
expect(capScores[4].score).toBeCloseTo(1.0); // 最弱的必忘
expect(capScores[2].score).toBeCloseTo(0.0); // 刚好在容量边界
工程化的几个细节
1. 异步但不阻塞
forget() 是 async 的,但调用方可以选择 await 或 .catch()。如果集成到 consolidate() 末尾,用 .catch() 确保遗忘失败不影响主流程。
2. 可插拔策略
ForgettingStrategy 是一个接口。内置了三种策略,但用户可以自己实现。比如基于访问频率的遗忘(多久没被检索到)、基于 LLM 评级的遗忘(让 LLM 判断这条记忆还有没有价值)。
3. 向量索引的同步清理
InMemoryLongTermMemory.delete() 已经处理了 vectorIndex?.delete(id)。遗忘执行器不需要关心存储细节,只要调 memory.delete(id) 就行。
4. 遗忘不触发合并
被删除的记忆不会进入合并流程。这是理所当然的,但值得明确——DefaultMemorySystem.forget() 和 consolidate() 是两个独立的操作,顺序执行,互不干扰。
这一篇结束,系列收尾
五篇下来,记忆系统从"能存"进化成了"会管理":
存入 ← 提取(规则 / LLM)
↓
长期记忆
↓
检索 ← 关键词 / 语义 / 混合
↓
重排 ← 时效加权 + MMR
↓
注入 Agent 上下文
↓
遗忘 ← 重要性 + 时效 + 容量
每一层都是独立可替换的模块。你可以只用关键词检索,也可以加上 Embedding 和 Reranker;可以只用规则提取,也可以换成 LLM 驱动;可以完全不遗忘,也可以叠加三种遗忘策略。
这个系统的核心设计哲学:每个组件只做一件事,做好一件事,通过接口和组合应对变化。
代码在 src/memory/forgetting.ts。下次见。
本系列完。
评论
发表评论