Skip to content

技能:Markdown 指引

源码版本v2026.6.11

职责

技能 (skill) 在 OpenClaw 里不是可调用的函数,而是一段 Markdown 指引——具体就是一个 SKILL.md 文件。系统在构建系统提示时,把所有可用技能的 name/description/location 列成一段 <available_skills> XML 塞进 prompt;模型看到任务匹配某技能的 description 时,自己用 read 工具把那个 SKILL.md 读出来,按里面写的步骤行事。

这套机制看起来很"轻",但它和工具系统有本质区别:工具是系统注册的函数,模型只能调名字;技能是模型自己决定读不读的文档。技能的内容可以是任意 Markdown——操作步骤、检查清单、术语表、代码模板——模型读完之后该用工具就用工具、该写代码就写代码,技能本身不参与运行时调用。

技能系统负责的事情因此也很纯粹:发现 (discovery) 找到所有 SKILL.md 文件、加载 (loading) 解析 frontmatter 出 Skill 对象、注入 (injection) 把摘要塞进系统提示、生命周期 (lifecycle) 管理技能的安装/归档/上传。模型自己负责剩下的"读不读、怎么用"。

设计动机

为什么不让技能也成为工具?因为工具的调用是确定性的——参数 schema、入参校验、executor 逻辑都得固定,每次调同名工具行为一致。但很多任务指引天然是叙述性的:"遇到 bug 先看日志、再复现、再定位"这种流程没法塞进 JSON schema。让模型用自然语言理解指引,再灵活组合工具调用,比硬塞成函数更合身。

另一个动机是懒加载 (lazy loading)。技能正文可能很长(几十 KB 的操作手册),但系统提示里只需要塞摘要。模型只在任务确实匹配时才 read 全文,大部分对话里技能正文根本不进上下文窗口。formatSkillsForPromptskill-contract.ts formatSkillsForPrompt:L34-L58 里只输出 name/description/location/version 四个字段,正是这个原因。

<version> 字段是关键设计:它是 SKILL.md 内容的稳定标记。系统提示里明确告诉模型:"If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it."(src/skills/loading/skill-contract.ts:L41-L43)——技能作者改了内容,模型下一轮就该重新读,而不是凭旧版本记忆乱来。

source 字段保留来源标识(openclaw-bundled / workspace / plugin 等),disableModelInvocation frontmatter 字段允许技能作者声明"模型不能自动调用,只能用户显式触发",这是为一些需要明确许可的高风险操作留的口子。

关键文件

数据流

技能加载从文件系统开始(local-loader.ts 加载单个目录:L38-L89):

typescript
function loadSingleSkillDirectory(params: {
  skillDir: string;
  source: string;
  rootRealPath: string;
  maxBytes?: number;
}): LoadedLocalSkill | null {
  const skillFilePath = path.join(params.skillDir, "SKILL.md");
  const raw = readSkillFileSync({
    rootRealPath: params.rootRealPath,
    filePath: skillFilePath,
    maxBytes: params.maxBytes,
  });
  if (!raw) {
    return null;
  }

  let frontmatter: Record<string, string>;
  try {
    frontmatter = parseFrontmatter(raw);
  } catch {
    return null;
  }

  const fallbackName = path.basename(params.skillDir).trim();
  const name = frontmatter.name?.trim() || fallbackName;
  const description = frontmatter.description?.trim();
  if (!name || !description) {
    return null;
  }
  const invocation = resolveSkillInvocationPolicy(frontmatter);
  // ...
  return {
    skill: {
      name,
      description,
      filePath,
      baseDir,
      promptVersion: computeSkillPromptVersion(raw),
      source: params.source,
      sourceInfo: createSyntheticSourceInfo(filePath, { /* ... */ }),
      disableModelInvocation: invocation.disableModelInvocation,
    },
    frontmatter,
  };
}

注意三道闸:读文件用 openRootFileSync(src/skills/loading/local-loader.ts:L16-L36),它会把路径解析到 root 真实路径上,防止 symlink 逃出技能目录;frontmatter 解析失败静默丢弃这个技能,不让一个坏技能拖死整个加载流程;name 和 description 都必须存在,缺一个就跳过。computeSkillPromptVersion 对内容算一个稳定标记,后面塞进 <version> 字段。

loadSkillsFromDirSafe(src/skills/loading/local-loader.ts:L107-L151) 是上层包装,先尝试把传入的 dir 当成单技能目录加载,失败再当作父目录枚举子目录批量加载。这种"先看自己是不是技能,再看是不是技能集合"的双层逻辑,让 skills/ 根目录(单技能)和 skills/coding-agent/(子技能)两种布局都能工作。

加载完的 Skill 数组最终在 system-prompt.ts 注入:L70-L74 被拼进 prompt:

typescript
// Append skills section (only if read tool is available)
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
if (customPromptHasRead && skills.length > 0) {
  prompt += formatSkillsForPrompt(skills);
}

这里有个关键守卫:只有 read 工具在激活列表里时才注入技能段。否则模型看到 <available_skills> 也读不动 SKILL.md,反而会陷入"知道有却调不到"的尴尬。formatSkillsForPrompt(src/skills/loading/skill-contract.ts:L34-L58) 产出的 XML 大致是:

