Agent Skill 机制:把工作流按需加载,而不是一次性塞满上下文
在代码 Agent 里,tool 和 skill 经常同时出现,但它们解决的是两类不同问题。
tool 解决的是“如何让模型调用外部能力”。比如读文件、搜索代码、执行命令、操作浏览器。模型返回一个结构化的 function_call,Agent runtime 执行对应工具,再把结果回传给模型。
skill 解决的是另一个问题:当任务变复杂时,如何让模型知道某个领域的专用流程、约束和最佳实践,同时又不把所有长说明一次性塞进上下文。
从一次真实请求抓包来看,skill 的核心机制可以概括为一句话:先把所有 skill 的简要说明放进 prompt,让模型自己判断是否命中;命中后再通过统一的 skill(name) 工具加载对应 SKILL.md 的完整内容。
这就是一种典型的渐进式披露。
Skill 和 Tool 的区别
普通 tool 是可直接执行的能力。
例如 read 工具的作用是读取本地文件或目录。它会作为 function schema 出现在请求的 tools 数组里:
{
"type": "function",
"name": "read",
"description": "Read a file or directory from the local filesystem...",
"parameters": {
"type": "object",
"properties": {
"filePath": { "type": "string" },
"offset": { "type": "integer" },
"limit": { "type": "integer" }
},
"required": ["filePath"]
}
}
模型如果想读取文件,可以直接返回:
{
"type": "function_call",
"name": "read",
"arguments": "{\"filePath\":\"/path/to/package.json\",\"offset\":1,\"limit\":200}"
}
Agent runtime 收到后,直接执行本地 read 工具。
skill 不一样。skill 本身通常不是“直接完成业务动作”的能力,而是一份可加载的任务说明、领域知识或工作流手册。它告诉模型在某类任务里应该怎么做、优先用什么工具、避免什么错误、遇到分支如何处理。
因此可以这样区分:
tool = 可执行动作
skill = 可按需加载的操作说明
或者更具体一点:
read / grep / bash / browser_click = tool
lark-doc / fix-issue / figma-to-lark-page / preview-lane-management = skill
Skill 列表的数据结构
在抓包中,skill 列表不是独立的 JSON 字段,而是放在 developer prompt 里的 XML-like 文本块中:
<available_skills>
<skill>
<name>lark-doc</name>
<description>飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容...</description>
<location>file:///Users/bytedance/.claude/skills/lark-doc/SKILL.md</location>
</skill>
<skill>
<name>fix-issue</name>
<description>修复 Bug 单问题</description>
<location>file:///Users/bytedance/code/project/.claude/skills/fix-issue/SKILL.md</location>
</skill>
</available_skills>
如果把它转换成 JSON,语义上大致是:
{
"available_skills": [
{
"name": "lark-doc",
"description": "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容...",
"location": "file:///Users/bytedance/.claude/skills/lark-doc/SKILL.md"
},
{
"name": "fix-issue",
"description": "修复 Bug 单问题",
"location": "file:///Users/bytedance/code/project/.claude/skills/fix-issue/SKILL.md"
}
]
}
每个 skill 至少有三个关键信息:
name:skill 的唯一名称,用于后续调用。description:给模型看的简短路由说明。location:完整 skill 文件的位置,通常指向某个SKILL.md。
与此同时,请求的 tools 数组里还会提供一个统一的 skill 工具:
{
"type": "function",
"name": "skill",
"description": "Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the skill from available_skills"
}
},
"required": ["name"]
}
}
这两部分合在一起,构成了 skill 的路由与加载机制:
developer prompt 里的 available_skills
+
tools 里的 skill(name)
=
按需加载 skill 的能力
Agent 如何命中 Skill
skill 的命中不是靠简单的字符串匹配,也不是 Agent runtime 在外部写死一堆 if-else。真正做判断的是模型。
流程通常是:
用户提出任务
-> 模型阅读 developer prompt
-> 模型看到 available_skills 列表
-> 模型根据 description 判断是否有 skill 匹配
-> 如果匹配,返回 function_call: skill({ name })
-> Agent runtime 读取对应 SKILL.md
-> SKILL.md 内容注入上下文
-> 模型按完整 skill 指南继续执行任务
举例来说,用户说:
帮我读取这个飞书文档并总结重点
模型会在 skill 列表中看到类似描述:
lark-doc:飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token...
于是它会返回:
{
"type": "function_call",
"name": "skill",
"arguments": "{\"name\":\"lark-doc\"}"
}
Agent runtime 执行这个 skill 工具后,会读取 lark-doc/SKILL.md,并把里面的完整说明注入当前上下文。接下来模型才会按照该 skill 的详细要求继续操作,比如使用指定 CLI、处理权限、解析文档 token、避免错误路由等。
Skill Content 实际放在哪里
一个容易误解的点是:skill 加载后,SKILL.md 的完整内容是不是被塞回了原始 developer prompt?
从抓包来看,不是。
在一次读取飞书文档的请求中,用户输入是:
https://bytedance.larkoffice.com/wiki/K8N8wm2xZiqm7KkIvZHcVH4tnOe 读取这篇飞书文档
模型第一轮响应返回了一个 skill 工具调用:
{
"type": "function_call",
"name": "skill",
"call_id": "call_rxVOjgF6hI1HU1xqtSYL0Bap",
"arguments": "{\"name\":\"lark-doc\"}"
}
Agent runtime 执行这个调用后,读取 lark-doc/SKILL.md。在下一轮 LLM 请求里,完整 skill 内容不是追加到 input[0] 的 developer message,而是作为这次工具调用的输出放进了 input 数组:
input[0] developer
input[1] user
input[2] reasoning
input[3] function_call: skill({ name: "lark-doc" })
input[4] function_call_output: <skill_content name="lark-doc">...</skill_content>
对应的结构大致是:
{
"type": "function_call_output",
"call_id": "call_rxVOjgF6hI1HU1xqtSYL0Bap",
"output": "<skill_content name=\"lark-doc\">\n# Skill: lark-doc\n\n# docs\n\n**身份:文档操作默认使用 `--as user`..."
}
这里的 call_id 和上一轮 function_call.call_id 对应,表示这段 skill_content 是 skill({ name: "lark-doc" }) 的执行结果。
因此更准确的说法是:
available_skills 简介:一开始在 developer prompt 里
SKILL.md 完整内容:命中后作为 function_call_output 注入下一轮 input
它在语义上仍然会影响模型后续行为,因为它已经进入了模型上下文;但从 API 数据结构上看,它不是被物理拼接回原始 developer prompt,而是作为工具执行结果进入会话历史。
渐进式披露:为什么不直接塞全部 SKILL.md
如果系统里有几十个 skill,每个 SKILL.md 都可能很长。假设一开始就把所有 skill 的完整内容全部塞进 prompt,会带来几个问题:
- 上下文成本高:大量无关说明会占用 token。
- 注意力被稀释:模型需要在一堆无关工作流里找重点。
- 冲突概率上升:不同 skill 的规则可能适用于不同场景,全部放一起容易互相干扰。
- 更新维护困难:每次请求都携带大量长文本,系统开销更大。
skill 机制采用的是渐进式披露:先给模型一张目录,再按需打开具体章节。
第一层是轻量索引:
name + description + location
这一层足够让模型判断“这个任务应该找谁”。
第二层是完整说明:
SKILL.md 的全部内容
这一层只在命中后加载,用来指导模型具体执行。
这很像软件里的 lazy loading,也像文档系统里的目录与正文:目录常驻,正文按需展开。
一个完整链路示例
假设当前有一个 skill:
<skill>
<name>preview-lane-management</name>
<description>泳道预览环境管理:启动、重启、查看、清理泳道调试服务。当用户说“启动泳道预览服务”或“给我一个临时预览链接”时使用。</description>
<location>file:///repo/.agents/skills/preview-lane-management/SKILL.md</location>
</skill>
用户说:
给我启动一个临时预览链接
模型看到 description 后,判断这个任务应该加载 preview-lane-management,于是返回:
{
"type": "function_call",
"name": "skill",
"arguments": "{\"name\":\"preview-lane-management\"}"
}
runtime 做的事情是:
读取 file:///repo/.agents/skills/preview-lane-management/SKILL.md
把内容作为 skill_content 注入会话
随后,模型会根据 SKILL.md 里的具体流程继续执行。它可能会调用 bash 启动服务,调用某个内部 CLI 创建泳道,或者按 skill 要求先检查当前分支、端口和依赖状态。
这个过程中,skill 只负责加载说明。真正执行任务的,仍然是后续的普通 tools。
Skill 的 description 是路由规则
因为模型是根据 description 来判断是否命中 skill,所以 description 的质量非常关键。
一个好的 skill description 应该说明:
- 这个 skill 负责什么任务。
- 用户出现哪些表达时应该使用。
- 哪些事情不归它负责。
- 如果有相邻 skill,应该如何区分。
例如一个文档类 skill 不应该只写:
处理文档。
更好的写法是:
飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。不负责电子表格或多维表格的数据操作。
后者不仅告诉模型“什么时候用”,也告诉模型“什么时候不要用”。这会显著降低误命中。
Skill 加载后的效果
加载 skill 后,本质上是给模型增加了一段新的上下文。它可能包含:
- 任务执行步骤。
- 推荐使用的 CLI 或工具。
- 参数格式和认证方式。
- 常见错误处理。
- 不允许做的事情。
- 多步骤工作流。
- 子 agent 或验证流程。
因此,skill 更像“领域操作手册”,而不是“函数”。
普通工具调用链路是:
模型 -> function_call(read) -> runtime 读文件 -> function_call_output
skill 调用链路是:
模型 -> function_call(skill) -> runtime 读取 SKILL.md -> 注入上下文 -> 模型继续调用其他工具
也就是说,skill 调用通常不是任务结束,而是任务进入某个专用工作流的开始。
这种设计的价值
skill 机制的价值在于,它把 Agent 的能力拆成了两层:
第一层是稳定、通用、可执行的工具层。
read / grep / bash / browser / apply_patch
第二层是可扩展、可维护、按需加载的知识层。
lark-doc / fix-issue / create-mr / figma-to-lark-page
这样设计有几个好处:
- 工具层保持简单,负责执行确定性动作。
- skill 层可以快速扩展,不需要改模型或核心 runtime。
- 大量领域知识不会默认污染上下文。
- 模型可以根据任务语义动态加载最相关的工作流。
- 不同项目可以拥有自己的本地 skill,适配项目规范和内部平台。
最终,Agent 不再只是“能调用工具的聊天机器人”,而是一个可以按任务加载专业操作手册的执行系统。
小结
skill 和 tool 的关系可以总结为:
tool 是手,skill 是说明书。
OpenCode 先把所有 skill 的简要说明放入 prompt,让模型知道有哪些“说明书”可用。模型根据用户任务判断是否需要某个 skill。如果需要,它通过结构化 function_call 调用统一的 skill(name) 工具。Agent runtime 再读取对应的 SKILL.md,把完整内容注入上下文,模型随后按这份说明继续调用普通工具完成任务。
这套机制的关键不是一次性给模型更多上下文,而是只在需要时给模型正确的上下文。
这就是 Agent skill 机制里的渐进式披露:先暴露目录,再加载正文;先判断场景,再展开流程;先控制上下文,再增强能力。