标签归档:Agent

深入 Claude Code 源码了解其记忆系统

最近做 Agent 的同学应该大部分都有研读 Claude Code 泄漏的源码,网上出了各种 AI 加持下的各种解读,教程,细节分析,甚至包括换了一种语言实现的版本,如 Python,Go,Rust 等等。感觉有点「一鲸落,万物生」的感觉。

之前学习了 Claude Code 的系统提示词,写了一篇关于记忆系统的提示词。今天我们再深入其源码,看看其实现的细节。

从其源码来看,

Claude Code 这套记忆系统把几类完全不同的问题拆开处理了:长期记忆、当前轮相关记忆、会话压缩摘要、子代理独立记忆。和 OpenClaw 不同,OpenClaw 使用了统一的 Memory Service,加上一个向量库做检索,Claude Code 走的是另一条路:文件系统优先,分层清晰,召回时机明确,代价可控

1. Claude Code 到底要记了什么

在 memoryTypes.ts#L14-L31 里,长期记忆的类型是的四类:

  • user
  • feedback
  • project
  • reference

这四类东西有一个共同点:它们都不容易从当前代码状态直接推导出来。用户习惯、项目背景、团队约束、外部系统入口,这些信息不写下来,下次对话就丢了。反过来,代码结构、文件路径、Git 历史、当前临时任务,这些内容源码里明确要求不要进长期记忆,因为它们本来就有权威来源。memoryTypes.ts#L183-L195

记忆系统只该保存「代码外的信息」和「会跨轮次继续影响决策的信息」。以编程为例,当我们把代码事实也塞进去,后面一定会出现双份真相。你会遇到一个很尴尬的局面:代码说 A,memory 说 B,模型开始摇摆。

2. 四层分工

第一层是 auto memory / team memory。这是长期记忆,负责跨会话保存信息。目录逻辑在 paths.ts#L79-L259 和 teamMemPaths.ts#L66-L94。

第二层是 relevant memories。这一层不关心长期存储,它只负责一件事:用户当前这一问,应该把哪几条历史记忆临时塞进上下文。入口在 findRelevantMemories.ts#L39-L141 和 attachments.ts#L2197-L2425。

第三层是 session memory。这层服务的是长会话压缩,不负责跨会话记忆。位于当前 session 下的 summary.md。sessionMemory.ts#L183-L350

第四层是 agent memory。子代理如果要持久化自己的经验,可以放 user/project/local 三种 scope 的独立目录。agentMemory.ts#L12-L177

这四层拆开之后,很多设计选择就顺了:

  • 长期记忆用文件,便于审计和手工修复
  • 当前轮召回走轻量检索,减少 prompt 污染
  • 长会话压缩用单独 summary,避免每次 compact 都从头总结
  • 子代理隔离状态,减少串味

3. 长期记忆为什么选文件

Claude Code 的长期记忆是使用的 Markdown 文件。每条记忆一个文件,外加一个 MEMORY.md 入口索引。这部分规则在 memdir.ts#L199-L316 里写得很明白。

源码里的写入约束是这样的:

'## How to save memories',
'',
'Saving a memory is a two-step process:',
'',
'**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:',
'',
...MEMORY_FRONTMATTER_EXAMPLE,
'',
`**Step 2** — add a pointer to that file in \`${ENTRYPOINT_NAME}\`. \`${ENTRYPOINT_NAME}\` is an index, not a memory — each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. It has no frontmatter. Never write memory content directly into \`${ENTRYPOINT_NAME}\`.`,

实现位置见 memdir.ts#L219-L230。

还有有三个工程判断。

第一,MEMORY.md 只是索引,不承载正文。如果把所有记忆都堆到一个大文件里,前期简单,后期灾难。Claude Code 从一开始就做拆分,每条记忆单文件,这样更新一条信息时不会引起全量重写。

第二,frontmatter 强制有 description 字段,这个字段后面要参与召回。很多团队做知识条目,只写正文,不写检索摘要,最后靠 embedding 硬扛。Claude Code 反过来,它要求记忆写入阶段就产出一条高质量摘要。召回质量在写入那一刻就埋下去了。

