原文:Sydney Runkle,Revamping Skills in Deep Agents,2026-10-08。 这篇是我的中文笔记,不是翻译。除了原文要点,也记下了我读的时候没弄懂、后来想明白的几个概念。代码都是示意,准确写法以 deepagents 官方 skills 文档为准。
Table of contents
Open Table of contents
先搞清楚几个概念
Skill 和 tool 是两种东西
一开始我把这两个混在一起了。最好记的区分方式是:
- Tool 是 agent 能做的动作。 它是一个真正会执行的函数,比如“查通话记录”“更新预算”。模型决定调用,程序负责执行,再把结果交回模型。没有 tool,模型只能说话。
- Skill 是教 agent 怎么做事的说明书。 它是一个
SKILL.md文件,写的是流程、规矩和经验,模型读了才知道“在我们团队这件事该怎么做”。Skill 本身不会执行。
拿人来类比:tool 是员工手里的系统权限,skill 是团队的 SOP。只有权限没看过 SOP,活能干但未必干对;只看过 SOP 没有权限,什么也干不了。
两者是互补的。有的 skill 只讲思路,不需要任何工具;有的工具很简单,也不需要说明书。但很常见的情况是:一个 skill 的主要内容,就是在讲怎么用某几个工具。 这篇文章的第一项改进就是从这里来的。
Schema:工具的接口说明
模型并不能看到工具的代码,它看到的是工具的 schema:名字、用途、要传哪些参数、参数是什么类型、哪些必填。一般用 JSON Schema 写,比如:
{
"name": "search_calls",
"description": "按客户名和日期范围搜索通话记录",
"parameters": {
"type": "object",
"properties": {
"customer": { "type": "string" },
"start_date": { "type": "string", "description": "YYYY-MM-DD" }
},
"required": ["customer"]
}
}
Schema 只描述结构,不包含实现。但它要作为文字放进每次请求,一个工具几百 token,接了上百个工具就是几万 token。所以才会有 tool search 这类“需要时才加载 schema”的机制。
Skill 怎么省上下文:渐进式披露
一个 skill 是一个文件夹,SKILL.md 的 frontmatter 里有 name 和 description,正文是具体指令,还可以附带 scripts/、references/、assets/。
加载分三层:
- 发现:启动时,system prompt 里只放每个 skill 的名字和描述。
- 激活:任务对上了某个 skill,agent 用
read_file读完整的SKILL.md。 - 执行:按指令做事,需要时再读脚本或参考资料。
没用到的 skill 只占 system prompt 里一行,所以 skill 库可以很大,上下文却不会被撑满。
“开放标准”统一的是格式,不是工具
原文说 Agent Skills 是开放标准,跨模型、跨产品可用。我当时的疑问是:不同产品里,skill 提到的工具都有吗?
答案是不一定。标准统一的是文件夹结构、frontmatter 格式和加载方式,所以同一个 skill 包放进任何支持标准的产品都能被识别;skill 本质是一段文字,任何模型也都能读懂。但 skill 里提到的具体工具,要由运行环境提供:
| Skill 类型 | 例子 | 可移植性 |
|---|---|---|
| 纯知识、流程 | 写作规范、评审清单 | 几乎到处能用 |
| 依赖通用能力 | 读文件、跑自带的 scripts/ | 宿主能执行代码就行 |
| 依赖特定业务工具 | 调公司的通话系统 | 宿主必须先接好这些工具 |
接工具常用的是 MCP,它是另一个开放标准。可以简单理解为:skill 管“怎么做”,MCP 管“用什么做”。
背景:skill 库越来越大
原文提到,企业里的 skill 注册表已经涨到上千个,在多个团队和 agent 之间共享。规模上来之后,出现了几类需求,Deep Agents 对应做了三项改进。
文中的例子是一个基于 Deep Agents 的销售(GTM)agent,有 50 多个 skill,比如 meeting-prep(会前准备)、call-transcripts(通话记录)、competitive-intel-card(竞品情报卡)。下面也沿用这个例子。
改进一:把工具绑定到 skill
以前的问题
Skill 和工具原来是两套独立机制,系统并不知道“哪个 skill 是在讲哪些工具”。于是会出现两种错位:
- 没读 skill 就调用工具。 用户问“上周和 Acme 聊了什么”,agent 直接搜到
search_calls就用了,没读call-transcripts里写的参数格式和注意事项,结果用错。 - 读了 skill 还得再搜工具。 Agent 先读了 skill,知道该用
search_calls,但这个工具的 schema 还不在上下文里,只好再做一次 tool search,多一轮调用。
现在的做法
在 skill 的 frontmatter 里,用 metadata.include_tools 声明它讲的是哪些工具:
---
name: call-transcripts
description: 查找和阅读客户通话记录
metadata:
include_tools: search_calls get_transcript
---
然后,把这些工具交给 SkillsMiddleware,而不是交给 agent:
agent = create_deep_agent(
model=...,
# 这里不传 tools
middleware=[
SkillsMiddleware(
sources=["/skills/"],
tools=[search_calls, get_transcript],
),
],
)
这一步是关键。工具交给谁,就由谁决定它什么时候出现:
- 直接传给 agent,工具从第一轮起一直可见。
- 传给
SkillsMiddleware,工具由中间件保管。中间件夹在 agent 循环和模型调用之间,每次调用模型前都会经过它。它本来就记录着哪些 skill 被读过,所以由它来判断“该不该放出这些工具”最自然。
效果是:agent 读到 call-transcripts 之前,这两个工具不存在,提前调用会按未知工具报错;读完之后,工具自动出现,不用再搜。两种错位都解决了。
就像设备钥匙交给管理员保管,员工来领时先确认“操作手册读过了吗”,读过才给。
不破坏 prompt cache
先补一个背景:模型服务商会缓存请求的前缀,下次请求开头一模一样的部分不用重新计算,更快也更便宜。前缀里改了一个字,后面的缓存就全部失效。
以前对话中途加工具,要改请求里的工具列表,缓存就没了。现在 Anthropic 和 OpenAI 的新模型支持对话中途接收工具,Deep Agents 会在 skill 被读取后,用一条新的 system message 把工具加进来,前面的内容不动,缓存保住。不支持的模型还是沿用旧方式。
进阶:用标签和解析函数
include_tools 除了直接写工具名,还可以写一个标签,比如 call_tools,再给 SkillsMiddleware 传一个函数,由它把标签翻译成具体工具:
def resolve(name, runtime):
if name == "call_tools":
return 通话系统 MCP server 上的全部工具
if name == "budget_tools":
if 当前用户是财务:
return [view_budget, edit_budget]
return [view_budget]
return []
SkillsMiddleware(sources=["/skills/"], tools=resolve)
函数签名是 (name, runtime) -> list[BaseTool]:输入标签和当前运行时上下文(比如当前用户是谁),返回这次该给的工具列表。
这样做有两个用处:
- 一个标签代表一组工具。 比如整个 MCP server 上的工具,server 增减工具时 skill 文件不用改。
- 按权限分配工具。 同一个 skill,财务读了能改预算,普通员工读了只能查看。这种判断没法写死在静态的
SKILL.md里,因为用户是谁要到运行时才知道。
简单场景直接列工具名就够了,需要动态或按权限控制时再用标签。
include_tools 是 Deep Agents 自己的扩展,写在标准预留的 metadata 字段里。别的产品大概率会忽略它,skill 正文照样能读,只是没有绑定效果。
改进二:Pinned skills
以前的问题
用户在消息里写了 /meeting-prep 帮我准备明天和 Acme 的会,已经点名要用哪个 skill 了。但模型一开始只看到 skill 的名字和描述,流程是:
- 第一次调用模型:模型决定先去读
meeting-prep,调用read_file。 - 第二次调用模型:读到完整指令,这才开始干活。
第一次调用只是在“翻说明书”,白白增加延迟。而且模型不一定理解 /meeting-prep 是在指定 skill,可能选错,也可能觉得自己会就不读了。
现在的做法
由应用从用户消息里识别出被点名的 skill,调用时传给 pinned_skills:
pinned = 从消息里提取 skill 名(message) # 例如 ["meeting-prep"]
agent.invoke({
"messages": [{"role": "user", "content": message}],
"pinned_skills": pinned,
})
中间件会在调用模型之前,把这些 skill 的完整指令加进对话。模型从第一次调用就能直接干活。
几个要点:
- 识别规则由你定。 Deep Agents 不解析消息,只接收列表。可以用正则匹配
/skill名,也可以让用户在 UI 里点选。 - 绑定的工具一起来。 Pin 一个 skill,它
include_tools里的工具也会一起解锁。 - 缓存不受影响。 Skill 指令是作为一条新消息追加进对话的,而且每个 skill 只加一次,前面的 system prompt 和历史消息都不变。
- 界面可以友好显示。 这条消息带有标记,聊天 UI 可以只显示一个“已加载 meeting-prep”的标签,不用把几百行指令摊给用户看。
类比一下:不 pin 是让助理自己去档案柜找手册;pin 是直接把手册翻到那一页递给他。
改进三:线程中途重新加载 skills
以前的问题
一个线程(thread)就是一段持续的对话,可以断断续续聊好几天。默认情况下,SkillsMiddleware 在线程开始时扫描一次 skill 目录,把结果(skills_metadata)存进 agent state,之后每一轮都复用。好处是快,system prompt 也稳定,缓存一直能命中。
问题是库更新了,老线程看不到。比如周一开了个线程,周三同事新加了 competitive-intel-card,周四回到这个线程让 agent 做竞品卡,它手里还是周一的列表,根本不知道有这个 skill。以前只能开新线程,之前的上下文就丢了。
现在的做法
调用时把 skills_metadata 设成 None:
agent.invoke({
"messages": messages,
"skills_metadata": None, # 作废旧列表
})
中间件发现列表为空,就会重新扫描所有 skill 来源,拿到最新的列表。对话历史保持不变。
代价,以及为什么通常可以接受
Skill 列表变了,system prompt 就变了。System prompt 在请求最前面,所以整段缓存都会失效。(如果重新扫描后没有任何变化,就没有这个代价。)
但缓存本来就会过期,服务商一般在几分钟到一小时不活跃后就清掉了。需要 reload 的典型场景,正是用户隔了一段时间才回到老线程,这时候缓存早就没了,顺便刷新列表几乎没有额外成本。要避免的是在连续对话中频繁 reload,那会反复打掉本来还有效的缓存。
什么时候触发
Reload 只是调用时多传一个参数,时机完全由应用决定:
- 做一个
/reload命令,交给用户手动触发; - 用 LangGraph 的
update_state在 agent 运行之外清空列表; - 在 middleware 里自动判断,比如线程闲置超过一定时间,或者检测到 skill 库版本变了。
小结
三项改进分别解决了 skill 规模化之后的三个问题:
| 改进 | 解决的问题 | 关键参数 |
|---|---|---|
| 工具绑定 | skill 和对应工具脱节,导致乱用或多一轮搜索 | metadata.include_tools,SkillsMiddleware(tools=...) |
| Pinned skills | 用户点名了 skill,模型还要多一轮去读,且可能选错 | pinned_skills |
| 重新加载 | 长线程看不到 skill 库的更新 | skills_metadata: None |
贯穿三项改进的一条线是 prompt cache:工具通过新的 system message 加入,pinned skill 作为追加消息只加一次,reload 只在缓存大概率已经过期时才做。能不动前缀就不动前缀。
这些功能都在最新版 deepagents 里。原文致谢:Rich Scarrott 主导开发,Hunter Lovell 参与功能和文章评审。