Agent 开发的 skills 机制设计 - 渐进式披露
这是 code-artisan 拆解系列第六篇。
前言
Claude 提出的 Agent Skills 机制是一种把"Agent 的领域知识"以文件形式外挂出去的设计。每个 skill 是一个独立目录,里面放一份 SKILL.md(YAML frontmatter + Markdown 主体)。Agent 启动时不会把所有 skill 主体灌进 system prompt,而是只把 name 和 description 这样的元数据注入。当用户的请求落到某个 skill 的描述场景上,Agent 才会主动用 read_file 把对应的 SKILL.md 主体读出来再用。这种"先看目录,需要时才翻页"的模式叫 渐进式披露(progressive disclosure) 。
它带来的好处:
- 能力可扩展:新增一项领域知识 = 在 skills 目录里加一个文件夹,不动 agent 源码,不发版
- token 消耗跟 skill 数量解耦:哪怕装了 30 个 skill,单次对话也只把"用到的那一个"的主体拉进上下文,其他只付一行 name + description 的成本
- 能力可分层组织:个人 / 项目 / 团队三层目录可以叠加,覆盖关系由调用方按目录顺序决定
官方介绍:Anthropic - Equipping agents for the real world with Agent Skills。
这一篇我们就按这套规范,在 code-artisan 里把它实现一遍。
Part 1:最小可用的 skills 加载器
我们先用最朴素的方式跑通:一个目录里放若干 skill 文件夹,agent 起来的时候把所有 skill 加载进 prompt。先看一个 skill 长什么样。
SKILL.md 的形态
一个 skill 是一个目录,目录里至少有一份 SKILL.md:
~/.agents/skills/
├── git-commit/
│ └── SKILL.md
├── react-codestyle/
│ ├── SKILL.md
│ └── examples/
│ ├── good-component.tsx
│ └── bad-component.tsx
└── company-api/
├── SKILL.md
└── api-reference.md
SKILL.md 本身是带 YAML frontmatter 的 Markdown:
---
name: git-commit
description: 团队约定的 git commit message 格式与示例。当用户让你 commit 改动、写提交信息、调整 commit message 时使用。
---
# Git Commit 规范
我们用 conventional commit + scope,scope 必须是 packages/ 下的目录名。
## 格式
`<type>(<scope>): <subject>`
- type: feat / fix / refactor / chore / docs / test
- scope: 受影响的 package,如 `agent` / `backend`
- subject: 祈使句,不带句号,不超过 60 字符
## 示例
...(具体内容略)
约定就两条:
- frontmatter 至少要有
name和description。name是 skill 的稳定 id(同名 skill 后定义覆盖前面的),description是给 LLM 看的"什么时候用我"。 - 目录里其他文件随便放。代码片段、JSON 模板、子文档,scaffolds 想塞什么塞什么,等 LLM 真要用的时候再读。
第一版加载器:全量拼进 prompt
最直接的实现:起 agent 之前扫一遍 skills 目录,把所有 SKILL.md 读出来,body 全部拼到 system prompt 尾巴上。我们用第四篇定义的 middleware 接口,挂在 beforeAgentRun 钩子上。
import matter from "gray-matter";
interface Skill {
name: string;
description: string;
body: string;
path: string;
}
function createSkillsMiddleware(skillsDir: string): AgentMiddleware {
let loadedSkills: Skill[] = [];
return {
beforeAgentRun: async ({ agentContext }) => {
const { sandbox } = agentContext;
const entries = await sandbox.listDir(skillsDir).catch(() => []);
const skills: Skill[] = [];
for (const entry of entries) {
if (!entry.is_dir || entry.path.includes("/")) continue;
const skillPath = `${skillsDir}/${entry.path}/SKILL.md`;
const content = await sandbox.readFile(skillPath).catch(() => null);
if (!content) continue;
const parsed = matter(content);
skills.push({
name: parsed.data.name,
description: parsed.data.description,
body: parsed.content,
path: skillPath,
});
}
loadedSkills = skills;
},
beforeModel: ({ modelContext }) => {
if (loadedSkills.length === 0) return;
const skillSections = loadedSkills
.map((s) => `## Skill: ${s.name}\n\n${s.description}\n\n${s.body}`)
.join("\n\n---\n\n");
return {
prompt: `${modelContext.prompt}\n\n# Available Skills\n\n${skillSections}`,
};
},
};
}
beforeAgentRun 在 agent 主循环开始前跑一次,把 skills 全读出来缓存在闭包里。beforeModel 每次调 LLM 前都执行,把缓存的 skills 全部拼到 system prompt 上。
两个细节单独说一下:
为什么用 sandbox.listDir / sandbox.readFile 而不是 node:fs? 因为上一篇我们已经把所有 IO 都走 sandbox 接口了。skills 也不例外。这样在本地 dev 时走 LocalSandbox 读本机的 ~/.agents/skills,在后端走 E2BSandbox 时就读 microVM 里预装的 /opt/skills,middleware 自己根本不需要关心。
为什么 entry.path.includes("/") 要排除? 因为 sandbox.listDir 默认会给两级深度的结果(详见上一篇的 listDir 实现)。这里我们只想要顶层目录,把带 / 的子项滤掉。
这一版的问题
按上面这套跑起来确实能工作:写一个 git-commit skill,agent 在涉及 commit 的对话里就能按规范输出。但只要 skills 数量上去:
- 一份 SKILL.md 写得详细一点,body 几百行很轻松
- 10 个 skill 就是几千行的 Markdown 直接灌进 system prompt
- 这一会话只用到一个 skill,其他 9 个的内容白白进了上下文,每次调 LLM 都重复携带
第一版加载器的 token 消耗 = 所有 skill body 之和 × 每次 LLM 调用次数。这是一个会随着团队往里加 skill 而单调上涨的成本。下一节我们换思路。
Part 2:渐进加载 - 只塞元数据,body 等 LLM 自己来取
上面那版的根本问题是:我们提前替模型决定了"所有 skill 都要看" 。但实际上模型在每一次对话里,最多只会用到其中一两个 skill。只要让模型自己判断哪个 skill 跟当前任务相关,再现场加载它的 body,token 就能从"所有 skill 之和"降到"实际用到的 skill 之和"。
改造点
system prompt 里只放 skill 的元数据(name + description + path),body 留在文件里不动。LLM 看到 description 后,如果觉得跟用户意图匹配,就主动调 read_file 把对应路径的文件读出来。read_file 这个 builtin 工具我们在第三篇就有了,正好直接复用,不用新加任何工具。
function createSkillsMiddleware(skillsDir: string): AgentMiddleware {
let loadedSkills: Array<{ name: string; description: string; path: string }> = [];
return {
beforeAgentRun: async ({ agentContext }) => {
const { sandbox } = agentContext;
const entries = await sandbox.listDir(skillsDir).catch(() => []);
const skills = [];
for (const entry of entries) {
if (!entry.is_dir || entry.path.includes("/")) continue;
const skillPath = `${skillsDir}/${entry.path}/SKILL.md`;
const content = await sandbox.readFile(skillPath).catch(() => null);
if (!content) continue;
// 只解析 frontmatter,body 完全不读取
const parsed = matter(content);
skills.push({
name: parsed.data.name,
description: parsed.data.description,
path: skillPath,
});
}
loadedSkills = skills;
},
beforeModel: ({ modelContext }) => {
if (loadedSkills.length === 0) return;
return {
prompt: `${modelContext.prompt}\n\n${buildSkillSystemPrompt(loadedSkills)}`,
};
},
};
}
function buildSkillSystemPrompt(skills: Array<{ name: string; description: string; path: string }>): string {
return `<skill_system>
You have access to skills that provide optimized workflows for specific tasks. Each skill is a folder; the entry file is referenced by `path` below.
Progressive Loading Pattern:
1. When a user query matches a skill's description, immediately call `read_file` on that skill's `path`
2. Follow the skill's instructions; the skill folder may reference additional files - load them only when needed
3. Do not pre-load skills that are not relevant to the current task
<skills>
${JSON.stringify(skills, null, 2)}
</skills>
</skill_system>`;
}
跟 Part 1 那版的差别只有两处:
beforeAgentRun里用matter(content)解析后只保留parsed.data(frontmatter),扔掉parsed.content(body) 。Skills 数据结构里也只留name + description + path三个字段。beforeModel塞进 prompt 的是一段固定模板 + skills 的 JSON 元数据列表。LLM 看到这段就知道:每个 skill 有一个path,需要的时候自己用read_file拉。
一次调用的开销变化
以一个有 30 个 skill 的 agent 为例,假设平均每个 skill body 800 字符,元数据(name + description)平均 100 字符:
- 第一版(全量加载):system prompt 增加
30 × 800 = 24,000字符的 skill 内容,每次调 LLM 都带 - 第二版(渐进加载):system prompt 增加
30 × 100 = 3,000字符 + 约 200 字符的指令模板。LLM 真用到某个 skill 时,多一次read_file工具调用,把那个 skill 的 800 字符 body 拉进 messages
如果整段对话只用到 1 个 skill,第二版的 prompt 体量是第一版的 1/7 左右。skill 越多差距越大;用不到的 skill 在第二版里永远是免费的(除了几行元数据)。
多出来的代价
渐进加载不是没有代价:
- 多一次 round-trip。LLM 看到 skill 元数据 → 决定要读 → 调 read_file → 拿到 body → 再继续推理。比起 body 直接躺在 prompt 里,慢一拍。
- description 质量决定命中率。LLM 完全靠 description 判断要不要拉 skill;description 写得不清楚,模型就找不到。
- 要相信 LLM 真的会去读。如果 prompt 模板写得不够明确,模型可能"自己想当然"地回答,错过 skill。
前两条我们下一节讲解,第三条靠 prompt 模板本身的明确性兜(Progressive Loading Pattern 那几行就是为此而生)。
Part 3:description 是被命中的唯一线索,写好它
渐进加载把 LLM 当成"路由器"用:看 description 决定要不要去拉 skill body。所以 description 写不好,整套机制就形同虚设。
三个最容易踩的坑
一:description 只描述了 skill 是什么,没说什么时候用。
# 反例
description: Git commit message 规范文档
LLM 看到这个 description 不知道"什么时候"该用它。我用户说"帮我提交一下" / "把这些改动写成一个 commit" / "调整一下最后这个 commit 的 message",这些场景到底算不算?模型只能猜。
# 正例
description: 团队约定的 git commit message 格式与示例。在用户请求 commit 改动、写 commit message、调整已有 commit message 时使用。
正例的关键词是后半句的触发条件:列出实际会触发的用户表述。LLM 不需要语义推理,只需要做"关键词匹配"。
二:description 列了一堆能力但没说应用范围。
# 反例
description: 包含组件命名、目录结构、CSS 写法、状态管理、props 类型、hooks 使用规范
这一条 description 在所有"写 React 代码"的场景里看起来都像匹配,结果是 LLM 几乎每次都会去 read 它,而真正用到的可能只有其中一条小规则。要么写细点把每条单独成一个 skill,要么明确说"应用于本仓库的 frontend 模块"。
三:description 写得像产品文档,太长。
LLM 在 prompt 里要扫的是所有 skills 的 description 之和。每条 200 字看似不多,30 条就是 6000 字符。description 自己变成了新的 token bomb。
一份能跑的 SKILL.md 模板
把上面三条结合一下,一份"能被正确命中"的 SKILL.md 看起来是这样:
---
name: company-api
description: 调用公司内部 ai-platform API 的认证、签名、错误码规范。当用户请求调用 ai-platform 的接口、处理 ai-platform 返回的错误、为 ai-platform 写客户端代码时使用。
---
# AI Platform API 客户端规范
## 认证
所有请求需要在 header 里带 `X-Tenant-Id` 和 `Authorization: Bearer <token>`。
token 通过 `/v1/auth/token` 接口换取,TTL 12 小时...
## 错误码
| code | 含义 | 处理方式 |
| ---- | ---------- | ---------------- |
| 401 | token 过期 | 重新调换取接口 |
| 429 | 限流 | 指数退避重试 3 次 |
| 500 | 服务端错误 | 直接抛出,不重试 |
## 示例代码
完整可跑的客户端代码见同目录下的 `client-example.ts`。
description 的第一句说做什么、第二句给出触发场景;body 才是真正的实施细节。Body 里那句"完整可跑的客户端代码见 client-example.ts" 也很关键:让 LLM 知道这个 skill 文件夹里还有附加资源,需要时可以二次 read_file 拉取。
一条 skill = 一份 prompt + 一份资源
退一步看,skill 在系统里的角色不是"工具",而是"可寻址的提示词 + 配套资源" 。它的价值不来自代码(skill 自身没有可执行逻辑),而来自两件事:
- description 让 LLM 在合适时机找到它
- body 给 LLM 一份足够具体的工作指引
剩下的活儿都由 LLM 接着用已有的 builtin 工具(read_file / write_file / bash 等)完成。skill 系统本身的代码量很少,但通过"把上下文按需注入"放大了 agent 的能力边界。
Part 4:多目录叠加 + 走 sandbox,让 skills 跟着环境跑
到这里 skills 系统功能上已经完整了,但只支持单个目录。真用起来,skills 往往来自多个源:
- 个人本地的私货:
~/.agents/skills/(每个人自己沉淀的 prompt) - 团队预装在镜像里的:
/opt/skills/(部署到 microVM 时随 base image 一起进去) - 项目专属规范:
<project>/.agent/skills/(提交进 git,跟项目走)
三层的关系:个人 > 项目 > 团队?还是倒过来?取决于产品定位。code-artisan 的选择是先到先得,同名后定义覆盖前定义,调用方按"覆盖优先级从低到高"传目录。
多目录加载器
function createSkillsMiddleware(skillsDirs: string[]): AgentMiddleware {
let loadedSkills: Array<{ name: string; description: string; path: string }> = [];
return {
beforeAgentRun: async ({ agentContext }) => {
const { sandbox } = agentContext;
const skillsByName = new Map<string, { name: string; description: string; path: string }>();
for (const skillsDir of skillsDirs) {
const entries = await sandbox.listDir(skillsDir).catch(() => []);
if (entries.length === 0) continue;
for (const entry of entries) {
if (!entry.is_dir || entry.path.includes("/")) continue;
const skillPath = `${skillsDir}/${entry.path}/SKILL.md`;
const content = await sandbox.readFile(skillPath).catch(() => null);
if (!content) continue;
const parsed = matter(content);
const name = parsed.data.name;
if (!name) continue;
// 同名后写覆盖前写;调用方靠 skillsDirs 顺序决定优先级
skillsByName.set(name, {
name,
description: parsed.data.description,
path: skillPath,
});
}
}
loadedSkills = Array.from(skillsByName.values());
},
beforeModel: ({ modelContext }) => {
if (loadedSkills.length === 0) return;
return {
prompt: `${modelContext.prompt}\n\n${buildSkillSystemPrompt(loadedSkills)}`,
};
},
};
}
跟 Part 2 那版的核心差别就两处:参数从 skillsDir: string 变成 skillsDirs: string[];用一个 Map<name, skill> 收集,同名后到的覆盖前者。
注入侧
调用方按"覆盖优先级从低到高"排列目录:
const agent = createAgent({
model,
sandbox,
middlewares: [
createSkillsMiddleware([
"/opt/skills", // 团队预装,最容易被覆盖
"/home/user/.agents/skills", // 个人沉淀,可覆盖团队默认
`${projectRoot}/.agent/skills`, // 项目专属,最终覆盖前两层
]),
],
});
这样一份"团队默认的 commit 规范"可以在某个项目里通过 <project>/.agent/skills/git-commit/SKILL.md 局部改写,全局其他项目不受影响。
跟 sandbox 抽象呼应
上面这段 middleware 整体只跟 sandbox.listDir 和 sandbox.readFile 打交道,不知道也不关心 sandbox 底下是本地文件系统还是 microVM。这意味着:
- 本地 dev 时,三个目录是真实的本地路径,走
LocalSandbox - 部署到后端走 E2B 时,目录指向 microVM 里的路径(团队 base image 把
/opt/skills预装好;用户私货通过 sandbox 同步到/home/user/.agents/skills;项目 skill 通过 git clone 进来),走E2BSandbox
middleware 代码一行不用动,第五篇抽出 sandbox 接口的回报在这里再一次兑现。
不要Skills:skillsDirs: []
如果某些场景完全不需要 skills(比如纯命令行的本地 agent、跑单测的 agent),把 skillsDirs 传 [],beforeAgentRun 里 for 循环跑 0 次,loadedSkills 永远为空,beforeModel 直接 return,没有任何额外开销。这是默认配置一个值得保留的开关:机制按需启用,不强加给所有使用者。
写在最后
到这一篇结束,code-artisan 多了一层"能力可外挂"的扩展点:
- Part 1:SKILL.md 形态(frontmatter + body)+ 最朴素的全量加载器,跑通机制,看到 token bomb 问题
- Part 2:渐进加载,prompt 只塞元数据,body 等 LLM 自己用
read_file拉 - Part 3:description 是命中的唯一线索,写好它的三条经验
- Part 4:多目录叠加 + 完全走 sandbox 抽象,本地 / 项目 / microVM 一套代码通吃
四个 Part 加起来代码量不算多,但换来的是一件结构性的事:Agent 的能力边界从"代码里硬编码"变成"运行时从外部资源加载" 。这跟前两篇的 middleware(横切关注点)、sandbox(执行环境)是同一种思路的延续:把跟主循环不直接相关的事抽离出去,让主循环始终保持瘦身。
下一篇我们换个方向:todo 系统。前面六篇的 agent 一直是"接到任务就一口气干到结尾"的形态,但稍微复杂一点的任务(比如重构一个模块、写一个有 5 个端点的 API)就需要拆步骤、跟踪进度、动态调整。我们会用同一套 middleware 接口落地一个 plan-scoped 的待办系统:agent 自己列计划、自己更新进度,调用方可以观察到中间步骤。
完整代码在 GitHub:github.com/lhz960904/c…(这一篇拆的核心文件是 packages/agent/middlewares/skills/)。如果觉得这个项目对你有帮助,欢迎点个 star ⭐。