深入 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]

以上。

深度拆解 Claude Code 系统提示词中的记忆管理逻辑

最近在做 Agent 相关的工作,研究了 Claude Code 的系统提示词。分享一下看到的东西。

Claude Code 这套逻辑最值得学习的部分,不是它有多少类型,也不是它怎么写文件,而是它把「记忆」从聊天历史里剥离成了一个有边界的系统对象。

其提示词给出了四段闭环:

  • 类型化存储
  • 索引化管理
  • 触发式召回
  • 使用前校验

这套闭环的根本目的只有一个:极度压缩进入大模型上下文的无效 Token

类型化存储

类型化存储解决的是「谁有资格被记住」的问题。

Claude Code 里把记忆分成 user / feedback / project / reference。这一步看上去像分类,实际上是在做准入控制。

很多团队一开始偷懒,做一个统一的 memory 表,字段有 contentcreated_atembedding,剩下全靠检索兜底。前期跑 demo 很爽,后期一团糟。因为「用户偏好」「项目约束」「纠错反馈」「外部入口」这几类东西的生命周期、可信度、更新频率和召回优先级完全不同。你把它们混在一起,后面所有策略都要靠额外条件补救。

Claude Code 这里的好处在于,它先承认记忆不是同质数据。类型不同,保存条件就不同,召回方式也不同。

  • user 影响的是回答风格和交互方式,天然高权重。
  • feedback 代表用户纠正过的内容,这类信息如果不复用,系统会反复踩一个坑。
  • project 带明显时效性,过期不处理就是埋雷。
  • reference 更接近外部入口或指针,重点在可定位,不在长文本本身。

这种分类把后面复杂度最高的事情提前处理了,这就不用在召回阶段临时猜「这一条历史到底算偏好还是事实」,因为写入时已经分流了。

索引化管理

Claude Code 会「先写独立记忆文件,再更新 MEMORY.md 索引」。这里把正文存储和索引存储分开了。

有两个收益。

第一,索引足够轻。MEMORY.md 只存索引,不存正文。这样它天然适合作为一个轻量入口,被优先加载、优先扫描、优先过滤。

第二,正文可以演进。真正的记忆文件有 frontmatter 和正文,这意味着它可以承载更完整的上下文,而不用把所有内容都堆到一个总文件里。总文件一旦既做索引又做正文,后面就很难控制体积,也很难做精细更新。

在写入时,有两条规则。

  • 同主题记忆优先 update,避免重复新增。
  • 用户明确说 forget,就删除对应记忆。

这两条是在控制系统熵增。记忆只要能无限追加,迟早会出现语义重复、事实冲突、时间污染。只做新增,不做更新和删除,系统很快就会进入「候选很多,但没有一条完全可信」的状态。到了那个阶段,召回层再聪明也救不回来。

触发式召回

Claude Code 的建议流程是:

  1. 先判断当前请求是否需要记忆;
  2. 按类型和关键词做少量 Top-K 粗召回;
  3. 再按「任务相关性 > 新鲜度 > 可靠性」精筛;
  4. 只注入必要片段;
  5. 如果和当前事实冲突,以当前事实为准并回写修正。

其逻辑有如下几种:

  1. 强指令触发(显式召回):当用户明确下达指令(如“查一下”、“回想一下”、“你还记得吗”)时,系统被强制(MUST)触发召回链路。
  2. 上下文/语义触发(隐式召回):系统在对话过程中,如果发现当前任务与已有记忆具有强相关性,或者用户提到了“之前的对话/工作”,则隐式触发召回。这要求大模型在理解当前意图时,顺带做一次记忆相关性判定。
  3. 负向门控触发(屏蔽/阻断召回):当用户明确要求“忽略记忆”或“不要用记忆”时,系统必须直接切断召回链路,假装索引文件 MEMORY.md 是空的,防止历史上下文污染当前的新任务。

使用前校验

使用前校验,解决的是「记忆不是事实源」

记忆里如果提到文件、函数、flag,落地前必须重新核验当前状态。

记忆的本质是「过去曾经成立过的信息」。代码仓库、配置开关、函数签名这些东西会变。如果把记忆当事实源,模型越有记忆,出错概率越高。尤其在代码场景里,这种错会放大。因为模型不是只回答一句话,它还会基于过期事实继续生成修改方案、命令、排障路径。

记忆负责缩小搜索空间,当前状态负责给出最终裁决。

做记忆系统时,最警惕的一直是脏记忆。空记忆顶多让模型少一点个性,脏记忆会直接让模型说错话。

以上。

附原始提示词(2.1.86 版本)


## auto memory

You have a persistent, file-based memory system at `/root/.claude/projects/-tmp-claude-history-1774690103689-avi2cu/memory/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).

You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.

If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.

### Types of memory

There are several discrete types of memory that you can store in your memory system:

<types>
<type>
    <name>user</name>
    <description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>
    <when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
    <how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>
    <examples>
    user: I'm a data scientist investigating what logging we have in place
    assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]

    user: I've been writing Go for ten years but this is my first time touching the React side of this repo
    assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]
    </examples>
</type>
<type>
    <name>feedback</name>
    <description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.</description>
    <when_to_save>Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>
    <how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
    <body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>
    <examples>
    user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
    assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]

    user: stop summarizing what you just did at the end of every response, I can read the diff
    assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]

    user: yeah the single bundled PR was the right call here, splitting this one would've just been churn
    assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]
    </examples>
</type>
<type>
    <name>project</name>
    <description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>
    <when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>
    <how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>
    <body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>
    <examples>
    user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
    assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]

    user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
    assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]
    </examples>
</type>
<type>
    <name>reference</name>
    <description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>
    <when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>
    <how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
    <examples>
    user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs
    assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]

    user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone
    assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]
    </examples>
</type>
</types>

### What NOT to save in memory

- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
- Anything already documented in CLAUDE.md files.
- Ephemeral task details: in-progress work, temporary state, current conversation context.

These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.

### 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:


---
name: {{memory name}}
description: {{one-line description — used to decide relevance in future conversations, so be specific}}
type: {{user, feedback, project, reference}}
---

{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}


**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` 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 `MEMORY.md`.

- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise
- Keep the name, description, and type fields in memory files up-to-date with the content
- Organize memory semantically by topic, not chronologically
- Update or remove memories that turn out to be wrong or outdated
- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.

### When to access memories
- When memories seem relevant, or the user references prior-conversation work.
- You MUST access memory when the user explicitly asks you to check, recall, or remember.
- If the user says to *ignore* or *not use* memory: proceed as if MEMORY.md were empty. Do not apply remembered facts, cite, compare against, or mention memory content.
- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it.

### Before recommending from memory

A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:

- If the memory names a file path: check the file exists.
- If the memory names a function or flag: grep for it.
- If the user is about to act on your recommendation (not just asking about history), verify first.

"The memory says X exists" is not the same as "X exists now."

A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.

### Memory and other forms of persistence
Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.

OpenClaw 的 Skills 的实现和 Claude Code 不一样

OpenClaw 的 Skills,本质上不是一个「可调用工具」,它更像一套经过约束的运行手册:启动时把技能目录扫描出来,压成一份 <available_skills> 清单塞进 system prompt,模型自己判断要不要选一个 skill,然后再通过 Read 工具去读这个 skill 的 SKILL.md。读完以后,没有任何独立执行器接管,还是在当前这条 session 的 tool-loop 里继续跑。

Claude Code 走的是另一条路。它把 skill 做成了 tool,工具里负责校验、加载、执行,甚至可以放进一个新上下文里跑完,再把结果回传主对话。

这两个实现方向,表面上都叫 skills,工程含义完全不一样。

