OpenClaw 深度解析(八):Skill 系统——让 LLM 按需学习工作流

0 阅读8分钟

场景:AI 怎么知道用哪个命令?

你问 OpenClaw:「帮我查一下上海今天的天气。」

AI 回复了一段 curl "wttr.in/Shanghai?format=3" 的命令,执行后准确拿到了天气数据。

但这里有个问题值得深究——LLM 是一个语言模型,它并不天然知道"查天气要用 wttr.in",也不知道"管理 GitHub PR 用 gh CLI",更不知道"控制 Spotify 用 spotify-player"。

显然有什么东西在"教"它这些。但如果把 50 个工具的完整文档全部塞进系统提示里,光文档本身就会把上下文窗口撑满。

这就是 Skill 系统要解决的问题:

  1. 文档规模问题:50+ 个工具,每个都有详细文档——全部预加载会把 LLM 的上下文撑爆。
  2. 工具可用性问题gh CLI 没装、spotify-player 没配置环境变量——向 LLM 暴露不可用的工具毫无意义还会引发错误。
  3. 工作流标准化问题:工具的用法需要让 LLM 精确理解和遵循,不能靠 LLM "猜"。
  4. 用户体验问题:用户想通过 /weather 上海 直接触发,而不是每次都打一段自然语言。

一、SKILL.md:给 LLM 看的文档格式

为什么是 Markdown 而不是代码?

Skill 不是一段程序——它是"给 LLM 看的操作手册"。LLM 理解自然语言和 Markdown,所以最合理的格式就是带 YAML frontmatter 的 Markdown 文件。

每个 skill 是一个目录,里面放一个 SKILL.md

---
name: weather
description: "Get current weather and forecasts via wttr.in or Open-Meteo.
  Use when: user asks about weather, temperature, or forecasts for any location.
  NOT for: historical weather data, severe weather alerts."
metadata:
  { "openclaw": { "emoji": "🌤️", "requires": { "bins": ["curl"] } } }
---

# Weather Skill

## When to Use**USE this skill when:**
- "What's the weather?"
- "Will it rain today/tomorrow?"

## Commands

```bash
# One-line summary
curl "wttr.in/London?format=3"

文件分两部分:

**frontmatter(机器读)**:

```typescript
// src/agents/skills/types.ts
type OpenClawSkillMetadata = {
  always?: boolean;          // 是否绕过资格检查,强制包含
  emoji?: string;            // 显示用
  primaryEnv?: string;       // 主要依赖的环境变量
  requires?: {
    bins?: string[];         // 需要哪些可执行文件
    anyBins?: string[];      // 满足其中一个即可
    env?: string[];          // 需要哪些环境变量
    config?: string[];       // 需要哪些配置键
  };
  install?: SkillInstallSpec[];  // 如何安装依赖
};

frontmatter 中的两个关键字段

  • description:一行摘要,是系统提示里的唯一"代言人",LLM 靠这一行决定是否使用这个 skill
  • metadata.openclaw.requires.bins:声明依赖哪些可执行文件,运行时若不存在则整个 skill 从系统提示中消失

正文(LLM 读):详细的"何时用"、"何时不用"、命令模板、注意事项——这部分不会出现在系统提示里,只有 LLM 主动读取时才加载到上下文。

这种分离是整个系统设计的核心:元数据给机器,正文给 LLM,摘要在中间传递决策信号


