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