1. 本章目标 #

前面五章都在讲 harness 提供的通用能力:文件系统、子智能体、权限、沙箱。但企业里真正难沉淀的是领域知识——「我们公司的结案报告必须包含哪六节」「处理时长要换算成 X 小时 Y 分钟」「客户手机号必须打码」。

这类知识有个尴尬的特点:又多又长,但每次任务只用得上其中一小块。全塞进 system_prompt 会把上下文撑爆,而且每一轮都要重付一次 token;不塞进去,模型就每次都得靠你在提示里现场交代。

Skills 就是为这个问题设计的:知识写成文件放在一边,启动时只给模型看一份目录,用得上的时候它自己去读。

学完你应能:

前置依赖: 第 42 章(后端与 read_file,尤其是默认只读 100 行这个细节,§3.3 会呼应)、第 43 章(permissions,§9 用它把技能库设成只读)、第 40 章(子智能体与 task)。

参考文档:

建议阅读顺序: §2 是格式手册。§3 和 §4 是本章的核心——前者讲清机制、后者告诉你什么时候值得用。§5 是完整实战。§6 建议一定要看,四个坑都是静默的,踩上之后从表现上完全看不出原因。

本章验证环境:deepagents 0.7.12,Windows 11 中文环境。§3、§4、§6 到 §8 的实验零 token(观察系统提示词不需要真模型),§5 用真模型(约 3.8 万 token)。

2. 一个技能长什么样 #

2.1. 目录结构 #

技能的载体是目录,不是单个文件:

skills/                     ← 传给 skills= 的是这一层(容器目录)
├── ticket-report/          ← 一个技能 = 一个目录
│   ├── SKILL.md            ← 必需:frontmatter + 指令正文
│   ├── references/         ← 可选:按需读的详细资料
│   │   └── format.md
│   ├── assets/             ← 可选:模板、schema、图片
│   │   └── template.md
│   └── scripts/            ← 可选:可执行脚本
│       └── validate.py
└── sql-analysis/
    └── SKILL.md

记住这个层级关系,skills= 要指向的是 skills/ 这一层(包含技能目录的容器),不是 skills/ticket-report/。写错了会静默失效,见 §6.1。

2.2. SKILL.md 的 frontmatter #

SKILL.md 是 YAML frontmatter 加 markdown 正文:

---
name: ticket-report
description: 生成工单结案报告。当用户要求写结案报告、复盘某个工单、或输出工单处理总结时使用。
---

# ticket-report

## 步骤

1. 用 `get_ticket` 工具取回工单数据。
2. 阅读 `/skills/ticket-report/references/format.md`,了解报告必须包含哪些字段。
3. 复制 `/skills/ticket-report/assets/template.md` 作为报告骨架。
4. 按模板填写,保存为 `/reports/<工单号>.md`。

规范定义的 frontmatter 字段:

字段 必填 说明
name 是 小写字母、数字、连字符,1–64 字符。应与父目录名一致(不一致的后果见 §6.3)
description 是 做什么 + 什么时候用。最多 1024 字符。这是发现阶段模型唯一能看到的信息
license 否 许可证名或捆绑的许可文件
compatibility 否 环境要求(系统包、网络访问)。最多 500 字符
metadata 否 任意键值对
allowed-tools 否 空格分隔的预授权工具列表。实验性

description 是整个机制里最关键的一个字段,§10 会专门讲怎么写。

另外有个硬限制:SKILL.md 超过 10 MB 会在发现阶段被直接跳过。

2.3. 三个可选资源目录 #

规范定义了三个约定目录,Deep Agents 在发现和激活阶段都不会加载它们——只有当 SKILL.md 的指令明确要求时,模型才会去读:

目录 放什么 典型用法
references/ 太细、不适合放进 SKILL.md 的资料 格式规范、错误码表、领域指南
assets/ 模型要用但不当指令读的静态文件 报告模板、JSON schema、图片
scripts/ 可执行代码 API 客户端、数据转换、校验脚本

两个要点:

  1. 模型不会自动发现这些文件。 你必须在 SKILL.md 里写清「这个文件是什么、什么时候读」,并用相对技能根的路径引用
  2. scripts/ 里的脚本能读,但要真的执行必须有沙箱后端(第 43 章 §9)。没有沙箱时模型只能把脚本当参考代码看

引用写法:

报告的字段要求见 [格式规范](references/format.md)。

要校验报告完整性,运行:
scripts/validate.py

官方建议引用只做一层深,别让模型为了拿到一条信息连读三四个文件。

3. 渐进式披露:三层到底加载了什么 #

「按需加载」这四个字听起来很美好,但到底哪些内容在什么时候进上下文?这一节用实测把它钉死。

官方给的三层模型:

层 加载什么 什么时候
1. 元数据 frontmatter 里的 name 和 description 启动时,每个配置的技能都加载
2. 指令 SKILL.md 完整正文 技能被激活时
3. 资源 scripts/ / references/ / assets/ 下的文件 激活之后,指令要求时

前两层由 SkillsMiddleware 负责,第三层靠模型自己按指令去读。

3.1. 实测:启动时注入的原文 #

要看清第一层,最直接的办法是截获 SystemMessage。用一个只记录、不推理的假模型:

"""零 token:截获系统提示词,看清技能的第一层到底注入了什么。"""
import json
import os
import shutil

from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langgraph.checkpoint.memory import InMemorySaver

from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend

ROOT = os.path.abspath("./skills_demo")
shutil.rmtree(ROOT, ignore_errors=True)

# 故意造一个正文很长的技能,用来观察正文有没有在启动时被塞进去
LONG_BODY = "\n".join(
    f"第 {i} 步:这是一段很长的操作说明,用来观察正文有没有在启动时被塞进上下文。"
    for i in range(1, 61)
)


def make_skill(container, name, desc, body="", extra=None):
    """在 container 目录下建一个技能目录。extra 是 {相对路径: 内容}。"""
    d = os.path.join(ROOT, container, name)
    os.makedirs(d, exist_ok=True)
    with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f:
        f.write(f"---\nname: {name}\ndescription: {desc}\n---\n\n# {name}\n\n{body}\n")
    for rel, content in (extra or {}).items():
        p = os.path.join(d, rel)
        os.makedirs(os.path.dirname(p), exist_ok=True)
        with open(p, "w", encoding="utf-8") as f:
            f.write(content)


make_skill(
    "skills", "ticket-report",
    "生成工单结案报告。当用户要求写结案报告、复盘工单或输出工单总结时使用。",
    LONG_BODY,
    {
        "references/format.md": "# 报告格式\n必须包含:问题、处理过程、根因、改进项。\n",
        "assets/template.md": "# 结案报告模板\n## 问题\n## 处理过程\n",
    },
)
make_skill(
    "skills", "sql-analysis",
    "把自然语言问题翻译成 SQL 并解释结果。当用户要查数、统计或分析数据库时使用。",
    "1. 先看表结构\n2. 写 SQL\n3. 解释结果\n",
)


class Fake(BaseChatModel):
    """只记录第一条 SystemMessage,然后立刻收尾。不消耗任何 token。"""

    cap: dict = {}

    @property
    def _llm_type(self) -> str:
        return "fake"

    def bind_tools(self, tools, **kw):
        return self

    def _generate(self, messages, stop=None, run_manager=None, **kw) -> ChatResult:
        if messages and "sys" not in Fake.cap:
            m0 = messages[0]
            # 系统提示词可能是字符串,也可能是内容块列表,统一转成文本
            Fake.cap["sys"] = (
                m0.content if isinstance(m0.content, str)
                else json.dumps(m0.content, ensure_ascii=False)
            )
        return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])


def sysprompt(skills, label):
    """建 agent、跑一次,返回系统提示词全文。"""
    Fake.cap = {}
    agent = create_deep_agent(
        model=Fake(),
        backend=FilesystemBackend(root_dir=ROOT, virtual_mode=True),
        skills=skills,
        checkpointer=InMemorySaver(),
    )
    agent.invoke(
        {"messages": [{"role": "user", "content": "hi"}]},
        {"configurable": {"thread_id": "t"}},
    )
    txt = Fake.cap.get("sys", "")
    print(f"{label}: 系统提示词 {len(txt)} 字符")
    return txt


base = sysprompt(None, "不传 skills")
withskills = sysprompt(["/skills/"], "传 skills=['/skills/']")

# 逐项检查:哪些内容进了启动上下文
for key, label in [
    ("ticket-report", "技能名"),
    ("生成工单结案报告", "description"),
    ("这是一段很长的操作说明", "SKILL.md 正文"),
    ("报告格式", "references 内容"),
    ("结案报告模板", "assets 内容"),
]:
    print(f"  提示词里有「{label}」吗: {'有' if key in withskills else '没有'}")

输出:

不传 skills: 系统提示词 0 字符
传 skills=['/skills/']: 系统提示词 2182 字符
  提示词里有「技能名」吗: 有
  提示词里有「description」吗: 有
  提示词里有「SKILL.md 正文」吗: 没有
  提示词里有「references 内容」吗: 没有
  提示词里有「assets 内容」吗: 没有

渐进式披露完全验证:技能名和 description 进去了,正文、references、assets 一个字都没进。

顺带印证了第 39 章那个发现:不传 skills 时系统提示词是 0 字符——create_deep_agent 本身不注入任何默认提示词,2182 字符全是 Skills 系统带来的。

3.2. 注入的格式 #

把新增部分打出来,看模型实际读到的样子:

## Skills System

You have access to a skills library that provides specialized capabilities and domain knowledge.

**Skills Skills**: `/skills/` (higher priority)

**Available Skills:**

- **sql-analysis**: 把自然语言问题翻译成 SQL 并解释结果。当用户要查数、统计或分析数据库时使用。
  -> Read `/skills/sql-analysis/SKILL.md` for full instructions
- **ticket-report**: 生成工单结案报告。当用户要求写结案报告、复盘工单或输出工单总结时使用。
  -> Read `/skills/ticket-report/SKILL.md` for full instructions

每个技能占两行:**名字**: 描述 加一行 -> Read <路径> for full instructions。

模型看到的「激活方式」就是一个 read_file 路径——没有专门的 load_skill 工具,激活技能就是普通地读一个文件。这也解释了为什么技能能跟第 42 章的文件系统无缝配合:它本来就是文件。

一个小细节:清单按技能名字母序排列(sql-analysis 在 ticket-report 前面),不是按你在 skills= 里写的顺序:

清单里的出现顺序: ['alpha-task', 'middle-task', 'zebra-task']

3.3. 框架替你写好的那段说明书 #

那 2182 字符里,技能清单只占约 200 字符,剩下 1990 字符是什么?是 SkillsMiddleware 的默认 system_prompt——一整套教模型怎么用技能的说明书。它的签名是:

  backend                默认=必填
  sources                默认=必填
  system_prompt          默认='## Skills System\n\n...'(约 1990 字符的完整模板)

这段模板里有两处值得单独拎出来。第一处是它明确教模型渐进式披露的流程:

1. **Recognize when a skill applies**: Check if the user's task matches a skill's description
2. **Read the skill's full instructions**: Use `read_file` on the path shown in the skill list above.
    Pass `limit=1000` since the default of 100 lines is too small for most skill files.
3. **Follow the skill's instructions**: SKILL.md contains step-by-step workflows, best practices, and examples
4. **Access supporting files**: Skills may include helper scripts, configs, or reference docs - use absolute paths

注意第 2 条那句 Pass limit=1000。 这直接呼应第 42 章 §2.3 的发现:read_file 默认只返回 100 行。框架知道技能文件通常更长,所以在提示词里手动教模型带上 limit=1000——否则模型会读到一个被截断的 SKILL.md,还以为自己读完了。§5.2 会看到模型确实照做了。

第二处是它还给了模型一个完整的示例工作流(web-research 那个例子)。这意味着你不需要在自己的 system_prompt 里再解释一遍「什么是技能、怎么用技能」,框架已经写好了。你只需要写业务相关的部分。

4. 开销账:什么时候用技能才划算 #

「按需加载省 token」这个说法要落到具体数字上才有意义。技能的成本分两块:一块固定、一块随技能数增长。量一下:

"""零 token:量化技能的启动开销。"""
# 先量固定开销:一个空的技能容器目录
os.makedirs(os.path.join(ROOT, "empty"), exist_ok=True)
fixed = sysprompt(["/empty/"], "空技能库")

# 再逐档加技能,看增量
DESC = "生成工单结案报告。当用户要求写结案报告、复盘工单或输出工单总结时使用。"
for n in (1, 2, 5, 10):
    shutil.rmtree(os.path.join(ROOT, "many"), ignore_errors=True)
    for i in range(n):
        make_skill("many", f"skill-{i:02d}", DESC, LONG_BODY)
    txt = sysprompt(["/many/"], f"{n} 个技能")
    print(f"   比空库多 {len(txt) - len(fixed)} 字符,平均每个 {(len(txt) - len(fixed)) // n}")

输出:

空技能库(清单为空): 1990 字符  ← 这就是固定开销
 1 个技能:  2039 字符(比空库多    49,平均每个   49)
 2 个技能:  2151 字符(比空库多   161,平均每个   80)
 5 个技能:  2487 字符(比空库多   497,平均每个   99)
10 个技能:  3047 字符(比空库多  1057,平均每个  105)

两个数字要记住:

再和「不用技能、把全部正文塞进 system_prompt」对照:

对照 · 把 10 个技能的正文全塞进 system_prompt: 14788 字符
用 skills 只花: 3047 字符
节省: 11741 字符(约 79%)

10 个技能省 79%。 但这里有个容易被忽略的反面:

设正文平均长度为 $L$ 字符、技能数为 $n$,两种方案的启动成本是

$$ C_{\text{skills}} = 1990 + 105\,n \qquad C_{\text{全塞}} = L \cdot n $$

令两者相等,得到临界技能数

$$ n^{*} = \frac{1990}{L - 105} $$

代入实验里的 $L \approx 1479$(每个技能正文约 1479 字符),得 $n^{*} \approx 1.4$——两个技能起就划算了。

但如果你的技能正文很短,比如 $L = 300$ 字符:

$$ n^{*} = \frac{1990}{300 - 105} \approx 10.2 $$

要凑到 10 个技能才回本。 换句话说:

技能适合「少而长」,不适合「多而短」。 三五条规则的约定直接写进 system_prompt 更省;只有当单个技能的正文足够长(几百字以上)、且不是每次任务都要用时,渐进式披露才划算。

另外要注意这个账只算了启动开销。技能一旦被激活,正文会通过 read_file 进上下文,那部分成本和直接塞提示词是一样的——省的是「用不上的时候不付钱」。所以真正的收益取决于命中率:技能越多、单次任务用到的比例越低,收益越大。§5.4 会看到一个极端对照。

5. 真模型跑一遍:三层依次点亮 #

现在用真模型完整走一遍,重点观察两件事:三层是不是真按顺序加载,以及只写在 references/ 里的规则会不会真的生效。

5.1. 技能设计 #

故意把规则分散在三个文件里,这样才能分辨哪一层生效了:

"""真模型:观察技能激活的完整轨迹。"""
import os
import shutil
import time

from dotenv import load_dotenv

load_dotenv(override=True)

from langchain.tools import tool

from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend

MODEL = "deepseek:deepseek-v4-flash"
ROOT = os.path.abspath("./skills_real")
shutil.rmtree(ROOT, ignore_errors=True)

SK = os.path.join(ROOT, "skills", "ticket-report")
os.makedirs(os.path.join(SK, "references"), exist_ok=True)
os.makedirs(os.path.join(SK, "assets"), exist_ok=True)

# 主文件:只写流程,细节推给 references / assets
with open(os.path.join(SK, "SKILL.md"), "w", encoding="utf-8") as f:
    f.write("""---
name: ticket-report
description: 生成工单结案报告。当用户要求写结案报告、复盘某个工单、或输出工单处理总结时使用。
---

# ticket-report

## 步骤

1. 用 `get_ticket` 工具取回工单数据。
2. 阅读 `/skills/ticket-report/references/format.md`,了解报告必须包含哪些字段和硬性规则。
3. 复制 `/skills/ticket-report/assets/template.md` 作为报告骨架。
4. 按模板填写,保存为 `/reports/<工单号>.md`。

## 硬性要求

- 报告里**禁止出现客户手机号**,必须打码成 `138****1234` 的形式。
- 「根因」一节不允许写「已修复」这类结论,必须写清技术原因。
""")

