# 「手搓系列 02」深入浅出 Agent Skills：架构、实现与思考

原文：https://blog.grtsinry43.com/posts/agent-skills-deep-dive/

作者：grtsinry43

发布时间：2026-05-05T06:55:40Z

内容更新时间：2026-05-05T06:58:34Z

手搓系列 02。

说来惭愧，01 和 02 之间隔了多久呢？久到海枯石烂...

倒不是没动力——01 写完的第二天我就受伤了。~~属于是身体抢在我之前完成了"用力过猛"的演出，接下来就是几个月的躺平（物理）。~~ 期间手动实现过微前端框架，写过简单的打包工具，但是一直没有发，感觉自己的理解还是不够，一拖就拖到了 AI 时代，这次也算是小小的蹭蹭热度，搞搞 skills。

真难绷。一篇博客的更新节奏，中途隔绝了天堑。

---

回到正题。

最早听说 Skills 的时候，我的想法是"就这？炒冷饭而已"。

动态加载提示词，找个文件写一堆 instruction，~~感觉不如直接塞进 `CLAUDE.md` 省事~~。但是实际测试中，不写进 `CLAUDE.md` / `AGENTS.md`，信息容易滑出上下文，compact 之后就失效；写太多，塞进去的内容一直占着上下文的位置，token 消耗巨大。

于是 Skills 这么诞生了，算是一种新的协议规范。

## 先看概念吧

Anthropic 的工程博客《Equipping agents for the real world with Agent Skills》（2025-10-16）把 Skill 定义为：

> Organized folders of instructions, scripts, and resources that agents can discover and load dynamically to perform better at specific tasks.

> Building a skill for an agent is like putting together an onboarding guide for a new hire.

我们可以圈几个关键词：**folders**（不是单个文件）、**dynamically**（不是预加载）、**onboarding guide**（不是 prompt 工程）。

### Skills 的目录长什么样

最简形态是一个目录，里面放一个 `SKILL.md`，开头是 YAML frontmatter，正文是 markdown。frontmatter 的**必需字段只有两个**：`name` 和 `description`。

更复杂一些的 skill 可以有 `scripts/`、`references/`、`assets/` 等子目录，正文里通过文件名引用。

```
my-skill/

├── SKILL.md # 必需
├── scripts/ # 可选：可执行代码
├── references/ # 可选：参考文档
└── assets/ # 可选：模板/字体/图标
```

### 本质：Markdown + frontmatter 的结构化封装

看刚才的目录就明白，Skill 本质上就是一个 `.md` 文件，开头用 YAML frontmatter 定义元数据，正文是带结构的 prompt 模板。

frontmatter 里能写的东西还挺多的（后文会展开讲）：

- `name`：技能叫什么
- `description`：干什么的
- `when_to_use`：什么时候该调用
- `arguments`：接受哪些参数
- `allowedTools`：执行时能用哪些工具

所以 Skills 感觉更多是定义了一个规范。谁可以用、什么时候用、接受什么参数、输出什么格式，定义清楚了 AI 才能稳定执行。~~听起来像废话，但是这样写感觉效果确实好一些~~。

## Skills 的优势架构

所以他相比提示词功能好在哪里呢？

Skill 系统最核心的设计是把 Skill 的信息分成两层。

**第一层是索引层**：Skill 的 name、description、when_to_use。这部分在 AI 启动时就会注入上下文，AI 靠这个来做路由决策——（Claude 偷偷想）"用户这句话好像匹配 brainstorming，我应该调用它"。

**第二层是正文层**：Skill 文件里的完整 prompt 内容。这部分只有在 Skill 被真正调用时才会展开注入对话，不会一开始就塞进上下文，这样说不了几句就压缩了。

这样做的好处是上下文经济学的微妙平衡：AI 不需要一开始就读完所有 Skill 的全部内容，只需要知道"有哪些技能、干什么的"，等真正要用了再加载正文。全塞进去的话，token 消耗巨大不说，AI 还容易在各种 Skill 的风格里左右摇摆 ~~，像个选择困难症患者~~。

这个「先路由、再加载」的设计，可能就是设计所在了。

> 像一本组织良好的手册——目录在最前面，章节在中间，详细附录在最后，**Claude 按需翻页**。

> Skill 文件在编辑器中的样子，以及 Skill 列表里出现的形态

