乐于分享
好东西不私藏

OpenClaw 拆解④丨Agent 的记忆系统和人脑一模一样,连「做梦」都有

OpenClaw 拆解④丨Agent 的记忆系统和人脑一模一样,连「做梦」都有

这是 OpenClaw 架构拆解系列的第四篇。

前面聊了 Agent Loop 和 System Prompt,今天聊一个让我研究了两天的模块,Memory 系统。

我之前对 Agent 记忆的理解很朴素,要么全放在上下文窗口里(短期记忆),要么用 RAG 检索外部知识库(长期记忆)。

读完 OpenClaw 的记忆源码后,我发现事情比这复杂得多。

也有意思得多。


OpenClaw 的记忆分三层。

       

         
           
           
         

层级 名称 容量 速度 持久性 类比
L1 ContextEngine ~100K tokens 即时 会话级 RAM
L2 MEMORY.md 无限 文件 I/O 永久 Flash
L3 MemorySearch 无限 检索 永久 搜索引擎

       

     

L1 是当前对话的上下文,全部在 token 窗口里。优点是即时可用,不需要额外操作。缺点是容量有限(受 token 预算约束),会话结束就没了。

L2 是持久化的 Markdown 文件。MEMORY.md 存永久性的事实和偏好,memory/YYYY-MM-DD.md 存每日笔记,DREAMS.md 存整理回顾的结果。三种文件,三种时间尺度。

L3 是语义检索层。你不需要记住信息在哪个文件的第几行,描述一下你要找什么,它帮你从所有记忆中检索相关内容。

💡 容量从小到大,速度从快到慢,持久性从弱到强。这就是经典的存储金字塔。计算机科学 70 年反复验证的层级设计,Agent 框架又重新发明了一遍。


📝 MEMORY.md 的文件格式,刻意的简单

MEMORY.md 不是 JSON。不是 YAML。不是任何结构化格式。

它就是纯 Markdown。

没有 frontmatter,没有特殊语法,没有 schema 约束。想写啥写啥。

## 用户偏好
-
 喜欢简洁的回复风格
-
 TypeScript > JavaScript
-
 用 pnpm 不用 npm

## 项目决策

-
 数据库选了 PostgreSQL,因为需要 JSONB 支持
-
 放弃了 MongoDB,因为团队不熟悉

## 经验教训

-
 上次部署忘了跑 migration,记得检查

反直觉 纯 Markdown 看似不利于程序化处理,但实际上有三个好处。第一,LLM 本身就是最好的「非结构化文本解析器」,不需要 schema。第二,用户可以直接手动编辑记忆文件,像改 README 一样简单。第三,避免了格式迁移问题,JSON 的 schema 变了要写 migration 脚本,Markdown 不用。

这是一种「LLM-native 设计」。当你的读者既有人类又有 LLM 的时候,Markdown 可能是最好的折中格式。它对人类友好(可读可写),对 LLM 也友好(自然语言嵌入结构)。

写入规则也很有意思。什么应该存,什么不应该存?

应该存的是持久性事实、用户偏好、项目决策、反复出现的模式。

不应该存的是临时性的跟进任务、一次性操作的细节。

判断标准只有一个,这个信息下次还有用吗?


🔍 QMD 双模搜索,渐进增强

L3 的语义检索有两个搜索模式。

源码在 extensions/memory-core/src/memory/qmd-manager.ts

       

         
           
           
         

模式 底层引擎 适用场景
search BM25 词法检索 精确关键词匹配
vsearch / query 向量检索 语义相似度搜索

       

     

判断逻辑一行代码就说完了。qmdUsesVectors(searchMode) => searchMode !== "search"

只要不是 search 模式,就走向量检索。

但这里有个关键设计,向量检索是可选增强,不是必需依赖。

QMD Manager 暴露了 probeVectorAvailability() 方法,运行时探测向量服务是否可用。如果不可用,自动降级到 BM25。Embedding 模型通过环境变量 QMD_EMBED_MODEL 配置,代码里不硬编码。

💡 这是经典的「渐进增强」设计。基线是 BM25 文本搜索(零外部依赖),向量检索是可选升级(需要 embedding 服务)。就像 Web 开发的 Progressive Enhancement 哲学,先保证基本功能可用,再逐步增强。

反直觉 BM25 虽然不是向量检索,但已经是很成熟的信息检索算法了。它是 TF-IDF 的改进版,在大多数「回忆」场景下效果足够。向量检索只在语义跨度很大的场景才真正必要。比如用户问「我之前怎么处理数据库的」,BM25 靠「数据库」这个关键词就能找到。但如果用户问「我之前那个性能优化的思路」,没有明确关键词,这时候向量检索的语义匹配才会明显优于 BM25。


🔄 Compact,LLM 驱动的「记忆巩固」

当 L1 的对话历史快要撑爆 token 预算的时候,就需要压缩。

Compact 的实现在 src/agents/pi-embedded-runner/compact.ts。桥接层在 src/context-engine/delegate.ts

