创见博客
Agent Skill 机制:把工作流按需加载,而不是一次性塞满上下文
七崽爱吃小饼干2026/07/14阅读 9专栏 AI开发

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 数组里:

json
{
  "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"]
  }
}

模型如果想读取文件,可以直接返回:

json
{
  "type": "function_call",
  "name": "read",
  "arguments": "{\"filePath\":\"/path/to/package.json\",\"offset\":1,\"limit\":200}"
}

Agent runtime 收到后,直接执行本地 read 工具。

skill 不一样。skill 本身通常不是“直接完成业务动作”的能力,而是一份可加载的任务说明、领域知识或工作流手册。它告诉模型在某类任务里应该怎么做、优先用什么工具、避免什么错误、遇到分支如何处理。

因此可以这样区分:

text
tool = 可执行动作
skill = 可按需加载的操作说明

或者更具体一点:

text
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 文本块中:

xml
<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,语义上大致是:

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 至少有三个关键信息:

  1. name:skill 的唯一名称,用于后续调用。
  2. description:给模型看的简短路由说明。
  3. location:完整 skill 文件的位置,通常指向某个 SKILL.md。

与此同时,请求的 tools 数组里还会提供一个统一的 skill 工具:

json
{
  "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 的路由与加载机制:

text
developer prompt 里的 available_skills
+
tools 里的 skill(name)
=
按需加载 skill 的能力

Agent 如何命中 Skill

skill 的命中不是靠简单的字符串匹配,也不是 Agent runtime 在外部写死一堆 if-else。真正做判断的是模型。

流程通常是:

text
用户提出任务
  -> 模型阅读 developer prompt
  -> 模型看到 available_skills 列表
  -> 模型根据 description 判断是否有 skill 匹配
  -> 如果匹配,返回 function_call: skill({ name })
  -> Agent runtime 读取对应 SKILL.md
  -> SKILL.md 内容注入上下文
  -> 模型按完整 skill 指南继续执行任务

举例来说,用户说:

text
帮我读取这个飞书文档并总结重点

模型会在 skill 列表中看到类似描述:

text
lark-doc:飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token...

于是它会返回:

json
{
  "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?

从抓包来看,不是。

在一次读取飞书文档的请求中,用户输入是:

text
https://bytedance.larkoffice.com/wiki/K8N8wm2xZiqm7KkIvZHcVH4tnOe 读取这篇飞书文档

模型第一轮响应返回了一个 skill 工具调用:

json
{
  "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 数组:

text
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>

对应的结构大致是:

json
{
  "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" }) 的执行结果。

因此更准确的说法是:

text
available_skills 简介:一开始在 developer prompt 里
SKILL.md 完整内容:命中后作为 function_call_output 注入下一轮 input

它在语义上仍然会影响模型后续行为,因为它已经进入了模型上下文;但从 API 数据结构上看,它不是被物理拼接回原始 developer prompt,而是作为工具执行结果进入会话历史。

渐进式披露:为什么不直接塞全部 SKILL.md

如果系统里有几十个 skill,每个 SKILL.md 都可能很长。假设一开始就把所有 skill 的完整内容全部塞进 prompt,会带来几个问题:

  1. 上下文成本高:大量无关说明会占用 token。
  2. 注意力被稀释:模型需要在一堆无关工作流里找重点。
  3. 冲突概率上升:不同 skill 的规则可能适用于不同场景,全部放一起容易互相干扰。
  4. 更新维护困难:每次请求都携带大量长文本,系统开销更大。

skill 机制采用的是渐进式披露:先给模型一张目录,再按需打开具体章节。

第一层是轻量索引:

text
name + description + location

这一层足够让模型判断“这个任务应该找谁”。

第二层是完整说明:

text
SKILL.md 的全部内容

这一层只在命中后加载,用来指导模型具体执行。

这很像软件里的 lazy loading,也像文档系统里的目录与正文:目录常驻,正文按需展开。

一个完整链路示例

假设当前有一个 skill:

xml
<skill>
  <name>preview-lane-management</name>
  <description>泳道预览环境管理:启动、重启、查看、清理泳道调试服务。当用户说“启动泳道预览服务”或“给我一个临时预览链接”时使用。</description>
  <location>file:///repo/.agents/skills/preview-lane-management/SKILL.md</location>
</skill>

用户说:

text
给我启动一个临时预览链接

模型看到 description 后,判断这个任务应该加载 preview-lane-management,于是返回:

json
{
  "type": "function_call",
  "name": "skill",
  "arguments": "{\"name\":\"preview-lane-management\"}"
}

runtime 做的事情是:

text
读取 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 应该说明:

  1. 这个 skill 负责什么任务。
  2. 用户出现哪些表达时应该使用。
  3. 哪些事情不归它负责。
  4. 如果有相邻 skill,应该如何区分。

例如一个文档类 skill 不应该只写:

text
处理文档。

更好的写法是:

text
飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。不负责电子表格或多维表格的数据操作。

后者不仅告诉模型“什么时候用”,也告诉模型“什么时候不要用”。这会显著降低误命中。

Skill 加载后的效果

加载 skill 后,本质上是给模型增加了一段新的上下文。它可能包含:

  1. 任务执行步骤。
  2. 推荐使用的 CLI 或工具。
  3. 参数格式和认证方式。
  4. 常见错误处理。
  5. 不允许做的事情。
  6. 多步骤工作流。
  7. 子 agent 或验证流程。

因此,skill 更像“领域操作手册”,而不是“函数”。

普通工具调用链路是:

text
模型 -> function_call(read) -> runtime 读文件 -> function_call_output

skill 调用链路是:

text
模型 -> function_call(skill) -> runtime 读取 SKILL.md -> 注入上下文 -> 模型继续调用其他工具

也就是说,skill 调用通常不是任务结束,而是任务进入某个专用工作流的开始。

这种设计的价值

skill 机制的价值在于,它把 Agent 的能力拆成了两层:

第一层是稳定、通用、可执行的工具层。

text
read / grep / bash / browser / apply_patch

第二层是可扩展、可维护、按需加载的知识层。

text
lark-doc / fix-issue / create-mr / figma-to-lark-page

这样设计有几个好处:

  1. 工具层保持简单,负责执行确定性动作。
  2. skill 层可以快速扩展,不需要改模型或核心 runtime。
  3. 大量领域知识不会默认污染上下文。
  4. 模型可以根据任务语义动态加载最相关的工作流。
  5. 不同项目可以拥有自己的本地 skill,适配项目规范和内部平台。

最终,Agent 不再只是“能调用工具的聊天机器人”,而是一个可以按任务加载专业操作手册的执行系统。

小结

skill 和 tool 的关系可以总结为:

text
tool 是手,skill 是说明书。

OpenCode 先把所有 skill 的简要说明放入 prompt,让模型知道有哪些“说明书”可用。模型根据用户任务判断是否需要某个 skill。如果需要,它通过结构化 function_call 调用统一的 skill(name) 工具。Agent runtime 再读取对应的 SKILL.md,把完整内容注入上下文,模型随后按这份说明继续调用普通工具完成任务。

这套机制的关键不是一次性给模型更多上下文,而是只在需要时给模型正确的上下文。

这就是 Agent skill 机制里的渐进式披露:先暴露目录,再加载正文;先判断场景,再展开流程;先控制上下文,再增强能力。

评论
0/100