跳转到正文
AstroPaper
返回

读书笔记:Deep Agents 的 Skills 改进

编辑页面
Note

原文:Sydney Runkle,Revamping Skills in Deep Agents,2026-10-08。 这篇是我的中文笔记,不是翻译。除了原文要点,也记下了我读的时候没弄懂、后来想明白的几个概念。代码都是示意,准确写法以 deepagents 官方 skills 文档为准。

Table of contents

Open Table of contents

先搞清楚几个概念

Skill 和 tool 是两种东西

一开始我把这两个混在一起了。最好记的区分方式是:

拿人来类比: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/。

加载分三层:

  1. 发现:启动时,system prompt 里只放每个 skill 的名字和描述。
  2. 激活:任务对上了某个 skill,agent 用 read_file 读完整的 SKILL.md。
  3. 执行:按指令做事,需要时再读脚本或参考资料。

没用到的 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 的 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 读到 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]:输入标签和当前运行时上下文(比如当前用户是谁),返回这次该给的工具列表。

这样做有两个用处:

简单场景直接列工具名就够了,需要动态或按权限控制时再用标签。

Note

include_tools 是 Deep Agents 自己的扩展,写在标准预留的 metadata 字段里。别的产品大概率会忽略它,skill 正文照样能读,只是没有绑定效果。

改进二:Pinned skills

以前的问题

用户在消息里写了 /meeting-prep 帮我准备明天和 Acme 的会,已经点名要用哪个 skill 了。但模型一开始只看到 skill 的名字和描述,流程是:

  1. 第一次调用模型:模型决定先去读 meeting-prep,调用 read_file。
  2. 第二次调用模型:读到完整指令,这才开始干活。

第一次调用只是在“翻说明书”,白白增加延迟。而且模型不一定理解 /meeting-prep 是在指定 skill,可能选错,也可能觉得自己会就不读了。

现在的做法

由应用从用户消息里识别出被点名的 skill,调用时传给 pinned_skills:

pinned = 从消息里提取 skill 名(message)   # 例如 ["meeting-prep"]

agent.invoke({
    "messages": [{"role": "user", "content": message}],
    "pinned_skills": pinned,
})

中间件会在调用模型之前,把这些 skill 的完整指令加进对话。模型从第一次调用就能直接干活。

几个要点:

类比一下:不 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 只是调用时多传一个参数,时机完全由应用决定:

小结

三项改进分别解决了 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 参与功能和文章评审。


编辑页面
分享这篇文章:

上一篇
How to Choose the Right Effort Level in Claude
下一篇
LeetCode 42. Trapping Rain Water: Four Approaches, from Brute Force to Two Pointers