Agent 开发的 skills 机制设计 - 渐进式披露

0 阅读10分钟

Agent 开发的 skills 机制设计 - 渐进式披露

这是 code-artisan 拆解系列第六篇。

上一篇:Agent 开发沙箱设计 - 使工具更安全地被执行

前言

Claude 提出的 Agent Skills 机制是一种把"Agent 的领域知识"以文件形式外挂出去的设计。每个 skill 是一个独立目录,里面放一份 SKILL.md(YAML frontmatter + Markdown 主体)。Agent 启动时不会把所有 skill 主体灌进 system prompt,而是只把 namedescription 这样的元数据注入。当用户的请求落到某个 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 字符

## 示例

...(具体内容略)

约定就两条:

  1. frontmatter 至少要有 namedescriptionname 是 skill 的稳定 id(同名 skill 后定义覆盖前面的),description 是给 LLM 看的"什么时候用我"。
  2. 目录里其他文件随便放。代码片段、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 那版的差别只有两处:

  1. beforeAgentRun 里用 matter(content) 解析后只保留 parsed.data(frontmatter),扔掉 parsed.content(body) 。Skills 数据结构里也只留 name + description + path 三个字段。
  2. 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.listDirsandbox.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[]beforeAgentRunfor 循环跑 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 ⭐。