# 这些细节规则只出现在 references 里,用来检验第三层是否生效
with open(os.path.join(SK, "references", "format.md"), "w", encoding="utf-8") as f:
    f.write("""# 结案报告格式规范

必须包含以下六节,缺一不可:

1. 工单概要(工单号、标题、优先级、处理时长)
2. 问题描述
3. 处理过程(按时间顺序列出关键动作)
4. 根因分析
5. 改进项(至少两条,每条要有责任人)
6. 客户沟通记录

## 硬性规则

- 处理时长必须换算成「X 小时 Y 分钟」的形式
- 优先级用中文:紧急 / 高 / 中 / 低
- 每条改进项必须以「[ ]」开头,方便后续跟踪
""")

with open(os.path.join(SK, "assets", "template.md"), "w", encoding="utf-8") as f:
    f.write("""# 工单结案报告 · {工单号}

## 一、工单概要
- 工单号:
- 标题:
- 优先级:
- 处理时长:

## 二、问题描述

## 三、处理过程

## 四、根因分析

## 五、改进项
- [ ]
- [ ]

## 六、客户沟通记录
""")


@tool
def get_ticket(ticket_id: str) -> str:
    """按工单号查询工单的完整处理记录。"""
    return (
        f"工单号: {ticket_id}\n"
        "标题: 支付回调超时导致订单状态未更新\n"
        "优先级: P1\n"
        "创建时间: 2026-08-30 09:12\n"
        "关闭时间: 2026-08-30 12:47\n"
        "客户联系方式: 13812341234\n"
        "处理记录:\n"
        "  09:12 客户报障,订单已付款但状态仍为待支付\n"
        "  09:40 排查发现支付网关回调 HTTP 超时,重试队列积压 3200 条\n"
        "  10:15 临时扩容回调消费者到 8 个实例,积压开始下降\n"
        "  11:30 积压清空,手动补偿了 47 笔状态异常订单\n"
        "  12:47 客户确认订单状态正常,工单关闭\n"
        "根因: 回调消费者单实例处理能力不足,且超时时间设为 30s 过长,"
        "导致单条消息阻塞队列\n"
    )


agent = create_deep_agent(
    model=MODEL,
    tools=[get_ticket],
    backend=FilesystemBackend(root_dir=ROOT, virtual_mode=True),
    skills=["/skills/"],
)

result = agent.invoke({
    "messages": [{"role": "user", "content": "给工单 T-2087 写一份结案报告。"}]
})

# 打印轨迹,重点看 read_file 有没有带 limit
for m in result["messages"]:
    for c in (getattr(m, "tool_calls", None) or []):
        args = c["args"]
        path = args.get("file_path") or args.get("path") or args.get("ticket_id") or ""
        extra = f"  limit={args['limit']}" if "limit" in args else ""
        print(f"  {c['name']:11s} {path}{extra}")

5.2. 激活轨迹 #

耗时 29.9s

工具调用轨迹:
  read_file   /skills/ticket-report/SKILL.md  limit=1000
  get_ticket  T-2087
  read_file   /skills/ticket-report/references/format.md  limit=1000
  read_file   /skills/ticket-report/assets/template.md  limit=1000
  ls          /reports
  ls          /
  glob        /
  ls          /skills/ticket-report
  write_file  /reports/T-2087.md

  第二层 · 读了 SKILL.md:        是
  第三层 · 读了 references/:     是
  第三层 · 读了 assets/:         是

  模型调用 7 次,输入 34435,输出 3841

三层严格按顺序点亮:

  1. 先 read_file 了 SKILL.md(第二层)——而且带了 limit=1000,正是 §3.3 那段提示词教它的
  2. 按 SKILL.md 步骤 1 调 get_ticket
  3. 按步骤 2、3 读了 references/format.md 和 assets/template.md(第三层)
  4. 中间那几个 ls / glob 是在找 /reports 目录(它还不存在),然后 write_file 写出报告

这个顺序不是框架编排的,是模型读了指令自己决定的。 第三层完全交给模型,框架只管把前两层铺好。

5.3. 产出核查:references 里的规则真的生效了 #

轨迹显示它读了 references,但读了不等于照做。去磁盘上核查产出(用 FilesystemBackend 时报告落在磁盘,不在 result["files"] 里——这是第 42 章 §4.2 的结论):

=== /reports/T-2087.md  (938 字符) ===
# 工单结案报告 · T-2087

## 一、工单概要
- 工单号:T-2087
- 标题:支付回调超时导致订单状态未更新
- 优先级:紧急(P1)
- 处理时长:3 小时 35 分钟(2026-08-30 09:12 至 12:47)
...
## 五、改进项
- [ ] 将支付回调超时时间由 30s 调整为更合理取值(如 5s),并配套超时重试与死信队列机制…… 责任人:支付平台开发负责人
- [ ] 回调消费者由单实例改造为支持水平扩展的多实例部署…… 责任人:支付平台架构负责人
- [ ] 增加回调队列积压量、消费延迟的实时监控与告警阈值…… 责任人:SRE 值班负责人

逐条核查:

规则 写在哪 结果
六节齐全 references/format.md 6/6 ✓
处理时长换算成「X 小时 Y 分钟」 references/format.md 「3 小时 35 分钟」✓
优先级用中文 references/format.md 「紧急(P1)」✓
改进项以 [ ] 开头 references/format.md ✓
每条改进项有责任人 references/format.md 3 条都有 ✓
手机号打码 SKILL.md 全文未出现手机号 ✓
根因写技术原因 SKILL.md 写清了单实例 + 30s 超时导致阻塞 ✓

前五条规则只存在于 references/format.md,一个字都没进启动上下文,但全部被遵守了。 这是第三层真正生效的硬证据——它不是摆设,而是一条完整的「知识按需送达」链路。