第三,完全基于文件系统,调试成本低。你可以直接去目录里看文件,团队同步时还能走 Git 或远端同步链路。数据库方案最大的问题不在性能,在可观察性。出了问题你要查 schema、查索引、查 embedding 版本、查写入日志,排障很慢。

文件方案当然也有代价。文件一多,目录扫描成本会上升;MEMORY.md 入口过长也会逼近 prompt token 上限。Claude Code 后面靠动态召回机制兜住了这个问题,这个设计是连起来看的。

4. 写入链路

它怎么把记忆真正落盘

4.1 主模型直接写

长期记忆的第一条写入链路,是主模型自己写。系统 prompt 里已经告诉它记忆目录在哪、允许写什么、怎么写文件、什么时候写。

loadMemoryPrompt() 会把 memory rules 注入系统提示词,入口在 [loadMemoryPrompt] memdir.ts#L419-L507。这一段 prompt 并没有替模型做决策,它只是把写入协议放进脑子里:目录、类型、索引格式、读取时机、失效校验。

这意味着 Claude Code 对模型的假设很明确:模型可以自己判断「这条信息值不值得保存」,然后调用写文件工具去落盘。写入不是一个外置 API,写入就是普通文件操作。

这条路有个好处:反馈延迟很低。用户刚说完「记住这个偏好」,主模型当轮就能写,不用等后台任务。

4.2 后台抽取器补写

如果主模型这一轮没动手写,系统会在 turn end 触发后台抽取器。stop hook 在 stopHooks.ts#L141-L156:

if (
  feature('EXTRACT_MEMORIES') &&
  !toolUseContext.agentId &&
  isExtractModeActive()
) {
  void extractMemoriesModule!.executeExtractMemories(
    stopHookContext,
    toolUseContext.appendSystemMessage,
  )
}

真正逻辑在 extractMemories.ts#L329-L567。

这条链路里最重要的一段判断是:

if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
  logForDebugging(
    '[extractMemories] skipping — conversation already wrote to memory files',
  )
  ...
  return
}

位置见 extractMemories.ts#L345-L360。

它防的是双写。主模型已经写过,后台抽取器就别再重做一遍。很多系统做异步归档时忘了这件事,最后要么生成重复记忆,要么覆盖用户刚刚确认的内容。

后台抽取器的权限也非常收敛。createAutoMemCanUseTool() 明确规定,只准:

  • Read / Grep / Glob
  • 只读 Bash
  • memory 目录内的 Edit / Write

实现见 [createAutoMemCanUseTool] extractMemories.ts#L166-L222。

extractor 的职责:它只做归档,不许顺手验证代码,不许顺手修改业务文件,不许借机跑工具链。权限如果不锁死,后台代理迟早会从归档器膨胀成第二个主代理。

4.3 KAIROS 下的写法

KAIROS 模式更有意思。它不要求模型实时维护 MEMORY.md,新记忆先按天追加到日志文件里。规则在 [buildAssistantDailyLogPrompt] memdir.ts#L318-L370。

"This session is long-lived. As you work, record anything worth remembering by **appending** to today's daily log file:",
` \`${logPathPattern}\` `,
'Write each entry as a short timestamped bullet. Create the file (and parent directories) on first write if it does not exist. Do not rewrite or reorganize the log — it is append-only. A separate nightly process distills these logs into `MEMORY.md` and topic files.',

这条策略很适合长驻 Agent。会话存活时间长时,频繁重写 topic files 和索引很贵,冲突也多。先写 append-only 日志,夜间再蒸馏,吞吐更稳,模型也更不容易在白天工作时把记忆目录写乱。

5. 召回链路

它怎么决定哪段记忆该进来

Claude Code 的召回要分成两种看。

一种是静态注入,也就是固定随上下文加载的那些东西。另一种是动态召回,根据当前 query 临时挑选最相关的记忆文件。

很多系统只做前者,结果上下文越来越肥。很多系统只做后者,结果基本行为约束丢了。Claude Code 两条都做。

5.1 静态注入

getUserContext() 会构造一个 claudeMd 字段,位置在 context.ts#L155-L188。

核心调用是:

const claudeMd = shouldDisableClaudeMd
  ? null
  : getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))

getMemoryFiles() 的实现很长,在 claudemd.ts#L790-L1075。它会统一加载:

  • Managed 指令
  • User 指令
  • Project 指令
  • Local 指令
  • AutoMem 的 MEMORY.md
  • TeamMem 的 MEMORY.md

然后 getClaudeMds() 把这些文件串成提示词内容,[getClaudeMds] claudemd.ts#L1153-L1195。

它给模型一个稳定的全局工作框架。它会知道项目规则、用户偏好、团队共享记忆索引。它适合放那些「大方向会持续生效」的内容。

5.2 动态召回

静态注入解决不了所有问题。长期记忆正文一多,全部塞进 prompt 代价太高。Claude Code 的处理方式,是每轮用户发言后启动一个相关记忆预取。

入口在 query.ts#L297-L304:

using pendingMemoryPrefetch = startRelevantMemoryPrefetch(
  state.messages,
  state.toolUseContext,
)

这个预取不会阻塞主流程。到后面条件满足时再消费,query.ts#L1595-L1617。

真正检索逻辑在 attachments.ts#L2197-L2425。它会先决定搜索哪个目录:如果用户显式提到某个 agent,就搜 agent memory;否则搜 auto memory。

然后调用 [findRelevantMemories] findRelevantMemories.ts#L39-L141。

这里最有意思的点在于,它没有用向量库。它的步骤是:

  1. scanMemoryFiles() 扫 memory 目录里的 .md 文件,读 frontmatter,产出一个 manifest
    见 memoryScan.ts#L35-L94

  2. 用户 query + manifest 发给一个 sideQuery 模型
    见 findRelevantMemories.ts#L77-L141

  3. 让这个模型返回最多 5 个文件名

  4. 再去读取这些文件正文,截断到限定行数和字节数
    见 [readMemoriesForSurfacing] attachments.ts#L2280-L2333

这套方案的好处:

  • 没有 embedding 构建成本
  • 没有索引维护复杂度
  • manifest 很小,side query 很快
  • 召回逻辑对开发者可见,容易调

缺点也明确。召回质量强依赖 frontmatter 的 description。写入时 description 写差了,后面召回一定差。

6. 记忆怎么进入上下文

不是一处注入,是四处入口

很多人看 Agent 源码时老在问「上下文是在什么地方拼进去的」。这个问题本身就有误导性。Claude Code 里记忆的注入入口不止一个。

6.1 system prompt 入口

第一处是 system prompt。这里进来的内容主要是「记忆系统的使用规则」,比如什么时候读、什么时候存、什么时候验证失效。对应实现是 prompts.ts#L492-L527 调 loadMemoryPrompt()

这是行为层指令。

6.2 user context 入口

第二处是 getUserContext() 构造的 claudeMd。这里进来的是 CLAUDE.md、rules、MEMORY.md 这种比较稳定的文本。context.ts#L155-L188

这是稳定背景层。

6.3 attachment 入口

第三处是 relevant memory attachment。被召回的正文不会直接拼到 claudeMd,而是先变成 attachment,再由 messages.ts#L3743-L3756 包装成 <system-reminder>

return wrapMessagesInSystemReminder(
  attachment.memories.map(m => {
    const header = m.header ?? memoryHeader(m.path, m.mtimeMs)
    return createUserMessage({
      content: `${header}\n\n${m.content}`,
      isMeta: true,
    })
  }),
)

这意味着这些记忆是临时的、按轮次加载的、带 freshness header 的系统提醒。它的优先级和普通用户消息不同。

6.4 compact summary 入口

第四处是 session memory compact。上下文过长后,系统会把会话前半段替换为一条 summary message,summary 内容来自 summary.md 的裁剪版。sessionMemoryCompact.ts#L437-L503

