技能:Markdown 指引
職責
技能 (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 全文,大部分對話裡技能正文根本不進上下文視窗。formatSkillsForPrompt 在 skill-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 欄位允許技能作者聲明「模型不能自動呼叫,只能使用者顯式觸發」,這是為一些需要明確許可的高風險操作留的口子。
關鍵檔案
skill-contract.ts Skill:L5-L15—Skill型別定義,包含 name/description/filePath/baseDir/promptVersion/sourceInfo。skill-contract.ts formatSkillsForPrompt:L34-L58— 把 Skill 陣列格式化成<available_skills>XML 塞進 prompt。local-loader.ts loadSingleSkillDirectory:L38-L89— 單個技能目錄的載入:讀 SKILL.md、解析 frontmatter、組裝 Skill。local-loader.ts loadSkillsFromDirSafe:L107-L151— 批次載入入口,失敗轉 diagnostics 不拋錯。local-loader.ts readSkillFileSync:L16-L36— 透過openRootFileSync在技能 root 邊界內讀檔案,防 symlink 逃逸。system-prompt.ts 技能注入:L70-L74— 系統提示建置時把技能列表用formatSkillsForPrompt拼進去。system-prompt.ts hasRead 守護:L169-L172— 只有 read 工具可用時才注入技能段,否則模型讀了也讀不動。tool-dispatch.ts resolveSkillDispatchTools:L59-L249— 技能觸發工具呼叫時按多層策略過濾工具集。install.ts installSkill:L458— 技能安裝入口,從 archive/source/upload 等通道拉技能。openclaw-provider-index.ts:L13-L69— 內建 provider 索引,可類比技能預聲明模式。
資料流
技能載入從檔案系統開始(local-loader.ts 載入單個目錄:L38-L89):
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:
// 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 大致是:
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 多層策略:
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 缺失即丟棄:
loadSingleSkillDirectory裡if (!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 欄位)。