设计 Skill 系统,这 3 个坑我替你踩过了
英文标题:设计 Skill 系统,这 3 个坑我替你踩过了
Skill 你写过不少,但 Agent 内部是怎么用它的,你可能从来没看过。这篇将会带你深入分析 Skill 的秘密
36.1 KB
53.5 KB
中文
前言
Skill 是什么?说白了就是一个技能包 ,核心是一个 SKILL.md 文件。Agent 干活的时候,会根据任务需要按需加载 对应的 Skill,比如要做 UI 设计、代码审查,就加载相应的那一个。
听起来是不是挺简单?你可能已经打开 AI 编程工具,准备直接丢一句"帮我实现 Skill 机制"过去。
等一下 ,先别急。 Skill 听着简单,但你真的想清楚怎么在 Agent 系统里实现它了吗?先看这几个问题你能不能答上来:
- SKILL.md 里除了 name 和 description,还有什么头部元信息?
- Agent 要用某个 Skill 的时候,如何定位 以及如何调用 Skill 的?
- Skill 去重 是否有考虑过?
如果这几个问题你还没想明白,那接下来我就一个一个拆 ,把整套 Skill 机制的设计讲清楚。
一、认识 SKILL.md:头部元信息
请先查收 Claude Code 官方给的标准Skill头字段:code.claude.com/docs/zh-CN/…
这里我挑几个典型的讲:
| 字段 | 作用 |
|---|---|
name |
展现名称 |
description |
给模型看,决定何时用 |
allowed-tools |
工具白名单 |
disallowed-tools |
工具黑名单 |
model |
限定模型 |
metadata |
自定义键值 |
agent |
指定执行用的 subagent |
context |
执行时是否开辟独立子上下文 |
-
其中
name和description决定了 Agent 什么时候调用它。 -
allowed-tools可能很多人会理解为只允许使用什么工具,实际上是赋予 Agent 执行这些工具的权限,无需你再盯着点同意。 -
disallowed-tools则是字面意思,硬性禁止某些工具/命令,即使模型想调也会被系统拦截 。
这里有个坑:此次禁用的工具,需要在下一个回合(turn)恢复,否则这些工具会在这个会话里一直被禁用,影响后续操作。。
(turn 是什么,后面文章会专门讲,这里先按这个理解:turn 就是一次会话回合。)
[第 1 回合] 你发消息 → 模型调用某 Skill → Skill 进入 active
→ disallowed-tools 里的工具被从可用工具池里移除
→ 这一回合里,模型根本"看不到" Write / Edit,想调都调不了
[第 2 回合] 你发下一条消息 → 限制清除 → Write / Edit 恢复可用
-
disallowed-tools和allowed-tools一样,都是临时作用域。只在调用 Skill 的那个回合预批准 -
model则是可以指定这个 Skill 只在特定模型下启用 ,别的模型加载不到。典型用法有两种:某个 Skill 依赖长上下文或复杂推理,小模型扛不住,就限定它只用大模型,免得跑崩;反过来,简单的 Skill 也可以限定用小模型,省钱。 -
context和agent则是配合用的:context决定这个 Skill 是在主对话里跑,还是开辟一个独立子上下文单独跑 ,agent决定用哪个子 Agent 来执行 。典型场景是:某个 Skill 过程很脏——读几十个文件、跑一堆命令——但你只关心最终结果。这时设context: fork,让它跑在独立上下文里、不污染主对话,再指定一个子 Agent 去干这票重活。 -
metadata则是用来塞任意自定义键值 的地方,模型一般不读它。通常是给团队的管理、审计、成本核算用的——比如记owner、tags、cost-center,Skill 管理平台扫目录时可以按这些字段分类统计。它不影响 Agent 怎么跑,只影响你怎么管。
Skill案例
简单场景,同时也是大多数Skill的头部元信息
---
name: frontend-ui-engineering
description: 构建生产级品质的用户界面。在构建或修改面向用户的界面时使用。在创建组件、实现布局、管理状态,或需要输出看起来达到生产级品质而非“AI 生成感”时使用。
---
复杂一点的:
---
name: code-review
description: 当用户要求审查代码、检查 PR、或查找潜在 bug 时使用。
allowed-tools:
- Read
- Grep
- Bash(git diff:*)
model: claude-sonnet-4-5
disable-model-invocation: false
license: MIT
version: 1.2.0
metadata:
scope: project
agents: [backend-bot, reviewer-bot]
---
(示例里的 license、disable-model-invocation、version 属于官方标准里的其他字段,基础版可以先不处理。)
结论:如果你实现的是基础 Skill 系统,只处理 name 和 description 就够了;后续要扩展,再基于上面这些字段往上加。
二、深入了解 Skill 与 Agent 的交互
1. Agent 如何 调用 Skill
Agent是怎么调用 Skill的 ? 目前有两种主流的做法:
-
单独为Skill设计一个工具,Agent 通过
skill_name加载 SkillSKILL_TOOL = { "name": "skill", "description": "按 name 加载一个已注册的 Skill,返回它的完整指令。", "parameters": { "skill_name": { "type": "string", "description": "要加载的 Skill 名称", "required": True, }, }, }
def execute_skill_tool(skill_name: str) -> str: skill = find_skill_by_name(skill_name)
if not skill: return f"未找到 Skill: {skill_name}"body = load_body(skill["path"]) apply_permissions(skill["meta"]) return { "activation": f"<command-name>{skill_name}</command-name>", "base_dir": skill["base_dir"], "body": body, }
注意这个返回值不只是 Skill.md 文件里面的内容,而是三样东西:
- activation(激活标记) :
<command-name>{skill_name}</command-name>。这是给系统看的——表示"这个 Skill 已被调用",用来做去重和状态追踪(下文作解释) ,也方便前端展示调用事件。 - base_dir(根目录) :Skill 所在的目录。
- body(正文) :SKILL.md 正文内容。
-
通过 ReadFile 工具根据路径读取 SKILL.md 正文
READ_FILE_TOOL = { "name": "read_file", "description": "按路径读取文件内容。", "parameters": { "path": { "type": "string", "description": "文件路径", "required": True, }, }, }
def execute_read_file_tool(path: str) -> str: return Path(path).read_text(encoding="utf-8")
SKILL_TOOL能把元信息字段都利用起来——细粒度权限、执行上下文、激活标记,适合复杂 Skill 系统;ReadFile只能读全文,简单,够前期用
顺带补充一个小知识:Skill 的组成不只有 SKILL.md 这一个文件。除了 SKILL.md,还可以带知识文档、可执行脚本、静态资源等:
{skill_name}/
├── SKILL.md
├── scripts/
├── references/
└── assets/
所以调用Skill的本质其实是工具调用,使用 读工具 读SKILL.md 或者其他知识文件,使用 Bash 工具执行脚本。
不过在Codex中我倒是发现其内部使用 PowerShell 的 cat 命令获取SKILL.md。
Claude Code 就偏向使用 SKILL_TOOL 完成Skill的加载
2. Agent 如何 定位 Skill
第一步系统首先扫一遍指定目录,找到所有 skill文件夹下的SKILL.md,只读头部的 name 和 description,收进注册表中
def register_skills(skill_dir: str) -> list[dict]:
skills = []
for path in Path(skill_dir).rglob("SKILL.md"):
meta = parse_frontmatter(path)
skills.append({
"name": meta["name"],
"description": meta["description"],
"path": str(path),
})
return skills
接着把这些信息注入系统提示词。两种工具对应的注入内容不一样:
- 用 SKILL_TOOL 的方式:只注入 name 和 description
- 用 ReadFile 的方式:除了 name 和 description,还要把 SKILL.md 的路径也注入,让模型知道去哪读
Skill 在上下文里的显示大概长这样:
<available_skills>
<skill>
<name>pdf</name>
<description>Comprehensive PDF manipulation toolkit for extracting text and tables, merging/splitting documents, and handling forms.</description>
<path>/absolute/path/to/pdf/SKILL.md</path>
</skill>
</available_skills>
(<path> 是给 ReadFile 方式定位文件用的;如果是 Skill 工具方式,路径留在注册表里,不暴露给模型。)
注意,这里只放了 name 和 description,没放正文 。这是整个 Skill 系统里最关键的一个设计:注册要轻。 如果这一步就把正文全塞进去,后面的"匹配"和"加载"就没意义了,上下文也会被无关内容占满。
三、Skill 的激活标记:去重与状态追踪
去重:别把同一个 Skill 重复加载
一次任务里,模型可能反复想用同一个 Skill。比如用户连续问两轮"再帮我审查一下代码",模型可能两次都想调 code-review。
如果没有去重,code-review 的正文会被注入两次 ,白白浪费 token,上下文里还多了份重复内容。
有激活标记 + 去重,系统就能判断:
code-review已经激活过了,这次不重复注入,直接复用。
一句话总结就是 :去重 = 防止同一个 Skill 的正文被反复塞进上下文。
状态追踪:记住"现在哪些 Skill 是激活的"
系统需要维护一个"当前激活中的 Skill 列表",因为好几件事都靠它:
- 权限 :Skill 激活期间,它的 allowed/disallowed-tools 生效;一旦退出激活,就要把权限收回来。系统得知道"现在该收谁的权限"。
- 前端展示 :界面上显示"当前正在执行 code-review",也是从这个列表读的。
- 生命周期 :记录 Skill 什么时候进 active、什么时候退出。
一句话总结就是 :状态追踪 = 系统维护一张"谁正在激活"的表,用来管权限生效和恢复。
放到你的 Skill 系统实现里 ,大概就是:
active_skills = set()
def activate(skill_name):
if skill_name in active_skills:
return "已激活,跳过"
active_skills.add(skill_name)
apply_permissions(skill)
def deactivate(skill_name):
active_skills.discard(skill_name)
restore_permissions()
active_skills 这个集合,同时干了"去重"和"状态追踪"两件事——判断重复靠它,知道该恢复谁也靠它。
English / 原文
前言
Skill 是什么?说白了就是一个技能包 ,核心是一个 SKILL.md 文件。Agent 干活的时候,会根据任务需要按需加载 对应的 Skill,比如要做 UI 设计、代码审查,就加载相应的那一个。
听起来是不是挺简单?你可能已经打开 AI 编程工具,准备直接丢一句"帮我实现 Skill 机制"过去。
等一下 ,先别急。 Skill 听着简单,但你真的想清楚怎么在 Agent 系统里实现它了吗?先看这几个问题你能不能答上来:
- SKILL.md 里除了 name 和 description,还有什么头部元信息?
- Agent 要用某个 Skill 的时候,如何定位 以及如何调用 Skill 的?
- Skill 去重 是否有考虑过?
如果这几个问题你还没想明白,那接下来我就一个一个拆 ,把整套 Skill 机制的设计讲清楚。
一、认识 SKILL.md:头部元信息
请先查收 Claude Code 官方给的标准Skill头字段:code.claude.com/docs/zh-CN/…
这里我挑几个典型的讲:
| 字段 | 作用 |
|---|---|
name |
展现名称 |
description |
给模型看,决定何时用 |
allowed-tools |
工具白名单 |
disallowed-tools |
工具黑名单 |
model |
限定模型 |
metadata |
自定义键值 |
agent |
指定执行用的 subagent |
context |
执行时是否开辟独立子上下文 |
-
其中
name和description决定了 Agent 什么时候调用它。 -
allowed-tools可能很多人会理解为只允许使用什么工具,实际上是赋予 Agent 执行这些工具的权限,无需你再盯着点同意。 -
disallowed-tools则是字面意思,硬性禁止某些工具/命令,即使模型想调也会被系统拦截 。
这里有个坑:此次禁用的工具,需要在下一个回合(turn)恢复,否则这些工具会在这个会话里一直被禁用,影响后续操作。。
(turn 是什么,后面文章会专门讲,这里先按这个理解:turn 就是一次会话回合。)
[第 1 回合] 你发消息 → 模型调用某 Skill → Skill 进入 active
→ disallowed-tools 里的工具被从可用工具池里移除
→ 这一回合里,模型根本"看不到" Write / Edit,想调都调不了
[第 2 回合] 你发下一条消息 → 限制清除 → Write / Edit 恢复可用
-
disallowed-tools和allowed-tools一样,都是临时作用域。只在调用 Skill 的那个回合预批准 -
model则是可以指定这个 Skill 只在特定模型下启用 ,别的模型加载不到。典型用法有两种:某个 Skill 依赖长上下文或复杂推理,小模型扛不住,就限定它只用大模型,免得跑崩;反过来,简单的 Skill 也可以限定用小模型,省钱。 -
context和agent则是配合用的:context决定这个 Skill 是在主对话里跑,还是开辟一个独立子上下文单独跑 ,agent决定用哪个子 Agent 来执行 。典型场景是:某个 Skill 过程很脏——读几十个文件、跑一堆命令——但你只关心最终结果。这时设context: fork,让它跑在独立上下文里、不污染主对话,再指定一个子 Agent 去干这票重活。 -
metadata则是用来塞任意自定义键值 的地方,模型一般不读它。通常是给团队的管理、审计、成本核算用的——比如记owner、tags、cost-center,Skill 管理平台扫目录时可以按这些字段分类统计。它不影响 Agent 怎么跑,只影响你怎么管。
Skill案例
简单场景,同时也是大多数Skill的头部元信息
---
name: frontend-ui-engineering
description: 构建生产级品质的用户界面。在构建或修改面向用户的界面时使用。在创建组件、实现布局、管理状态,或需要输出看起来达到生产级品质而非“AI 生成感”时使用。
---
复杂一点的:
---
name: code-review
description: 当用户要求审查代码、检查 PR、或查找潜在 bug 时使用。
allowed-tools:
- Read
- Grep
- Bash(git diff:*)
model: claude-sonnet-4-5
disable-model-invocation: false
license: MIT
version: 1.2.0
metadata:
scope: project
agents: [backend-bot, reviewer-bot]
---
(示例里的 license、disable-model-invocation、version 属于官方标准里的其他字段,基础版可以先不处理。)
结论:如果你实现的是基础 Skill 系统,只处理 name 和 description 就够了;后续要扩展,再基于上面这些字段往上加。
二、深入了解 Skill 与 Agent 的交互
1. Agent 如何 调用 Skill
Agent是怎么调用 Skill的 ? 目前有两种主流的做法:
-
单独为Skill设计一个工具,Agent 通过
skill_name加载 SkillSKILL_TOOL = { "name": "skill", "description": "按 name 加载一个已注册的 Skill,返回它的完整指令。", "parameters": { "skill_name": { "type": "string", "description": "要加载的 Skill 名称", "required": True, }, }, }
def execute_skill_tool(skill_name: str) -> str: skill = find_skill_by_name(skill_name)
if not skill: return f"未找到 Skill: {skill_name}"body = load_body(skill["path"]) apply_permissions(skill["meta"]) return { "activation": f"<command-name>{skill_name}</command-name>", "base_dir": skill["base_dir"], "body": body, }
注意这个返回值不只是 Skill.md 文件里面的内容,而是三样东西:
- activation(激活标记) :
<command-name>{skill_name}</command-name>。这是给系统看的——表示"这个 Skill 已被调用",用来做去重和状态追踪(下文作解释) ,也方便前端展示调用事件。 - base_dir(根目录) :Skill 所在的目录。
- body(正文) :SKILL.md 正文内容。
-
通过 ReadFile 工具根据路径读取 SKILL.md 正文
READ_FILE_TOOL = { "name": "read_file", "description": "按路径读取文件内容。", "parameters": { "path": { "type": "string", "description": "文件路径", "required": True, }, }, }
def execute_read_file_tool(path: str) -> str: return Path(path).read_text(encoding="utf-8")
SKILL_TOOL能把元信息字段都利用起来——细粒度权限、执行上下文、激活标记,适合复杂 Skill 系统;ReadFile只能读全文,简单,够前期用
顺带补充一个小知识:Skill 的组成不只有 SKILL.md 这一个文件。除了 SKILL.md,还可以带知识文档、可执行脚本、静态资源等:
{skill_name}/
├── SKILL.md
├── scripts/
├── references/
└── assets/
所以调用Skill的本质其实是工具调用,使用 读工具 读SKILL.md 或者其他知识文件,使用 Bash 工具执行脚本。
不过在Codex中我倒是发现其内部使用 PowerShell 的 cat 命令获取SKILL.md。
Claude Code 就偏向使用 SKILL_TOOL 完成Skill的加载
2. Agent 如何 定位 Skill
第一步系统首先扫一遍指定目录,找到所有 skill文件夹下的SKILL.md,只读头部的 name 和 description,收进注册表中
def register_skills(skill_dir: str) -> list[dict]:
skills = []
for path in Path(skill_dir).rglob("SKILL.md"):
meta = parse_frontmatter(path)
skills.append({
"name": meta["name"],
"description": meta["description"],
"path": str(path),
})
return skills
接着把这些信息注入系统提示词。两种工具对应的注入内容不一样:
- 用 SKILL_TOOL 的方式:只注入 name 和 description
- 用 ReadFile 的方式:除了 name 和 description,还要把 SKILL.md 的路径也注入,让模型知道去哪读
Skill 在上下文里的显示大概长这样:
<available_skills>
<skill>
<name>pdf</name>
<description>Comprehensive PDF manipulation toolkit for extracting text and tables, merging/splitting documents, and handling forms.</description>
<path>/absolute/path/to/pdf/SKILL.md</path>
</skill>
</available_skills>
(<path> 是给 ReadFile 方式定位文件用的;如果是 Skill 工具方式,路径留在注册表里,不暴露给模型。)
注意,这里只放了 name 和 description,没放正文 。这是整个 Skill 系统里最关键的一个设计:注册要轻。 如果这一步就把正文全塞进去,后面的"匹配"和"加载"就没意义了,上下文也会被无关内容占满。
三、Skill 的激活标记:去重与状态追踪
去重:别把同一个 Skill 重复加载
一次任务里,模型可能反复想用同一个 Skill。比如用户连续问两轮"再帮我审查一下代码",模型可能两次都想调 code-review。
如果没有去重,code-review 的正文会被注入两次 ,白白浪费 token,上下文里还多了份重复内容。
有激活标记 + 去重,系统就能判断:
code-review已经激活过了,这次不重复注入,直接复用。
一句话总结就是 :去重 = 防止同一个 Skill 的正文被反复塞进上下文。
状态追踪:记住"现在哪些 Skill 是激活的"
系统需要维护一个"当前激活中的 Skill 列表",因为好几件事都靠它:
- 权限 :Skill 激活期间,它的 allowed/disallowed-tools 生效;一旦退出激活,就要把权限收回来。系统得知道"现在该收谁的权限"。
- 前端展示 :界面上显示"当前正在执行 code-review",也是从这个列表读的。
- 生命周期 :记录 Skill 什么时候进 active、什么时候退出。
一句话总结就是 :状态追踪 = 系统维护一张"谁正在激活"的表,用来管权限生效和恢复。
放到你的 Skill 系统实现里 ,大概就是:
active_skills = set()
def activate(skill_name):
if skill_name in active_skills:
return "已激活,跳过"
active_skills.add(skill_name)
apply_permissions(skill)
def deactivate(skill_name):
active_skills.discard(skill_name)
restore_permissions()
active_skills 这个集合,同时干了"去重"和"状态追踪"两件事——判断重复靠它,知道该恢复谁也靠它。