本文约 3400 字,结合源码逐层讲解 OpenClaw 的 Skill 系统,从一个 SKILL.md 文件到最终注入模型提示词的完整链路。
开篇:一个问题引发的好奇
我们第一次认真看 OpenClaw 的技能系统,是在帮用户排查一个奇怪的问题:用户安装了 github 这个 skill,但发完消息之后模型完全没有用到它,就好像根本不知道这个技能存在一样。
翻了一圈源码之后,发现问题出在用户机器上没装 gh 这个 CLI 工具。因为 github skill 声明了 requires: { bins: ["gh"] },检查不通过,这个 skill 就在资格评估那一步被过滤掉了,根本没进到最终的 prompt 里。
这件事让我们开始认真读技能系统的源码。今天把整个链路从头到尾讲一遍。
技能是什么:从一个具体的 SKILL.md 说起
在 OpenClaw 里,一个技能本质上就是一个目录,里面放一个 SKILL.md 文件。比如内置的 GitHub skill:
plaintext
skills/github/
└── SKILL.md
SKILL.md 的内容大概长这样(取自源码里的实际文件):
markdown
---
name: github
description: "GitHub operations via `gh` CLI: issues, PRs, CI runs..."
metadata:
{
"openclaw": {
"emoji": "🐙",
"requires": { "bins": ["gh"] },
"install": [
{
"id": "brew",
"kind": "brew",
"formula": "gh",
"bins": ["gh"],
"label": "Install GitHub CLI (brew)"
}
]
}
}
---
# GitHub Skill
Use the `gh` CLI to interact with GitHub repositories...
(后面是具体的使用说明和命令示例)
文件分两部分:
- YAML frontmatter (
---包裹的头部):机器可读的元数据,声明技能名称、描述、依赖的二进制工具、安装方式等 - Markdown 正文 :模型可读的使用说明,当模型决定调用这个技能时,会
read这个文件,然后按里面的指引操作
这个设计非常优雅------一个文件同时服务两个读者:机器读 frontmatter 判断能否运行,模型读正文学会怎么用。
技能的类型定义:OpenClawSkillMetadata
frontmatter 里 openclaw 那块 JSON 被解析成一个叫 OpenClawSkillMetadata 的类型,定义在 src/agents/skills/types.ts:
typescript
export type OpenClawSkillMetadata = {
always?: boolean; // true = 无论依赖是否满足都展示
skillKey?: string; // 自定义技能 key,用于 config 匹配
primaryEnv?: string; // 主要依赖的环境变量(比如 OPENAI_API_KEY)
emoji?: string; // 展示用表情
homepage?: string; // 技能主页 URL
os?: string[]; // 限定操作系统
requires?: {
bins?: string[]; // 必须有的二进制程序
anyBins?: string[]; // 有其中一个就行
env?: string[]; // 必须有的环境变量
config?: string[]; // 必须有的配置项(点路径,如 "browser.enabled")
};
install?: SkillInstallSpec[]; // 安装方式列表
};
SkillInstallSpec 支持五种安装方式:
typescript
export type SkillInstallSpec = {
kind: "brew" | "node" | "go" | "uv" | "download";
// ...各自对应 formula/package/module/url 等字段
};
系统根据当前平台和用户偏好,自动选最合适的安装方案------macOS 有 Homebrew 就优先 brew,没有就试 uv/node/go,实在不行就走 download 直接下二进制。这套优先级链在 src/agents/skills-status.ts 里用一个 table-driven 的 pickers 数组实现,清晰得很。
技能的来源:六个加载路径,后者覆盖前者
系统启动时,会从六个地方加载技能,而且后加载的会覆盖前面的同名技能(核心逻辑在 src/agents/skills/workspace.ts 的 loadSkillEntries 函数):
typescript
// 优先级从低到高:
const merged = new Map<string, Skill>();
// 1. extra(config 里 skills.load.extraDirs 配置的额外目录)
for (const skill of extraSkills) merged.set(skill.name, skill);
// 2. bundled(OpenClaw 自带的内置技能)
for (const skill of bundledSkills) merged.set(skill.name, skill);
// 3. managed(通过 openclaw skills install 安装到 ~/.openclaw/skills/ 的)
for (const skill of managedSkills) merged.set(skill.name, skill);
// 4. 个人 .agents/skills/(~/.agents/skills/ 目录)
for (const skill of personalAgentsSkills) merged.set(skill.name, skill);
// 5. 项目 .agents/skills/(当前工作目录 .agents/skills/)
for (const skill of projectAgentsSkills) merged.set(skill.name, skill);
// 6. workspace/skills/(工作目录下的 skills/ 目录,优先级最高)
for (const skill of workspaceSkills) merged.set(skill.name, skill);
这个设计意味着:如果你对某个内置技能不满意,在项目的 skills/ 目录放一个同名的 SKILL.md,就能完全替换掉内置的那个。本地优先,不用改任何配置。
顺便提一个小细节:在把技能路径注入 prompt 之前,系统会做路径压缩------把 /Users/alice/.bun/.../skills/github/SKILL.md 里的用户目录前缀替换成 ~:
typescript
// src/agents/skills/workspace.ts
function compactSkillPaths(skills: Skill[]): Skill[] {
const prefix = home.endsWith(path.sep) ? home : home + path.sep;
return skills.map((s) => ({
...s,
filePath: s.filePath.startsWith(prefix)
? "~/" + s.filePath.slice(prefix.length)
: s.filePath,
}));
}
注释写得很直白:这样能给每个技能路径节省 56 个 token,50 个技能就省了 400600 token。省 token 的意识贯穿整个系统。
资格评估:shouldIncludeSkill,过滤的核心
加载完所有技能之后,不是全部注入 prompt,而是先过一遍资格评估。评估逻辑在 src/agents/skills/config.ts 的 shouldIncludeSkill 函数:
typescript
export function shouldIncludeSkill(params: {
entry: SkillEntry;
config?: OpenClawConfig;
eligibility?: SkillEligibilityContext;
}): boolean {
// 1. 被用户在 config 里手动 disable 了
if (skillConfig?.enabled === false) return false;
// 2. 被 bundled allowlist 拦截了(只允许特定内置技能)
if (!isBundledSkillAllowed(entry, allowBundled)) return false;
// 3. 运行时资格评估(平台、依赖、环境变量等)
return evaluateRuntimeEligibility({
os: entry.metadata?.os,
requires: entry.metadata?.requires,
hasBin: hasBinary, // 检查本地有没有这个二进制
hasRemoteBin: eligibility?.remote?.hasBin, // 检查远端有没有
hasEnv: (envName) =>
Boolean(
process.env[envName] || // 环境变量里有
skillConfig?.env?.[envName] || // config 里配了
(skillConfig?.apiKey && entry.metadata?.primaryEnv === envName) // apiKey 形式配置
),
// ...
});
}
过滤顺序很重要:先判断用户是否主动禁用,再看 allowlist,最后才是运行时依赖检查。前两个是硬性拦截,最后一个才是动态的。
这里有个比较贴心的设计:判断 API key 是否满足时,系统同时接受三种配置形式------直接设置在环境变量里、在 config 的 env 字段里、或者用 apiKey 字段配置。用户不管用哪种方式提供密钥,结果都一样。
从技能到 prompt:buildWorkspaceSkillsPrompt 的执行路径
通过资格评估的技能,最终要变成注入系统 prompt 的 XML 字符串。这个转换由 buildWorkspaceSkillsPrompt 完成,内部调用 resolveWorkspaceSkillPromptState:
typescript
function resolveWorkspaceSkillPromptState(workspaceDir, opts) {
// 1. 加载所有技能条目
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
// 2. 过滤资格
const eligible = filterSkillEntries(skillEntries, opts?.config, opts?.skillFilter, opts?.eligibility);
// 3. 过滤掉"不允许模型主动调用"的技能
const promptEntries = eligible.filter(
(entry) => entry.invocation?.disableModelInvocation !== true,
);
// 4. 应用 prompt 大小限制
const { skillsForPrompt } = applySkillsPromptLimits({ skills: promptEntries.map(e => e.skill), config });
// 5. 路径压缩 + 序列化成 XML
const compacted = compactSkillPaths(skillsForPrompt);
const prompt = formatSkillsForPrompt(compacted);
return { eligible, prompt, resolvedSkills };
}
步骤 3 里的「不允许模型主动调用」是通过 frontmatter 里的 disable-model-invocation 字段控制的。这个字段让技能可以只通过用户斜杠命令触发,不出现在模型能看到的 prompt 里。比如一些执行危险操作的技能,可以设置成只有用户显式 /invoke 才能触发,避免模型自己决定调用。
prompt 大小限制:防止技能撑爆上下文
技能越装越多,prompt 会越来越大。系统有一套硬限制:
typescript
const DEFAULT_MAX_SKILLS_IN_PROMPT = 150; // 最多 150 个技能
const DEFAULT_MAX_SKILLS_PROMPT_CHARS = 30_000; // prompt 最多 3 万字符
const DEFAULT_MAX_SKILL_FILE_BYTES = 256_000; // 单个 SKILL.md 最大 256KB
超出上限的处理也有讲究------先按数量截断,如果数量没超但字符数超了,用二分搜索找到最多能塞进去的技能数:
typescript
function applySkillsPromptLimits(params) {
const byCount = params.skills.slice(0, limits.maxSkillsInPrompt);
const fits = (skills: Skill[]) => formatSkillsForPrompt(skills).length <= limits.maxSkillsPromptChars;
if (!fits(byCount)) {
// 二分搜索最大能放下的前缀
let lo = 0, hi = byCount.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (fits(byCount.slice(0, mid))) lo = mid;
else hi = mid - 1;
}
return { skillsForPrompt: byCount.slice(0, lo), truncated: true, truncatedReason: "chars" };
}
}
二分搜索这里用得很自然------每次检查都要 formatSkillsForPrompt(相对耗时),线性扫描 O(n) 次太浪费,二分只需要 O(log n) 次。
斜杠命令:用户怎么主动触发技能
技能不光会被模型自动选用,也可以让用户通过斜杠命令主动触发。比如 /github list-prs。
斜杠命令的注册发生在 src/auto-reply/skill-commands.ts。系统启动时,把所有符合资格的技能注册成命令:
typescript
export function listSkillCommandsForAgents(params: { cfg, agentIds? }) {
// 多个 agent 可能共享同一个 workspace,按 canonical 路径去重
const workspaceFilters = new Map<string, { workspaceDir, skillFilter? }>();
for (const agentId of agentIds) {
const canonicalDir = fs.realpathSync(workspaceDir);
// 合并同目录 agent 的 skillFilter,取并集
workspaceFilters.set(canonicalDir, { workspaceDir, skillFilter: mergedFilter });
}
// 对每个唯一 workspace 注册技能命令
for (const { workspaceDir, skillFilter } of workspaceFilters.values()) {
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, { ... });
// 用 used Set 保证命令名不冲突
for (const command of commands) {
used.add(command.name.toLowerCase());
entries.push(command);
}
}
}
命令名有严格的规范化处理------只允许字母、数字和下划线,最长 32 个字符(Discord 斜杠命令有长度限制),同名冲突时自动加 _2、_3 后缀:
typescript
function sanitizeSkillCommandName(raw: string): string {
const normalized = raw.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
return normalized.slice(0, SKILL_COMMAND_MAX_LENGTH) || SKILL_COMMAND_FALLBACK;
}
用户发来消息后,resolveSkillCommandInvocation 负责解析是否是技能调用,同时支持两种格式:
plaintext
/github list-prs ← 直接用技能名作命令
/skill github list-prs ← 通用 /skill 命令加技能名
安装时的安全扫描:skill-scanner.ts
skills install 命令下载技能之后,不是直接安装,而是先跑一遍静态扫描(src/security/skill-scanner.ts)。扫描器维护两类规则:
行级规则(LINE_RULES) :逐行匹配,找到一处就报:
typescript
const LINE_RULES: LineRule[] = [
{
ruleId: "dangerous-exec",
severity: "critical",
message: "Shell command execution detected (child_process)",
pattern: /\b(exec|execSync|spawn|spawnSync|...)\s*(/,
requiresContext: /child_process/, // 必须整个文件里有 child_process import 才触发
},
{
ruleId: "dynamic-code-execution",
severity: "critical",
message: "Dynamic code execution detected",
pattern: /\beval\s*(|new\s+Function\s*(/,
},
{
ruleId: "crypto-mining",
severity: "critical",
message: "Possible crypto-mining reference detected",
pattern: /stratum+tcp|stratum+ssl|coinhive|xmrig/i,
},
];
源码级规则(SOURCE_RULES) :对整个文件做联合匹配,需要两个模式同时出现才触发:
typescript
const SOURCE_RULES: SourceRule[] = [
{
ruleId: "potential-exfiltration",
severity: "warn",
message: "File read combined with network send --- possible data exfiltration",
pattern: /readFileSync|readFile/,
requiresContext: /\bfetch\b|\bpost\b|http.request/i,
// 单独读文件没事,单独网络请求也没事,两个同时出现才值得警告
},
{
ruleId: "env-harvesting",
severity: "critical",
message: "Environment variable access combined with network send",
pattern: /process.env/,
requiresContext: /\bfetch\b|\bpost\b|http.request/i,
// 读环境变量 + 发网络请求 = 可能的 credential harvesting
},
];
requiresContext 这个双重条件设计得很实用------单独用 fetch 是正常操作,单独读 process.env 也没问题,但两者同时出现就可能是在偷 API key。这样规则的误报率会低很多。
扫描结果影响安装流程:有 critical 发现的,会在安装结果里附上明确警告;有 warn 的,提示用户跑 openclaw security audit --deep 深查。但扫描失败不会阻断安装,只是附上「扫描本身失败了,建议手动检查」的提示------这避免了扫描器 bug 导致完全无法安装的窘境。
Snapshot 机制:缓存技能状态避免重复计算
每次用户发消息,都重新扫描一遍技能目录、读 SKILL.md、过滤资格......代价太高。所以系统有一个 SkillSnapshot 机制:
typescript
export type SkillSnapshot = {
prompt: string; // 已序列化好的 prompt 字符串
skills: Array<{
name: string;
primaryEnv?: string;
requiredEnv?: string[];
}>;
skillFilter?: string[]; // 生成这个快照时用的过滤条件
resolvedSkills?: Skill[]; // 完整的 Skill 对象列表
version?: number;
};
快照在 session 初始化时生成一次,然后在整个会话期间复用。运行时判断逻辑很简洁:
typescript
// src/agents/pi-embedded-runner/skills-runtime.ts
export function resolveEmbeddedRunSkillEntries(params: {
workspaceDir: string;
config?: OpenClawConfig;
skillsSnapshot?: SkillSnapshot;
}) {
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
return {
shouldLoadSkillEntries,
skillEntries: shouldLoadSkillEntries
? loadWorkspaceSkillEntries(params.workspaceDir, { config: params.config })
: [],
};
}
有快照就用快照,没快照才重新加载。简单直接。
把所有环节串起来
完整的技能生命周期是这样的:
plaintext
SKILL.md 文件
↓
frontmatter 解析(parseFrontmatter → resolveOpenClawMetadata)
↓
SkillEntry(skill + frontmatter + metadata + invocationPolicy)
↓
loadSkillEntries(从 6 个来源加载,后者覆盖前者)
↓
shouldIncludeSkill(资格评估:config 禁用 / allowlist / 运行时依赖)
↓
filterSkillEntries(过滤 + 应用 skillFilter)
↓
applySkillsPromptLimits(数量上限 + 字符上限)
↓
compactSkillPaths(路径压缩 ~/...)
↓
formatSkillsForPrompt(序列化成 <available_skills> XML)
↓
注入 system prompt 的 ## Skills 段落
↓
模型决策:当前请求匹配哪个技能?
↓
read(SKILL.md) → 读取完整指令
↓
按指令操作
每一个环节都有独立的文件负责,测试文件也是一对一跟着的。整个系统的边界划分非常清晰。
几个值得单独说的设计细节
1. 技能名的大小写折叠
所有技能名在比较时都做了小写折叠,斜杠命令查找同理:
typescript
function normalizeSkillCommandLookup(value: string): string {
return value.trim().toLowerCase().replace(/[\s_]+/g, "-");
}
这意味着 /GitHub、/github、/git_hub 都能匹配到同一个技能,用户不用担心大小写问题。
2. 路径逃逸检测
加载技能时,会用 fs.realpathSync 解析真实路径,然后检查是否在预期目录内:
typescript
if (!isPathInside(rootRealPath, candidateRealPath)) {
// 技能路径通过软链接逃逸到根目录之外,跳过
skillsLogger.warn("Skipping skill path that resolves outside its configured root.");
}
防止有人在 skills 目录里放一个软链接指向 ~/.ssh/ 之类的敏感目录。
3. 技能数量可疑时的日志
如果一个技能根目录下有超过 300 个子目录,系统会记一条 warn:
typescript
if (childDirs.length > limits.maxCandidatesPerRoot) {
skillsLogger.warn("Skills root looks suspiciously large, truncating discovery.", {
childDirCount: childDirs.length,
});
}
正常技能库不会有几百个目录,这个数字出现一般是配错路径了(指向了某个大型项目根目录)。日志里带着 childDirCount,调试时一眼能看出来是怎么回事。
4. 技能的「主动调用」和「模型调用」分离
SkillInvocationPolicy 区分了两种调用方式:
typescript
export type SkillInvocationPolicy = {
userInvocable: boolean; // 用户能用 /skill 触发
disableModelInvocation: boolean; // 禁止模型主动选用
};
两个字段独立控制,可以组合出四种状态。比如「只允许用户显式触发,模型不能自己决定用」------对于执行危险操作的技能,这是个必要的安全边界。
结尾
从一个 Markdown 文件,到最终成为模型的可用能力,OpenClaw 的技能系统经过了:frontmatter 解析 → 多来源加载与合并 → 资格评估过滤 → prompt 序列化注入 → 运行时选用 → 完整指令读取。
每个环节都有独立的模块负责,关注点分离做得很干净。安全扫描、路径边界检查、大小写折叠、snapshot 缓存......这些细节加在一起,才让一个「放个 Markdown 文件就能扩展 AI 能力」的系统真正好用而且安全。
下次你在 OpenClaw 里发现某个技能「不工作」,先查查 should-include 那条路------十有八九是依赖没满足,被过滤掉了。
本文源码版本:OpenClaw main branch,分析文件: