OpenClaw 记忆系统的做法是给 Agent 配一套文件级的记忆体系。下面这张图是完整的搜索流程,后文会把这个流程讲清楚。

记忆从哪来
Agent 每次发起会话是空的。但系统会注册三类文件作为它的记忆来源(源码 packages/memory-host-sdk/src/host/backend-config.ts)。
第一类是 MEMORY.md,workspace 根目录下的长期记忆文件。它在每次会话启动时直接注入上下文窗口,Agent 不用搜就能查到里面的内容。真实内容长这样:
# MEMORY.md - DocPilot Agent 长期记忆
## 项目概况
- DocPilot 是文档智能体产品(全链路文档生成与诊断)
- 源码路径:xxx/文档智能体架构研究/
- 架构:Agent + Operator 双层,沙箱执行环境
## Promoted From Short-Term Memory (2026-07-13)
- 根因确认:线上 Pod 报 COOKIE_EXPIRED 不是真的过期;
问题在 inject-cookies.cjs 的 CDP Network.setCookies 注入方式有 bug
- v3 发现 SameSite=None + secure=false 被 Chromium 拒绝:
18/21 个 Cookie 被丢弃;修复:secure Cookie → None,non-secure → Lax
- CronJob 频率改为每 4 小时:从 0 3,11,19 * * * → 0 */4 * * *
这些内容不是人自己手写的。是 Agent 自己在之前的工作中觉得"这个结论以后还会用到",主动写进去的(和Dream机制有关,后续文章会分享)。
第二类是 memory/ 目录下按日期组织的工作日记:
# 2026-07-09 Memory
## Cookie 注入问题定位完成
### 根因确认
- 线上 Pod 报 COOKIE_EXPIRED 不是真的过期
- ConfigMap baidu-cookies(21 个 Cookie)通过 curl 测试返回 200
- CronJob refresh-bce-cookie 每 8 小时正常刷新
- 问题在 inject-cookies.cjs 的 CDP Network.setCookies 注入方式有 bug:
- 缺少 url 字段
- sameSite 默认值不对
- 验证等待时间 6s 不够
- 判断条件过宽
Agent 每次工作结束时自动把重要结论写入当天的日记。这些日记不会在启动时加载(太多了),但会被建立索引,需要时通过搜索召回。
第三类是通过 extraPaths 配置纳入的外部目录:
memorySearch:
enabled: true
extraPaths:
- /Users/dapeng/资料/docpilot_debug/docpilot_debug # Obsidian 调试笔记
- /Users/dapeng/学习资料/文档智能体架构研究/xxx # DocPilot 源码仓库
默认索引模式是 **/*.md(源码 resolveCustomPaths 函数:let pattern = entry.pattern?.trim() || "**/*.md")。.ts、.go、.json 等源码文件不进入索引——配了源码仓库的 extraPath,实际索引的是仓库里的 README 和文档。这个 pattern 可以自定义(改成 **/*.ts),但分块策略是按 Markdown 行结构设计的,对代码文件效果不佳。
光纳入索引解决的是"能搜到"。Agent 搜到一个文件后还需要理解上下文——0709/ 是什么?dot/ 目录能不能改?这通过 TOOLS.md 告诉它:
### Obsidian
- Vault: docpilot_debug(路径 /Users/dapeng/资料/docpilot_debug/docpilot_debug)
- 结构:MMDD 日期目录(0304~),每个目录下含:
- dot/ — Graphviz .dot 源文件
- images/ — 渲染后的 .png 流程图
- *.md — 工作笔记(Bug 分析、架构设计等)
- 命名规范:子目录用 {主题}-{动作} 格式
### 代码仓库
- 路径:/Users/dapeng/学习资料/文档智能体架构研究/xxx
- 用途:DocPilot 源码(Agent、Operator、前端、后端)
- 技术栈:TypeScript/Node.js 为主
有了这层描述,Agent 搜到 0429/code-johari-window-deploy-env.md 就知道这是 4 月 29 日的部署环境记录,而不是一个意义不明的路径。
文件怎么变成可搜索的
三类文件注册完毕后,所有 .md 文件都会经过分块、向量化、写入存储这个流程。
分块
源码 packages/memory-host-sdk/src/host/internal.ts:387中的chunkMarkdown` 函数,一个长文件不能整体做 embedding,因为文本太长时向量会丢失细节。所以逐行读取,按字符权重累加,到 1600 字符(= 400 token × 4)时 flush 一块,同时保留末尾 320 字符(= 80 token × 4)作为下一块开头。
CJK 字符每个计为 4(源码 src/utils/cjk-chars.ts,常量 CHARS_PER_TOKEN_ESTIMATE = 4)——因为 1 个汉字约等于 1 个 token,而 4 个拉丁字母才约等于 1 个 token。不做加权的话,400 个汉字和 400 个英文字母会被当作同样大的块,但前者实际消耗的 token 是后者的 4 倍。加权后不管中英文,每块稳定在约 400 token。
核心逻辑伪代码:
maxChars = 400 × 4 = 1600
overlapChars = 80 × 4 = 320
逐行读取:
累加 estimateStringChars(line) // 拉丁 1:1,CJK 1:4
超过 maxChars → flush 当前块 {startLine, endLine, text, hash}
保留末尾 overlapChars → 下一块开头
单行超长:按 maxChars 切片;CJK 仍超则再按 400 字符细切
每个 chunk 产出 {startLine, endLine, text, hash}——记住它来自原文件的哪几行,后面召回时要用。
向量化
每个分块独立调用 OpenAI text-embedding-3-small(1536 维),生成一条 embedding,源码中常量 DEFAULT_MEMORY_EMBEDDING_PROVIDER = "openai",memory-search.ts:133。embedding 是一组浮点数,表示这段文本在语义空间中的位置。
存储
存储引擎是本地 SQLite,通过加载 sqlite-vec 扩展获得向量搜索能力(sqlite-vec 是 SQLite 的 loadable extension,源码 packages/memory-host-sdk/src/host/sqlite-vec.ts:sqliteVec.load(params.db))。每个 chunk 写入同一个 .sqlite 文件的三张表,用同一个 id 关联(源码 extensions/memory-core/src/memory/manager-embedding-ops.ts:753-798):
memory_index_chunks 是主表,存 chunk 原文、embedding JSON、元数据(文件路径、起止行号、hash、模型名、时间戳),带 UPSERT 去重。memory_index_chunks_vec 是 sqlite-vec 虚拟表,存 embedding 二进制 blob,用于向量近似最近邻搜索。memory_index_chunks_fts 是 FTS5 全文索引表,存 chunk 纯文本,用于关键词检索。
一个 chunk 存三份——原文一份、向量一份、纯文本一份。三张表各自优化各自的查询路径,搜索时分别查向量表和 FTS 表,再合并结果。
Agent 怎么想起来
当 Agent 工作中需要回忆——比如用户问"上次 Cookie 问题怎么解决的"——它调用 memory_search(query)。
系统同时走两条路。向量搜索对 memory_index_chunks_vec 做余弦相似度计算,权重 0.7。FTS 全文检索对 memory_index_chunks_fts 做 BM25 关键词匹配,权重 0.3。两路结果加权合并:总分 = 向量分 × 0.7 + FTS 分 × 0.3,低于 0.35 的丢弃,最多保留 6 条(源码常量 DEFAULT_MAX_RESULTS = 6、DEFAULT_MIN_SCORE = 0.35,memory-search.ts:122-123)。
为什么不纯用向量?语义搜索对专有名词(COOKIE_EXPIRED、inject-cookies.cjs)不够精确,FTS 能补上这块。
返回的是 chunk 片段,不是全文。每条结果包含 {path, startLine, endLine, snippet, score},snippet 从主表 text 字段截取,源码manager-search.ts:224snippet: truncateUtf16Safe(row.text, params.snippetMaxChars))。Agent 拿到片段后判断信息够不够用——不够就按行号回读磁盘原文件:memory_get(path="memory/2026-07-09.md", from=15, lines=30)。
相邻 chunk 之间 80 token 的重叠保证了边界信息不丢失,大多数情况下单条 chunk 自己就有足够上下文。需要更多时再回读。SQLite 里是检索副本,磁盘上的 .md 文件才是 source of truth。
记忆怎么流动
每次开新 session,MEMORY.md 直接注入上下文。工作中需要回忆旧事,Agent 调 memory_search 在所有集合中搜索。工作结束,Agent 把重要结论写入当天日记。定期提炼——系统把日记中反复被召回的高分内容 promote 到 MEMORY.md,下次启动直接可用。
日记是每天的摘要,MEMORY.md 是从每天摘要里沉淀。前者是需要工具才能搜到,后者每次启动直接加载。
夜雨聆风