Skills:把方法论变成标准化调用的能力
Tools 是动作,Skills 是方法论。用 SKILL.md 按需注入 SOP,让模型把同一类任务做得稳定一致。
这篇笔记解决什么问题
给 Agent 接上工具之后,它已经能查资料、读写文件、发请求了。但只要你让它干一类"有固定套路"的活儿——整理会议纪要、写发版公告、做代码评审——很快就会发现一个恼人的现象:同一个任务,跑十次能给你十种格式。今天的纪要有行动项表格,明天的没有;这次评审按严重程度分级,下次又变成流水账。
这不是模型笨,而是它压根不知道你心里的"标准答案"长什么样。你们团队约定纪要必须有"结论 + 行动项 + 负责人",评审必须先看错误处理再看性能——这些约定存在于你的脑子里,不在模型的上下文里。
Skills(技能)机制解决的就是这件事:把"做某类事情的标准流程"沉淀成文件,让模型在需要的时候把它加载进来、照着执行。这篇笔记先讲清概念,再拆解它的注入机制,最后动手在一个 Agent 循环里实现一版可运行的技能系统。
核心概念:Tools 是动作,Skills 是方法论
先把两个容易混淆的东西分开。
Tool 是一个原子动作:读文件、查数据库、调一次搜索接口。它由你写好的代码实现,模型只负责决定"调不调、传什么参数",真正干活的是程序。
Skill 是一套做事的方法论,也就是 SOP 或 Workflow:会议纪要该分哪几段、评审代码先看什么后看什么。它的载体不是代码,而是一段写给模型看的指令文档。模型拿到这段文档后,自己按步骤执行——中间可能还会去调用若干工具。
一句话概括:工具决定 Agent 能做什么,技能决定 Agent 怎么把事做对、做稳定。
| 维度 | Tool | Skill |
|---|---|---|
| 载体 | 代码函数 | Markdown 文档 |
| 入参 | 按需设计的 JSON Schema | 统一的一个 query 字符串 |
| 调用产物 | 执行结果(数据) | 一段流程指令 |
| 真正的执行者 | 程序 | 模型本身 |
| 迭代方式 | 改代码、重新部署 | 改文档、立即生效 |
最后一行值得多看一眼:技能的迭代成本极低。发现纪要少了"待认领事项"这一栏?改一行 Markdown 就完事,不用动任何代码。这让非工程角色(产品、运营)也能参与维护 Agent 的行为。
一个 Skill 就是一个目录
约定俗成的组织方式(由 Anthropic 的 Agent Skills 规范确立,各家基本都沿用了):每个技能是一个独立目录,目录名就是技能 id。
skills/
└── meeting-minutes/
├── SKILL.md # 必备:技能主文件
├── assets/ # 可选:输出模板、示例文件
├── scripts/ # 可选:技能要用到的辅助脚本
└── references/ # 可选:详细规范、背景手册只有 SKILL.md 是必须的。它顶部是一段 YAML frontmatter,正文是方法论本身。以"会议纪要"技能为例:
---
name: meeting-minutes
description: 把零散的会议信息整理成结构化纪要,含结论与行动项。适用于评审会、周会、跨组对齐会。
---
# 会议纪要整理规范
当用户要求整理会议纪要时,按以下流程执行。
## 第一步:确认要素齐全
纪要必须包含四类信息,缺哪个就先向用户提问:
- 会议主题与时间
- 参会人
- 讨论了哪些议题、各自的结论
- 产生了哪些行动项
用户消息里已经给全的,不要重复追问。
## 第二步:按固定结构输出
**会议纪要:{主题}({日期})**
参会人:……
议题与结论
1. {议题}:{结论}
行动项
| 事项 | 负责人 | 截止时间 |
## 第三步:硬性约束
- 结论必须是明确的判断句,不许出现"大家充分交换了意见"这类空话
- 每个行动项必须有负责人,没有的标"待认领"并在结尾提醒
- 除表格外全文不超过 300 字frontmatter 里的 name 和 description 是整个机制的"门面"。后面会看到:模型在决定要不要用这个技能时,唯一能看到的就是这两行。description 写得含糊,技能就永远不会被命中;写得过于宽泛,又会被误触发。好的写法是"做什么 + 什么时候用",就像上面示例那样把适用场景点出来。
关键机制:按需注入,而不是常驻系统提示词
看到这里你可能会想:这不就是一段提示词吗?直接塞进系统提示词不就完了?
如果你的 Agent 一辈子只干整理纪要这一件事,确实可以。但生产级 Agent 往往要同时会十几种活儿。把十几套 SOP 全塞进系统提示词,意味着每一轮对话——哪怕用户只是问一句"你好"——模型都要背着几千 token 的无关指令。上下文里无关内容越多,模型的注意力被稀释得越厉害,指令遵循质量反而下降。
Skills 的设计是分两级加载:
- 常驻的只有元信息。Agent 启动时扫描技能目录,把每个技能的
name+description汇总给模型(每条也就一两行,开销可以忽略)。 - 正文按需进入上下文。模型判断当前任务匹配某个技能时,才发起一次"调用",此时
SKILL.md全文才被送进对话。用不到的技能,模型全程感知不到它的存在。
这就是它和系统提示词的本质区别:系统提示词是永远在场的背景音,技能是需要时才翻开的操作手册。
工程实现:把技能伪装成工具
那"调用一个技能"在 API 层面是怎么发生的?答案很取巧:复用 function calling 通道,把技能包装成一个假工具。
每个技能都注册成一个函数定义,塞进请求的 tools 数组。三个设计要点:
- 函数名加
skill__前缀(真工具加tool__前缀),执行时靠前缀分发; - 所有技能共用同一套入参:只有一个
query字符串。因为技能的"执行"逻辑是统一的——不管传什么,都是把SKILL.md原文返回去——所以不需要为每个技能设计参数; description直接取自 frontmatter,这是模型做路由决策的唯一依据。
一个技能被注册后长这样:
{
"type": "function",
"function": {
"name": "skill__meeting-minutes",
"description": "把零散的会议信息整理成结构化纪要,含结论与行动项。适用于评审会、周会、跨组对齐会。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "用户希望用这个技能完成的原始任务"
}
},
"required": ["query"]
}
}
}而所谓"执行技能",就是读出对应的 SKILL.md,把全文作为 tool result 塞回消息列表。模型在下一轮看到这段方法论,就会照着做。整个生命周期是:
启动扫描目录 → name/description 注册为假工具
↓
模型判断任务匹配 → 发起 skill__xxx 调用
↓
程序读 SKILL.md → 全文作为 tool result 返回
↓
模型按方法论逐步执行(期间可继续调真工具)技能注册器
下面用 Python 实现这一层。先写扫描与读取(frontmatter 用正则手工解析,没必要为两个字段引入 YAML 依赖):
# skill_registry.py
import re
from pathlib import Path
SKILL_ROOT = Path("skills")
_META_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _read_meta(md_text: str) -> dict:
"""提取 SKILL.md 顶部 frontmatter 里的键值对。"""
meta = {}
m = _META_RE.match(md_text)
if not m:
return meta
for raw in m.group(1).splitlines():
if ":" not in raw:
continue
key, _, val = raw.partition(":")
meta[key.strip()] = val.strip().strip("'\"")
return meta
def scan_skills() -> list[dict]:
"""遍历技能根目录,只收集元信息,不加载正文。"""
found = []
if not SKILL_ROOT.exists():
return found
for folder in sorted(SKILL_ROOT.iterdir()):
entry = folder / "SKILL.md"
if not (folder.is_dir() and entry.exists()):
continue
meta = _read_meta(entry.read_text(encoding="utf-8"))
found.append({
"id": folder.name,
"name": meta.get("name", folder.name),
"description": meta.get("description", ""),
})
return found
def load_skill_body(skill_id: str) -> str:
"""读取完整 SKILL.md,作为 tool result 交还给模型。"""
entry = SKILL_ROOT / skill_id / "SKILL.md"
if not entry.exists():
return f"[skill error] 找不到技能:{skill_id}"
return entry.read_text(encoding="utf-8")合并注册与前缀分发
把真工具和技能拼成同一个 tools 数组,执行时按前缀分流。假设真工具以 {name: {"description": ..., "schema": ..., "run": 可调用}} 的形式维护在一个字典里:
# agent_core.py
import json
from openai import OpenAI
from skill_registry import scan_skills, load_skill_body
# 任意 OpenAI 兼容服务均可:DeepSeek / Kimi / GLM / 本地 vLLM……
client = OpenAI(
base_url="https://your-provider.example.com/v1",
api_key="YOUR_API_KEY",
)
MODEL = "your-model-name"
SKILL_QUERY_SCHEMA = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "用户希望用这个技能完成的原始任务",
}
},
"required": ["query"],
}
def build_tool_specs(native_tools: dict) -> list[dict]:
"""真工具 + 技能,统一构造成 function calling 的 tools 数组。"""
specs = []
for name, tool in native_tools.items():
specs.append({
"type": "function",
"function": {
"name": f"tool__{name}",
"description": tool["description"],
"parameters": tool["schema"],
},
})
for sk in scan_skills():
specs.append({
"type": "function",
"function": {
"name": f"skill__{sk['id']}",
"description": sk["description"] or sk["name"],
"parameters": SKILL_QUERY_SCHEMA,
},
})
return specs
def dispatch(func_name: str, args: dict, native_tools: dict) -> str:
"""按前缀分发:skill__ 走文档加载,tool__ 走真实执行。"""
if func_name.startswith("skill__"):
return load_skill_body(func_name.removeprefix("skill__"))
if func_name.startswith("tool__"):
real = func_name.removeprefix("tool__")
if real not in native_tools:
return f"[dispatch error] 未注册的工具:{real}"
return native_tools[real]["run"](**args)
return f"[dispatch error] 无法识别的调用:{func_name}"接入 Agent 循环
def run_turn(messages: list[dict], native_tools: dict) -> str:
specs = build_tool_specs(native_tools)
while True:
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=specs,
)
msg = resp.choices[0].message
if not msg.tool_calls: # 没有调用请求,说明是最终回答
return msg.content
messages.append(msg)
for call in msg.tool_calls:
output = dispatch(
call.function.name,
json.loads(call.function.arguments or "{}"),
native_tools,
)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": output,
})注意 while True 这个循环形态很关键:技能返回的只是方法论,不是最终答案。模型拿到 SKILL.md 之后往往还要继续跑好几轮——追问用户、调真工具、最后组装输出。循环必须允许这个过程自然发生。
跑起来验证:对 Agent 说"帮我整理一下刚才架构评审会的纪要,定了三件事……",日志里会先出现一次 skill__meeting-minutes 调用,随后模型按模板产出结构一致的纪要。如果只说"帮我整理个纪要"而不给任何细节,模型会先追问主题、参会人和结论——因为 SKILL.md 第一步就要求它这么做。
技能的两个进阶玩法
引用外部文件,保持正文精简。 复杂技能不必把所有内容塞进一个 Markdown。比如一个"竞品速览"技能,SKILL.md 里只写一句"输出格式参照 assets/brief-template.md"——模型看到这句话,会自己调用文件读取工具把模板拿进来。方法论和素材就此解耦:流程稳定不变,模板可以随时替换。
指挥模型调工具。 SKILL.md 里完全可以写"先用 tool__web_search 检索目标公司近三个月的动态,再用 tool__read_file 读取本地已有的调研笔记,最后按模板汇总"。这时技能就从"格式规范"升级成了"多步作业的编排脚本",而编排的执行者是模型自己。
这两个玩法叠加起来,一个技能目录就能承载相当复杂的工作流,而系统提示词始终保持干净。
实践要点与常见坑
- description 是唯一的路由信号。 技能不被命中,九成是 description 的问题。别写"处理会议相关任务"这种模糊描述,要写"把零散会议信息整理成含行动项的结构化纪要"。
- 步骤写成可判断的条件,而不是含糊的建议。 "如果用户已提供参会人则跳过提问"是可执行的;"酌情收集信息"是不可执行的。SKILL.md 越像一份能被新员工照做的 SOP,模型执行得越稳。
- 别把技能正文搬回系统提示词"图省事"。 这等于亲手拆掉按需注入机制,退化回注意力被稀释的老问题。
query参数可以不消费,但别删。 统一入参是为了让所有技能共享一套执行路径;即使实现里暂时用不上query,保留它也能让调用日志里留下"模型当时想干什么"的记录,排查路由错误时非常有用。- 技能数量多了以后,同样要控制元信息总量。 几十个技能的 name/description 也会累积成可观的 token。定期合并职责重叠的技能、下线没人用的技能,和管代码依赖是一个道理。
Skills 本质上是结构化、可版本管理的提示词。它把"这类事我们怎么做"从口口相传变成了仓库里的文件——可以 review、可以 diff、可以回滚。这一层补上之后,Agent 的扩展就有了两个正交的维度:工具扩展它的能力边界,技能扩展它的做事章法。本系列后面还会讲到 MCP,解决的则是第三个维度:如何低成本接入别人已经写好的外部工具。
动手清单
- 照本文的注册器实现,建一个
bug-report技能:规定"复现步骤 / 期望行为 / 实际行为 / 环境信息"四段结构。同一段口头描述分别在有无技能的情况下各跑五次,对比输出结构的稳定性。 - 给
meeting-minutes技能加双模式:速记版(100 字内)和归档版(500 字内),在 SKILL.md 里写清两者的区别与选择规则,观察模型是否会主动让用户确认用哪种。 - 把纪要的输出模板抽到
assets/minutes-template.md,SKILL.md 里只留一句引用,验证模型会不会自己调文件工具把模板读进来。 - 写一个需要编排工具的技能(例如"每周技术雷达":先搜索再汇总成固定表格),体会 SKILL.md 如何驱动多轮工具调用。
- 做一个反向实验:把某个技能的 description 改成一句含糊的"处理相关任务",统计命中率的变化——亲身感受 description 作为路由信号的分量。
*本文为学习笔记,知识框架参考自叶小钗「生产级 Agent」系列课程;文字与代码示例为本站原创整理。*