二、多来源发现与优先级(workspace.ts

问题:skill 来自哪里?

一个用户可能同时有系统内置 skill、自己安装的 skill、项目级别的 skill。这些都要能被发现,而且同名时要有明确的覆盖规则。

loadSkillEntries() 扫描六个来源,优先级从低到高:

extra(openclaw.yml 中 skills.load.extraDirs 指定)
  < bundled(核心内置,代码库 skills/ 目录,随 OpenClaw 发布)
    < managed(~/.openclaw/skills/,用户通过 openclaw skills install 安装的)
      < agents-skills-personal(~/.agents/skills/,个人全局 skill)
        < agents-skills-project(工作区 .agents/skills/,项目级 skill)
          < workspace(工作区 skills/,最高优先级)

优先级用一个 Map<name, Skill> 实现——后赋值的覆盖先赋值的:

// src/agents/skills/workspace.ts
const merged = new Map<string, Skill>();
for (const skill of extraSkills)          merged.set(skill.name, skill);
for (const skill of bundledSkills)        merged.set(skill.name, skill);
for (const skill of managedSkills)        merged.set(skill.name, skill);
for (const skill of personalAgentsSkills) merged.set(skill.name, skill);
for (const skill of projectAgentsSkills)  merged.set(skill.name, skill);
for (const skill of workspaceSkills)      merged.set(skill.name, skill);

这意味着:项目里的 skills/github/SKILL.md 会完全覆盖系统内置的 github skill,而不是合并。用户可以为特定项目定制任何 skill 的行为。

嵌套目录探测

resolveNestedSkillsRoot() 有一个启发式逻辑:如果 dir/skills/*/SKILL.md 存在,则把 dir/skills 视为真正的 skills 根目录。这样 ~/.openclaw/skills/ 目录下既可以直接放 github/SKILL.md,也可以放一整个包含 skills/ 子目录的工具包——两种结构都能被正确识别。


三、资格过滤:只暴露可用的 skill

问题:gh CLI 没装,还要把 GitHub skill 展示给 LLM 吗?

shouldIncludeSkill() 在加载后做运行时资格检查:

// 检查 requires.bins:这些可执行文件存在吗?
// 检查 requires.anyBins:至少有一个存在吗?
// 检查 requires.env:这些环境变量设置了吗?
// 检查 requires.config:配置文件里有这些键吗?
// 检查 os:当前操作系统匹配吗?(如 macOS-only skill)
// always: true → 跳过所有检查,强制包含

没装 gh 时,requires.bins: ["gh"] 检查失败,GitHub skill 被从列表中移除——LLM 的系统提示里不会出现任何关于它的信息。

过滤后还有第二步:剔除 disable-model-invocation: true 的 skill。这类 skill 只能通过 /命令 显式触发,LLM 自主决策时看不到它们。

资格上下文:远端信息

SkillEligibilityContext.remote 支持注入远端节点的状态:

type SkillEligibilityContext = {
  remote?: {
    platforms: string[];
    hasBin: (bin: string) => boolean;   // 目标节点上 curl 存在吗?
    hasAnyBin: (bins: string[]) => boolean;
    note?: string;
  };
};

当 Agent 在远端 Node Host 上执行时(参见第六篇),资格检查针对的是目标节点的环境,而不是 Gateway 所在机器——所以如果远端 Linux 服务器有 gh 但本地 Mac 没有,GitHub skill 依然会显示给 LLM。


四、渐进式披露:系统提示里只有摘要

问题:150 个 skill 的完整文档有多大?

以每个 SKILL.md 平均 2000 字节计算,150 个 skill 就是 300KB 纯文本——远超大多数模型的上下文窗口。

解决方案是渐进式披露:系统提示里只放每个 skill 的三个字段(name、description、location),正文留到 LLM 决定使用时才读取。

formatSkillsForPrompt()(来自 @mariozechner/pi-coding-agent SDK)把过滤后的 skill 列表格式化成:

<available_skills>
<skill>
  <name>weather</name>
  <description>Get current weather and forecasts via wttr.in or Open-Meteo.
    Use when: user asks about weather, temperature, or forecasts for any location.
    NOT for: historical weather data, severe weather alerts.</description>
  <location>~/.openclaw/skills/weather/SKILL.md</location>
</skill>
<skill>
  <name>github</name>
  <description>GitHub operations via gh CLI: issues, PRs, CI runs, code review.
    Use when: (1) checking PR status or CI, (2) creating/commenting on issues...</description>
  <location>~/.openclaw/skills/github/SKILL.md</location>
</skill>
</available_skills>

注意 location 字段中的路径:/Users/alice/.openclaw/skills/weather/SKILL.md 被压缩为 ~/.openclaw/skills/weather/SKILL.md——这个细节在 compactSkillPaths() 里实现,每个路径节省约 5-6 个 token,150 个 skill 合计节省约 600-900 token。

Token 预算控制

// src/agents/skills/workspace.ts
const DEFAULT_MAX_SKILLS_IN_PROMPT = 150;
const DEFAULT_MAX_SKILLS_PROMPT_CHARS = 30_000;
const DEFAULT_MAX_SKILL_FILE_BYTES = 256_000;

// 超出字符限制时,二分搜索找最大可容纳前缀
if (!fits(skillsForPrompt)) {
  let lo = 0, hi = skillsForPrompt.length;
  while (lo < hi) {
    const mid = Math.ceil((lo + hi) / 2);
    if (fits(skillsForPrompt.slice(0, mid))) lo = mid;
    else hi = mid - 1;
  }
  skillsForPrompt = skillsForPrompt.slice(0, lo);
}

五、系统提示中的元指令:告诉 LLM 如何使用

问题:LLM 看到 skill 列表后,知道该怎么做吗?

光有列表还不够——LLM 需要明确的行为规则。buildSkillsSection() 把列表和指令一起注入系统提示:

// src/agents/system-prompt.ts
function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) {
  return [
    "## Skills (mandatory)",
    "Before replying: scan <available_skills> <description> entries.",
    `- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${readToolName}\`, then follow it.`,
    "- If multiple could apply: choose the most specific one, then read/follow it.",
    "- If none clearly apply: do not read any SKILL.md.",
    "Constraints: never read more than one skill up front; only read after selecting.",
    trimmed,   // ← <available_skills> 摘要块
  ];
}

这段指令的设计有几个关键点:

  1. (mandatory):标记为强制——LLM 每次回复前都要扫描,而不是"偶尔参考"。
  2. "read its SKILL.md at <location>":明确指定用 read 工具加载,位置就在 <location> 字段——LLM 不需要猜路径。
  3. "never read more than one skill up front":防止 LLM 一次性读取所有可能相关的 skill(会浪费 token)。
  4. "then follow it":读取后要遵循,而不只是参考。

最终效果:用户问「查一下上海天气」→ LLM 扫描摘要 → 匹配 weather skill 的 description → 调用 read("~/.openclaw/skills/weather/SKILL.md") → 读取完整工作流 → 执行 curl "wttr.in/Shanghai?format=3"

整个过程 LLM 是主动参与者,而不是被动执行脚本——Skill 系统通过"摘要 + 路径"给了 LLM 恰好足够的信息来作出决策,完整内容只在真正需要时才加载。


六、/命令:用户显式触发路径

问题:用户想打 /weather 上海 而不是自然语言

buildWorkspaceSkillCommandSpecs() 扫描所有 user-invocable: true 的 skill(默认为 true),为消息平台注册斜杠命令:

// src/auto-reply/skill-commands.ts
// /weather → weather skill
// /github → github skill
// 冲突时自动加 _2 后缀

命令名做规范化处理:

function sanitizeSkillCommandName(raw: string): string {
  return raw
    .toLowerCase()
    .replace(/[^a-z0-9_]+/g, "_")
    .replace(/_+/g, "_")
    .replace(/^_+|_+$/g, "")
    .slice(0, 32);   // Discord 限制:命令名最长 32 字符
}

两种触发模式

用户发送 /weather 上海 后,系统查找 weather 对应的 SkillCommandSpec

模式一:经过 LLM(默认)

/weather 上海
  → resolveSkillCommandInvocation() 识别命令
  → 把 "weather 上海" 作为用户消息注入会话
  → LLM 正常处理(仍会读 SKILL.md 并决策)

模式二:确定性工具分发(command-dispatch: tool

如果 SKILL.md 的 frontmatter 中声明了:

command-dispatch: tool
command-tool: exec
command-arg-mode: raw

则触发完全绕过 LLM:

/weather 上海
  → dispatch.kind === "tool"
  → 直接调用 exec 工具,args = "上海"(原样转发)
  → LLM 不参与

这对"输入明确、工具已知、无需推理"的场景非常有价值——执行速度更快,且行为完全可预期。


七、沙盒环境下的 skill 同步

当 Agent 在 Docker 沙盒中运行时(参见第七篇),skill 文件需要从宿主机同步进容器:

// src/agents/skills/workspace.ts
export async function syncSkillsToWorkspace(params: {
  sourceWorkspaceDir: string;  // 宿主机工作区
  targetWorkspaceDir: string;  // 容器内工作区
}) {
  // 1. 加载宿主机的 skill 列表
  // 2. 清空容器内的 skills/ 目录
  // 3. 把每个 skill 目录 cp 进容器
  // 4. 路径安全检查(防路径遍历)
}

同步完成后,容器内的 read 工具读取的是容器内的 SKILL.md 副本,而不是宿主机路径。resolveSandboxPath() 确保每个 skill 目录名都是安全的,不会通过 ../.. 这类名称逃逸到容器外。


小结:渐进式披露驱动的 LLM 工作流

Skill 系统的核心是一个简洁的设计哲学:不把文档变成代码,而是把文档教给 LLM,让 LLM 按文档行动

阶段机制目的
发现六来源扫描 + Map 优先级覆盖让用户/项目可以覆盖系统内置 skill
过滤bins/env/os 资格检查只向 LLM 暴露当前环境真正可用的 skill
摘要注入name + description + location,字符预算控制最小 token 开销让 LLM 能决策
元指令## Skills (mandatory) + read 工具路径告诉 LLM 如何用这些信息
渐进式披露LLM 决策后主动调用 read(SKILL.md)完整文档只在真正需要时才进入上下文
/命令buildWorkspaceSkillCommandSpecs() 注册斜杠命令用户显式触发,绕过自然语言推理
确定性分发command-dispatch: tool执行路径完全不经过 LLM

这个设计让 skill 作者只需写 Markdown,而不需要了解 LLM 推理、工具注册或消息平台——一个 SKILL.md 文件,就能让 AI 按照作者的意图行动。