这是上下文续命层。

四处入口分工以后,就能看明白为什么 Claude Code 的行为相对稳定:规则、稳定背景、临时相关信息、压缩摘要,各走各的通道,互相不抢角色。

7. 真正的压缩发生在哪里

很多人一听「记忆系统」,第一反应是长期 memory 压缩。Claude Code 里最成熟的压缩逻辑,实际上落在 session memory 上。

前面说过,session memory 是 summary.md,它本身就是会话结构化摘要。维护逻辑在 sessionMemory.ts#L272-L350。

当上下文真的不够时,系统优先尝试 trySessionMemoryCompaction(),sessionMemoryCompact.ts#L514-L619。

它的动作顺序:

  1. 先确认 session memory 功能和 compact 功能都开着
  2. 等待正在进行中的 session memory 抽取结束
  3. 读取 summary.md
  4. 如果还是空模板,放弃,退回传统 compact
  5. 计算需要保留的 recent messages 窗口
  6. summary.md 的裁剪版构造 compact summary
  7. 组装 boundary + summary + recent messages + attachments + hooks

这里的「保留 recent messages」特别关键。作者没有图省事把所有旧消息都抹掉,而是保留一段最近窗口。窗口大小由 [DEFAULT_SM_COMPACT_CONFIG] sessionMemoryCompact.ts#L56-L66 定义,默认:

  • minTokens = 10000
  • minTextBlockMessages = 5
  • maxTokens = 40000

保留窗口的计算在 [calculateMessagesToKeepIndex] sessionMemoryCompact.ts#L324-L397。

这个策略解决的问题是:摘要永远会损失细节,最近一段工作现场最好保留原始消息,模型续做时不至于失真。要是所有内容都只剩 summary,模型会失去工具调用上下文、局部错误信息、最近的计划变更。

更细的一层防御在 adjustIndexToPreserveAPIInvariants()。 sessionMemoryCompact.ts#L232-L314

它干的事情很硬核,也很必要:如果最近保留窗口里出现了 tool_result,系统必须把匹配的 tool_use 也补进来;如果 assistant 消息因为流式输出被拆成多个共享 message.id 的块,thinking 和 tool_use 也要一起补齐。否则 compact 后发给 API 的消息链会断,直接报错。

这一段代码说明作者踩过坑,或者至少认真想过 API 侧不变量。很多开源 Agent 框架在消息压缩这里写得很草,最后线上 bug 都长一个样:tool result 找不到 parent,thinking 丢了,message 合并失败。

8. session memory 自己也会被裁剪

就算 summary.md 已经是摘要,compact 时还会再做一次 section 级裁剪。逻辑在 [truncateSessionMemoryForCompact] prompts.ts#L249-L295。

过程如下:

  • # section 拆段
  • 每个 section 允许的大小用 MAX_SECTION_LENGTH * 4 粗略换算成字符数
  • 超过就按行保留前半部分
  • 最后插入 [... section truncated for length ...]

实际截断函数见 [flushSessionSection] prompts.ts#L298-L324。

这套逻辑谈不上优雅,语义理解也谈不上深入,但它有一个优点:非常稳。系统真的到了上下文极限时,保底截断总比把整个 compact 失败掉强。工程里很多时候要的是「退化可接受」,不是「完美压缩」。

然后 createCompactionResultFromSessionMemory() 把裁剪后的 session memory 包成 summary message。 sessionMemoryCompact.ts#L437-L503

这里还有一个细节:如果发生过裁剪,它会额外附一句话,告诉模型和人类完整 session memory 文件路径在哪。排障时你可以直接打开原始 summary.md,不用猜裁掉了什么。

9. KAIROS 的压缩逻辑和普通模式不一样

KAIROS 里还有另一种「压缩」,它压的不是当前上下文,而是长期事件流。

在 memdir.ts#L321-L349 里能看到,KAIROS 模式下白天写的是 append-only daily log。到夜间,/dream 流程会把这些日志蒸馏成 topic files 和 MEMORY.md

这是另一类压缩:

  • 输入是时间顺序日志
  • 输出是主题化长期记忆

session memory compact 处理的是「上下文窗口」问题。KAIROS dream 处理的是「长期事件沉淀」问题。这两类压缩混在一起看会非常乱,源码里其实已经把它们分得很开。

10 小结

这套设计的工程代价与收益

10.1 一些值得学习的点

第一,分层彻底。长期记忆、当前轮召回、会话摘要、子代理记忆,各自有自己的存储形态和注入入口。系统复杂度是被隔离开的。

第二,文件优先。排查方便,审计方便,人工纠错方便。很多团队高估了数据库和向量库的必要性,低估了可观察性的重要性。

第三,动态召回走轻量 manifest + side query。对 CLI Agent 这种高频交互场景,这个方案的性价比很高。它把复杂度留给模型的小规模选择,而不是重型检索基础设施。

第四,压缩时保 recent window,并修补 tool_use/tool_result 不变量。这一点极少有团队一开始就写对。

10.2 一些代价

第一,frontmatter 的 description 质量变成关键依赖。这个字段一旦写烂,召回效果会大幅波动。它省掉了 embedding 的复杂度,也把一部分压力前置给写入质量。

第二,双通道写入意味着状态机会更复杂。主模型可以写,后台 extractor 也能写。虽然代码里有跳过逻辑,但这类架构天然比单通道更需要小心。

第三,session memory 的 section 截断是粗粒度的。它靠字符数近似 token,再按行截断,这属于保底工程,不属于精细压缩。能用,谈不上漂亮。

第四,MEMORY.md 仍然有索引容量压力。即便动态召回已经分担了很大一部分负担,入口索引的组织质量依然重要。

10.3 我们能用什么

如果把这套思路迁移到我们自己的 Agent 系统,可以借鉴(抄):

第一,先拆问题,再选技术。你要先决定自己在解哪件事:跨会话长期记忆、当前轮检索、超长对话压缩、团队共享经验。不要一上来就建一个统一 Memory API。

第二,先用文件,再考虑数据库。只要你的系统规模还没逼到那个份上,文件系统几乎总是更划算。它便宜、透明、好调试。很多团队用数据库,是因为觉得那样「更像正经系统」,这个判断没什么含金量。

第三,把召回质量的责任前移到写入阶段。Claude Code 用 description 做 manifest 检索这件事,给我的启发很大。与其指望后面靠复杂召回算法弥补,不如要求写入时就产出高质量摘要和类型信息。

如果你们团队正在做本地化的企业内 Agent,我甚至建议先抄一版这种架构原型:
长期记忆用 Markdown + frontmatter,当前轮召回走 manifest + 小模型筛选,会话压缩单独维护结构化 summary。三周内就能跑起来。比起一开始堆向量库、事件总线、关系数据库,这条路短得多。

11. 其它

Claude Code 的记忆系统没有神秘技术。它的难点不在某个单点算法,在边界控制和时机设计。什么时候写,写到哪里,什么时候读,读多少,压缩后保留什么,这些问题都比「用什么模型做召回」更重要。

如果你把这篇文章里的结论压成一句工程建议,那就是:
先把长期记忆、短期相关记忆、会话压缩拆开,再谈检索和存储。

源码入口可以优先读这几组文件:

  • 长期记忆规则与路径:
    [memdir.ts] [paths.ts] [memoryTypes.ts]

  • 记忆静态注入与动态召回:
    [claudemd.ts] [attachments.ts] [findRelevantMemories.ts]

  • 会话记忆与压缩:
    [sessionMemory.ts] [prompts.ts] [sessionMemoryCompact.ts]

  • 团队共享记忆:
    [teamMemPaths.ts] [teamMemorySync/index.ts]

以上。

聊下 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 在关键时刻召回到正确内容,就得用「未来的查询语句」来写记忆。

以上。

如何构建行业 Agent 的 RAG

行业 Agent 和我们常用的「通用聊天 Agent」不是一类东西。

