场景:AI 怎么知道用哪个命令?
当你向 OpenClaw 提问「帮我查一下上海今天的天气」时,AI 是如何确定该执行哪条指令的?

AI 随后返回了一段 curl "wttr.in/Shanghai?format=3" 的命令,执行后准确获取了天气数据。
然而,这背后隐藏着一个值得深入探讨的核心问题:LLM 本质上只是一个语言模型,它并非天生知晓“查天气需要调用 wttr.in”,也不熟悉“管理 GitHub PR 需使用 gh CLI”,更不曾了解“控制 Spotify 要借助 spotify-player”这类具体操作。
因此,必定有一种机制在“教导”它掌握这些技能。但若将 50 个工具的完整文档一股脑塞进系统提示,单是文档本身就可能撑爆上下文窗口。
这正是 Skill 系统要解决的核心矛盾:
- 文档规模难题:50 多个工具,每个都附带详尽的文档。若全部预加载,LLM 的上下文内存根本不堪重负。
- 工具可用性问题:
ghCLI 未安装,spotify-player的环境变量也未配置——将这类不可用的工具暴露给 LLM,只会引发不可预知的错误。 - 工作流标准化需求:工具的用法必须让 LLM 精准理解并严格执行,绝不能依靠“猜测”来运作。
- 用户体验优化:用户更期望直接输入
/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。系统必须能发现这些 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 实现——后赋值的项会覆盖先赋值的项:
// src/agents/skills/workspace.ts
const merged = new Map
这意味着,如果你在项目中放置一个 skills/github/SKILL.md,它将完全覆盖系统内置的 github skill,而不是合并。用户可以为特定项目定制任意 skill 的行为。
嵌套目录探测
resolveNestedSkillsRoot() 采用了一套精巧的启发式逻辑:如果 dir/skills/*/SKILL.md 存在,则将 dir/skills 视为真正的 skill 根目录。这样一来,~/.openclaw/skills/ 目录下既可以直接存放 github/SKILL.md,也可以放置一个包含 skills/ 子目录的完整工具包——两种结构都能被正确识别。
三、资格过滤:仅暴露可用的 skill
问题:gh CLI 未安装,还需要向 LLM 展示 GitHub skill 吗?
shouldIncludeSkill() 在加载后会执行运行时资格检查:
// 检查 requires.bins:这些可执行文件存在吗?
// 检查 requires.anyBins:至少有一个存在吗?
// 检查 requires.env:这些环境变量设置了吗?
// 检查 requires.config:配置文件中有这些键吗?
// 检查 os:当前操作系统匹配吗?(如仅限 macOS 的 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() 将过滤后的 skill 列表格式化为:
注意 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
这段指令的设计有几个关键要点:
(mandatory)标记为“强制”,意味着 LLM 每次回复前都必须扫描,而非“偶尔参考”。- 明确指定使用
read工具加载处的 SKILL.md,LLM 无需自行猜测路径。 - “never read more than one skill up front” 这条规则防止 LLM 一次性读取所有可能相关的 skill,从而浪费大量 token。
- “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 按照作者的意图精准行动。