如果你是做 Agent 平台、企业内 Copilot、代码助手、任务执行器,这个差异不只是架构图上的审美问题。它会直接影响:

  • prompt 预算怎么花
  • 权限边界放在哪里
  • skill 的治理成本有多高
  • 运行链路是可控还是失控
  • 你后面想不想做隔离执行、审计、回放、灰度发布

1. OpenClaw 的 Skills,到底是怎么被「召回」的

很多人一看到 skills,第一反应是:是不是和 memory 一样,先走 embedding 检索,再把相关技能召回进上下文。

不是。

OpenClaw 的 skills 召回机制非常直接,甚至可以说有点「朴素」:扫描目录,生成目录清单,注入提示词,让模型自己选。整条链路分三段:

  1. 发现
  2. 注入
  3. 读取

OpenClaw 反过来做了一件更工程化的事:先把 skill 列表显式给模型,再用 system prompt 约束它怎么选。

这个机制的起点在 src/agents/skills/workspace.tsloadSkillEntries()

1.1 技能发现:先扫目录,再谈调用

OpenClaw 会从多个 root 目录扫描 SKILL.md,然后合并成最终技能集。这里是有明确的覆盖优先级的:

extra < bundled < managed < agents-personal < agents-project < workspace

它的逻辑是:越靠近当前工作空间、越贴近用户项目的 skill,优先级越高。平台预置的 bundled skill 可以兜底,但项目级 skill 要能覆盖它。否则你做企业落地时会很难受,团队定制流程永远被平台内置逻辑压着打。

从目录结构看,OpenClaw 支持两种 skill 形态:

  • root 本身就是一个 skill,目录下直接有 SKILL.md
  • root 下的子目录分别是 skill,每个子目录里有自己的 SKILL.md

这样,团队在实际维护 skill 时,有两种典型组织方式:

  • 单一 skill 仓库,根目录就是技能内容
  • skills 集合仓库,每个子目录一个技能

都支持,落地阻力会小很多。

它对 SKILL.md 做了体积限制,默认超过 256KB 就直接跳过。

SKILL.md 本来就应该是高密度操作指南,不应该演变成一个什么都往里塞的大文档。你一旦允许 skill 文件无限变大,后面一定会有人把 SOP、FAQ、设计文档、事故复盘全扔进去,最后技能选择和加载成本一起爆炸。OpenClaw 在扫描阶段直接卡体积,其实是在替平台维护纪律。

1.2 frontmatter 解析

给 skill 增加最小限度的结构化元信息

扫描到 SKILL.md 后,OpenClaw 会读原文并解析 frontmatter。坏掉或者缺失的 frontmatter 会被忽略。

这个决策也很对。

OpenClaw 这里更像是「有就用,没有拉倒」。它承认 markdown 本体才是 skill 的核心,frontmatter 只是辅助控制面。这个姿态很适合 skills 这种增长很快、来源很多的资产类型。

但这里也埋了一个工程 trade-off:frontmatter 被弱约束,意味着后续治理和平台能力扩展会受限。你今天只做 discovery 和 basic gating,这么玩没问题;你明天如果要做 skill 分类、依赖分析、版本兼容、批量审计,元信息松散会让成本陡增。

OpenClaw 当前站在了「先把系统跑起来」的一边,没有走「先把规范做重」的路子。

这意味着它更适合中小规模 skill 生态,或者说更适合个人助手这类工具,而不是一个强治理的企业级 skill marketplace。

2. 不是所有 skill 都会进 <available_skills>

扫描出来只是候选集,不等于模型能看到。

OpenClaw 在注入 system prompt 之前会做一轮 gating。这个步骤比很多人想象得重要,因为它决定了模型到底暴露给了哪些能力。

过滤逻辑里有几类条件。

2.1 配置开关

最基础的 enable/disable

如果某个 skill 在配置里被标成 skills.entries.<skillKey>.enabled === false,它就会被剔除。

这是最基础的 kill switch。工程上没什么可争议的,必须有。不然你没法快速止血。某个 skill 写坏了、依赖环境挂了、被发现会引导模型做危险操作,没有一键下线能力,平台就不配上线。

2.2 bundled allowlist

对内置技能单独管控

skills.allowBundled 只约束 bundled 来源。

这个细节很有意思。它说明 OpenClaw 把 bundled skills 当成一类特殊资产处理:平台自带,但不默认无条件信任。

为什么这事重要?因为内置 skill 经常是平台演进中最容易「偷偷变多」的那部分。你今天打包 5 个,明天为了演示方便塞到 20 个,后天 prompt 里一大坨 descriptions,模型选 skill 的噪声越来越大。allowlist 的存在,本质上是在给平台预装能力上保险栓。

2.3 eligibility 判断

按运行环境判断 skill 是否可用

OpenClaw 会根据 metadata.requires 去判断 skill 能不能用,条件包括:

  • 二进制依赖是否存在
  • 环境变量是否满足
  • 配置是否具备
  • 操作系统是否匹配
  • remote 平台是否匹配

这个设计非常工程化。因为 skill 如果涉及真实操作,它一定和环境耦合。比如某个 skill 依赖特定 cli,或者要求某个 API token,或者只能在 Linux 跑。你如果不在注入前做 eligibility,而是让模型先看到 skill,再在执行时失败,用户体验会很差,模型行为也会变形:它会看到一个貌似可用的方案,实际一调就炸。

OpenClaw 选择「先过滤,再暴露」,这是我非常认同的策略。因为对模型来说,看得见就等于潜在可用。你让它看见无效 skill,本质上是在制造认知噪声。

2.4 disable-model-invocation

系统内部保留,模型不可见

如果某个 skill 标记了 disable-model-invocation,它不会进入 prompt 的 <available_skills>,但仍然存在于系统内部,可用于管理或校验。

因为技能资产不只有「给模型用」这一种角色。你可能有些 skill 需要:

  • 用于测试
  • 用于审核
  • 用于运维校验
  • 用于内部 pipeline
  • 用于未来发布但暂不开放

OpenClaw 没把「存在」和「可被模型调用」绑死,这样 skill registry 才像个平台能力,而不是一个 prompt 拼装器。

3. <available_skills> 才是 OpenClaw 的真正召回入口

很多人说 skills 被召回,其实这句话在 OpenClaw 语境里容易让人误解。

真正被放进上下文里的第一层,不是 SKILL.md 内容,而是 <available_skills> 这个目录清单。它由 buildWorkspaceSkillsPrompt() 生成,注入到 system prompt。

也就是说,模型最先看到的是技能目录,不是技能正文。

这个做法也算是渐进式披露的一种实现。先给模型足够决定是否要读 skill 的最小信息,避免一开始就把所有技能全文灌进去。

3.1 system prompt 里的强规则,决定了技能选择流程

OpenClaw 在 system prompt 里有一段明确的 Skills mandatory 规则,大意是:

  • 先扫描 <available_skills> 里的 <description>
  • 只有明确匹配一个 skill 时,才去读对应的 SKILL.md
  • 最多只读一个 skill

这三条里,最重要的是后两条。

3.1.1 只有明确匹配一个才读

这是在压制模型的泛化冲动。模型很喜欢「我觉得这个也相关,那个也相关」,然后多读几个,最后把自己卷进上下文泥潭。OpenClaw 用提示词硬性要求「明确匹配一个」才允许进入下一步,这本质上是在给模型加稀疏化约束。

效果未必 100% 可控,但方向是对的。

3.1.2 最多只读一个 skill

如果你做过基于 prompt 的 tool orchestration,就会知道一个常见死法:模型在一堆说明里来回跳,读完 A 觉得 B 也有用,再读 B,顺手 C 也看看,最后 token 花了不少,任务还没干。

OpenClaw 这里直接规定 upfront 只能读一个 skill。我个人判断,这不是因为理论上最优,而是因为工程上最稳。

