标签归档:Memory System

聊下 OpenClaw 的记忆系统

OpenClaw 是最近 AI 圈最火的一个开源项目,没有之一。

从去年的 Agent 年,到今年的 AI 个人助理,OpenClaw 和去年 Manus 一样的,爆到不行,而且还是开源的版本。

由于最近自己也在做 Agent,于是也看了 OpenClaw 的代码来了解其记忆系统的实现。有一些觉得可以借鉴学习的地方。

OpenClaw 的记忆系统其实比较简单:它把「记忆」拆成了文件索引召回注入

1. 「Agent 记忆系统」的定义

在 Agent 工程里,记忆是一套能力组合:

  1. 持久化:跨会话保存事实、偏好、决策、未完成事项。
  2. 可检索:能在需要时把相关片段拉出来,且可控预算。
  3. 可注入:把召回结果以确定的结构进入模型上下文,不靠「它自己想起来」。
  4. 可审计:出了错能定位「写入发生在什么时候」「召回命中了什么」「注入了哪些行」。
  5. 可治理:能处理泄露风险、过期信息、重复信息、冲突信息。

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.mdsrc/memory/internal.ts:115-145 )。

这个机制对「人类工作流」很友好。很多团队的真实使用是:今天临时开了个话题,明天又忘了开在另一个会话里。会话快照能把碎片变成可检索素材,后面再沉淀进 MEMORY.md

3.2 memory/main.sqlite:索引库

  • memory/main.sqlite 基本可以确定是「记忆搜索(memory_search)」用的 SQLite 索引库。
  • 索引对象:MEMORY.mdmemory/**/*.md,以及你配置的 extraPaths,可选 session transcripts。
  • 检索方式:FTS/BM25 关键词检索 +(可选)向量相似度检索(sqlite-vec)。
  • 它存的典型结构:fileschunksembedding_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 * 4overlapChars = overlap * 4
  • 以「行」为主,超长行会被切段
  • 每块生成 hash=sha256(text),并有 embeddingInput

tokens*4 这种近似在工程里挺常见,优点是简单、稳定、跨模型大差不差。缺点也明显:

  • 语言差异会影响 token/char 比例;中英文混排时 chunk 尺寸会漂。
  • 以行切块对 Markdown 友好,但对「一行很长的 JSON 或日志」不友好。

可以盯两个指标来调 chunking:

  1. 平均召回 snippet 的「可读性」和「自洽性」;
  2. SQLite 体积与索引更新耗时。chunk 太小召回碎,太大注入贵。

4.4 chunk 的唯一标识与 upsert

去重靠 id,chunk 写入策略如下:

  • chunk idsha256("${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 在触发上给了多条路径:

  1. watch:chokidar 监听 MEMORY.mdmemory.mdmemory/**/*.md(以及 extraPaths、多模态扩展),变更标记 dirty,debounce 后 sync(reason="watch")
  2. intervalsync.intervalMinutes>0setInterval 定时跑。
  3. onSessionStart:search 前 warmSession(sessionKey),若开了 sync.onSessionStart,每个 sessionKey 首次触发后台 sync。
  4. onSearch:search 时如果 dirty 且开了 sync.onSearch,后台触发 sync。
  5. 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,

有三点:

  1. 触发条件写得具体:prior work / decisions / dates / people / preferences / todos。模型不需要猜「算不算记忆相关」。
  2. 两阶段召回:先 memory_search 找片段,再 memory_get 精读少量行,控制注入体积。
  3. 引用策略可控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. 怎么「调教」

把记忆当成协议,不当成日记

「告诉她哪些重要」。把「重要」拆成几类,每类有明确写入规则,避免模型自由发挥。

一般的规则:

  1. 稳定偏好:例如输出格式偏好、技术栈偏好、代码风格偏好。
  2. 组织事实:团队结构、系统边界、核心服务依赖、环境约束。
  3. 关键决策:ADR 级别的决策,包含时间点与理由。
  4. 长期目标与在途事项:能跨周追踪的,不写「今天要做的」。
  5. 事故教训:明确到「哪个坑踩过」「如何避免」。

短期记忆(memory/YYYY-MM-DD.md)会允许更多过程性信息,但要满足一个条件:未来能被搜索问题命中。比如你写「今天讨论了 A」,基本没用;你写「决定 A 的原因是 B,后续若出现 C 用 D 回滚」,会有用一些。

如果希望 memory_search 在关键时刻召回到正确内容,就得用「未来的查询语句」来写记忆。

以上。