流程是这样的。

  1. 1. delegateCompactionToRuntime() 被 ContextEngine 调用
  2. 2. 内部调用 compactEmbeddedPiSessionDirect()
  3. 3. 构建完整的 buildEmbeddedSystemPrompt() + 运行时 prompt contribution
  4. 4. Memory 指导通过 buildMemoryPromptSection() 注入
  5. 5. 把完整 System Prompt + 全部对话历史发给 LLM
  6. 6. LLM 生成摘要,替代原始历史

你注意到了吗?第 3 步是构建完整的 System Prompt。

不是一个精简的「请总结以下对话」。而是带着完整角色定义、工具列表、行为规范的 System Prompt。

为什么要这么奢侈?

因为如果 LLM 在压缩时不知道自己是谁、能做什么,它生成的摘要就会「跑偏」。一个不知道自己是代码助手的 LLM,在总结编程对话时可能会忽略代码细节,只保留自然语言部分。这对后续的编码任务是致命的。

反直觉 压缩操作比正常对话更贵(因为要发完整 System Prompt + 全部历史),但这是正确的设计。用短期 token 成本换长期摘要质量。就像 PostgreSQL 的 VACUUM,清理死元组需要读取完整的表结构信息。

压缩后还有一个关键步骤,repairToolUseResultPairing()

压缩可能在 tool_usetool_result 之间截断对话,导致「孤儿调用」。这个修复函数会扫描 transcript,缺失的 result 插入合成占位,重复的删除,孤儿的丢弃。

💡 这和文件系统的 fsck 是同一个东西。ext4 日志在 crash 后可能留下不完整的事务,fsck 通过扫描+修复保证一致性。repairToolUseResultPairing 就是 Prompt Transcript 的 fsck。


💤 events.jsonl,Agent 真的在「做梦」

这是整个拆解过程中让我最惊叹的发现。

memory/.dreams/events.jsonl 文件中,OpenClaw 记录了三种事件。

       

         
           
           
         

事件 触发时机 记录内容
memory.recall.recorded 每次检索记忆 query, resultCount, results[{path,score}]
memory.promotion.applied 短期→长期提升 memoryPath, candidates[{key,score,recallCount}]
memory.dream.completed 记忆整理完成 phase, reportPath, lineCount, storageMode

       

     

recall → promotion → dream。

检索 → 巩固 → 整合。

你不觉得这三个阶段和人脑的记忆巩固过程惊人地相似吗?

工作记忆中的信息被反复检索(recall)。被频繁检索的信息被标记为「重要」,由海马体转移到皮层(promotion)。然后在睡眠期间,大脑对这些信息进行重组整合(dream)。

OpenClaw 甚至把这个目录叫 .dreams

我不确定起名的人是不是有意为之,但 recallCount 这个字段暴露了设计意图,一条记忆被检索的次数越多,被 promote 的概率越高。这就是人脑的「间隔重复」效应。

💡 events.jsonl 是 append-only 日志,不可变。当前记忆状态是所有事件的「投影」。这就是事件溯源(Event Sourcing)模式。任何记忆变更都可以通过回放事件日志来审计和重建。


🔒 多 Agent 记忆隔离

最后聊一个我本来以为很简单但其实设计很深的问题,多个 Agent 共享记忆吗?

答案是,不。

源码在 extensions/memory-core/src/memory/search-manager.ts

每个 Agent 的 Memory Manager 按 normalizedAgentId + workspace 作为 key,完全独立。

       

         
           
           
         

场景 记忆可见性
同一 Agent 不同会话 ✅ 共享
父 Agent → 子 Agent ❌ 隔离
子 Agent → 父 Agent ❌ 隔离
不同用户同 Agent ❌ 隔离

       

     

反直觉 子 Agent 不能读取父 Agent 的记忆。看起来不合理(子任务应该继承上下文),但这是安全隔离的刻意选择。

信息传递不通过共享记忆,而是通过 prepareSubagentSpawn() 时显式传递的 context。

这个模型和 Android 的进程隔离几乎一模一样。

agentId ≈ Linux UID。
workspace ≈ /data/data/<package>/
显式 context 传递 ≈ Binder IPC。

默认隔离,需要共享就走显式的通信通道。Unix 的 fork() 也是这样,子进程拿到的是内存快照副本,不是共享指针。想共享就用 pipe / shared memory / socket,显式声明意图。


回顾一下,OpenClaw 的记忆系统。

三层架构 ≈ 存储金字塔。

MEMORY.md 纯 Markdown ≈ LLM-native 设计。

QMD 双模搜索 ≈ 渐进增强。

Compact 复用完整 Prompt ≈ MVCC 快照读。

events.jsonl 三阶段 ≈ 人脑记忆巩固。

多 Agent 隔离 ≈ Android 进程模型。

每一个设计决策,都能在计算机科学或者认知科学中找到对应的原型。

这不是巧合。好的 Agent 记忆系统,归根结底就是在解一个跨了几十年的老问题,怎么在有限的快速存储上高效管理「什么该记住、什么该忘掉」。

以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~

谢谢你看我的文章,我们,下次再见。