它牺牲了一部分组合式技能能力,换来了几个非常实际的好处:

  • token 消耗更可控
  • 模型决策路径更短
  • skill 选择失败更容易定位
  • 调试和回放更容易做

代价也很明确:如果任务天然需要多个 skill 协同,OpenClaw 当前机制会显得笨。模型要么自己在单个 skill 指导下绕着做,要么根本选不准。

这也是它和 Claude Code 一个很大的分歧。Claude Code 把 skill 视作 tool,更容易天然支持复杂编排;OpenClaw 把 skill 视作可读说明,天然倾向于单次聚焦。

3.2 技能过多时的降级策略:compact 和 truncated

<available_skills> 不是无上限注入的。技能太多时,OpenClaw 会降级成 compact 格式,甚至截断,并提示 skills truncated

这个点看起来像 prompt 小技巧,其实是很重要的预算治理。

因为技能系统一旦跑起来,数量几乎只会越来越多。最初十几个 skill,description 全量塞进去还行;到几十上百个 skill,再这么搞,system prompt 很快会变成垃圾堆。OpenClaw 至少意识到了这个问题,所以做了两级退化:

  • compact:保留更少信息
  • truncated:截断并明确提示

这说明它把 prompt 当成有限资源,而不是无限容器。

但实话实说,这个策略只是在「延缓爆炸」,不是根治。skill 数量继续增长以后,靠 compact/truncate 顶不住。因为问题不只是 token 多了,而是模型在目录里做决策的辨识难度会越来越高。description 再压缩,skill 之间的区分度也会变差。

所以我对这块的判断是:OpenClaw 的 catalog 注入机制适合中等规模 skill 集,不适合无限扩张。
如果你团队未来真要做上百个 skill 的企业级平台,迟早要引入更分层的 catalog、分类路由、或者显式 selector,而不是靠一坨 <available_skills> 让模型裸选。

4. 真正的 SKILL.md 是怎么进入上下文的

OpenClaw 的第二层加载是按需读取。模型先看到目录清单,选中 skill 以后,才通过 Read 工具去读对应路径的 SKILL.md

4.1 技能正文不是 system prompt 的一部分

system prompt 里只有规则和技能目录。真正的 SKILL.md 内容,是在模型发起一次 read tool call 后,作为 toolResult 追加进当前消息流。

也就是说,skill 正文进入上下文的方式,和你用工具读一个普通文件没有本质区别。差别只是 system prompt 先规定了什么时候允许这样读。

这个设计的好处非常明确:

  • 大幅降低初始上下文体积
  • 让技能正文成为按需成本,而不是固定成本
  • skill 更新后无需改 system prompt 模板,只影响运行时读到的内容
  • 便于把 skill 当文件资产管理,而不是 prompt 模板片段

坏处也很明确:

  • skill 的执行效果更依赖模型有没有正确触发 read
  • skill 的权威性被弱化成一条 toolResult,而不是顶层指令
  • 如果 toolResult 很长,后续上下文里它和普通读文件结果没什么层级差异

从实现角度看,OpenClaw 这里是很克制的。它没有发明一个 Skill Runtime,没有做一套专门的 skill 调度协议,就是借已有的 read 工具完成正文加载。

这让系统保持简单,但也让 skill 更像「读到的一份说明书」,而不是「被激活的执行单元」。

5. OpenClaw 的安全边界

既然 skill 是文件,模型要去读 SKILL.md,安全问题马上就来了:路径能不能逃逸?symbolic link 能不能把 skill 指到 root 外?模型能不能顺着 location 读到不该读的内容?

OpenClaw 这里做了两层保护。

5.1 第一层:扫描阶段的 containment 检查

在技能发现阶段,它会对 realpath 做 containment 检查,防止 symlink 把 skill 指到 root 外面。

