Home
avatar

.𝙃𝙖𝙣

详解 OpenClaw 的记忆实现:文件、检索、主动召回和 Memory Flush 是怎么接起来的

这篇我想直接回答一个很具体的问题:OpenClaw 里的“记忆”到底是怎么实现的。

最开始我以为它就是一个向量库加检索。

但对着源码看一圈以后,感觉不能这么概括。OpenClaw 现在这套实现,至少要拆成四层看:

  • 工作区里的记忆文件怎么组织
  • 记忆怎么被索引和检索
  • 记忆怎么主动插进 prompt
  • 上下文太长的时候,记忆又是怎么被“刷回”磁盘的

它不只是 search,还带了 prompt 注入、跨会话召回、session transcript 纳管、memory flush 这些运行时动作。

1. 先说结论:OpenClaw 的“记忆”不是一个点功能,而是一条链

我现在会把它先记成下面这条链:

工作区里的 MEMORY.md / memory/*.md / 额外路径
→ memory backend 建索引(builtin 或 qmd)
→ 需要时通过 memory_search / active-memory 做检索
→ 检索结果进 prompt 或返回给工具调用
→ 对话太长时再通过 memory flush 把当前上下文沉淀回文件

这个顺序一旦立起来,后面源码就比较好看了。

因为它说明:

OpenClaw 的记忆不是“存一下再搜一下”,而是贯穿输入、检索、注入、压缩回写的完整回路。

2. 记忆的最底层起点,还是文件

只看运行时,很容易把“记忆”想成数据库里的向量条目。

但 OpenClaw 这边最底层起点其实还是文件。

2.1 根记忆文件是 MEMORY.md

root-memory-files.ts 里,能直接看到:

  • 规范根记忆文件名:MEMORY.md
  • 旧名字:memory.md
  • 还有专门的 repair 目录逻辑

里面几个关键函数也很直白:

  • resolveCanonicalRootMemoryPath(...)
  • resolveCanonicalRootMemoryFile(...)
  • shouldSkipRootMemoryAuxiliaryPath(...)

这几个函数的意思其实就是:

  • 当前工作区里,真正被当作根记忆入口的是谁
  • 哪些旧文件或修复残留不要重复算进去

所以 OpenClaw 的记忆不是从数据库开始的, 而是先假设:

工作区里有一批可读、可维护、对人也友好的 Markdown 记忆文件。

这一点我挺喜欢。

因为它保留了“人能直接看和改”的那层可控性。

2.2 默认还会扫 memory/ 目录

backend-config.ts 里,resolveDefaultCollections(...) 这段默认集合也很关键。

默认会纳入两块:

  • 工作区根的 MEMORY.md
  • workspaceDir/memory/**/*.md

这意味着 OpenClaw 默认的记忆布局,大致就是:

workspace/
  MEMORY.md
  memory/
    *.md
    **/*.md

所以它不是把“记忆”藏在很深的私有数据库里, 而是先把人能维护的 Markdown 文件当成主语料。

3. 但它又不只是文件系统,它后面有完整的 memory backend

只停在文件层,很多事情做不了:

  • 快速检索
  • 相似度排序
  • FTS
  • 向量检索
  • embedding cache
  • 跨会话 recall

所以 OpenClaw 后面又补了一层 memory backend。

3.1 backend 分两类:builtinqmd

这个在 types.memory.tsbackend-config.ts 里都能看到。

顶层类型:

  • MemoryBackend = "builtin" | "qmd"

我的理解是:

  • builtin:OpenClaw 自己内置这一套 SQLite / FTS / vector 方案
  • qmd:外接 QMD 这一类独立后端

这点很重要。

因为它说明 OpenClaw 没把“记忆”写死成某一种检索实现。

它更像是先定义一套 runtime 契约,再让 backend 去实现它。

3.2 MemorySearchManager 才是它真正的运行时接口

host/types.ts 里,MemorySearchManager 这个接口很关键。

它至少包括这些能力:

  • search(...)
  • readFile(...)
  • status()
  • sync?(...)
  • probeEmbeddingAvailability()
  • probeVectorAvailability()
  • close?()

这一下我就比较清楚了:

OpenClaw 里的记忆不只是 search API,而是一整个 manager。

它既负责:

  • 同步
  • 状态探测
  • 后端可用性探测

又要把这些能力统一暴露给上层 runtime。

4. builtin 这条线,本质上是 Markdown → chunk → SQLite / FTS / vector

只看 builtin 方案,我现在会把它理解成一条很标准但做得比较完整的链。

4.1 先 chunk

engine-storage.ts 里导出了这些东西:

  • chunkMarkdown
  • buildFileEntry
  • buildMultimodalChunkForIndexing
  • readMemoryFile
  • normalizeExtraMemoryPaths

这说明 builtin 方案第一步还是老老实实把 Markdown 切 chunk。

同时在 memory-search.ts 里也能看到默认 chunk 参数:

  • DEFAULT_CHUNK_TOKENS = 400
  • DEFAULT_CHUNK_OVERLAP = 80

OpenClaw 至少在默认配置上,已经把“记忆不是整文件吃,而是 chunk 化索引”这件事定下来了。

4.2 再落到 SQLite 里的几张表

这块在 memory-schema.ts 里最直观。

几个关键常量:

  • memory_index_sources
  • memory_index_chunks
  • memory_index_fts
  • memory_index_paths_fts
  • memory_index_state
  • memory_embedding_cache
  • memory_index_chunks_vec

我现在会把它粗分成三组:

第一组:来源和状态

  • sources
  • state

负责知道“哪些文件被纳进来了、现在索引到什么状态”。

第二组:文本检索

  • chunks
  • fts
  • paths_fts

负责 chunk 文本和全文索引。

第三组:语义检索

  • embedding_cache
  • chunks_vec

负责 embedding 缓存和向量索引。

所以 builtin 方案不是只有向量,也不是只有 FTS。

它本身就是一个混合检索底盘

5. 检索配置这块也比我一开始以为的细很多

我原来以为 memory_search 可能就是:

  • 查一下 topK
  • 回来几条结果

memory-search.ts 这块看下来,它把检索配置拆得挺细。

5.1 数据源可以不只一类

ResolvedMemorySearchConfig 里有:

  • sources
  • searchSources

而且源类型至少包括:

  • memory
  • sessions

这说明 OpenClaw 里“记忆”不是只有手写记忆文件。

当配置打开以后,session transcript 也能变成一个被检索的记忆源

5.2 它默认就带 hybrid search 思路

在同一个文件里还能看到:

  • hybrid.enabled
  • vectorWeight
  • textWeight
  • candidateMultiplier
  • mmr.enabled
  • mmr.lambda
  • temporalDecay.enabled
  • temporalDecay.halfLifeDays

OpenClaw 的 builtin memory 搜索至少在配置层已经考虑了:

  • 向量分和文本分怎么混
  • 结果去重 / 多样化怎么做(MMR)
  • 时间衰减怎么做

这让我更愿意把它理解成:

它不是“有 embedding 就行”,而是在认真做检索排序。

5.3 向量并不是强依赖,fallback 可以退回去

同一个文件还能看到:

  • provider
  • fallback
  • vector.enabled
  • cache.enabled

再结合 types.memory.ts 看,会更清楚一点:

  • embedding provider 可以换
  • vector store 可以关
  • cache 可以关
  • fallback provider 也能设

所以它对“语义记忆”这件事不是死硬绑定的。

这也合理。

因为现实里最常出问题的,往往就是 embedding provider、成本和可用性。

6. rememberAcrossConversations 是我觉得最关键的一个开关

只看“记忆”这个词,很容易混淆两个东西:

  • 当前会话里的上下文记住了什么
  • 当前 agent 的其他会话能不能被拿来检索

OpenClaw 这里把这件事单独拎出来了。

6.1 类型上就明确了

types.memory.ts 里:

rememberAcrossConversations?: boolean;

注释写得也很准:

Use relevant context from this agent’s other private conversations.

它不只是“记忆功能开不开”, 而是:

允不允许当前 agent 把别的私有会话当成检索语料。

6.2 配置解析时,这个开关会直接影响 session source

memory-search.ts 里, 能看到它会把:

  • rememberAcrossConversations
  • experimental.sessionMemory

组合起来决定 session memory 是否真的纳管。

我的理解是:

  • experimental.sessionMemory 更像能力开关
  • rememberAcrossConversations 更像使用策略开关

这两个不是完全一回事。

6.3 active-memory 插件也会专门看它

extensions/active-memory/session-policy.ts 里, 也能看到:

  • hasRememberAcrossConversationsAgent(...)
  • shouldRememberAcrossConversations(...)

这说明跨会话 recall 不是只在底层 backend 起作用, 上层主动召回插件也会按这个策略收边界。

7. session transcript 为什么也会被纳进记忆,我这次也想通了

如果想做跨会话 recall,只搜 MEMORY.mdmemory/*.md 其实不够。

很多信息根本还没被人手工整理成记忆文件。

所以 OpenClaw 才会把 transcript 也拉进来。

7.1 session-transcript-corpus.ts 就是在干这个事

这个文件本质上是在回答:

  • 哪些 transcript 可以被当成 memory corpus
  • active session 和 archive artifact 怎么区分
  • SQLite transcript 和文件 transcript 怎么统一
  • dreaming narrative / cron run 这种内部 session 怎么识别

这里我觉得最关键的是:

OpenClaw 没有把 transcript 当成“顺手附带的数据”,而是明确把它建模成一种 corpus source。

7.2 这也解释了为什么 memory source 会有 sessions

所以现在回头看:

  • memory source 更偏人维护的长期记忆文件
  • sessions source 更偏运行期自然沉淀出来的对话材料

这两个源混在一起以后,OpenClaw 才可能同时做到:

  • 能搜你手工整理过的偏稳定事实
  • 也能搜最近还没来得及整理进记忆文件的会话内容

8. prompt 注入这条线,不是直接在检索工具里做,而是通过 memory plugin state 接上去

这一点我一开始没看明白,后面才顺过来。

8.1 memory-state.ts 是中间桥

这个文件很像一块注册中心。

里面维护了几类东西:

  • promptBuilder
  • promptPreparations
  • corpusSupplements
  • runtime
  • flushPlanResolver

我现在会把它理解成:

memory 插件能力先注册到这里,再由 agent runtime 在适当时机取出来。

8.2 prompt section 不是现拼字符串,而是有 prepared snapshot

memory-state.ts 里,有两块我觉得挺关键:

  • prepareMemoryPromptSection(...)
  • buildMemoryPromptSection(...)

这里的意思大概是:

  • 先异步准备一份本轮 run 专属的 memory prompt snapshot
  • 再在真正组 prompt 时同步拿出来拼进去

这点其实挺工程化。

因为 context engine 很多时候希望 prompt assembly 是同步的, 但 memory retrieval 前面又可能带异步动作。

所以它中间多做了一层 prepared section 缓冲。

8.3 prepareAgentMemoryPrompt(...) 会把当前工具上下文也带进去

memory-prompt-prepare.ts 里, 可以看到它准备 memory prompt 时,不只是传 agentId,还会传:

  • toolNames
  • capabilityToolNames
  • citationsMode
  • agentSessionKey
  • sandboxed

这说明 memory prompt 不是纯静态文案。

它也要知道:

  • 当前这轮可用哪些工具
  • 当前是不是 sandbox
  • 当前是哪个 agent / session

所以记忆注入本身也是 runtime-sensitive 的。

9. 主动召回这条线,其实主要是 active-memory 插件在做

如果只盯 core,很容易少看一半。

因为 OpenClaw 的“主动记忆”不是全在 core 里硬编码, 而是明显通过插件化方式挂出来的。

9.1 active-memory 插件挂在 before_prompt_build

extensions/active-memory/index.ts 里, 它会监听:

  • before_prompt_build

在最终 prompt 真正定稿之前, 它有机会先跑一轮 recall。

这一下就很合理。

因为主动记忆的本质就是:

在主模型回答前,先帮它查一轮“有没有值得插进来的记忆”。

9.2 它不是直接让主模型搜,而是起一个 recall subagent

extensions/active-memory/recall.ts 里, 能看到 runRecallSubagent(...) 这条线。

active recall 不是“主模型顺手搜一下”。

它是专门拉出一轮独立的 recall 执行。

我很认同这个设计。

因为主动记忆如果和主回答完全揉在一起,会很容易互相污染:

  • 检索 query 不稳定
  • recall 判断不稳定
  • 主回答受 recall 过程噪声影响太大

拆成 recall subagent 后,边界就清楚很多。

9.3 它还会专门构造 recall prompt 和 search query

extensions/active-memory/prompt.ts 里, 可以看到它会单独做:

  • buildQuery(...)
  • buildSearchQuery(...)
  • buildRecallPrompt(...)

这里的意图很明确:

  • 给 recall 子流程一份更适合检索的 query
  • 把 JSON fence、旧 recall block、外部不可信块先剥掉
  • 再让 recall 子流程只输出很短的 summary 或 NONE

这一下我就更确定了:

OpenClaw 的主动记忆不是简单把 search 结果塞给主模型,而是先做一轮专门的 recall 整理。

10. memory flush 这块,是我这次觉得最有意思的一层

只看 recall,很多系统都会做。

但 OpenClaw 这边还有一层:

  • 对话太长以后,怎么把当前上下文刷回记忆文件

这块主要在:

10.1 flush 不是随时做,而是有 gate

memory-flush.ts 里,能看到几层判断:

  • shouldRunMemoryFlush(...)
  • shouldRunPreflightCompaction(...)
  • hasAlreadyFlushedForCurrentCompaction(...)

flush 不是“上下文长了就立刻写文件”。

它会先判断:

  • 当前 token 压力是不是到阈值了
  • 当前 compaction cycle 里是不是已经 flush 过了
  • 这次是 preflight compaction 还是正式 flush

这层很重要。

因为如果没有 gate,memory flush 很容易变成高频噪声写入。

10.2 flush plan 是插件给的,不是 core 写死的

memory-state.ts 里:

  • resolveMemoryFlushPlan(...)

会从 memory capability 里拿 flushPlanResolver

core 只管:

  • 什么时候该 flush
  • flush run 怎么调度

但“写到哪个相对路径、用什么 prompt、保留多少 token buffer”这种事, 可以由 memory plugin 决定。

这也是一个很标准的 runtime / policy 分层。

10.3 agent-runner-memory.ts 负责把 flush 真跑起来

这个文件比较长,但我现在只记住它做的几件事:

  • 判断当前 run 是否需要 preflight compaction / memory flush
  • 确保 flush 目标文件路径在 workspace 里
  • 调 embedded agent 跑一轮专门的 memory flush run
  • 更新 session entry 上的 flush / compaction 计数

这说明 memory flush 在 OpenClaw 里不是一个同步小函数, 而是一轮受 runtime 管控的 maintenance run。

这点很关键。

因为它再次说明:

OpenClaw 的记忆不是静态知识库,而是会随着对话运行被持续整理和回写的。

11. 所以现在回头看,OpenClaw 的“记忆”其实包含了两种完全不同的东西

看到这里,我自己会把它明确拆成两类:

第一类:可检索记忆层

包括:

  • MEMORY.md
  • memory/*.md
  • extraPaths
  • sessions transcript corpus
  • SQLite / FTS / vector / embedding cache

这层的重点是:

  • 存什么
  • 怎么索引
  • 怎么搜
  • 怎么排序

第二类:运行时记忆层

包括:

  • active-memory recall
  • prepared memory prompt section
  • cross-conversation gating
  • memory flush
  • compaction cycle 协同

这层的重点是:

  • 什么时候该查
  • 查到以后怎么注入
  • 太长时怎么沉淀回文件
  • 哪些 session / agent / channel 允许这样做

这两层不分开看,源码会很乱。

一分开以后就很清楚了:

前者更像 memory database,后者更像 memory runtime。

12. 如果只用一句话总结我这次看源码后的理解

我现在不会再把 OpenClaw 的记忆实现理解成“向量库 + recall”。

更准确一点说,它像是:

以 Markdown 记忆文件为起点,用 SQLite / FTS / 向量检索做底盘,再通过 active-memory 和 memory flush 把检索、提示词注入、跨会话召回、长上下文沉淀回写串成一条运行链。

所以我觉得这套实现挺有意思。

因为它没有把“记忆”只做成一个工具, 而是把它做成了 agent runtime 的一个正式子系统。

13. 总结

  1. OpenClaw 的记忆实现起点是工作区 Markdown 文件,而不是纯数据库黑盒
  2. builtin backend 本身就是 chunk + FTS + vector + embedding cache 的混合检索底盘
  3. rememberAcrossConversations 真正控制的是“其他会话能不能进入当前 agent 的检索视野”
  4. 主动记忆主要由 active-memory 插件挂在 before_prompt_build 上完成,而且它会单独起 recall subagent
  5. memory flush 不是附加功能,它其实是长上下文治理的一部分:对话太长时,把当前运行态重新沉到记忆文件里

这篇先记到这里。

OpenClaw 记忆 智能体 源码拆解 向量检索