OpenClaw 技能系统源码解析:一个 Markdown 文件是怎么变成 AI 的能力的

4 阅读12分钟

本文约 3400 字,结合源码逐层讲解 OpenClaw 的 Skill 系统,从一个 SKILL.md 文件到最终注入模型提示词的完整链路。

开篇:一个问题引发的好奇

我们第一次认真看 OpenClaw 的技能系统,是在帮用户排查一个奇怪的问题:用户安装了 github 这个 skill,但发完消息之后模型完全没有用到它,就好像根本不知道这个技能存在一样。

翻了一圈源码之后,发现问题出在用户机器上没装 gh 这个 CLI 工具。因为 github skill 声明了 requires: { bins: ["gh"] },检查不通过,这个 skill 就在资格评估那一步被过滤掉了,根本没进到最终的 prompt 里。

这件事让我们开始认真读技能系统的源码。今天把整个链路从头到尾讲一遍。


技能是什么:从一个具体的 SKILL.md 说起

在 OpenClaw 里,一个技能本质上就是一个目录,里面放一个 SKILL.md 文件。比如内置的 GitHub skill:

skills/github/
└── SKILL.md

SKILL.md 的内容大概长这样(取自源码里的实际文件):

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

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 支持五种安装方式:

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 函数):

// 优先级从低到高:
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 里的用户目录前缀替换成 ~

// 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 函数:

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

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 会越来越大。系统有一套硬限制:

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

超出上限的处理也有讲究——先按数量截断,如果数量没超但字符数超了,用二分搜索找到最多能塞进去的技能数:

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。系统启动时,把所有符合资格的技能注册成命令:

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 后缀:

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 负责解析是否是技能调用,同时支持两种格式:

/github list-prs         ← 直接用技能名作命令
/skill github list-prs   ← 通用 /skill 命令加技能名

安装时的安全扫描:skill-scanner.ts

skills install 命令下载技能之后,不是直接安装,而是先跑一遍静态扫描(src/security/skill-scanner.ts)。扫描器维护两类规则:

行级规则(LINE_RULES) :逐行匹配,找到一处就报:

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) :对整个文件做联合匹配,需要两个模式同时出现才触发:

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 机制:

export type SkillSnapshot = {
  prompt: string;                    // 已序列化好的 prompt 字符串
  skills: Array<{
    name: string;
    primaryEnv?: string;
    requiredEnv?: string[];
  }>;
  skillFilter?: string[];            // 生成这个快照时用的过滤条件
  resolvedSkills?: Skill[];          // 完整的 Skill 对象列表
  version?: number;
};

快照在 session 初始化时生成一次,然后在整个会话期间复用。运行时判断逻辑很简洁:

// 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 })
      : [],
  };
}

有快照就用快照,没快照才重新加载。简单直接。


把所有环节串起来

完整的技能生命周期是这样的:

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. 技能名的大小写折叠

所有技能名在比较时都做了小写折叠,斜杠命令查找同理:

function normalizeSkillCommandLookup(value: string): string {
  return value.trim().toLowerCase().replace(/[\s_]+/g, "-");
}

这意味着 /GitHub/github/git_hub 都能匹配到同一个技能,用户不用担心大小写问题。

2. 路径逃逸检测

加载技能时,会用 fs.realpathSync 解析真实路径,然后检查是否在预期目录内:

if (!isPathInside(rootRealPath, candidateRealPath)) {
  // 技能路径通过软链接逃逸到根目录之外,跳过
  skillsLogger.warn("Skipping skill path that resolves outside its configured root.");
}

防止有人在 skills 目录里放一个软链接指向 ~/.ssh/ 之类的敏感目录。

3. 技能数量可疑时的日志

如果一个技能根目录下有超过 300 个子目录,系统会记一条 warn:

if (childDirs.length > limits.maxCandidatesPerRoot) {
  skillsLogger.warn("Skills root looks suspiciously large, truncating discovery.", {
    childDirCount: childDirs.length,
  });
}

正常技能库不会有几百个目录,这个数字出现一般是配错路径了(指向了某个大型项目根目录)。日志里带着 childDirCount,调试时一眼能看出来是怎么回事。

4. 技能的「主动调用」和「模型调用」分离

SkillInvocationPolicy 区分了两种调用方式:

export type SkillInvocationPolicy = {
  userInvocable: boolean;          // 用户能用 /skill 触发
  disableModelInvocation: boolean; // 禁止模型主动选用
};

两个字段独立控制,可以组合出四种状态。比如「只允许用户显式触发,模型不能自己决定用」——对于执行危险操作的技能,这是个必要的安全边界。


结尾

从一个 Markdown 文件,到最终成为模型的可用能力,OpenClaw 的技能系统经过了:frontmatter 解析 → 多来源加载与合并 → 资格评估过滤 → prompt 序列化注入 → 运行时选用 → 完整指令读取。

每个环节都有独立的模块负责,关注点分离做得很干净。安全扫描、路径边界检查、大小写折叠、snapshot 缓存……这些细节加在一起,才让一个「放个 Markdown 文件就能扩展 AI 能力」的系统真正好用而且安全。

下次你在 OpenClaw 里发现某个技能「不工作」,先查查 should-include 那条路——十有八九是依赖没满足,被过滤掉了。


本文源码版本:OpenClaw main branch,分析文件: