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

发布时间:2026-05-05 14:55   浏览量:22
grtsinry43
Grtblog博客系统
该内容由 RSS 渲染生成,最佳阅读体验请前往:https://blog.grtsinry43.com/posts/agent-skills-deep-dive手搓系列 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 按需翻页。

::: gallery height="300px" caption="Skill 文件在编辑器中的样子,以及 Skill 列表里出现的形态"


:::
然后 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 的。
::: callout type="warning" title="注意"
接下来说的是 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 才出现。
::: callout type="idea" title="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 解析
核心就是一个正则:
export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/;

解析完拿到 { frontmatter, content } ,frontmatter 用 yaml 库 parse 就行了。
模块二:registry
Step 1: scanSkillsDir 递归扫描 .md,子目录用冒号作为命名空间分隔符。
Step 2: buildIndexPrompt 在字符预算内生成索引文本(给模型的菜单):
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 sum + s.name.length + 4, 0);
const available = charBudget - nameOverhead;
const maxDescLen = Math.max(20, Math.floor(available / skills.length));
// ...
}

Step 3: loadSkill 调用时才读全文,做变量替换:
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 还要"继续遵守它们的指引"。
export class InvokedSkillsStore {
private skills = new Map();

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

同一个 skill 可能被多次调用,这里用 Map 天然去重。
压缩时按调用时间倒序、截断保留头部:
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 });
}

::: gallery caption="demo.ts 的运行结果(左)和两个测试 skill 的样子(右)"


:::
来分析一下
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,还在更新。
::: callout type="info" title="参考资料"


Anthropic 官方工程博客: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 写的)


:::
自愿蒸馏
::: callout type="info" title=""
个人观点,仅供参考
:::
有一天我突然意识到一件事。
我花了很长时间写 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 + 按需加载" 这种模式,至少活到现在,说明还是有优点在的。

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

该站点最新相关文章