记忆的生命周期:演化、巩固、遗忘
本章讲什么: 记忆不是只进不出的日志。agentmemory 给记忆装了「演化」——新版本取代旧版本、零散观察巩固成知识、不用的记忆衰减消失。这章讲三件事:取代、巩固、遗忘。
1. 两种记忆先分清
项目里有两种东西容易混,先分清:
| 观察(Observation) | 记忆(Memory) | |
|---|---|---|
| 来源 | hook 自动捕获工具调用 | agent 主动调 memory_save |
| 类型 | CompressedObservation | Memory |
| 存哪 | KV.observations(sessionId)(按会话分 scope) | KV.memories(全局一个 scope) |
| 粒度 | 一次工具调用 | 一条提炼出的决定/模式/偏好 |
| 定义 | types.ts:46-64 | types.ts:83-105 |
本章主角是记忆——它有版本、有取代、有衰减;观察相对更「流水账」。
2. 取代:新记忆怎么替掉旧记忆
要解决的小问题: agent 上周存了「用 yarn 装依赖」,这周改主意存「用 pnpm」。不能两条都留着自相矛盾——新的该取代旧的。
思路: 存新记忆时,扫一遍现有记忆,如果内容足够相似(Jaccard > 0.7)就判 定为「同一件事的新版本」,把旧的标记为非最新、新的版本号 +1。
这段逻辑在 mem::remember(src/functions/remember.ts:14,符号 registerRememberFunction),包在 withKeyedLock("mem:remember") 里串行执行。核心比较(remember.ts:77-87):
// remember.ts:77 —— Jaccard 相似度判定取代(真实源码)
const similarity = jaccardSimilarity(lowerContent, existing.content.toLowerCase());
if (similarity > 0.7) {
supersededId = existing.id;
supersededVersion = existing.version ?? 1;
supersededMemory = existing;
break;
}
jaccardSimilarity(src/state/schema.ts:94)很朴素:把两段文本切成 >2 字符的词集合,算交集/并集。
命中取代后(remember.ts:98-127):
- 新
Memory的version = 旧版本 + 1,parentId = 旧id,supersedes = [旧id]。 - 旧记忆
isLatest = false写回(不删!保留历史)。 - 触发
mem::cascade-update(异步 void),让引用旧记忆的关系跟着更新。
两个保护边界:
- 项目隔离(
remember.ts:74):两条记忆都有明确project且不同时,绝不跨项目取代。无project的旧记忆当通配,不让历史数据被孤立。 isLatest === false的记忆跳过比较(remember.ts:69):只和「当前最新」比,不和已被取代的历史比。
搜索时也只索引最新版本——rebuildIndex 里 if (memory.isLatest === false) continue(src/functions/search.ts:260)。
3. 巩固:4 层记忆
要解决的小问题: 一堆零散观察和会话摘要,怎么沉淀成「我知道的事」和「我会做的事」?
agentmemory 借用人脑记忆巩固的比喻(README 说「不亚于睡眠巩固」),分 4 层(README.md:886-891):
| 层 | 是什么 | 类比 |
|---|---|---|
| 工作记忆(Working) | 工具调用的原始观察 | 短期记忆 |
| 情景记忆(Episodic) | 压缩后的会话摘要 | 「发生了什么」 |
| 语义记忆(Semantic) | 抽取的事实和模式 | 「我知道什么」 |
| 程序记忆(Procedural) | 工作流和决策模式 | 「怎么做」 |
巩固由 mem::consolidate-pipeline(src/functions/consolidation-pipeline.ts,符号 registerConsolidationPipelineFunction)驱动,可定时跑(src/index.ts:582-590,默认每 2 小时),也可手动触发。
它是 opt-in 的且要 LLM。 没开 CONSOLIDATION_ENABLED 或没配 LLM provider 就直接 skip(consolidation-pipeline.ts:51-53)——因为语义合并和程序抽取都要调模型。
语义层做的事(consolidation-pipeline.ts:62-72):攒够 5 个会话摘要,取最近 20 个,喂给 LLM 做语义合并(SEMANTIC_MERGE_SYSTEM prompt),沉淀成 SemanticMemory。程序层类似,从摘要里抽工作流。
4. 衰减与遗忘
要解决的小问题: 不加遗忘,记忆库会无限膨胀,且陈旧记忆会污染召回。
三种遗忘机制:
Ebbinghaus 衰减。 巩固管线里有 applyDecay(consolidation-pipeline.ts:21-43):一条记忆从上次访问起,每过一个「衰减周期」(默认天数),strength 乘 0.9 的幂,下限 0.1。常被访问的记忆 lastAccessedAt 刷新、不衰减——这就是「频繁访问会强化」的实现。
// consolidation-pipeline.ts:35 —— 指数衰减(真实源码)
const decayPeriods = Math.floor(daysSince / decayDays);
item.strength = Math.max(0.1, item.strength * Math.pow(0.9, decayPeriods));
TTL 过期。 存记忆时可给 ttlDays,转成 forgetAfter 时间戳(remember.ts:120-122)。
自动遗忘扫描。 worker 起一个定时器(src/index.ts:539-547,默认每小时)触发 mem::auto-forget,清理过期 / 低重要度 / 矛盾的记忆。还有 lesson/insight 的衰减扫描(每 24h)。这些定时器都 unref()——不阻止进程退出。
显式遗忘。 mem::forget(remember.ts:170)按 memoryId / 会话 / 观察删除,同时从 BM25 和向量索引移除(remember.ts:189-190),并 flushIndexSave() 同步刷盘——因为删除如果只在内存里,一次硬退出会让持久化快照在下次启动「复活」被删的条目(search.ts:63-74)。删除还会 recordAudit 留审计。
5. 这一章的设计取舍
- 取代而非覆盖:旧版本标记
isLatest=false保留,不物理删——可追溯 、可级联更新,但占空间。 - 廉价的相似度:取代判定用 Jaccard(词集合)而非嵌入相似度——快、确定、零成本,代价是对语义改写不敏感。
- 巩固是奢侈品:4 层巩固要 LLM,所以默认关;基础的捕获/召回/取代/衰减不需要 LLM 就能跑。这条「贵的 opt-in」原则贯穿全项目。
下一章看支撑这一切的底座:为什么只有三个原语 → 04-iii-foundation.md。