场景:AI 怎么知道用哪个命令?
你问 OpenClaw:「帮我查一下上海今天的天气。」
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 精确理解和遵循,不能靠 LLM "猜"。
- 用户体验问题 :用户想通过
/weather 上海直接触发,而不是每次都打一段自然语言。
一、SKILL.md:给 LLM 看的文档格式
为什么是 Markdown 而不是代码?
Skill 不是一段程序------它是"给 LLM 看的操作手册"。LLM 理解自然语言和 Markdown,所以最合理的格式就是带 YAML frontmatter 的 Markdown 文件。
每个 skill 是一个目录,里面放一个 SKILL.md:
markdown
---
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"
ini
文件分两部分:
**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 靠这一行决定是否使用这个 skillmetadata.openclaw.requires.bins:声明依赖哪些可执行文件,运行时若不存在则整个 skill 从系统提示中消失
正文(LLM 读) :详细的"何时用"、"何时不用"、命令模板、注意事项------这部分不会出现在系统提示里,只有 LLM 主动读取时才加载到上下文。
这种分离是整个系统设计的核心:元数据给机器,正文给 LLM,摘要在中间传递决策信号。
二、多来源发现与优先级(workspace.ts)
问题:skill 来自哪里?
一个用户可能同时有系统内置 skill、自己安装的 skill、项目级别的 skill。这些都要能被发现,而且同名时要有明确的覆盖规则。
loadSkillEntries() 扫描六个来源,优先级从低到高:
javascript
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> 实现------后赋值的覆盖先赋值的:
typescript
// 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() 在加载后做运行时资格检查:
typescript
// 检查 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 支持注入远端节点的状态:
typescript
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 列表格式化成:
xml
<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 预算控制
typescript
// 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() 把列表和指令一起注入系统提示:
typescript
// 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> 摘要块
];
}
这段指令的设计有几个关键点:
(mandatory):标记为强制------LLM 每次回复前都要扫描,而不是"偶尔参考"。- "read its SKILL.md at
<location>" :明确指定用read工具加载,位置就在<location>字段------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),为消息平台注册斜杠命令:
typescript
// src/auto-reply/skill-commands.ts
// /weather → weather skill
// /github → github skill
// 冲突时自动加 _2 后缀
命令名做规范化处理:
typescript
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(默认)
bash
/weather 上海
→ resolveSkillCommandInvocation() 识别命令
→ 把 "weather 上海" 作为用户消息注入会话
→ LLM 正常处理(仍会读 SKILL.md 并决策)
模式二:确定性工具分发(command-dispatch: tool)
如果 SKILL.md 的 frontmatter 中声明了:
yaml
command-dispatch: tool
command-tool: exec
command-arg-mode: raw
则触发完全绕过 LLM:
ini
/weather 上海
→ dispatch.kind === "tool"
→ 直接调用 exec 工具,args = "上海"(原样转发)
→ LLM 不参与
这对"输入明确、工具已知、无需推理"的场景非常有价值------执行速度更快,且行为完全可预期。
七、沙盒环境下的 skill 同步
当 Agent 在 Docker 沙盒中运行时(参见第七篇),skill 文件需要从宿主机同步进容器:
typescript
// 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 按照作者的意图行动。