OpenClaw 是最近 AI 圈最火的一个开源项目,没有之一。
从去年的 Agent 年,到今年的 AI 个人助理,OpenClaw 和去年 Manus 一样的,爆到不行,而且还是开源的版本。
由于最近自己也在做 Agent,于是也看了 OpenClaw 的代码来了解其记忆系统的实现。有一些觉得可以借鉴学习的地方。
OpenClaw 的记忆系统其实比较简单:它把「记忆」拆成了文件、索引、召回注入。
1. 「Agent 记忆系统」的定义
在 Agent 工程里,记忆是一套能力组合:
-
持久化:跨会话保存事实、偏好、决策、未完成事项。 -
可检索:能在需要时把相关片段拉出来,且可控预算。 -
可注入:把召回结果以确定的结构进入模型上下文,不靠「它自己想起来」。 -
可审计:出了错能定位「写入发生在什么时候」「召回命中了什么」「注入了哪些行」。 -
可治理:能处理泄露风险、过期信息、重复信息、冲突信息。
OpenClaw 的实现路径非常「工程」:Markdown 作为事实源,SQLite 作为检索索引,toolResult 作为注入通道。
2. OpenClaw 的三层记忆
OpenClaw 的记忆从存储逻辑上来看可以分为三层:
2.1 会话记忆
-
介质:内存为主,但 OpenClaw 会把 session 打印成类似日志的文件,放到 sessions目录。 -
内容:用户消息、OpenClaw 的思考过程、工具调用、skill 调用、最终回复。 -
边界:会话结束后「可用性」就不可靠了。你能在文件里回放,但模型下一次对话并不会天然带着它。
会话记忆更像「trace」。我们不能指望它解决跨会话连续性,只用它做排障、复盘、抽取素材(写入短期/长期)。
2.2 短期记忆
-
介质:磁盘, memory/YYYY-MM-DD.md为主(参考内容给了例子2026-03-10.md)。 -
内容:当天重要事件、过程笔记、TODO。关键点是「重要性」由人设与调教决定。 -
边界:短期记忆是追加式日志,质量会漂移。写得越多,噪声越大;但写得太少,又召回不到。
短期记忆适合承接「会话压缩之前的落盘」和「跨几天的上下文连续性」。它不是最终事实源,别把它当永久协议文档。
2.3 长期记忆
-
介质:工作区根目录 MEMORY.md(参考内容明确)。 -
内容:核心认知与关系、偏好风格、长期目标、进行中任务、关键事件/教训/决策。 -
边界:参考内容强调「只在主会话加载」,群聊等任务不加载,避免泄露。
MEMORY.md ==「可执行的组织记忆」。它的价值不在于「写得多」,在于「冲突少、可被召回、能约束后续行为」。这层要治理,要像维护配置一样维护。
3. OpenClaw 的文件布局
3.1 「会话快照」文件
快照文件主要是解决 /new、/reset 指令的断片
session-memory 的 Hook 会在你执行 /new 或 /reset 前,把上一会话最近 N 条对话(默认 15 条)抽出来,写成一个 Markdown 文件放到 workspace/memory/ 下。
-
命名: YYYY-MM-DD-<slug>.md,slug 通常由 LLM 根据主题生成;LLM 不可用就回退成HHMM。 -
关键点:这种文件也会被检索索引到。原因是 listMemoryFiles()会递归扫描workspace/memory/下所有.md,并不要求必须是YYYY-MM-DD.md(src/memory/internal.ts:115-145)。
这个机制对「人类工作流」很友好。很多团队的真实使用是:今天临时开了个话题,明天又忘了开在另一个会话里。会话快照能把碎片变成可检索素材,后面再沉淀进 MEMORY.md。
3.2 memory/main.sqlite:索引库
-
memory/main.sqlite基本可以确定是「记忆搜索(memory_search)」用的 SQLite 索引库。 -
索引对象: MEMORY.md、memory/**/*.md,以及你配置的extraPaths,可选 session transcripts。 -
检索方式:FTS/BM25 关键词检索 +(可选)向量相似度检索(sqlite-vec)。 -
它存的典型结构: files、chunks、embedding_cache,以及可选的 FTS 表、向量虚表。
事实源是文本文件,索引是可重建的派生物。索引坏了你删掉重建就行;事实源坏了才是真的坏。
4. 怎么建索引:
建索引我们关心的三件事:增量、去重、成本
OpenClaw 的索引构建不是「每次全量重算」,也不是「精细 diff」:
-
更新粒度:按文件做增量。文件没变直接跳过。 -
分块粒度:文件变了就重建该文件 chunks。 -
向量成本:chunk embedding 通过缓存复用,避免重复调用 embedding provider。
4.1 扫描哪些文件会进索引
OpenClaw 会递归扫描 workspace/memory/ 下所有 .md,并包含 MEMORY.md/memory.md。对应定位是 src/memory/internal.ts:115-145。
4.2 文件级 hash:没变就跳过
文件 hash 的策略:
-
Markdown: hash = sha256(content)(internal.ts:L245-L263) -
多模态:buffer 也会 hash,最后把 {path, contentText, mimeType, dataHash}做 JSON 再 sha256(internal.ts:L204-L243) -
增量判定:对比 files表里的 hash,一致就跳过(参考内容列了 memory 文件与 session 文件两条路径)。
hash 是增量的核心。它的意义不止省时间,还省钱:embedding provider 往往是计费点。这种主要是对于使用第三方 embedding 的。
4.3 分块策略:tokens*4 的字符近似
chunkMarkdown() 的策略:
-
maxChars = tokens * 4,overlapChars = overlap * 4 -
以「行」为主,超长行会被切段 -
每块生成 hash=sha256(text),并有embeddingInput
tokens*4 这种近似在工程里挺常见,优点是简单、稳定、跨模型大差不差。缺点也明显:
-
语言差异会影响 token/char 比例;中英文混排时 chunk 尺寸会漂。 -
以行切块对 Markdown 友好,但对「一行很长的 JSON 或日志」不友好。
可以盯两个指标来调 chunking:
-
平均召回 snippet 的「可读性」和「自洽性」; -
SQLite 体积与索引更新耗时。chunk 太小召回碎,太大注入贵。
4.4 chunk 的唯一标识与 upsert
去重靠 id,chunk 写入策略如下:
-
chunk id:sha256("${source}:${path}:${startLine}:${endLine}:${chunk.hash}:${provider.model}") -
同一 id: ON CONFLICT(id) DO UPDATE覆盖更新 -
文件要重建时会先清旧再写新(参考内容总结了「清旧再写新」语义)
这里有个很实际的 trade-off:
-
它不做「chunk diff」,所以文件变了就重建该文件 chunks,逻辑简单,坏处是 IO 多。 -
但 embedding 通过缓存复用,把最贵的部分压下去了。
另外,id 里带了 provider.model,这会带来一个工程后果:embedding 模型换了,chunk id 会变,索引层面等价于全量重建。 provider/model/providerKey 变化会触发 full reindex。
4.5 embedding_cache
embedding 缓存的主键设计:
-
主键: (provider, model, provider_key, hash) -
provider_key会把 endpoint/headers 等纳入指纹,避免跨配置污染缓存(而且会剔除授权头的细节在参考内容里提到)。 -
批量加载命中就跳过 embed;miss 才请求,成功回写缓存(对应 manager-embedding-ops.ts的行段)。
这是「上线能用」的关键。否则就会遇到一个很尴尬的情况:
索引更新频繁触发 embedding 重算 → 延迟抖动 → API 费暴涨 → 还可能被 provider 限流。 system prompt 的 skills 段落甚至提醒「假设有 rate limits,避免 tight loop」,这就有点被打过之后写进规范的味道。
5. 更新怎么触发
watch、interval、onSearch、session-delta
索引更新如果做得「过勤」,会把 CPU 和 IO 吃满;做得「过懒」,召回就是过期的。OpenClaw 在触发上给了多条路径:
-
watch:chokidar 监听 MEMORY.md、memory.md、memory/**/*.md(以及 extraPaths、多模态扩展),变更标记 dirty,debounce 后sync(reason="watch")。 -
interval: sync.intervalMinutes>0就setInterval定时跑。 -
onSessionStart:search 前 warmSession(sessionKey),若开了sync.onSessionStart,每个 sessionKey 首次触发后台 sync。 -
onSearch:search 时如果 dirty 且开了 sync.onSearch,后台触发 sync。 -
session-delta:监听 transcript 更新,累计新增 bytes/lines,达到阈值后把相关 session 文件标脏,再 sync。
还有两点:
-
单飞锁:同一时刻只跑一个 sync,复用同一个 Promise(参考内容定位 manager.ts:452-467)。 -
全量重建的原子 swap:写到 .tmp-UUID,完成后 swap(含wal/-shm),避免半成品索引(参考内容定位manager-sync-ops.ts:1050-1158)。
6. 召回是怎么发生的
主要看 buildMemorySection() 的代码,因为它把策略写死了:
提示词:
function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) {
const trimmed = params.skillsPrompt?.trim();
if (!trimmed) {
return [];
}
return [
"## Skills (mandatory)",
"Before replying: scan <available_skills> <description> entries.",
`- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${params.readToolName}\`, then follow it.`,
"- If multiple could apply: choose the most specific one, then read/follow it.",
"- If none clearly apply: do not read any SKILL.md.",
"Constraints: never read more than one skill up front; only read after selecting.",
"- When a skill drives external API writes, assume rate limits: prefer fewer larger writes, avoid tight one-item loops, serialize bursts when possible, and respect 429/Retry-After.",
trimmed,
"",
];
}
function buildMemorySection(params: {
isMinimal: boolean;
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}) {
if (params.isMinimal) {
return [];
}
if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) {
return [];
}
const lines = [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
];
if (params.citationsMode === "off") {
lines.push(
"Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.",
);
} else {
lines.push(
"Citations: include Source: <path#line> when it helps the user verify memory snippets.",
);
}
lines.push("");
return lines;
}
工具描述:
label: "Memory Search",
name: "memory_search",
description:
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos; returns top snippets with path + lines. If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.",
parameters: MemorySearchSchema,
有三点:
-
触发条件写得具体:prior work / decisions / dates / people / preferences / todos。模型不需要猜「算不算记忆相关」。 -
两阶段召回:先 memory_search找片段,再memory_get精读少量行,控制注入体积。 -
引用策略可控: citationsMode可以关掉,避免模型动不动把路径行号甩出来(对产品形态很重要)。
两阶段召回比「一次性把相关文件 wholefile 塞进去」靠谱太多。
7. 召回结果怎么进上下文
toolResult 消息是关键通道
很多人以为「记忆」是把内容写进 system prompt。OpenClaw 不是这么干的。
召回结果会以工具执行结果的形式进入会话消息列表,后续模型调用自然「看得到」。
-
工具返回会被包装成 JSON 文本块(参考内容定位 jsonResult()在src/agents/tools/common.ts:230-239)。 -
tool 返回会被标准化成 content[] + details(参考内容定位src/agents/pi-tool-definition-adapter.ts的 normalize)。 -
这些 toolResult 会被追加到 session messages,下一次模型调用会携带。
这条通道对排障非常友好:我们可以在 transcript 里看到「这次回答之前它到底召回了什么」。而且 toolResult 天然可控预算、可控格式,比让模型把记忆揉进自由文本稳得多。
8. 写入时机
8.1 会话快照写入
人为触发的「切会话」
当执行 /new、/reset 时,上一段会话尾部会被抽取成 YYYY-MM-DD-<slug>.md。这是「防断片」写入,价值是保住最近上下文。
坑:
-
抽取的 N 条对话里可能包含敏感信息。它会落在 memory/,并进入索引。 -
如果你把 workspace 目录同步到团队共享盘或提交到 repo,泄露面会扩大。
我们的做法:
-
明确区分「个人 workspace」和「团队 workspace」。个人的 memory/默认不进 repo。 -
开启 citations 时,产品侧要想清楚是否允许暴露路径与行号。
8.2 短期记忆写入
「需要我们调教,告诉她哪些重要」。
短期记忆要走「稀疏高密度」路线:条目少,但每条都能在未来的某个问题上直接复用。写入策略要围绕「将来会搜什么」来定,不要围绕「当下发生了什么」来记流水账。
8.3 长期记忆更新
心跳 / AGENTS.md / cron
三种更新机制:心跳、核心流程、cron。
读最近几天短期记忆 → 选值得长期记住的 → 提炼写入 MEMORY.md。
观点:
-
cron 最稳,可观测、可控、可回滚。 -
心跳更新很容易在负载高时抖动,或者在你最不想更新的时候更新。 -
把它塞进核心流程(AGENTS.md)要谨慎,一旦每次任务都触发提炼,会把延迟拉上去。
一般做法:
-
工作日每天固定一次 consolidation(cron)。 -
遇到重大决策或事故复盘,当天手动提炼写入 MEMORY.md,不等自动化。
9. 怎么「调教」
把记忆当成协议,不当成日记
「告诉她哪些重要」。把「重要」拆成几类,每类有明确写入规则,避免模型自由发挥。
一般的规则:
-
稳定偏好:例如输出格式偏好、技术栈偏好、代码风格偏好。 -
组织事实:团队结构、系统边界、核心服务依赖、环境约束。 -
关键决策:ADR 级别的决策,包含时间点与理由。 -
长期目标与在途事项:能跨周追踪的,不写「今天要做的」。 -
事故教训:明确到「哪个坑踩过」「如何避免」。
短期记忆(memory/YYYY-MM-DD.md)会允许更多过程性信息,但要满足一个条件:未来能被搜索问题命中。比如你写「今天讨论了 A」,基本没用;你写「决定 A 的原因是 B,后续若出现 C 用 D 回滚」,会有用一些。
如果希望 memory_search 在关键时刻召回到正确内容,就得用「未来的查询语句」来写记忆。
以上。