# Step 41: Skills 系统

一句话导读:把「专门的流程 / 指令」封装成技能(skill),用渐进式披露接入——系统提示里只放技能的「名字 + 描述」,模型需要时才用 Skill 工具加载完整正文。用两段式把「token 成本」和「能力覆盖」解耦。


# 一、这一步做了什么(What)

新增 utils/skills.ts(发现 + 加载)和 tools/skill.ts(Skill 工具),分两段接入技能:

① 发现(便宜):启动只读每个 SKILL.md 的 frontmatter(name + description)
                → 这些"名字+描述"注入系统提示的 # Available Skills 段
② 加载(贵):  模型看到某技能相关 → 调 Skill({name})
                → 工具读取该 SKILL.md 的【完整正文】回给模型 → 模型照着做
1
2
3
4

技能目录结构 skills/<name>/SKILL.md:YAML frontmatter(name + description)+ 正文(流程指令)。

skills/
├── commit/SKILL.md         规范地提交 git 改动
└── explain-code/SKILL.md   系统地讲解一段代码
1
2
3

实测:系统提示里有 # Available Skills(commit / explain-code 的描述),但没有技能正文(如 commit 里的 git status 步骤);只有 Skill('commit') 被调用后,完整流程才进入上下文。


# 二、面试官视角:为什么要做?(Why)

面试题:既然这些流程指令有用,为什么不像 CLAUDE.md 记忆那样直接全塞进系统提示,反而要多套一层「Skill 工具按需加载」?

因为技能和记忆的相关性分布根本不同,决定了不该用同一种注入策略。

CLAUDE.md 记忆(step40)每轮都相关、体量小,所以常驻划算。但技能是「一段可能用到、大多数时候用不到、且篇幅长的流程」——比如「规范提交 git」的完整流程可能几十行,「讲解代码的方法论」又是几十行。如果把每个技能的长正文全塞进系统提示,会有两个问题:

  1. 费 token:十个技能 × 每个几十行 = 几百行常驻,而任何一次对话可能一个都用不上。为「以防万一」持续付费。
  2. 互相干扰:一堆彼此无关的详细流程堆在系统提示里,会稀释注意力、增加模型「串台」的概率(该讲代码时冒出提交流程的步骤)。

渐进式披露(progressive disclosure)解决的正是这个:把「知道有这个技能存在」和「加载这个技能的完整内容」拆成两步。发现阶段只花「一行描述」的成本让模型知道技能存在;只有模型判断「这个技能和当前任务相关」时,才用工具加载那几十行正文。token 成本按实际使用付费,而非按「可能用到」预付费。

一句话:渐进式披露把「能力覆盖」和「上下文成本」解耦——覆盖靠一行描述(便宜、常驻),成本靠按需加载(贵、用时才付)。


# 三、原理:它是怎么工作的(How)

# 发现(便宜):只解析 frontmatter

discoverSkills() 扫描 skills/ 下每个子目录的 SKILL.md,只读 frontmatter,不读正文:

export function discoverSkills(): SkillMeta[] {
  const dir = skillsDir();
  if (!existsSync(dir)) return [];
  const out: SkillMeta[] = [];
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    if (!entry.isDirectory()) continue;
    const p = join(dir, entry.name, "SKILL.md");
    if (!existsSync(p)) continue;
    const { meta } = parseSkill(readFileSync(p, "utf-8"));  // 只取 meta,丢掉 body
    out.push({ name: meta.name || entry.name,
               description: meta.description || "(无描述)", path: p });
  }
  return out;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

parseSkill 是个轻量 frontmatter 解析器——用一个正则切开 --- ... --- 之间的 key: value 和后面的正文:

function parseSkill(raw: string): { meta: Record<string, string>; body: string } {
  const m = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
  if (!m) return { meta: {}, body: raw };
  const meta: Record<string, string> = {};
  for (const line of m[1].split("\n")) {
    const idx = line.indexOf(":");
    if (idx > 0) meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
  }
  return { meta, body: m[2].trim() };
}
1
2
3
4
5
6
7
8
9
10

这些「名字+描述」注入系统提示的 # Available Skills 段。

# 加载(贵):Skill 工具取完整正文

loadSkill(name) 才真正读正文;Skill 工具是渐进式披露的后半段:

export function createSkillTool(
  discover: () => { name: string; description: string }[],
  load: (name: string) => string | null,
): ToolDef {
  return {
    name: "Skill",
    async execute(input): Promise<ToolResult> {
      const name = String(input?.name ?? "");
      const body = load(name);
      if (!body) {
        const avail = discover().map((s) => s.name).join(", ");
        return { content: [{ type: "text", text: `未找到技能 "${name}"。可用技能:${avail}` }], isError: true };
      }
      // 把完整正文回给模型,加个头部提示它"这是技能指令,请遵循"
      return { content: [{ type: "text", text: `# 技能「${name}」的完整指令(请遵循)\n\n${body}` }] };
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 数据流

启动
  → discoverSkills() 只读 frontmatter → [{name, description}, ...]
  → buildSystemPrompt(..., skills) 注入 "# Available Skills"(只名字+描述)
对话中,模型看到"帮我提交代码"
  → 从系统提示知道有个 commit 技能与之相关
  → 调 Skill({name:"commit"})
  → loadSkill("commit") 读完整正文 → 作为 tool_result 进入上下文
  → 模型照着正文里的 git status → diff → 写信息 → 提交 流程做
1
2
3
4
5
6
7
8

# 四、深入追问(面试常见 follow-up)

Q:技能、工具、记忆都是往上下文里注入东西,三者到底怎么分? A:按「注入时机」和「用途」分:

放进上下文的时机 用途
工具 description 始终(API tools 参数) 模型怎么调一个原子动作
CLAUDE.md 记忆 始终(系统提示) 项目约定 / 偏好,每轮都要
技能正文 按需(Skill 工具加载) 一段可能用到、平时不必占上下文的流程

工具是「原子动作」、记忆是「常驻约定」、技能是「按需流程」——技能补上了「篇幅长、偶尔用」这一格。

Q:为什么用 frontmatter(name + description)做「发现」,而不是把整个文件名当描述,或者启动就全读进来? A:因为「发现」这一步的成本必须足够低才划算——它是常驻在系统提示里的。frontmatter 让每个技能用一行结构化的 description 自我介绍,比文件名信息量大得多(文件名只有 commit,描述能说清「先看 diff、写清晰信息、再提交」),又比读整个正文便宜得多。这是「用最小成本让模型知道技能存在」的最优点。

Q:模型怎么知道该在什么时候调 Skill 加载哪个技能? A:靠系统提示里那段 # Available Skills 的引导 + 每个技能的 description。提示明确告诉它「当某技能相关时,调 Skill 工具加载完整指令再遵循」。所以 description 写得好不好直接决定技能会不会被用对——它既是给人看的说明,更是给模型的触发条件。描述模糊,模型就不知道何时该加载。

Q:Skill 工具找不到技能时返回什么?为什么要把可用技能列表也返回? A:返回 isError: true + 「未找到 "X"。可用技能:commit, explain-code」。把可用列表一并返回,是给模型一次自我纠错的机会——它可能名字记错了(写成 git-commit),看到真实列表就能重试正确的名字。这和 step36 「拦截要反馈而非静默」是同一原则:错误响应要携带足够信息让模型下一步能走对。


# 五、设计权衡

  • 两段式的代价:渐进式披露省了 token,但多了一次工具往返(模型要先决定加载、拿到正文、再执行)。对「篇幅长、偶尔用」的流程这笔往返很值;对「每轮都用的短约定」(记忆)就不值——那直接常驻。是否值得两段式,取决于「篇幅 × 使用频率」。
  • frontmatter 手写解析 vs 引 YAML 库:这里手写一个只认 key: value 的极简解析器,零依赖、够用。代价是不支持嵌套 YAML、列表等复杂结构。对 SKILL.md 只需要 name + description 两个平铺字段的场景,手写正好。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
discoverSkills() 只读 frontmatter skills/ 发现 + frontmatter 解析
# Available Skills 注入 真实版把技能清单注入(attachment / section)
Skill 工具按需加载正文 tools/SkillTool/
还缺:用户级/插件技能目录、技能附带文件、/discover-skills 真实版都有

# 七、一句话总结

Skills = 渐进式披露:名字+描述常驻(便宜、让模型知道技能存在),完整正文按需加载(贵、用时才付)——用两段式把「能力覆盖」和「上下文成本」解耦。它是记忆的对立面(记忆常驻、技能按需),是工具/记忆之外的第三种上下文注入方式,专治「篇幅长、偶尔用」的流程。

# 下一节预告

step42 进入 Plan Mode:AI 先只读研究出实施计划、交你审批,批准后才允许动手改文件——提示引导 + 钩子硬拦 + ExitPlanMode 三道防线。