这是典型的「尽早失败」策略。我一直觉得文件系统相关的安全问题,能在发现阶段拦的,不要拖到执行阶段。因为一旦把异常路径放进 skill registry,后面每个环节都得假设它可能有问题,整个系统会变得很难推理。

扫描时就把越界项挡掉,registry 内部的数据至少是干净的。

5.2 第二层:Read 工具的 workspace root guard

模型真正发起读取时,Read 工具还会做 sandbox root 校验,禁止 .. 或绝对路径逃逸 workspace root。

也就是说,即使 discovery 阶段没出问题,真正读取文件时还有一层运行时防线。

文件边界这事,单点防护永远不够。扫描阶段挡的是「注册进来的 skill 本身」,读取阶段挡的是「模型实际提出的路径参数」。二者保护的对象不一样。

工程上要是只做第一层,你会被运行时路径拼接坑;只做第二层,你会把很多脏数据带进 registry,影响可观测性和调试。

OpenClaw 在这块虽然不算复杂,但做法是标准的。

6. Skills 的渐进式披露

OpenClaw 的 skills 机制,如果只看「枚举目录 -> 注入 prompt -> read 文件」,会有人觉得太朴素。真正值得注意的是,它和 memory 一样,贯彻了一套很明确的渐进式披露思路。

这是上下文预算治理的逻辑。

原则就两条:

  1. 先给最小可用信息
  2. 确认需要后再扩大读取范围

在 OpenClaw 里,memory 和 skills 都是这么干的,只是路径不同。

6.1 Memory 是先搜 snippet,再精读指定行段

memory_search 先返回短片段,带 path 和 line range,不是全文注入。底层还有 SNIPPET_MAX_CHARS = 700 的硬上限。如果后端预算紧,还会继续裁结果。

之后再通过 memory_getpath + from/lines 去拉具体行段。

这个流程是非常标准的 progressive disclosure:先定位,再精读。

6.2 Skills 是先给目录,再只读一个 SKILL.md

skills 这边没有向量召回,而是目录清单。模型先看 <available_skills>,只在明确匹配一个时才读正文,并且 upfront 最多一个。

两条链路表面不同,本质一样:都在防止大块文本无脑灌进上下文。

我为什么说这点值钱?因为很多团队做 Agent 系统时,最大的问题不是模型不会做事,而是上下文管理太粗糙。什么都想喂进去,最后 token 烧得飞快,模型还因为噪声太高做不准。

OpenClaw 至少在架构层面承认了一件事实:上下文是预算,不是仓库。

这件事说起来简单,真正落实到工具语义、提示词规则、底层裁剪,很多系统做不到。

7. OpenClaw 没有「独立 Skill 执行器」

在 OpenClaw 里,skill 读完之后,并没有一个单独的执行环境接管它。没有所谓「Skill Runtime」去解释 SKILL.md,也没有「新上下文执行 skill」这回事。它还是在同一个 activeSession.prompt(...) 的 tool-loop 里继续跑。

链路大概是这样:

  1. system prompt 里给出 <available_skills> 和选择规则
  2. 模型决定某个 skill 匹配当前请求
  3. 模型调用 read 工具读取对应 SKILL.md
  4. toolResult 里带回 SKILL.md 文本
  5. 这个 toolResult 被追加到当前 session 的 messages
  6. 模型再次被调用,看到刚才读到的 skill 内容
  7. 模型按 skill 指导,继续发起后续 toolCall,比如 execwriteedit
  8. 直到输出最终回答

关键点:skill 内容只是同一消息流里多了一条 toolResult

这意味着什么?

意味着 OpenClaw 的 skill 执行,本质上是「模型读了一份流程说明,然后继续在原对话里行动」。它没有新的边界,没有新的记忆隔离,没有新的权限域。真正的隔离,来自工具列表裁剪和文件读写沙箱,不来自上下文切换。

这和 Claude Code 很不一样。

8. Claude Code 的 skills,更像「可调用子程序」

