跳到主要内容

记忆的生命周期:演化、巩固、遗忘

本章讲什么: 记忆不是只进不出的日志。agentmemory 给记忆装了「演化」——新版本取代旧版本、零散观察巩固成知识、不用的记忆衰减消失。这章讲三件事:取代、巩固、遗忘。

1. 两种记忆先分清

项目里有两种东西容易混,先分清:

观察(Observation)记忆(Memory)
来源hook 自动捕获工具调用agent 主动调 memory_save
类型CompressedObservationMemory
存哪KV.observations(sessionId)(按会话分 scope)KV.memories(全局一个 scope)
粒度一次工具调用一条提炼出的决定/模式/偏好
定义types.ts:46-64types.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):

  • Memoryversion = 旧版本 + 1,parentId = 旧id,supersedes = [旧id]
  • 旧记忆 isLatest = false 写回(不删!保留历史)。
  • 触发 mem::cascade-update(异步 void),让引用旧记忆的关系跟着更新。

两个保护边界:

  1. 项目隔离(remember.ts:74):两条记忆都有明确 project 且不同时,绝不跨项目取代。无 project 的旧记忆当通配,不让历史数据被孤立。
  2. isLatest === false 的记忆跳过比较(remember.ts:69):只和「当前最新」比,不和已被取代的历史比。

搜索时也只索引最新版本——rebuildIndexif (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