![image.png](https://blog.grtsinry43.com/uploads/pictures/2026-05-05-06:46:08-a1.png)

![image.png](https://blog.grtsinry43.com/uploads/pictures/2026-05-05-06:46:25-4a.png)



然后 Anthropic 还说：

> Agents with a filesystem and code execution tools don't need to read the entirety of a skill into their context window when working on a particular task. This means that the amount of context that can be bundled into a skill is effectively unbounded.

换句话说，只要 agent 有文件系统和代码执行工具，bundle 的内容就可以"实质上无限大"。Skill 不依赖任何特殊运行时，它只假设 agent 有 bash 和文件系统，由此他就能无缝迁移到别的 Agent 实现。

## Skills 的调用机制

谈到这里，Skills 是怎么被使用的呢？
Skills 有两条触发路径，用户和模型都可以调用，但最后执行的流程都是大同小异的。

**用户入口**：

比如用户在对话框输入 `/blog-writer`，Agent 框架判断这是一个 prompt 类型的命令，生成完整的 skill 原文。skill 内容作为一条**隐藏的用户消息**注入当前对话，emm 至少形态上和普通对话绘画里的用户消息一样。同时，skill 声明的 hooks 会被注册，已调用的 skill 会被记录。

**模型入口**：

模型启动时只能看到一份「技能索引」，就像你点菜时候看到的菜单一样：每个 skill 的 name、description、when_to_use，来自 SkillTool 的 prompt 和 skill_listing attachment。模型靠这份索引做路由决策——"用户这句话好像匹配 git-commit，我应该调用 SkillTool"。

**fork 的情况**：

当然，有一个特殊情况，就是 Agent 是会使用小弟的，也就是 subagent
如果 skill 的 frontmatter 里声明了 `context: fork`，skill 的内容会被送往一个子 agent 独立执行。

## Skills 的核心语义（也就是之前的字段）

然后前段时间 cc 的代码不是不小心开源了么，于是我们来看看 Anthropic 是怎么设计 Skills 的。

> 注意
接下来说的是 Anthropic 的实现，因为我们想学习他们的设计，Skills 的通用字段其实很少


### context：inline 与 fork

frontmatter 里的 `context` 字段决定 skill 被调用后如何执行。

`inline` 是默认行为：skill 内容注入当前对话，AI 在当前上下文中继续工作。适合需要借助当前对话历史才能完成的技能，得知道用户之前聊了什么。（这里默认就是这样，同一个上下文）

`fork` 则是把 skill 内容发给一个子 agent 独立执行，完毕后再把结果汇回来。适合需要屏蔽当前上下文干扰的技能——~~比如你让 AI 写代码，但它总想顺便帮你修 bug~~。

### paths：条件可见，而不是条件执行

skill 的 frontmatter 里可以声明 `paths`，指定绑定到哪些文件路径。

也就是指定哪些 skills 可见：当 AI 操作的文件路径匹配了 skill 的 paths 声明，这个 skill 才进入可用技能列表；路径不匹配，这个 skill 对 AI 来说就不存在。
大仓库里不需要一次性暴露所有 skill，skill 和对应部分相关，AI 进入那片区域时对应的 skill 才出现。

> paths 的典型用法
比如你有一个 `css-naming` skill，专门检查命名规范，就可以用 `paths: ["**/*.css", "**/*.scss"]` 绑定到样式文件。只有当 AI 在处理样式文件时，这个 skill 才会出现——平时它就像不存在一样。


### 上下文压缩摘要保留

对话历史变长时，AI 会做上下文压缩。skill 内容是作为用户消息注入的，会随对话历史自然在上下文空间向后移动。
但是这里 Anthropic 开了个后门，压缩时系统不会把 skill 内容彻底删掉，而是**摘要后作为 attachment 继续保留**——记录 name、path 和内容摘要。所以压缩后 AI 仍然知道这个 skill 被调用过，继续遵守它的指引。

但是 fork 出来的 skill，子 agent 结束后会主动清理 skill 内容，毕竟用完就扔掉了。（过河拆桥说是）

### allowedTools、hooks、变量替换

**allowedTools**：skill 可以声明执行时能用哪些工具，调用时会临时放开权限边界。

**hooks**：调用 skill 时可以规范下 agent 的行为，比如"执行前先做 X"、"执行完后做 Y"。

**变量替换**：skill 正文是带插槽的模板，cc 会插入一些东西。常用的有：

- `$ARGUMENTS`：代表用户传入的参数
- `${CLAUDE_SKILL_DIR}`：代表 skill 文件所在的目录，AI 可以基于这个路径去读取同目录下的资源文件
  skill 文件里还支持 inline shell 块——用反引号包裹的 shell 命令会在执行时运行。不过这是本地 skill 的专属能力，来自远端 MCP server 的 skill 出于安全考虑不允许执行本地 shell。
  不过这都是 cc 的设计了，还是很强的。

## 着手开始实现吧

### 项目结构

```
mini-skill-runtime/
├── src/
│   ├── types.ts        # 集中类型定义
│   ├── frontmatter.ts  # YAML 解析
│   ├── registry.ts     # 扫描 → 索引 → 按需加载
│   ├── compaction.ts   # 上下文压缩保留
│   └── demo.ts         # 演示入口
└── skills/
    ├── greeter.md
    └── code-reviewer.md
```

生命周期可以分为五步：

```
扫描 → 索引 → 按需加载 → 注入对话 → 压缩保留
```

### 模块一：frontmatter 解析

核心就是一个正则：

```ts
export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/;
```

解析完拿到 `{ frontmatter, content }` ，frontmatter 用 `yaml` 库 parse 就行了。

### 模块二：registry

**Step 1: `scanSkillsDir`** 递归扫描 `.md`，子目录用冒号作为命名空间分隔符。

**Step 2: `buildIndexPrompt`** 在字符预算内生成索引文本（给模型的菜单）：

```ts
const MAX_DESC_CHARS = 250;  // 对标 Claude Code 的 MAX_LISTING_DESC_CHARS

export function buildIndexPrompt(skills: SkillIndex[], charBudget = 2000): string {
  if (skills.length === 0) return "";

  const fullLines = skills.map((s) => {
    const desc = s.whenToUse ? `${s.description} - ${s.whenToUse}` : s.description;
    const trimmed = desc.length > MAX_DESC_CHARS
      ? desc.slice(0, MAX_DESC_CHARS - 1) + "…"
      : desc;
    return `- ${s.name}: ${trimmed}`;
  });

  const fullText = fullLines.join("\n");
  if (fullText.length <= charBudget) return fullText;

  // 超出部分，那就截断 description
  const nameOverhead = skills.reduce((sum, s) => sum + s.name.length + 4, 0);
  const available = charBudget - nameOverhead;
  const maxDescLen = Math.max(20, Math.floor(available / skills.length));
  // ...
}
```

**Step 3: `loadSkill`** 调用时才读全文，做变量替换：

```ts
let prompt = content;
prompt = prompt.replaceAll("${SKILL_DIR}", skillDir);

if (args !== undefined) {
  if (prompt.includes("$ARGUMENTS")) {
    prompt = prompt.replaceAll("$ARGUMENTS", args);
  } else if (args) {
    // 没有占位符但有参数 → 追加到末尾
    prompt += `\n\nARGUMENTS: ${args}`;
  }
}
```

`${SKILL_DIR}` 对应 Claude Code 的 `${CLAUDE_SKILL_DIR}`，让 skill 的 markdown 里能拿到自己所在目录的绝对路径，然后 AI 用 Read/Grep 工具去读同目录下的资源文件。

### 模块三：compaction

对话变长时上下文会被压缩，已调用过的 skill 内容不能彻底丢掉——因为 AI 还要"继续遵守它们的指引"。

```ts
export class InvokedSkillsStore {
  private skills = new Map<string, InvokedSkillInfo>();

  add(name: string, path: string, content: string): void {
    this.skills.set(name, {
      skillName: name,
      skillPath: path,
      content,
      invokedAt: Date.now(),
    });
  }
}
```

同一个 skill 可能被多次调用，这里用 Map 天然去重。

压缩时按调用时间倒序、截断保留头部：

```ts
const sorted = Array.from(all.values()).sort((a, b) => b.invokedAt - a.invokedAt);

for (const skill of sorted) {
  const truncated = skill.content.length > maxPerSkill
    ? skill.content.slice(0, maxPerSkill) + "\n\n[... truncated]"
    : skill.content;

  if (usedChars + truncated.length > budget) break;
  usedChars += truncated.length;
  result.push({ name: skill.skillName, path: skill.skillPath, content: truncated });
}
```

> demo.ts 的运行结果（左）和两个测试 skill 的样子（右）
![image.png](https://blog.grtsinry43.com/uploads/pictures/2026-05-05-06:47:42-60.png)
![image.png](https://blog.grtsinry43.com/uploads/pictures/2026-05-05-06:47:49-32.png)


## 来分析一下

### 1. Progressive Disclosure 的作用

"渐进披露"不仅是节省 token 的小技巧，**它真正解决的不是 token 经济，而是注意力分配**。

如果把所有 skill 的全文都塞进系统提示，会出现一个非常具体的问题——AI 在多种风格指引里左右摇摆。它一会儿想用写作助手的腔调，一会儿想套代码审查的格式，~~像个开学第一周的大学生选课选麻了~~。让 AI 先看到"目录"再去翻"章节"，其实也就是在帮它做注意力路由。

### 2. 字符预算 > 条目数

`buildIndexPrompt` 用的是字符预算（默认 2000，Claude Code 这里默认 8000）。

这里之所以没用数量，可以这样理解：Haiku 和 Opus 不是同一个量级，更不要提以后的更小/更大模型。固定条目数会在跨模型时出问题，转向字符预算是一种跨模型的"普通话"。

### 3. 截断保留头部，因为 skill 文件的开始更重要

压缩时为什么截断保留头部、丢弃尾部？其实这里感觉是个设计取舍，分析 skills 感觉太过度了，Skill 文件本身就有一种**倒金字塔结构**约定：

- 文件开头：你是谁、你该怎么做、什么时候该做
- 文件中间：工作模式、分类规则
- 文件末尾：示例、边界 case、扩展说明

这个结构不是 Anthropic 强制的，但你去看官方 skill 仓库（`anthropics/skills`）里的写法、Claude Code 内置的那些 skill，几乎全是这种模式。**截断保留头部和这种写法是配套设计**——丢掉示例和边界 case，AI 还能干活；丢掉"你是谁"，AI 就没法搞了。

### 4. Skill 只有一份约定

所谓 Skill 运行时，根本没有什么不可替代的引擎。它就是一份约定：

- 一个目录
- 一个带 frontmatter 的 markdown 文件
- 启动时把元数据塞进 system prompt
- 让 AI 用 bash 自己去读全文

剩下的事情，全是 agent 平台已经具备的能力——文件系统、bash 工具、对话注入。

## 自己写一个 Skill ？

我最近正在写一套自己的 Skill，可以试试。

https://github.com/grtsinry43/agent-skills

刚写完元 skill，还在更新。

> 参考资料

- Anthropic 官方工程博客：[Equipping agents for the real world with Agent Skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)

- Anthropic 官方完整指南：The Complete Guide to Building Skills for Claude（PDF）

- 本文最小复刻代码：`mini-skill-runtime/`（仓库内）
- 完整源码导读：`06-skill系统实现详解.md`（仓库内，AI 写的）



## 自愿蒸馏


个人观点，仅供参考


有一天我突然意识到一件事。

我花了很长时间写 skill 文件——就是那种告诉 AI "遇到这种情况你应该怎么做"的 markdown。写的时候很投入，把脑子里那些模糊的、说不太清的判断，一条一条拆成规则，写成机器能读的格式。写完之后 agent 真的变聪明了，PR 质量肉眼可见地上去了。觉得自己做了一件很酷的事。

然后某个瞬间，一个念头冒出来：我是不是在免费给厂商蒸馏自己？

以前的 prompt 是分散的，一次性的，格式随意，拿去训练也提取不出什么结构化知识。但 skill 不一样。它有 frontmatter，有触发条件，有适用范围，有正例反例。它是你主动把自己几年踩坑攒出来的经验，整整齐齐地打包成了训练集最喜欢的样子。而且你还会反复迭代，因为你自己要用，所以你会把它打磨得越来越精确。

更妙的是，agent 用了你的 skill 之后跑出来的 PR 过没过、reviewer 改了多少，这些都是天然的训练数据。一份带标注的、高质量的、按领域组织好的专家知识库，附带效果验证——做训练数据的人做梦都想要这个（我们这种搞机器学习的最喜欢了）。

我跟朋友说了这个想法，他说我阴谋论。我不确定他是不是对的。

这不是 Anthropic 一家在做的事——Cursor rules、`AGENTS.md`、Gemini 的配置文件、Windsurf 的规则集——整个行业都在让你做同一件事：把你脑子里的东西外化成机器可读的格式。谁先让用户完成这个动作，谁的 agent 就更懂你，你就越难迁移。

所以真正的意图可能不是训练，而是锁定。你在一个平台的 skill 格式上投入了一百个小时，迁移成本就是一百个小时。

但这两件事并不矛盾。锁定是短期动机，训练是长期可能。说"他们不会拿去训练"我是不信的。

但是写的过程有些经验经得起推敲，写下来之后更清晰了；有些经验写到一半我发现自己其实说不清楚为什么要这么做，只是一直这么做而已。

至于厂商会怎么用，那是他们的事。我能控制的只有：写不写，写多具体，以及写完之后我自己有收获了。

## 写在最后

至于未来， Skills 会变成 AI 协作工具的基础设施——不只是 Claude Code，各种 AI 工具都会需要一种方式来 "安装能力"。"Markdown + frontmatter + 按需加载" 这种模式，至少活到现在，说明还是有优点在的。

---

这一期算是比较简单的了，一共也没多少代码。
之后还是回到前端的基建，手搓点好玩的