顺带注意「处理时长 3 小时 35 分钟」:原始数据只给了 09:12 和 12:47 两个时间点,是模型按 references 里的规则自己算的。

5.4. 对照:无关任务几乎零成本 #

渐进式披露的价值在于「用不上就不付钱」。同一个 Agent,换一个和技能完全无关的任务:

result = agent.invoke({
    "messages": [{"role": "user", "content": "你好,用一句话介绍你自己。"}]
})
耗时 1.1s

工具调用轨迹:
(空)

  第二层 · 读了 SKILL.md:        否
  第三层 · 读了 references/:     否
  第三层 · 读了 assets/:         否

  模型调用 1 次,输入 3343,输出 88

零工具调用、1 次模型调用、输入 3343 token。 对比匹配任务的 34435 token,差了整整一个数量级。

技能库在这次调用里只花了那 2000 字符左右的目录成本,正文和资源全程没进上下文。这就是渐进式披露的全部意义:把「可能有用」的知识放在触手可及的地方,而不是全部背在身上。

6. 四个静默失效的坑 #

技能的加载过程有个共同特点:出问题时几乎不报错。你的 Agent 照样跑,只是那个技能从来没被用过——而这和「模型觉得不该用」在表现上完全一样。这一节把四个坑挨个复现。

6.1. 路径写到技能目录本身 #

最常见的坑:skills= 应该指向容器目录,但很容易写成技能目录本身。

# 对的:指向包含技能目录的容器
ok = sysprompt(["/skills/"], "skills=['/skills/']")

# 错的:直接指向技能目录
bad = sysprompt(["/skills/ticket-report/"], "skills=['/skills/ticket-report/']")
skills=['/skills/']: 系统提示词 2182 字符
skills=['/skills/ticket-report/']: 系统提示词 2028 字符
  提示词里有 ticket-report 吗: 没有

官方文档说这种写法「discovers nothing and raises no error」。实测比这个描述更糟一点:它不是「什么都没发生」,而是 2028 字符照付、技能清单是空的。

那 2028 字符就是 §4 量到的固定说明书:你付了全套「怎么使用技能库」的教程钱,却一个技能都没给模型。 模型收到的是一份空目录加一整页使用说明,比不传 skills= 还差。

排查办法很简单——技能配好后先看一眼系统提示词里有没有你的技能名:

# 上线前的自检:确认技能真的被发现了
txt = sysprompt(["/skills/"], "自检")
for name in ["ticket-report", "sql-analysis"]:
    assert name in txt, f"技能 {name} 没有被发现,检查 skills= 的路径层级"

6.2. frontmatter 不合规 #

frontmatter 缺失或缺字段时,那个技能会被跳过。好消息是这一类会打印警告:

for sub, content, label in [
    ("no-frontmatter", "# 我没有 frontmatter\n只有正文。\n", "完全没有 frontmatter"),
    ("no-desc", "---\nname: no-desc\n---\n\n# 只有 name\n", "缺 description"),
    ("no-name", "---\ndescription: 只有描述没有名字。\n---\n\n# x\n", "缺 name"),
]:
    d = os.path.join(ROOT, f"s_{sub}", sub)
    os.makedirs(d, exist_ok=True)
    with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f:
        f.write(content)
    txt = sysprompt([f"/s_{sub}/"], label)
    print(f"  技能名出现在提示词里: {'是' if sub in txt else '否'}")

标准错误流上的输出:

Skipping /s_no-frontmatter/no-frontmatter/SKILL.md: no valid YAML frontmatter found
Skill at /s_no-frontmatter/no-frontmatter/SKILL.md failed metadata parse or name validation; skipping
Skipping /s_no-desc/no-desc/SKILL.md: missing required 'name' or 'description'
Skipping /s_no-name/no-name/SKILL.md: missing required 'name' or 'description'

三种情况都是「跳过 + 明确告知原因」,技能名不会出现在提示词里。但这些警告走的是日志,在生产环境里很容易被淹没,所以 §6.1 那个断言自检仍然值得做。

6.3. name 与目录名不一致 #

规范要求 name 必须和父目录名一致。实测这条是警告,不是强制:

# 目录叫 dir-name-a,frontmatter 里的 name 却是 totally-different
d = os.path.join(ROOT, "skills2", "dir-name-a")
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f:
    f.write("---\nname: totally-different\ndescription: 目录名和 name 不一致。\n---\n\n# x\n")

txt = sysprompt(["/skills2/"], "name 与目录名不一致")
Skill 'totally-different' in /skills2/dir-name-a/SKILL.md does not follow Agent
Skills specification: name 'totally-different' must match directory name
'dir-name-a'. Consider renaming for spec compliance.

  提示词里出现 'totally-different': 是
  提示词里出现 'dir-name-a': 否
  加载成功了吗: True

技能照样加载了,用的是 frontmatter 里的 name,目录名被忽略。 只是打了一条 spec 合规警告。

这不影响功能,但会让你困惑:你按目录名去找技能,提示词里却是另一个名字。保持两者一致,省得给自己添麻烦。

6.4. StateBackend 给裸字符串 #

用默认的 StateBackend 时,技能文件通过 invoke(files={...}) 传入,而且必须用 create_file_data() 包装:

"""StateBackend 下技能文件的正确传法。"""
from deepagents.backends import StateBackend
from deepagents.backends.utils import create_file_data

skill_md = (
    "---\nname: ticket-report\n"
    "description: 生成工单结案报告。当用户要求写结案报告时使用。\n---\n\n"
    "# ticket-report\n\n1. 收集工单信息\n2. 按模板写报告\n"
)

# create_file_data 返回的是带元数据的字典,不是裸字符串
print(create_file_data(skill_md).keys())
# dict_keys(['content', 'encoding', 'created_at', 'modified_at'])

