OpenClaw memory-core 源码精读
主题:从
query进入memory_search开始,一路追到 recall result,再看结果如何回到模型可见的工具结果里。本文默认你已经看过第一篇《OpenClaw 长期记忆与 RAG 深入笔记》,这一篇专注源码链路。
1. 这篇的目标是什么
第一篇主要解决的是:
- OpenClaw 的长期记忆是什么;
- 默认 RAG 的思想是什么;
memory-core和memory-lancedb有什么区别。
这一篇不再讲那么多概念,而是回答一个更“工程”的问题:
当模型想调用
memory_search时,代码到底怎么一层一层走到最终结果?
你可以把它理解成一条源码追踪路线:
- tool 是怎么创建出来的
- tool 执行时怎么知道自己属于哪个 agent
- 怎么拿到 memory manager
- manager 怎么决定用 builtin 还是 QMD
- builtin manager 怎么做 embedding
- 怎么做 vector search / keyword search / hybrid merge
- 怎么给结果加 citation
memory_get又怎么沿着另一条分支把具体行读出来
2. 先给你一张“大地图”
如果先不看细节,默认 memory-core 的主链路可以简化成这张图:
memory_search tool -> tools.ts -> tools.shared.ts -> tools.runtime.ts -> memory/index.ts -> memory/search-manager.ts -> MemoryIndexManager.get() -> MemoryIndexManager.search() -> embedQueryWithTimeout() -> searchKeyword() + searchVector() -> mergeHybridResults() -> decorateCitations() -> jsonResult(...)如果用户或模型接着要读具体内容:
memory_get tool -> tools.ts -> tools.runtime.ts -> readAgentMemoryFile() 或 manager.readFile() -> 返回 { path, text }看起来很多层,但其实每一层职责都挺清楚。下面我们按真实代码顺序走。
3. 第一站:memory_search 这个 tool 是在哪里定义的
最直接的入口在:
extensions/memory-core/src/tools.ts
其中最关键的是:
createMemorySearchTool(...)createMemoryGetTool(...)
先看 memory_search。
3.1 createMemorySearchTool() 做了什么
它不是立刻开始搜索,而是先做 3 件事:
- 定义 tool 的名字、描述、参数 schema;
- 通过共享工具层创建一个真正可执行的 tool;
- 把执行逻辑包装成一个 async execute 函数。
对应位置:
extensions/memory-core/src/tools.ts:24
你可以把它理解成:
这一层不是“搜索引擎本体”,而是“把搜索引擎包装成 agent 能调用的工具接口”。
这在 agent 系统里很常见。
4. 为什么工具层和检索层要分开
很多零基础读者看到这一步会问:
为什么不在
tools.ts里直接把搜索全写完?
因为 tool 层和 engine 层关心的问题不同。
4.1 tool 层关心什么
- tool 叫什么
- 参数怎么校验
- agent 当前能不能用这个工具
- 返回结果怎么包装成 JSON
- 是否给 snippet 加 citation
4.2 engine 层关心什么
- memory store 在哪
- embedding provider 怎么初始化
- query 怎么 embed
- vector table / fts table 怎么查
- 结果怎么 merge
所以 OpenClaw 把这两层拆开,是一个很正常、也很值得学习的工程设计。
5. 第二站:tools.shared.ts 是真正的“入口门卫”
接下来会进入:
extensions/memory-core/src/tools.shared.ts
这是一个很重要的文件,因为它负责解决一个本质问题:
一个 tool 被调用时,怎么知道该连到哪个 agent、哪个 config、哪个 memory manager?
5.1 resolveMemoryToolContext()
这个函数做了几件很关键的事情:
- 拿到 config;
- 根据 session key 解析 agentId;
- 检查该 agent 是否启用了 memory search;
- 如果没启用,直接返回 null。
对应位置:
extensions/memory-core/src/tools.shared.ts:33
这说明:
- 不是所有 agent 都一定有 memory_search;
- tool 创建阶段就已经带有 agent 作用域判断。
5.2 createMemoryTool()
这个函数就是一个通用工厂:
- 先 resolve context;
- 如果没有合法上下文,就不创建 tool;
- 如果有,就返回带 execute 的 tool 定义。
对应位置:
extensions/memory-core/src/tools.shared.ts:86
也就是说:
memory_search不是“始终存在的全局命令”,而是“对当前 agent 合法时才装上的工具”。
6. 第三站:tool 执行时并不会直接碰到数据库
memory_search 真正执行时,会先调:
loadMemoryToolRuntime()
对应:
extensions/memory-core/src/tools.shared.ts:16
这个函数做的事很简单,但很关键:
- 懒加载
./tools.runtime.js
为什么要这样做?
因为运行时桥接通常不想在模块一加载就把整套 memory runtime 全拉起来。
这是一个典型的 lazy-load 设计:
- 首次真正需要 memory 时再加载 runtime;
- 避免不必要的初始化开销。
7. 第四站:tools.runtime.ts 很薄,但地位很关键
文件:
extensions/memory-core/src/tools.runtime.ts
内容非常短:
- 导出
readAgentMemoryFile - 导出
resolveMemoryBackendConfig - 导出
getMemorySearchManager
对应位置:
extensions/memory-core/src/tools.runtime.ts:1
这意味着它本质上是一个:
tool 层与底层 memory runtime / manager 之间的桥接文件
它本身逻辑不多,但把来源拆清楚了:
- 读文件能力来自 host runtime files
- search manager 来自 memory/index.ts
8. 第五站:memory/index.ts 是 memory 子系统的总出口
文件:
extensions/memory-core/src/memory/index.ts
它导出了:
MemoryIndexManagergetMemorySearchManagercloseAllMemorySearchManagers
对应位置:
extensions/memory-core/src/memory/index.ts:1
这一层很像一个 barrel file,它的作用是:
- 对外隐藏内部文件结构;
- 让外部只依赖一个稳定出口。
这对源码阅读很重要:
你看到
getMemorySearchManager()时,第一反应就该去memory/search-manager.ts。
9. 第六站:真正的 manager 选择发生在 search-manager.ts
文件:
extensions/memory-core/src/memory/search-manager.ts
这里最关键的函数就是:
getMemorySearchManager(...)
对应位置:
extensions/memory-core/src/memory/search-manager.ts:43
9.1 它先做什么
第一步不是直接 new MemoryIndexManager,而是:
- 先调用
resolveMemoryBackendConfig(params)
这一步的意义是:
先搞清楚这次到底要用哪个 memory backend。
9.2 builtin vs QMD
这个函数里最值得注意的一点是:
- 如果配置的是 QMD,就优先走 QMD manager;
- 如果 QMD 失败,还可以 fallback 到 builtin;
- 默认情况下,还是走 builtin
MemoryIndexManager。
也就是说:
默认链路并不是“写死 builtin”,而是“先看 backend 配置,再选择默认 builtin 或特定 backend”。
9.3 这层为什么重要
因为它把“外部 API”变成了“统一 manager 接口”。
tool 层不需要知道:
- 你用的是 builtin 还是 QMD
- 后面是不是 fallback
- manager 是缓存的还是新建的
tool 层只需要知道:
- 我拿到一个能
search()/readFile()的 manager 就行。
这就是一个很典型的多后端抽象层。
10. 第七站:默认情况下落到 MemoryIndexManager.get()
如果不是 QMD,或者 QMD 不可用,搜索管理器会进入 builtin 路线:
MemoryIndexManager.get(...)
位置:
extensions/memory-core/src/memory/manager.ts:159
10.1 这个 get() 不只是 new 一个对象
它做的事情比“new manager”多得多:
- 解析
resolveMemorySearchConfig; - 判断 memorySearch 是否启用;
- 计算 workspaceDir;
- 生成 cache key;
- 如果已有缓存 manager,直接复用;
- 如果没有,再创建新的
MemoryIndexManager。
10.2 为什么它要缓存
因为 memory manager 不是一个“无状态函数对象”,而是维护着:
- sqlite 连接
- provider 状态
- watcher
- session listener
- dirty 标记
- sync 队列
这种对象如果每次 tool 调用都重新造,成本会很高,也会丢失持续状态。
所以缓存是很自然的设计。
11. 第八站:MemoryIndexManager 构造函数到底干了什么
看构造函数部分:
extensions/memory-core/src/memory/manager.ts:217
你会发现它一上来就做很多“基础设施”初始化:
- 打开数据库
- 计算 provider key
- 初始化 cache 配置
- 初始化 fts / vector 配置
ensureSchema()- 读 meta
- 非 status 模式下启动 watcher / session listener / interval sync
11.1 这说明默认 memory-core 是“常驻型组件”
不是说:
- 搜索时才临时看一眼文件;
而是:
- 启动后维护一个持续存在的本地索引管理器。
这点你一定要建立直觉,否则很容易误以为它每次 query 都要完整重扫文件。
12. 第九站:search() 是真正的 recall 主体
核心函数:
extensions/memory-core/src/memory/manager.ts:315
这一段几乎就是默认 memory-core 的 recall 主体。
我们一步一步拆。
12.1 第一步:清洗 query
const cleaned = query.trim();if (!cleaned) return [];这一步很普通,但告诉你一个事实:
- manager 层还是尽量守住输入安全的。
12.2 第二步:warmSession()
void this.warmSession(opts?.sessionKey);这一步的作用是:
- 若配置了
sync.onSessionStart - 会在 session 刚开始时推动一次 memory sync
也就是说,查询不只是“读”,还可能顺手触发记忆索引变新。
12.3 第三步:如果 dirty,就尝试 async sync
if (this.settings.sync.onSearch && (this.dirty || this.sessionsDirty)) { void this.sync({ reason: "search" })}这一步非常值得研究。
它说明:
- 搜索发生时,系统可能意识到索引过期了;
- 然后异步地刷新索引;
- 但不会硬阻塞搜索。
这个设计非常“工程化”:
- 优先保证响应;
- 容忍 recall 结果可能略微滞后;
- 让 freshness 和 latency 做平衡。
12.4 第四步:检查是否真的有 indexed content
函数:
hasIndexedContent()
如果根本没有 chunk,自然直接返回空结果。
这一步避免了后面无意义地 embed query。
12.5 第五步:初始化 embedding provider
await this.ensureProviderInitialized();这一步很关键。
因为前面 manager 虽然已经构造好了,但 provider 通常还是 lazy init 的。
也就是说:
- manager 可以先存在;
- provider 到真正 search 时再初始化。
13. 第十站:为什么 provider 也是懒加载的
初始化 provider 的逻辑在:
extensions/memory-core/src/memory/manager.ts:274extensions/memory-core/src/memory/manager.ts:142
它最终会调用:
createEmbeddingProvider(...)
位于:
extensions/memory-core/src/memory/embeddings.ts
13.1 为什么不在构造函数里直接初始化 provider
因为 provider 可能意味着:
- 本地模型加载
- 远程 API 配置检查
- fallback 决策
- batch 能力探测
这些事情都不一定每次都用得上。
lazy init 的好处:
- 启动轻一点
- 真有 recall 需求时再付代价
- 某些 agent 即使挂了 memory tool 也未必每轮都搜索
14. 第十一站:query 是怎么变成 embedding 的
这部分在:
extensions/memory-core/src/memory/manager-embedding-ops.ts
最关键的函数:
embedQueryWithTimeout(text)
位置:
extensions/memory-core/src/memory/manager-embedding-ops.ts:401
14.1 它做了什么
- 确认当前不是 FTS-only 模式;
- 根据 provider 是否 local 选不同 timeout;
- 调用
this.provider.embedQuery(text); - 用
withTimeout()包一层超时保护。
这说明 query embedding 并不是“裸调 provider”,而是有运行时保护的。
14.2 为什么超时设计很重要
因为 memory search 是回答链路的一部分。
如果 embedding provider 卡死,整个聊天就会卡死。
所以它必须有:
- timeout
- retry(batch 场景)
- fallback
这就是一个真正可运行系统和 demo 之间的差别。
15. 第十二站:检索不是只有一种,而是 3 种模式
回到 search() 函数,你会看到后面开始分流。
15.1 模式一:FTS-only
如果没有 provider:
if (!this.provider) { ... }对应:
extensions/memory-core/src/memory/manager.ts:346
这时它会:
- 检查 FTS 是否可用;
- 提取关键词;
- 对每个关键词做
searchKeyword(); - 合并、去重、按分数排序。
这意味着:
没有 embedding provider 时,memory search 并不会彻底瘫痪,只要 FTS 可用还能工作。
15.2 模式二:vector-only
如果 embedding provider 可用,但 FTS 不可用或 hybrid 关闭:
- 只做向量检索。
15.3 模式三:hybrid
这是默认最重要的模式。
它会:
- 先
searchKeyword(...) - 再
embedQueryWithTimeout(...) - 再
searchVector(...) - 最后
mergeHybridResults(...)
也就是说:
默认 recall 不是“先关键词后向量”的二选一,而是两个都跑,再融合。
16. 第十三站:searchVector() 具体怎么查
文件:
extensions/memory-core/src/memory/manager-search.ts
关键函数:
searchVector(...)
位置:
extensions/memory-core/src/memory/manager-search.ts:23
16.1 如果 sqlite-vec 可用
它会走数据库里的向量检索:
vec_distance_cosine(v.embedding, ?)
这意味着:
- queryVec 会转成 blob
- 在向量表里做 cosine distance
- 再 join 回 chunks 表
最后把距离转成分数:
score: 1 - row.dist16.2 如果 sqlite-vec 不可用
它就退化成:
- 把所有 chunk embedding 拉出来;
- 在 JS 里算
cosineSimilarity(); - 再排序取 top-k。
也就是说:
vector search 有数据库加速路径,也有纯 JS fallback 路径。
这是 OpenClaw 很典型的“可退化工程设计”。
17. 第十四站:searchKeyword() 具体怎么查
同一个文件里:
extensions/memory-core/src/memory/manager-search.ts:139
关键点:
- 它先
buildFtsQuery(raw) - 再对 FTS 表执行
MATCH - 使用
bm25(...) AS rank - 再把 rank 转成 0~1 左右的
textScore
注意这里不是直接拿 BM25 原始值做最终分数,而是经过:
bm25RankToScore(row.rank)
然后统一进入后面的融合步骤。
这一步说明:
- OpenClaw 不是“把两个结果简单拼一起”;
- 它有一个明确的 score normalization / merge 思路。
18. 第十五站:hybrid merge 发生在哪里
回到 manager.ts,关键函数:
mergeHybridResults(...)
位置:
extensions/memory-core/src/memory/manager.ts:500
这一层会把:
- vector results
- keyword results
都转换成统一结构,再带着:
vectorWeighttextWeightmmrtemporalDecay
交给真正的 merge 实现。
这里你要建立一个很重要的直觉:
recall 结果不是“搜到了就直接返回”,而是经过一个排名重建过程后再返回。
所以 RAG 质量不只是看 embedding,好不好很多时候看的是:
- merge 策略
- cut-off 策略
- MMR
- temporal decay
19. 第十六站:结果是怎么变成最终 tool payload 的
回到 tools.ts。
在 createMemorySearchTool 里,真正拿到 raw results 后会做这些事:
- 解析 citations mode
- 判断当前 session 是否应显示 citation
- 调用
memory.manager.search(...) - 读取
memory.manager.status() - 对 raw results 做
decorateCitations(...) - 如果是 QMD,按 injected chars 做裁剪
- 返回
jsonResult(...)
对应位置:
extensions/memory-core/src/tools.ts:41extensions/memory-core/src/tools.ts:52extensions/memory-core/src/tools.ts:57extensions/memory-core/src/tools.ts:58
也就是说:
manager 负责“搜到什么”,tool 层负责“怎么把搜到的东西包装给模型看”。
20. 第十七站:citation 是什么时候加上的
文件:
extensions/memory-core/src/tools.citations.ts
核心函数:
resolveMemoryCitationsMode()shouldIncludeCitations()decorateCitations()
20.1 decorateCitations() 做了什么
它会给每个 result:
- 生成 citation 字符串
- 格式像
path#Lx-Ly - 再把它 append 到 snippet 后面
对应位置:
extensions/memory-core/src/tools.citations.ts:16
具体格式逻辑在:
extensions/memory-core/src/tools.citations.ts:30
20.2 为什么 citation 不在 manager 层做
因为 citation 更像“面向展示和 prompt 的格式化”,不是纯检索引擎逻辑。
manager 层应该尽量只负责:
- 结果是什么
- path / line range / snippet / score 是什么
tool 层再决定要不要展示 citation。
这也是分层合理的一个体现。
21. 第十八站:结果到底怎么进入模型“可见范围”
这点特别容易误解。
很多人会问:
memory_search 返回之后,是不是系统又偷偷把结果写进某个隐藏 prompt 里?
默认最直接的理解应该是:
memory_search的返回值本身就是 tool result,而 tool result 本来就是模型当前上下文的一部分。
也就是说:
- 模型调用 tool
- tool 返回 JSON payload
- 这个 payload 出现在当前运行的工具结果里
- 模型随后基于这个结果继续推理或调用
memory_get
所以不一定要有一个专门叫“inject memory into prompt”的函数。
在 OpenClaw 的 agent 体系里:
- tool result 本身就是 prompt 生态的一部分。
21.1 那 chat-transcript-inject.ts 是干什么的
文件:
src/gateway/server-methods/chat-transcript-inject.ts
这个文件的作用更偏向:
- 向 transcript 里追加一条注入型 assistant message;
- 保证 parentId chain 不断。
它不是默认 memory_search 主链路的必经步骤,但能帮助你理解:
- OpenClaw 对“注入型消息”是怎么安全写进 transcript 的。
22. 第十九站:memory_get 为什么是另一条重要支线
很多新手以为 memory_search 找到结果后,系统就一定把整段大文本塞给模型。
实际上 OpenClaw 给了一个更细的后续动作:
memory_get
定义在:
extensions/memory-core/src/tools.ts:81
22.1 memory_get 做什么
它接收:
pathfromlines
然后根据 backend 分两种情况:
builtin backend
- 调用
readAgentMemoryFile(...)
非 builtin / status 情况
- 通过 manager 的
readFile(...)
这说明:
memory_get是 recall 之后的“精准读原文”步骤。
它能让模型避免把整个文件无脑读进上下文,而是只读需要的部分。
23. 第二十站:readFile() 最后落到哪里
在 builtin manager 里,对应:
extensions/memory-core/src/memory/manager.ts:673
MemoryIndexManager.readFile(...) 最终调用:
readMemoryFile(...)
它会根据:
- workspaceDir
- extraPaths
- 相对路径
- 起始行
- 行数
去返回:
{ text, path }
这一步非常重要,因为它告诉你:
- recall 的“搜索结果”只是线索;
- 真正要精读文件,还得走 read path。
这和搜索引擎很像:
- 搜索结果页 ≠ 原文页。
24. 第二十一站:索引是怎么被建立的
虽然这篇主线是 query → recall,但你最好顺便知道索引生成逻辑在哪。
关键文件:
extensions/memory-core/src/memory/manager-embedding-ops.ts
24.1 indexFile(...)
位置:
extensions/memory-core/src/memory/manager-embedding-ops.ts:589
它会做这些事:
- 把文件内容 chunk 化;
- 对 chunk 做 embedding;
- 生成 chunk id;
- upsert 到
chunks表; - 如果向量表可用,再写
chunks_vec; - 如果 FTS 可用,再写
chunks_fts; - 更新 files 表记录。
这说明整个 memory-core 默认索引存储其实是一个小型本地检索引擎。
24.2 它还带 embedding cache
你会看到:
embedding_cacheloadEmbeddingCache()upsertEmbeddingCache()
这说明 OpenClaw 也考虑到了:
- 文件稍改一点就全量重 embed 很浪费;
- 相同 chunk 可复用 embedding。
这对长期使用非常重要。
25. 第二十二站:为什么这条链路值得学
这套源码链路很适合拿来学习一个真正可运行的 RAG/记忆系统,因为它同时包含:
25.1 工具层
- tool schema
- lazy runtime load
- agent scope resolve
25.2 后端抽象层
- builtin vs QMD
- fallback manager
- borrowed manager
25.3 检索层
- query embedding
- vector search
- keyword search
- hybrid merge
25.4 结果格式层
- citation formatting
- result shaping
- json payload
25.5 读取层
- search-first
- read-later
这几层拼起来,基本就是一个现代 agent memory/RAG 体系的缩影。
26. 你读源码时最值得盯住的数据结构
如果你接下来要真正逐行看代码,我建议重点关注这些“数据形状”:
26.1 tool input
MemorySearchSchema:
querymaxResultsminScore
位置:
extensions/memory-core/src/tools.shared.ts:21
26.2 search result
结果里最核心的字段通常是:
pathstartLineendLinescoresnippetsource
这些字段是你理解 recall payload 的关键。
26.3 tool output
memory_search 最后会返回:
resultsprovidermodelfallbackcitationsmode
位置:
extensions/memory-core/src/tools.ts:65
26.4 read result
memory_get 最终是:
{ path, text }
这是 search → read 两阶段设计的第二阶段数据形状。
27. 你可以自己做一个“手工追踪练习”
如果你真的想把这套源码吃透,我建议你亲手做这条练习:
练习题
假设模型发起:
{ "query": "我们之前关于 memory flush 的讨论是什么?", "maxResults": 3, "minScore": 0.35}你就按下面顺序追:
createMemorySearchTool()收到参数后在哪里读querygetMemoryManagerContext()如何拿到 managergetMemorySearchManager()为什么默认落到 builtinMemoryIndexManager.search()是怎么决定 hybrid 路线的embedQueryWithTimeout()在什么时候被调用searchKeyword()和searchVector()分别返回什么decorateCitations()如何修改 snippetjsonResult(...)最终包出来是什么样子
只要你能完整回答这 8 步,你就真的掌握默认 memory-core recall 主线了。
28. 一个非常重要的工程理解:谁负责“事实”,谁负责“展示”
这套源码最值得你学习的一个点,是职责分离做得很清楚。
28.1 manager 负责“事实”
manager 层关心:
- 有没有结果
- score 是多少
- path / line range 是多少
- 哪个 provider
- 哪种 backend
28.2 tools 层负责“展示给模型看”
tools 层关心:
- citation 要不要加
- snippet 怎么包装
- JSON payload 长什么样
- 出错时 warning / action 怎么提示
28.3 这为什么重要
因为以后你自己设计聊天 AI 时,也会反复碰到这类问题:
- retrieval engine 不该掺太多 UI / prompt 格式逻辑;
- tool/result formatting 也不该污染底层检索逻辑。
OpenClaw 在这点上是个不错的参考。
29. 再给你一个最终压缩版链路
如果以后你忘了,可以只记这 12 步:
memory_search在tools.ts被定义tools.shared.ts解析 agent/contextloadMemoryToolRuntime()懒加载 runtimetools.runtime.ts导出 manager / file runtime 能力memory/index.ts暴露getMemorySearchManagersearch-manager.ts决定 builtin 还是 QMD- 默认落到
MemoryIndexManager.get() MemoryIndexManager.search()进入 recall 主流程embedQueryWithTimeout()得到 query vectorsearchKeyword()+searchVector()得到候选mergeHybridResults()重排融合decorateCitations()+jsonResult()返回给模型
接着如果模型要精读:
- 调
memory_get - 走
readAgentMemoryFile()或manager.readFile() - 返回
{ path, text }
这就是默认 memory-core 从 query 到 recall result 的完整主线。
30. 最后总结
如果第一篇解决的是:
- “OpenClaw 的长期记忆是什么?”
那这一篇解决的就是:
- “OpenClaw 的默认 memory-core 在代码里到底怎么跑?”
你现在应该已经能建立这样一个理解:
memory_search不是一个孤零零的函数,而是一条跨越 tool 层、runtime 桥接层、backend 选择层、manager 层、embedding 层、检索层、结果格式层的完整调用链。
这个理解一旦建立起来,你之后再看:
- QMD
- session memory
- memory flush
- memory-lancedb
就会轻松很多,因为你已经抓住默认主干了。
31. 建议你下一篇继续看什么
如果你还想继续,我最推荐的第三篇题目是:
《OpenClaw memory flush 源码精读:它为什么不是简单的“刷盘”,而是上下文压缩前的记忆沉淀机制》
这一篇会专门讲:
- compaction 和 memory flush 的关系
- token threshold 怎么算
- silent turn 是怎么触发的
- 为什么这个设计对长期 agent 很关键