Claude Code 的 skill 流程有几个特征:

  • skill 是 tools 列表里的一个明确工具
  • 模型会调用 Skill tool,而不是自己去 read 某个 markdown
  • skill prompt 的加载是 tool 内部动作
  • 权限校验也主要发生在 tool 内
  • skill 可以在新上下文执行,执行完再把结果带回主对话

从工程抽象上看,Claude Code 的 skill 更像一个「子程序入口」。它有名字、有调用接口、有内部装载逻辑,甚至有上下文隔离能力。

OpenClaw 没有走这条路。它把 skill 设计成「模型可读的文件」,由模型自己决定是否展开,并在原上下文里继续操作。

这两个方向,没有谁天然高级,但适用面不同

如果你要的是:

  • 更强的执行隔离
  • 更容易做权限封装
  • 更容易做结果回传和子任务边界
  • 更适合复杂、多步骤、可复用的任务单元

Claude Code 那种 tool 化 skill 更合适。

如果你要的是:

  • 实现简单
  • skill 作者门槛低
  • 把 skill 当 markdown 资产管理
  • 快速把流程知识接进现有 ReAct tool-loop

OpenClaw 这种文件化 skill 更实用。

工程上不看理念,看代价结构。

9. OpenClaw 为什么会做成这样

我猜它的设计动机是:尽量复用现有 agent runtime,而不是为 skills 单独发明一层执行框架。

你看它的做法就知道了:

  • discovery 复用文件系统扫描
  • selection 复用 system prompt
  • loading 复用 read 工具
  • execution 复用原有 tool-loop
  • safety 复用 sandbox path guard 和 tool policy

这是一套很节制的架构思路。好处是:

实现成本低 :不用造新协议,不用加新消息类型,不用多一层 runtime。对一个正在演进中的 agent 系统来说,这非常现实。 Skill 作者认知负担小:本质上写一个 SKILL.md 就行。对于组织内部推广,这是巨大优势。因为真正阻碍 skill 规模化的,从来不是模型能力,而是作者生态能不能起来。 运行链路可观测性还不错:所有事情都发生在同一 session 里。read 了什么、接着调了什么工具、toolResult 长什么样,回放起来相对直观。

但它的代价也有。

代价 1:skill 缺少强执行边界

skill 本身不是 tool,没有独立权限面。你没法像封装一个函数那样,给 skill 绑定专属 schema、专属校验、专属 side effect 边界。最终还是模型在拿到 skill 文本后,自由地调用后续工具。

这就意味着 skill 的约束力主要来自提示词,不来自执行器。

代价 2:组合式编排能力弱

system prompt 里要求 upfront 最多读一个 skill,这对预算控制很好,对复杂任务不友好。多 skill 协同在这个体系里不是一等公民。

代价 3:skill 的结果不可天然封装

Claude Code 那种「在新上下文执行 skill,再返回结果」,天然有个输入输出边界。OpenClaw 这里没有。skill 一旦展开,后续动作直接混进主会话消息流。你很难把它当作一个独立执行单元来治理。

代价 4:对模型的选择质量要求高

因为没有独立 selector,也没有 tool-level dispatcher,skill 能不能选对,主要靠 <available_skills> 的 descriptions 和 system prompt 规则。这对 skill 描述质量要求很高。写得不清楚,模型就选歪。数量一多,问题更明显。

10. 权限控制

OpenClaw 的思路是「先裁剪能力,再让模型行动」

这一点我很喜欢,因为它比「调用时再临时拦截」更稳。

OpenClaw 的权限控制,不是把所有工具都亮给模型,然后在调用某个危险工具时说不行。它更像是:

  1. 先根据 policy pipeline 把不允许的工具从列表里移掉
  2. 再把剩下的工具暴露给模型
  3. 模型根本看不到被禁的能力

skills 这边也一样:

  • 不合格的 skill 不进入 <available_skills>
  • disable-model-invocation 的 skill 对模型不可见
  • Read 路径有 workspace root guard