# 正确:用 create_file_data 包装
agent = create_deep_agent(
    model=Fake(),
    backend=StateBackend(),
    skills=["/skills/"],
    checkpointer=InMemorySaver(),
)
agent.invoke(
    {
        "messages": [{"role": "user", "content": "hi"}],
        "files": {"/skills/ticket-report/SKILL.md": create_file_data(skill_md)},
    },
    {"configurable": {"thread_id": "t"}},
)

正确用法技能能被发现。直接给裸字符串则会抛异常:

用 create_file_data: 提示词里有 ticket-report = 有
给裸字符串: 报错 TypeError: string indices must be integers, not 'str'

报错信息完全看不出根因——string indices must be integers 指向的是框架内部按字典取 content 键的那行代码。遇到这个 TypeError,先检查 files= 里有没有漏掉 create_file_data()。

用 FilesystemBackend 或 StoreBackend 时不存在这个问题:磁盘上已有的技能文件直接加载,或者用 backend.upload_files() 上传。

7. 同名覆盖与分层 #

skills= 接受多个路径,同名技能后来者胜(last one wins)。这是为了支持「基础库 + 项目定制」的分层:

make_skill("base", "ticket-report", "【基础版】通用结案报告。")
make_skill("override", "ticket-report", "【项目定制版】按本项目模板生成结案报告。")

for order, label in [
    (["/base/", "/override/"], "base 在前、override 在后"),
    (["/override/", "/base/"], "override 在前、base 在后"),
]:
    txt = sysprompt(order, label)
    which = "项目定制版" if "项目定制版" in txt else "基础版"
    both = ("基础版" in txt) and ("项目定制版" in txt)
    print(f"  生效的是【{which}】| 两个都在: {both}")
base 在前、override 在后          -> 生效的是【项目定制版】| 两个都在: False
override 在前、base 在后          -> 生效的是【基础版】| 两个都在: False

只有最后那个生效,另一个完全消失(不是两份都列出来让模型选)。所以路径顺序要按从通用到专用排:

skills = [
    "/skills/company/",   # 公司通用库(优先级最低)
    "/skills/team/",      # 团队库
    "/skills/project/",   # 项目定制(优先级最高,同名时它赢)
]

这个规则有个反向风险,官方在排查清单里专门提了:一个后来的、过期的或空的技能会悄悄覆盖掉你以为在用的那个。 排查技能行为异常时,如果有多个源,先确认到底是哪一份生效了。

另外 SDK 不会自动扫描 CLI 的目录(~/.deepagents/...、~/.agents/...)。要模拟 CLI 的分层,得自己按低到高的优先级顺序把路径全列出来。

8. 子智能体的技能 #

子智能体的技能规则分两种,差别很大:

子智能体类型 技能来源
内置 general-purpose 自动继承主智能体的 skills
自定义子智能体 完全不继承,必须自己写 skills=

而且官方明说:技能状态是完全隔离的——主智能体看不到子智能体的技能,反之也一样。

"""子智能体的技能隔离。"""
from deepagents import SubAgent

make_skill("main_sk", "overview", "项目总览技能。主智能体用。")
make_skill("res_sk", "deep-research", "深度调研技能。调研子智能体专用。")

researcher = SubAgent(
    name="researcher",
    description="调研助手",
    system_prompt="你是调研员。",
    skills=["/res_sk/"],   # 自定义子智能体必须自己声明技能
)

agent = create_deep_agent(
    model=Fake(),
    backend=FilesystemBackend(root_dir=ROOT, virtual_mode=True),
    skills=["/main_sk/"],       # 主智能体和 general-purpose 子智能体用这些
    subagents=[researcher],     # researcher 只有它自己的
    checkpointer=InMemorySaver(),
)

主智能体的系统提示词里:

主智能体提示词里有 'overview': 有
主智能体提示词里有 'deep-research': 没有

隔离确认。这个设计是合理的——子智能体的价值就在于上下文独立(第 40 章 §6),如果技能全都互相可见,那份隔离就打了折扣。

但它也是排查清单里的一条常见问题:「自定义子智能体看不到主智能体的技能」不是 bug,是设计。 需要共享就把同一个路径同时传给两边。

9. 技能库的权限与多租户 #

技能库通常是共享资产,不该被智能体随手改。用第 43 章的 permissions 把它设成只读:

"""让技能库只读:智能体能发现能读,不能改。"""
from deepagents import FilesystemPermission as FP, create_deep_agent
from deepagents.backends import FilesystemBackend

agent = create_deep_agent(
    model=MODEL,
    backend=FilesystemBackend(root_dir=ROOT, virtual_mode=True),
    skills=["/skills/"],
    permissions=[
        # 技能库禁止写入(注意用第 43 章 §3 的 {,/**} 写法覆盖目录自身)
        FP(operations=["write"], paths=["/skills{,/**}"], mode="deny"),
        # 读根目录,让 ls 和 glob 可用
        FP(operations=["read"], paths=["/"], mode="allow"),
        # 工作区正常读写
        FP(operations=["read", "write"], paths=["/reports{,/**}"], mode="allow"),
        # 兜底全拒,四段式写法
        FP(operations=["read", "write"], paths=["/{**,**/.*,.*,**/.*/**}"], mode="deny"),
    ],
)

这段配置是错的,而且错得很隐蔽。实测三个操作:

A:没有显式放行 /skills 的读
   读 SKILL.md(激活技能)           拒绝      ← 技能废了
   改 SKILL.md(应被拒)             拒绝
   写 /reports(应放行)             放行