行业 Agent 是要能解决问题的,而查资料只是其中一步,后面还要做判断、走流程、调用系统、校验结果、留痕、可回放。

RAG 在这里的角色也变了:它不只是给模型喂上下文,而是给 Agent 提供可执行任务所需的依据、约束、参数和证据链。

今天我们聊一下行业 Agent 构建过程中的 RAG 怎么写,从目标、数据,检索以及使用 RAG 等等方面。

1. 行业 Agent 的 RAG 要服务什么能力

行业 Agent 常见的工作方式是「多步闭环」:

  1. 识别问题类型与业务对象(客户、设备、合同、工单、订单、项目、账号等)
  2. 查依据(制度、手册、知识库、历史工单、标准、接口文档)
  3. 做动作(查系统、下发指令、开通配置、生成工单、发邮件、写报告、提审批)
  4. 校验与回写(确认变更成功、回填字段、留痕、把引用/证据挂到工单)
  5. 解释(给用户说明依据、影响范围、回滚方案、下一步建议)

所以行业 Agent 的 RAG 不只是「问答检索」,而是至少要覆盖这些信息类型:

  • 规则依据:制度、条款、SOP、合规模板、变更规范
  • 操作依据:系统使用手册、接口文档、参数含义、错误码处理
  • 对象事实:来自业务系统的实时/准实时数据(用户信息、资源状态、库存、账单、设备状态)
  • 历史经验:工单处理记录、故障复盘、已知问题(KEDB)
  • 风险边界:禁用操作清单、权限范围、需要人工复核的条件

如果我们只做「文档向量库 + 生成」,Agent 走到第 3 步就会卡:它不知道该调用哪个系统、需要哪些字段、什么情况下要停下来让人确认,也不知道怎么证明自己做对了。

2. 指标

行业 Agent 场景里,最好用三类指标描述:

2.1 任务完成类指标

  • 任务成功率(最终动作成功并通过校验)
  • 平均完成时长(端到端)
  • 人工介入率(需要人确认/补充信息/兜底)
  • 回滚率(执行后需要撤销/修正)

2.2 风险类指标(红线不能过)

  • 越权率(检索/执行是否越权,目标是 0)
  • 误执行率(不该执行却执行)
  • 误答导致的错误操作(把“编出来的依据”当成执行依据)
  • 引用不可追溯率(给不出来源或来源不支持结论)

2.3 知识与检索类指标(用于驱动迭代)

  • 依据命中率(标准依据是否出现在 topK)
  • 冲突处理正确率(新旧版本/多来源冲突时是否选对)
  • 时效正确率(是否引用过期/废止内容)
  • 覆盖率(高频问题是否覆盖)

行业 Agent 的 RAG 设计,最终要对这些指标负责。否则我们会陷入「答得像那么回事,但不敢让它动系统」的状态。

3. 数据层

行业 Agent 的 RAG,数据比模型更重要。

3.1 三类数据

  1. 静态权威知识:制度、规范、手册、标准、产品文档
    目标:可追溯、版本可控、可引用
  2. 动态业务事实:来自业务系统的数据(CRM、工单、CMDB、监控、计费、IAM 等)
    目标:可校验、可审计、最好可回放(至少保留查询快照)
  3. 过程与经验:历史工单、故障复盘、处理记录、FAQ 演进
    目标:可过滤(质量参差)、可分级(权威/经验/猜测)

很多项目失败是因为把第 3 类当第 1 类用,把「经验」当「制度」。Agent 一旦据此去执行动作,风险会放大。

3.2 每个知识片段必须带的元数据

行业 Agent 需要的不只是「能搜到」,还要「能用来做动作」。建议每个 chunk 至少包含:

  • doc_id / chunk_id
  • source(系统/库)
  • source_url(可点击或可定位)
  • title_path(标题链)
  • doc_type(制度/手册/接口文档/复盘/工单等)
  • versionstatus(草稿/已发布/已废止)
  • effective_from / effective_to(能给就给)
  • owner(维护人/团队)
  • updated_at
  • 适用范围标签:产品线/地区/客户/机型/环境(生产/测试)
  • 权限标签:RBAC/ABAC 所需字段
  • 可执行性标签(建议加):
    • 是否可作为执行依据(例如制度/已发布 SOP 才能)
    • 是否需要人工复核(高风险操作)
    • 是否仅供参考(复盘/经验)