typescript
const lines = [
  "\n\nThe following skills provide specialized instructions for specific tasks.",
  "Use the read tool to load a skill's file when the task matches its description.",
  "If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it.",
  "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use the absolute path in tool commands.",
  "",
  "<available_skills>",
];
for (const skill of skills) {
  lines.push("  <skill>");
  lines.push(`    <name>${escapeXml(skill.name)}</name>`);
  lines.push(`    <description>${escapeXml(skill.description)}</description>`);
  lines.push(`    <location>${escapeXml(skill.filePath)}</location>`);
  if (skill.promptVersion) {
    lines.push(`    <version>${escapeXml(skill.promptVersion)}</version>`);
  }
  lines.push("  </skill>");
}
lines.push("</available_skills>");

注意第三行明确告诉模型:"If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it."——版本变了就重读,别凭记忆。

技能被用户显式触发(走 chat command)或模型自动决定读时,会进入 tool-dispatch.ts resolveSkillDispatchTools:L59-L249。这个函数不是"执行技能",而是"按技能触发的上下文过滤工具集"——比如 coding-agent 技能触发时,主循环要给子 agent 配哪些工具,得走一遍 profile/global/group/sender/sandbox/subagent/inherited 多层策略:

typescript
const tools = createOpenClawTools({
  agentSessionKey: params.sessionKey,
  // ... 大量上下文
  pluginToolAllowlist: collectExplicitAllowlist(explicitPolicyList),
  pluginToolDenylist: explicitDenylist,
  // ...
});
const policyFiltered = applyToolPolicyPipeline({
  tools,
  toolMeta: (tool) => getPluginToolMeta(tool),
  warn: logVerbose,
  steps: [
    ...buildDefaultToolPolicyPipelineSteps({ /* profile/provider/global/agent/group/sender */ }),
    { policy: sandboxPolicy, label: "sandbox tools.allow" },
    { policy: subagentPolicy, label: "subagent tools.allow" },
    { policy: inheritedToolPolicy, label: "inherited tools" },
  ],
  declaredToolAllowlist: buildDeclaredToolAllowlistContext({ /* ... */ }),
});

注意注释里写了"Keep this aligned with the normal tool surfaces so GHSA-mhm4-93fw-4qr2 stays closed across allow/deny, group, sandbox, and subagent policy layers"——这是个安全 seam,技能触发的工具调用必须和普通工具调用走同一套策略管线,不能因为"技能触发的"就绕过 allowlist。

边界与失败

  • name/description 缺失即丢弃:loadSingleSkillDirectoryif (!name || !description) return null;(src/skills/loading/local-loader.ts:L64-L66)。技能没有 description 模型就不知道该什么时候用它,这种技能等于没用,加载阶段直接丢。
  • frontmatter 解析失败丢弃:try { parseFrontmatter(raw) } catch { return null; }——一个坏 frontmatter 不拖死整个加载流程,但这个技能就不可见。loadSkillsFromDirSafe 把所有失败转成"该技能不出现"而不是抛错,保证整体可用。
  • symlink 逃逸防御:readSkillFileSync 通过 openRootFileSync 在技能 root 真实路径边界内打开文件(src/skills/loading/local-loader.ts:L16-L36)。这防止恶意技能目录里放一个指向 /etc/passwd 的 symlink,然后被 read 工具读出去。
  • read 工具未激活时不注入:hasRead && skills.length > 0 是注入的硬条件(src/agents/sessions/system-prompt.ts:L71-L74)。如果用户的工具 allowlist 把 read 砍掉了,系统就不告诉模型有技能——避免"看得见吃不到"。
  • disableModelInvocation:resolveSkillInvocationPolicy(frontmatter) 解析这个字段。某些高风险技能(比如直接执行任意 shell 的技能)作者可以声明"模型不能自动调,必须用户显式 chat command",系统在注入段就不会把它放进 <available_skills>
  • promptVersion 漂移检测:computeSkillPromptVersion 对 SKILL.md 内容算稳定标记,版本变了模型下一轮该重读。这是为热重载场景设计的:开发者改了技能内容,新一轮对话模型不会凭旧版本记忆乱来。
  • 技能不是工具的执行体:resolveSkillDispatchTools 看起来像"执行技能",其实它只做工具集过滤,真正"按技能干活"的还是模型 + 工具。技能系统从不直接调用工具,这是和 工具系统 最大的边界。

小结

技能是注入系统提示的 Markdown 指引,不是可调用函数。加载阶段用边界读防 symlink 逃逸、用 frontmatter 解析失败丢弃容错、用 promptVersion 检测内容漂移;注入阶段只在 read 工具可用时塞 <available_skills> 摘要,正文由模型按需 read。技能触发的工具调用走 resolveSkillDispatchTools 多层策略管线,和普通工具调用共用同一套 allow/deny 规则——这条 seam 关着 GHSA-mhm4-93fw-4qr2。和 工具 的区别:工具是 Map 里的函数,技能是 Markdown 指引;和 插件 的区别:插件是按目录约定发现的扩展包,技能是插件可以携带的一种内容形式(插件 skills/SKILL.md 字段)。

对照官方资料:Skills 文档 · README