这种「先裁剪,再推理」的方式有个很大的好处:模型认知空间更干净。它不会围着一堆不可用能力打转,也不会生成大量被拒调用的无效动作。

如果你做企业场景,这比事后 deny 好得多。因为用户只会看到 agent 做合理尝试,而不是不停撞墙。

当然,它的代价是灵活性差一点。你没法在非常细粒度的时刻做动态放行,除非重新构建 tool list 或 skill list。但我认为这笔账是划算的。Agent 系统的第一优先级不是灵活,是可控。

11. 从平台设计角度看呢

觉得比较好的点:

第一,简单。
这个简单不是简陋,是尽量不新增系统概念。skill 就是文件,加载靠 read,执行靠原有 tool-loop。对演进中的 agent 框架来说,这是非常健康的选择。

第二,预算意识强。
<available_skills> 目录注入、最多只读一个 skill、compact/truncated 降级,这些都说明它把 context 当稀缺资源处理。

第三,权限思路对。
先过滤 skill 和 tool,再让模型行动。可见性先于可调用性,这很工程化。

第四,适合知识流程化。
很多团队真正需要的,不是一个会自己发明流程的 agent,而是把组织内已有 SOP 结构化地喂给模型。OpenClaw 这套很适合干这个。

一些局限:

第一,规模扩展性一般。
catalog 注入机制天然不适合 skill 数量无限增长。它适合几十级别,不适合大规模 marketplace。

第二,组合能力弱。
「最多只读一个 skill」对稳态有帮助,对复杂任务编排是限制。

第三,skill 缺少运行时身份。
它不是 tool,没有明确的输入输出边界,也没有独立权限与审计单元。后续做深治理会比较难。

第四,过度依赖模型选择。
一旦 descriptions 写得不好,或者目录过大,skill 选择质量会变成系统上限。

12. 适用场景

在系统架构选型时,如果有以下的条件,可以优先选择 OpenClaw 的设计逻辑:

  • 团队已经有一套成熟的 tool-loop,想低成本引入 skills
  • 主要目标是把流程知识、操作手册、领域步骤注入 agent
  • skill 作者很多,技术水平参差不齐,需要低门槛 markdown 入口
  • 更看重实现速度和治理可落地,而不是复杂编排能力
  • skill 数量在可控范围内,不会迅速膨胀到几百个

有如下的情况时,就不太适用使用这种方案:

  • skill 本质上是可执行业务子任务,需要独立生命周期
  • 需要强隔离执行、单独审计、结果回传和失败恢复
  • 任务天然需要多个 skill 协同编排
  • 技能库规模会非常大,必须做复杂路由
  • 权限模型要求细到「某个 skill 可以做 A 但不能做 B」

这些场景下,我更倾向 Claude Code 那种 tool 化 skill,或者更进一步,直接走 subagent / workflow node / task runtime。

13. 小结

如果只用一句话概括两者区别:

Claude Code 的 skill 更像可调用子程序,OpenClaw 的 skill 更像按需展开的运行手册。

前者强调执行单元,后者强调上下文注入。
前者天然适合隔离和编排,后者天然适合轻量接入和流程沉淀。
前者的复杂度在 runtime,后者的复杂度在 prompt 和内容治理。

OpenClaw 用极其精简的 Prompt 规则和现成的 Read 工具,低成本实现了 Agent Skills 标准。它够用,但也把长文本管理的压力,原封不动地推给了底层大模型的 Context Window。

我个人对 OpenClaw 这套实现是认可的,前提是别把它想象成一个万能技能平台。它解决的是「如何在不重写 agent runtime 的前提下,把结构化流程知识接进模型执行链路」这个问题。这个问题它解得挺干净。

但如果你要的是 Claude Code 那种「新上下文执行 skill、像子程序一样调用、跑完把结果带回来」,你该找的是子会话、subagent、任务编排层,而不是继续往 SKILL.md 上堆规则。

以上。