这些标签对 Agent 比较关键:它能决定「能不能做、要不要停、怎么解释」。

3.3 文档解析与切分

行业 Agent 的 RAG 的切分策略,优先级一般是:

  1. 按结构切:章/节/条款/接口字段说明/错误码条目
  2. 把“前置条件/限制/例外”跟规则放一起
  3. 表格与字段定义要保表头(字段含义脱离表头就没法用)
  4. 把可执行步骤单独成块(SOP、Runbook、变更步骤)

注意:不要把「定义」「适用范围」「例外条款」切碎。Agent 执行动作时,最需要的就是边界条件和限制。

4. 索引与检索

行业 Agent 和常规的 Agent 不同,其更依赖于「过滤 + 排序 + 证据链」

4.1 使用混合检索

行业 Agent 的查询里会出现大量「硬信息」:

  • 条款号、标准号、型号、错误码、参数名、接口路径、工单号、配置项名

纯向量在这些场景不稳。工程上更常用的是:

  • 关键词/BM25:抓编号、术语、字段名、错误码
  • 向量召回:抓语义相近、同义表达
  • 融合 + 重排:把候选集排序成「最能支持动作/结论」的那几段

4.2 检索要先过滤,再找相似

行业 Agent 的过滤通常是强约束,如下:

  • 权限过滤(用户/角色/租户/数据域)
  • 状态过滤(废止、草稿默认不进)
  • 生效时间过滤(尤其制度、计费、合规)
  • 适用范围过滤(产品/地区/环境)
  • 数据域隔离(内部/客户侧/合作方)

如果我们把这些留到生成阶段「让模型自己注意」,效果不可控,风险也不可控。

4.3 Agent 专用检索

不止检索答案,还要检索工具与参数

行业 Agent 经常需要两类额外检索:

  1. 工具检索(Tool RAG)
    从「工具说明库/接口文档/SOP」里检索:该用哪个工具、需要哪些参数、有哪些限制、失败怎么处理。
  2. 参数与字段检索(Schema RAG)
    从「数据字典/字段说明/枚举值」里检索:字段含义、可选值、校验规则、示例格式。

这两类检索的结果不一定直接展示给用户,但会决定 Agent 能不能把动作做对。

5. 固化 Agent 使用 RAG 的逻辑

行业 Agent 的 RAG 关键是要「把 RAG 插进决策点」

行业 Agent 常见的内部循环大致是:

  • Plan(决定下一步做什么)
  • Act(调用工具/检索/执行)
  • Observe(拿到结果)
  • Decide(是否继续、是否需要人确认、是否结束)
  • Explain(对外输出)

RAG 的插入点建议固定成三处:

5.1 决策前

用 RAG 找「规则边界」

在 Agent 做出关键决策前,先检索:

  • 是否允许执行(权限、合规、风险等级)
  • 前置条件是什么(必须具备哪些信息、哪些系统状态)
  • 需要的审批/确认是什么(是否必须人工确认)

这一步的输出的是「约束」,不是「答案」。它会影响下一步是继续、暂停还是转人工。

5.2 执行前

用 RAG 找「操作步骤与参数」

执行某个动作前,检索:

  • SOP / Runbook / 接口文档
  • 必填参数、参数来源
  • 校验方式(执行后如何确认成功)
  • 回滚方式(失败/异常如何撤销)

这一步的输出是「可执行步骤」,不是「解释性段落」。

5.3 执行后

用 RAG 做「结果判定与错误处理」

拿到工具返回值后,检索:

  • 错误码含义与处理建议
  • 常见失败原因
  • 是否需要升级/转人工
  • 是否需要二次校验(比如跨系统一致性)

