遗忘机制的工程化:让 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 条记忆每条都算一遍分数,再决定删不删,开销不小。批量清理快,但可能误伤。

我的方案是先算分,后批量删

  1. 策略为所有记忆计算可遗忘分数
  2. 按分数排序,取超过阈值的前 N 条
  3. 一次性删除

这里有个关键设计:分数和删除是两个阶段。分数计算是策略的职责,删除决策是 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。下次见。


本系列完。

评论

此博客中的热门博文

我写了半年 prompt,这是我发现的最好的技巧