OpenClaw 长期记忆实现方式
从核心源码出发,探寻OpenClaw的长期记忆实现方式。
大多数人对 Agent 记忆的理解停在"存向量、搜向量"
这个理解漏掉了真正的问题。记忆系统有三个递进的难题:什么该记住、记住之后能不能被正确召回、召回之后会不会反而污染当前推理。存储只是第一个问题的子集,检索只是第二个问题的手段。OpenClaw 的记忆系统之所以值得研究,不是因为它用了什么花哨的技术,而是因为它在每个阶段都做了明确的设计取舍——而每个取舍都有具体的失效条件。
要理解这套系统,先看全景。记忆不是一个动作,是一条五阶段管道:
对话上下文 ──→ [1. Memory Flush] ──→ memory/YYYY-MM-DD.md(日记)
│
[2. memory_search 召回时记录]
│
▼
召回追踪 (.dreams/)
│
[3. 3AM cron: 梦境晋升]
│ 六维评分筛选
▼
MEMORY.md(常青索引)
│
[4. memory_search 检索]
│ 向量 0.7 + 关键词 0.3
▼
[5. 注入 LLM context]
每个箭头都是信息可能丢失的地方。Flush 阶段依赖 LLM 的判断——它觉得不重要的信息就不存,compaction 之后永久丢失。召回记录阶段依赖向量质量——embedding 不好的内容搜不到。晋升阶段依赖评分阈值——没跨过阈值的知识留在日记里,随着时间衰减被遗忘。检索阶段依赖混合搜索的精度——语义相似但不相关的结果会污染上下文。注入阶段依赖 context window 的空间——注入太多挤占推理能力,注入太少关键信息缺失。
这不是一个"存进去就完了"的系统。它更像一条有泄漏的管道——设计者的工作不是堵住所有泄漏,而是确保最重要的信息有足够的概率流到终点。
LLM 无状态意味着所有记忆都必须重注入
LLM 没有持久状态。所谓"记忆",本质是每次对话时把历史信息重新塞进 context window。这意味着记忆注入量和推理质量是零和博弈——注入太多,留给推理的 token 就少了。
更致命的是 compaction。当对话 token 数接近 context window 上限时,系统会把旧对话压缩成摘要。压缩不可逆——被摘要掉的信息无法恢复。OpenClaw 的应对是在 compaction 之前插入一个抢救环节:Memory Flush。当对话 token 数接近 contextWindow - reserveTokens - softThreshold(默认 softThreshold=4000 tokens)时,系统注入一条指令,让 LLM 自己判断哪些信息值得持久化,写入当天的日记文件 memory/YYYY-MM-DD.md。还有一个硬底线:transcript 达到 2 MB 时强制触发(flush-plan.ts:10-11)。
Flush 指令有三条不可覆盖的安全提示:
// flush-plan.ts:13-19
const MEMORY_FLUSH_TARGET_HINT = "Write to memory/YYYY-MM-DD.md";
const MEMORY_FLUSH_APPEND_ONLY_HINT = "APPEND only, never overwrite";
const MEMORY_FLUSH_READ_ONLY_HINT = "MEMORY.md, SOUL.md, TOOLS.md are read-only";
const MEMORY_FLUSH_REQUIRED_HINTS = [TARGET, APPEND_ONLY, READ_ONLY];
即使用户自定义了 flush prompt,这三条也会被 ensureMemoryFlushSafetyHints(line 74)强制追加。这是一种针对用户误配置的防御——防止覆盖已有记忆或文件散乱。但写入本身不是原子操作。进程崩溃时日记文件可能损坏。系统选择了简单性而非安全性,因为崩溃是稀有事件,而原子写入的复杂度每次 flush 都要付出。
整个 flush 机制建立在一个关键假设上:LLM 自己最清楚什么值得保存。这个假设在多数情况下成立,但代价是——LLM 判断失误时,被跳过的信息在 compaction 之后永久消失。没有后悔药。
Markdown 文件作为真相源是一个刻意的选择
OpenClaw 没有把记忆存在向量数据库里。短期记忆存在 memory/YYYY-MM-DD.md——每天的追加写入日记。长期记忆存在 MEMORY.md——一个人类可以直接编辑的常青知识索引。
选择 Markdown 而非数据库作为真相源是深思熟虑的。用户可以随时打开 MEMORY.md 删掉一条过时的记忆,或者手动补一条 Agent 遗漏的信息。这种可审计性在纯向量数据库方案中不可能——你无法直接"看到"LanceDB 里存了什么,更无法精确地删除一条错误记忆。Git 友好是另一个好处:记忆的变更历史天然可追踪。
代价是查询能力受限。你没法用 SQL 对 Markdown 文件做复杂查询,也没法轻松实现条件过滤。系统通过在 Markdown 之上建一层 SQLite 索引来弥补——后面会看到这个混合方案的取舍。
混合检索 0.7/0.3 不是调参结果
当 Agent 需要回忆时,它调用 memory_search 工具。系统执行混合检索:0.7 权重的向量语义搜索 + 0.3 权重的 FTS5 关键词搜索(默认值在 memory-search.ts:102-103,合并逻辑在 hybrid.ts)。
为什么需要混合?因为两种搜索模态的能力边界互补但不重叠:
| 检索模态 | 能覆盖 | 不能覆盖 |
|---|---|---|
| 向量语义 | 语义相似、措辞不同 | 精确标识符、错误码、专有名词 |
| FTS5 关键词 | 精确匹配、代码片段、ID | 同义词、语义关联、不同表述 |
但混合检索隐藏着一个粗暴的简化。FTS5 返回的 BM25 分数没有被直接使用——系统用 1/(1+rank) 将排名序数转换成分数:
// hybrid.ts:46-55
function bm25RankToScore(rank: number): number {
if (!Number.isFinite(rank)) return 1 / (1 + 999);
if (rank < 0) return rank / (1 + rank); // negative = raw relevance
return 1 / (1 + rank); // rank 0 → 1.0, rank 1 → 0.5
}
排名第 0 和排名第 1 的文档,无论实际相关性差距多小,分数都从 1.0 跳到 0.5。绝对相关性信息被完全丢弃。两个"差不多相关"的结果在分数上可能差了一倍。线性组合的前提是向量分数和关键词分数可比较——实际上它们不在同一尺度上。固定的 0.7/0.3 比例在不同查询类型下可能远非最优。
两个"高级"检索特性默认关闭:时间衰减(30 天半衰期,temporal-decay.ts:11)和 MMR 多样性重排(lambda=0.7,mmr.ts)。默认关闭本身就是一个判断——设计者认为这些特性在多数场景下的收益不足以抵消它们引入的噪声。
"遗忘"是精心设计的:六维评分与梦境系统
这是整个记忆系统最值得研究的部分。
OpenClaw 用一个仿生学隐喻来管理记忆生命周期:"梦境"(dreaming.ts)。每天凌晨 3 点,一个 cron 任务触发晋升流程,回顾所有被 memory_search 召回过的记忆片段,用六个维度的加权分数决定哪些值得从日记文件晋升到 MEMORY.md:
| 维度 | 权重 | 度量 | 设计意图 |
|---|---|---|---|
| 相关性 | 0.30 | 平均召回分数 | 语义匹配质量 |
| 频率 | 0.24 | log(recallCount+1)/log(11) |
对数缩放,防高频主导 |
| 多样性 | 0.15 | 不同查询数 / 5,上限 5 | 跨场景通用性 |
| 时效性 | 0.15 | exp(-λ × ageDays),14天半衰期 |
近期优先 |
| 巩固度 | 0.10 | 跨天召回间隔 | 间隔重复启发 |
| 概念 | 0.06 | 概念标签数 / 6 | 主题丰富度 |
(默认权重定义在 short-term-promotion.ts:36-43)
这个六维模型的核心洞察是:单维度评分会在边界条件下失效。一条在单次会话中被频繁召回的记忆(高频率)可能只是当天的话题焦点,跨天来看并不重要;一条只被召回过两次但间隔一周的记忆(低频率、高巩固度)反而更可能是值得长期保留的核心知识。
间隔重复的启发是关键创新。巩固度维度的公式(short-term-promotion.ts:280-298)衡量召回是否发生在不同天:两次召回间隔 7 天的得分高于同一天连续召回两次。这不是在保留"被记住次数多"的东西,而是在优先保留"在不同时间点都被需要"的东西。
但整个晋升管道的并发模型暴露了工程上的妥协。短期回忆的记录使用文件锁(memory/.dreams/short-term-promotion.lock),锁内存储 PID 和时间戳,用 process.kill(pid, 0) 检测进程存活来处理过期锁(short-term-promotion.ts:462-474)。10 秒的锁获取超时意味着高并发时记录操作直接失败——被召回的记忆不会被追踪,后续晋升评分时这条记忆会凭空消失。单机场景下足够用,NFS 或分布式文件系统上不可靠。
两套记忆系统的哲学冲突
OpenClaw 实际上有两套独立的记忆插件,设计哲学完全不同,而且互不通信。
memory-core(文件中心)信任 LLM 的判断:记忆存储依赖 LLM 在 flush 时决定什么值得保存;记忆检索依赖 LLM 主动调用 memory_search 工具。系统提示注入的指令是"在回答关于先前工作的问题之前先搜索记忆"——但没有强制机制。LLM 可以直接从参数化知识回答而跳过搜索,系统无法检测也无法纠正。
memory-lancedb(向量数据库中心)不信任 LLM:它在 before_agent_start 钩子中自动将用户 prompt 编码为向量,搜索最相似的 3 条记忆,直接注入上下文。在 agent_end 钩子中用正则规则自动捕获用户消息。零摩擦——但代价是捕获质量低(正则匹配粗糙),自动注入的记忆无论相关性如何都消耗 token。
| 维度 | memory-core | memory-lancedb |
|---|---|---|
| 真相源 | Markdown 文件(人类可编辑) | LanceDB 向量库(黑箱) |
| 召回方式 | LLM 主动调用工具 | 自动注入 <relevant-memories> |
| 晋升机制 | 六维评分 + 梦境 cron | 无 |
| 存储决策 | LLM 判断(flush) | 正则触发(auto-capture) |
| 并发模型 | 文件锁(单机) | LanceDB 内置 |
| 人类可审计 | 直接编辑 Markdown | 无法直接查看/编辑 |
两套系统互不通信意味着:LanceDB 自动捕获的记忆不影响 memory-core 的晋升评分,反之亦然。同时启用两套的用户实际上在维护两个割裂的记忆空间。
设计在何处失效
任何记忆系统的价值不在于理想条件下的表现,而在于退化开始的边界。
Embedding provider 切换导致向量空间不兼容。 用户从 OpenAI 的 text-embedding-3-small(1536 维)切换到 Gemini 的 embedding(768 维),SQLite 中已存储的所有向量与新向量不在同一空间——搜索结果变成噪声。系统没有维度不匹配检测,没有切换告警,也没有自动清空旧索引。唯一的安全措施是存储 provider key 做缓存失效,但缓存失效只影响重新索引的决策,不影响已有向量的兼容性。
LLM 可能无视记忆搜索指令。 系统提示是一段建议而非约束。在长上下文或复杂指令场景下,LLM 的注意力可能分散,"先搜记忆再回答"被稀释。没有反馈回路来检测这种遗漏。
梦境系统静默降级。 如果 cron 服务在启动时不可用,系统记录一条警告然后什么都不做(dreaming.ts:383)。梦境功能降级为"关闭",但没有用户可见的状态指示。长期运行的 Agent 可能连续数天没有记忆晋升。
工程结论
从 OpenClaw 的记忆系统中提取三条可迁移的设计原则。
原则一:记忆系统优化的不是存储,是"什么该记住"的决策质量。 OpenClaw 用六维评分回答这个问题,每个维度对应一种不同的"重要性"信号。单一维度(频率、相关性、时效性)在边界条件下都会失效。如果你在设计记忆系统,先问"什么条件下我的重要性评分会给出错误答案",再决定需要几个维度。
原则二:人类可审计性优于纯机器效率。 Markdown 文件作为真相源意味着用户可以查看、编辑、删除任何一条记忆。向量数据库做不到这一点。如果用户需要信任 Agent 的记忆,他们必须能够审计它。代价是查询能力受限——OpenClaw 用 SQLite 索引层来弥补,但复杂查询仍然不如原生数据库。
原则三:检索式记忆永远受限于语义相似性 ≠ 任务相关性的根本鸿沟。 无论用多好的 embedding 模型,语义上相似的片段不一定是当前任务需要的片段。混合搜索(0.7 语义 + 0.3 关键词)是对这个缺陷的缓解,不是消除。
何时使用这种架构: 单用户或小团队 Agent、需要人类可审计性、记忆量在万条以内、单机部署。Markdown + SQLite 的方案在这些条件下够用且维护成本低。
何时不该使用: 多租户 SaaS(文件锁不支持)、需要毫秒级检索延迟(SQLite 做不到)、记忆量在百万条以上(暴力 KNN 不可扩展)、或多实例共享记忆(没有分布式一致性)。这些场景下应该用专用向量数据库加分布式方案,代价是放弃人类可审计性和单机简洁性。