这一步的输出是「下一步动作建议 + 证据」。

6. 生成与输出

行业 Agent 的输出要分层,不要把所有东西都写给用户

行业 Agent 的输出建议拆成三层,分别服务不同目标:

  1. 用户层:结论/进展、需要用户补充什么、下一步怎么走
  2. 证据层:引用依据(链接、页码、版本、生效日期)
  3. 执行层(留痕层):本次调用了什么工具、参数摘要、返回结果摘要、校验结果、回滚点

用户不一定要看到执行层细节,但系统必须存储这些内容。只有出了问题能回放,才敢放权。

同时,行业 Agent 的生成要有硬规则:

  • 没有命中权威依据:不输出肯定结论
  • 有冲突:必须把冲突来源、版本、生效时间写清楚
  • 涉及高风险动作:必须停下来请求确认(并把依据与影响范围给出来)
  • 引用必须来自检索上下文:不允许来虚的,「凭印象补一句」

7. 权限、审计、隔离

**行业 Agent 的 RAG 必须「检索前隔离」。

行业 Agent 一旦能调用系统,风险比问答高一个量级。权限要分两层:

7.1 知识权限

能不能看的问题

  • 文档/知识片段按 ABAC/RBAC 做过滤
  • 按租户隔离(多客户必做)
  • 按数据域隔离(内部策略、客户信息、合作方信息)

7.2 行为权限

能不能做的问题

  • 工具级权限:这个角色能调用哪些工具
  • 动作级权限:同一工具下哪些操作允许(例如只读查询 vs 修改/下发)
  • 参数级权限:同一动作下哪些资源范围允许(例如仅能操作自己负责的项目/客户)

很多团队只做了「知识权限」,没做「行为权限」。

这会导致不放心,即使 Agent 能查到 SOP,也能学会「怎么做」,但你又不敢让它真的做。

7.3 审计要能回答四个问题

  • 为什么这么做(依据是什么)
  • 做了什么(调用了哪些工具、关键参数是什么)
  • 得到了什么(返回结果与校验结果)
  • 谁批准的(如果需要人工确认)

没有这四个问题的答案,行业 Agent 很难通过安全审查,也很难在出事后定位责任与修复点。

8. 灰度上线策略

先控制风险,再谈覆盖率

行业 Agent 的上线节奏建议按权限逐步放开:

  1. 只读 Agent:只检索、只解释、只给建议,不执行任何写操作
  2. 半自动 Agent:可以生成“执行计划/工单草稿/变更单草稿”,必须人工确认后执行
  3. 受限自动 Agent:只允许低风险、可回滚、可校验的动作自动执行(例如查询、对账、生成报表、创建工单、补全字段)
  4. 高风险动作:默认保留人工确认,除非你能做到严格的权限、校验、回滚、审计,并且有明确的责任边界

上线必须准备三套兜底:

  • 超时与降级:检索失败/重排失败/模型失败时怎么退化
  • 失败回滚:执行失败怎么撤销,撤销失败怎么升级
  • 人工接管:在关键节点能一键转人工,并把证据与执行轨迹带过去

9. 常见坑

  1. 把「经验工单」当「标准答案」:Agent 会把偶发处理当成通用规则。必须分级与降权。
  2. 只做知识库,不做数据字典与工具库:Agent 会不知道参数怎么填、字段是什么意思、错误码怎么解。
  3. 只做检索,不做执行前校验与执行后校验:敢执行的前提是可校验、可回滚。
  4. 权限只管文档,不管工具:最容易在这里翻车。
  5. 没有回放评测:你不知道一次小改动会不会让 Agent 在某个分支上开始乱走。
  6. 把「多轮对话」当「任务编排」:行业 Agent 的关键是状态机与决策点,不是聊得多。

最后,行业 Agent 的 RAG 如果要构建,不仅仅是算法的事情,需要更多的业务专家,业务 Owner 来直接参与,他们才是最懂行业的人。需要他们来定义「什么算答对/答错」、哪些文档权威、版本如何取舍、哪些内容不能答。

以上。