激活技能的那次 read_file 被拦了。 原因是 /skills{,/**} 那条只 deny 了 write,read 会继续往下匹配;而 FP(["read"], ["/"], "allow") 只放行了根目录本身、不含子路径,于是读 SKILL.md 一路落到最后那条兜底 deny 上。

结果就是技能看得见、读不了:name 和 description 照样出现在系统提示词里,模型按提示去 read_file,却撞上权限拒绝。正确的写法要显式放行技能库的读:

permissions = [
    FP(operations=["write"], paths=["/skills{,/**}"], mode="deny"),      # 先禁写
    FP(operations=["read"], paths=["/skills{,/**}"], mode="allow"),      # 再放行读
    FP(operations=["read"], paths=["/"], mode="allow"),
    FP(operations=["read", "write"], paths=["/reports{,/**}"], mode="allow"),
    FP(operations=["read", "write"], paths=["/{**,**/.*,.*,**/.*/**}"], mode="deny"),
]
B:禁写在前、放行读在后
   读 SKILL.md(激活技能)           放行
   改 SKILL.md(应被拒)             拒绝
   写 /reports(应放行)             放行

顺序很关键(第 43 章 §2.3 的首条命中):禁写规则在前、放行读规则在后,两条各管一半——先被 write 的 deny 命中就拒,read 落到下一条 allow 上就放行。反过来写(放行读在前)也能工作,因为两条规则的 operations 不重叠;但一旦把两条合成 operations=["read","write"],就只能是一个结果,技能必然废掉。

多租户场景下,官方推荐用 CompositeBackend 把 /skills/ 路由到带命名空间的 StoreBackend(第 42 章 §4.4):

"""每个用户一套独立技能库。"""
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend

agent = create_deep_agent(
    model=MODEL,
    skills=["/skills/"],
    backend=CompositeBackend(
        default=StateBackend(),
        routes={
            # 按用户身份分命名空间,各租户的技能库互相隔离
            "/skills/": StoreBackend(
                namespace=lambda rt: (
                    rt.server_info.assistant_id,
                    rt.server_info.user.identity,
                ),
            ),
        },
    ),
)

注意 /skills/ 这个路由前缀在读 store 记录时会被剥掉,所以 store 里的键形如 /ticket-report/SKILL.md。

如果只是想按角色给不同的技能集,不必上多租户,直接在建 Agent 前拼 skills 列表就行:

SKILLS_BY_ROLE = {
    "engineering": ["/skills/common/", "/skills/engineering/"],
    "support": ["/skills/common/", "/skills/support/"],
}


def create_agent_for(role: str):
    # 技能文件只有一份,变的只是传进去的路径
    return create_deep_agent(
        model=MODEL,
        skills=SKILLS_BY_ROLE.get(role, []),
        backend=FilesystemBackend(root_dir=ROOT, virtual_mode=True),
    )

10. 怎么写好一个技能 #

description 是整个机制的咽喉:发现阶段模型能看到的只有它。写得含糊,技能就永远不会被激活——而且这个失败是静默的。

官方给的对比:

# 好:说清了做什么、什么时候用,带可匹配的关键词
description: >-
  Extract text and tables from PDF files, fill PDF forms, and merge
  multiple PDFs. Use when working with PDF documents or when the user
  mentions PDFs, forms, or document extraction.

# 差:太含糊,模型没法可靠匹配
description: Helps with PDFs.

一个好 description 的公式:做什么 + 什么时候用 + 用户可能说的关键词。§5 那个技能的描述就是按这个写的:

description: 生成工单结案报告。当用户要求写结案报告、复盘某个工单、或输出工单处理总结时使用。

「写结案报告」「复盘工单」「工单处理总结」三个说法都列上了,用户不管怎么问都能匹配到。

其余几条建议:

建议 原因
SKILL.md 正文控制在 5000 token / 500 行以内 激活时全文进上下文,太长就失去了分层的意义
正文超了就把细节挪到 references/ §5.3 证明了这条链路完全有效
引用只做一层深 避免模型连读多个文件才拿到信息
技能少而精,别多而重叠 描述相似的技能越多,模型选错的概率越高
相似的技能合并成一个,用小节分子任务 比拆成多个描述打架的技能更可靠

正文该写什么,官方的建议是「写成模型能直接执行的指令」:

§5 那个技能的正文就是这个结构:四个编号步骤 + 两条硬性要求。

最后一个很实用的用法:让智能体自己写技能。 你和它一起完成一个任务之后,让它把刚才的流程沉淀成一个 SKILL.md。下次同类任务它就已经知道该怎么做了。

11. 技能 / 记忆 / 工具怎么分 #

这三样都是「给智能体提供能力或上下文」,容易混。官方的对照表:

技能(Skills) 记忆(Memory) 工具(Tools)
用途 按需发现的领域能力 启动即加载的持久上下文 可调用的程序动作
加载时机 模型判断相关时才读 每次启动都加载 每一轮都可用
载体 具名目录里的 SKILL.md AGENTS.md 文件 绑定到 Agent 的函数
分层 用户 → 项目,后者胜 用户 → 项目,合并 建 Agent 时确定
什么时候用 指令与具体任务相关、且篇幅可能很大 上下文永远相关(项目规范、用户偏好) 需要程序化动作,或没有文件系统

一句话区分:

「永远相关」的放记忆,「有时相关」的放技能,「要动手做」的放工具。

举例:

注意最后一行的分层差异:技能同名是覆盖(last wins),记忆是合并。 这个区别在第 45 章会详细讲。

官方也提醒这三者是光谱而非硬边界。技能可以被智能体自己更新,从而变成一种渐进式披露的记忆——按需取用而不是每次全量加载。

12. 实战约定与坑 #

  1. skills= 指向容器目录,不是技能目录。 写错会白付 1990 字符固定开销、技能清单为空,且不报错(§6.1)
  2. 上线前加一条断言自检:确认技能名真的出现在系统提示词里。这是唯一可靠的验证方式
  3. description 决定一切。 写清「做什么 + 什么时候用 + 关键词」,含糊的描述会导致技能永不激活,且没有任何报错(§10)
  4. name 和父目录名保持一致。 不一致只是警告、照样加载,但会让你按目录名找不到技能(§6.3)
  5. StateBackend 下必须用 create_file_data() 包装技能文件。 裸字符串会抛看不出根因的 TypeError: string indices must be integers(§6.4)
  6. 技能适合「少而长」。 正文短的话,凑不到十来个技能反而比直接写进 system_prompt 更贵(§4 的临界点公式)
  7. 正文控制在 500 行 / 5000 token 内,细节挪进 references/——§5.3 证明第三层完全可靠
  8. references/ 等资源不会被自动发现,必须在 SKILL.md 里写明「是什么、什么时候读」
  9. scripts/ 能读不能跑,除非配了沙箱后端(第 43 章 §9)
  10. 多个源同名时后来者胜,且另一个完全消失。 路径按「通用 → 专用」排;排查技能异常时先确认哪一份生效了(§7)
  11. 自定义子智能体不继承主智能体的技能,general-purpose 才继承。这是设计不是 bug(§8)
  12. 技能库设只读时,别把 read 一起拒掉。 禁写规则在前、放行读规则在后,否则技能看得见读不了(§9)
  13. SKILL.md 超过 10 MB 会被静默跳过
  14. 框架已经在提示词里写好了「什么是技能、怎么用技能」那 1990 字符,你的 system_prompt 只需要写业务部分(§3.3)
  15. 用 FilesystemBackend 时技能产出落在磁盘上,result["files"] 是空的——去磁盘上找(第 42 章 §4.2)

13. 练习 #

  1. 三层验证。 造一个技能,正文里塞一句独特的标记串、references/ 里再塞一句。启动后打印系统提示词,确认两句都不在里面;然后给一个匹配的任务,确认模型先读 SKILL.md、再读 references。

  2. 算你自己的临界点。 用 §4 的公式 $n^{*} = 1990 / (L - 105)$ 算一下:如果你的技能正文平均 800 字符,要几个技能才比全塞 system_prompt 划算?

  3. 踩一遍路径坑。 故意把 skills= 写成技能目录本身,对比系统提示词长度和技能清单。然后加上 §6.1 那个断言,确认它能拦住这个错误。

  4. 覆盖实验。 建 base/ 和 project/ 两个源,放同名技能。交换顺序各跑一次,确认只有最后那个生效。再把其中一个改成空的 SKILL.md(只有 frontmatter),观察它是否仍然覆盖掉另一个。

  5. 技能库只读。 按 §9 配好权限,然后让智能体「把 /skills/ticket-report/SKILL.md 里的手机号打码要求删掉」,确认写操作被拒但读操作正常。

  6. 子智能体隔离。 给主智能体和一个自定义子智能体各配一套技能,让主智能体通过 task 派子智能体干活,从轨迹里确认两者读的是各自的技能文件。

  7. (选做)让智能体写技能。 和智能体一起完成一个多步任务(比如「分析一份日志并输出报告」),然后让它把流程沉淀成 SKILL.md。检查它写出的 description 是否符合 §10 的标准,不符合就让它改。

14. 本章小结 #

  1. 技能是目录,不是文件:skills/<name>/SKILL.md 加可选的 references/ / assets/ / scripts/。skills= 要指向容器目录。
  2. 三层渐进式披露经实测确认:启动时只注入 name 和 description,SKILL.md 正文、references、assets 一个字都不进启动上下文。
  3. 激活技能就是 read_file 一个路径。 没有专门的加载工具,所以技能天然复用第 42 章那套文件系统。
  4. 框架的提示词里明确教模型带 limit=1000,因为 read_file 默认只读 100 行。实测模型确实照做了。
  5. 开销是「固定 1990 字符 + 每技能约 105 字符」。 10 个技能 3047 字符,比全塞提示词的 14788 省 79%。
  6. 但技能少的时候不划算。 临界点 $n^{*} = 1990/(L-105)$:正文越短,越需要更多技能才回本。技能适合少而长。
  7. 第三层是真生效的。 §5.3 那份报告里,五条只写在 references/format.md 的规则全部被遵守,包括模型自己算出来的「3 小时 35 分钟」。
  8. 无关任务几乎零成本:3343 token vs 匹配任务的 34435 token,差一个数量级。
  9. 四个坑全是静默的:路径写到技能目录本身(白付 1990 字符)、frontmatter 不合规(有日志警告)、name 与目录名不一致(警告但仍加载)、StateBackend 给裸字符串(抛看不懂的 TypeError)。
  10. 唯一可靠的验证方式是检查系统提示词里有没有你的技能名,建议写成断言放进启动流程。
  11. 同名技能后来者胜,另一个完全消失。 路径按「通用 → 专用」排;一个过期或空的技能能悄悄覆盖掉你以为在用的那个。
  12. 自定义子智能体不继承主智能体的技能,general-purpose 自动继承。技能状态完全隔离。
  13. description 是唯一的发现依据,必须写清「做什么 + 什么时候用 + 关键词」。含糊的描述导致的永不激活,是最难排查的一类问题。
  14. 技能 / 记忆 / 工具的分工:「永远相关」放记忆,「有时相关」放技能,「要动手做」放工具。技能同名覆盖,记忆同名合并。