1. 本章目标 #
前面五章都在讲 harness 提供的通用能力:文件系统、子智能体、权限、沙箱。但企业里真正难沉淀的是领域知识——「我们公司的结案报告必须包含哪六节」「处理时长要换算成 X 小时 Y 分钟」「客户手机号必须打码」。
这类知识有个尴尬的特点:又多又长,但每次任务只用得上其中一小块。全塞进 system_prompt 会把上下文撑爆,而且每一轮都要重付一次 token;不塞进去,模型就每次都得靠你在提示里现场交代。
Skills 就是为这个问题设计的:知识写成文件放在一边,启动时只给模型看一份目录,用得上的时候它自己去读。
学完你应能:
- 按 Agent Skills 规范写出一个技能目录(
SKILL.md+scripts//references//assets/) - 看懂三层渐进式披露的实测证据:启动时到底注入了什么、正文和资源在哪一步才进上下文(§3)
- 算清技能的开销账:固定成本 1990 字符、每个技能约 105 字符,以及「技能少的时候反而不划算」这个临界点(§4)
- 看一次真模型的完整激活轨迹,并验证只写在
references/里的规则真的生效了(§5) - 绕开四个会静默失效的坑(§6)——它们全都不报错,最坑的那个还会让你白付 1990 字符
- 说清同名技能的
last wins分层规则、子智能体的技能隔离 - 分清技能、记忆、工具三者该用哪个(§11)
前置依赖: 第 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 客户端、数据转换、校验脚本 |
两个要点:
- 模型不会自动发现这些文件。 你必须在
SKILL.md里写清「这个文件是什么、什么时候读」,并用相对技能根的路径引用 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)两个数字要记住:
- 固定开销约 1990 字符(那段说明书),只要你传了
skills=就要付,哪怕一个技能都没加载成功 - 每个技能约 105 字符(
name+description+ 那行 Read 提示)
再和「不用技能、把全部正文塞进 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. 技能设计 #
故意把规则分散在三个文件里,这样才能分辨哪一层生效了:
SKILL.md:只写流程和两条硬性要求(手机号打码、根因要写技术原因)references/format.md:写细节格式规则(六节齐全、时长换算、中文优先级、[ ]勾选框、改进项要有责任人)assets/template.md:报告骨架
"""真模型:观察技能激活的完整轨迹。"""
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三层严格按顺序点亮:
- 先
read_file了SKILL.md(第二层)——而且带了limit=1000,正是 §3.3 那段提示词教它的 - 按
SKILL.md步骤 1 调get_ticket - 按步骤 2、3 读了
references/format.md和assets/template.md(第三层) - 中间那几个
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 时确定 |
| 什么时候用 | 指令与具体任务相关、且篇幅可能很大 | 上下文永远相关(项目规范、用户偏好) | 需要程序化动作,或没有文件系统 |
一句话区分:
「永远相关」的放记忆,「有时相关」的放技能,「要动手做」的放工具。
举例:
- 「我们团队的代码风格是 4 空格缩进」→ 记忆(每次都相关)
- 「结案报告的六节格式」→ 技能(只有写报告时才相关)
- 「查询工单数据」→ 工具(需要真的去调接口)
注意最后一行的分层差异:技能同名是覆盖(last wins),记忆是合并。 这个区别在第 45 章会详细讲。
官方也提醒这三者是光谱而非硬边界。技能可以被智能体自己更新,从而变成一种渐进式披露的记忆——按需取用而不是每次全量加载。
12. 实战约定与坑 #
skills=指向容器目录,不是技能目录。 写错会白付 1990 字符固定开销、技能清单为空,且不报错(§6.1)- 上线前加一条断言自检:确认技能名真的出现在系统提示词里。这是唯一可靠的验证方式
description决定一切。 写清「做什么 + 什么时候用 + 关键词」,含糊的描述会导致技能永不激活,且没有任何报错(§10)name和父目录名保持一致。 不一致只是警告、照样加载,但会让你按目录名找不到技能(§6.3)StateBackend下必须用create_file_data()包装技能文件。 裸字符串会抛看不出根因的TypeError: string indices must be integers(§6.4)- 技能适合「少而长」。 正文短的话,凑不到十来个技能反而比直接写进
system_prompt更贵(§4 的临界点公式) - 正文控制在 500 行 / 5000 token 内,细节挪进
references/——§5.3 证明第三层完全可靠 references/等资源不会被自动发现,必须在SKILL.md里写明「是什么、什么时候读」scripts/能读不能跑,除非配了沙箱后端(第 43 章 §9)- 多个源同名时后来者胜,且另一个完全消失。 路径按「通用 → 专用」排;排查技能异常时先确认哪一份生效了(§7)
- 自定义子智能体不继承主智能体的技能,
general-purpose才继承。这是设计不是 bug(§8) - 技能库设只读时,别把
read一起拒掉。 禁写规则在前、放行读规则在后,否则技能看得见读不了(§9) SKILL.md超过 10 MB 会被静默跳过- 框架已经在提示词里写好了「什么是技能、怎么用技能」那 1990 字符,你的
system_prompt只需要写业务部分(§3.3) - 用
FilesystemBackend时技能产出落在磁盘上,result["files"]是空的——去磁盘上找(第 42 章 §4.2)
13. 练习 #
三层验证。 造一个技能,正文里塞一句独特的标记串、
references/里再塞一句。启动后打印系统提示词,确认两句都不在里面;然后给一个匹配的任务,确认模型先读SKILL.md、再读references。算你自己的临界点。 用 §4 的公式 $n^{*} = 1990 / (L - 105)$ 算一下:如果你的技能正文平均 800 字符,要几个技能才比全塞
system_prompt划算?踩一遍路径坑。 故意把
skills=写成技能目录本身,对比系统提示词长度和技能清单。然后加上 §6.1 那个断言,确认它能拦住这个错误。覆盖实验。 建
base/和project/两个源,放同名技能。交换顺序各跑一次,确认只有最后那个生效。再把其中一个改成空的SKILL.md(只有 frontmatter),观察它是否仍然覆盖掉另一个。技能库只读。 按 §9 配好权限,然后让智能体「把
/skills/ticket-report/SKILL.md里的手机号打码要求删掉」,确认写操作被拒但读操作正常。子智能体隔离。 给主智能体和一个自定义子智能体各配一套技能,让主智能体通过
task派子智能体干活,从轨迹里确认两者读的是各自的技能文件。(选做)让智能体写技能。 和智能体一起完成一个多步任务(比如「分析一份日志并输出报告」),然后让它把流程沉淀成
SKILL.md。检查它写出的description是否符合 §10 的标准,不符合就让它改。
14. 本章小结 #
- 技能是目录,不是文件:
skills/<name>/SKILL.md加可选的references//assets//scripts/。skills=要指向容器目录。 - 三层渐进式披露经实测确认:启动时只注入
name和description,SKILL.md正文、references、assets一个字都不进启动上下文。 - 激活技能就是
read_file一个路径。 没有专门的加载工具,所以技能天然复用第 42 章那套文件系统。 - 框架的提示词里明确教模型带
limit=1000,因为read_file默认只读 100 行。实测模型确实照做了。 - 开销是「固定 1990 字符 + 每技能约 105 字符」。 10 个技能 3047 字符,比全塞提示词的 14788 省 79%。
- 但技能少的时候不划算。 临界点 $n^{*} = 1990/(L-105)$:正文越短,越需要更多技能才回本。技能适合少而长。
- 第三层是真生效的。 §5.3 那份报告里,五条只写在
references/format.md的规则全部被遵守,包括模型自己算出来的「3 小时 35 分钟」。 - 无关任务几乎零成本:3343 token vs 匹配任务的 34435 token,差一个数量级。
- 四个坑全是静默的:路径写到技能目录本身(白付 1990 字符)、frontmatter 不合规(有日志警告)、
name与目录名不一致(警告但仍加载)、StateBackend给裸字符串(抛看不懂的TypeError)。 - 唯一可靠的验证方式是检查系统提示词里有没有你的技能名,建议写成断言放进启动流程。
- 同名技能后来者胜,另一个完全消失。 路径按「通用 → 专用」排;一个过期或空的技能能悄悄覆盖掉你以为在用的那个。
- 自定义子智能体不继承主智能体的技能,
general-purpose自动继承。技能状态完全隔离。 description是唯一的发现依据,必须写清「做什么 + 什么时候用 + 关键词」。含糊的描述导致的永不激活,是最难排查的一类问题。- 技能 / 记忆 / 工具的分工:「永远相关」放记忆,「有时相关」放技能,「要动手做」放工具。技能同名覆盖,记忆同名合并。