1. 本章目标 #
第 40 章 §6 让智能体派过一次子智能体,结论是「能用,但贵了 11 倍」。第 46 章又说子智能体是最有效的上下文手段。这两句话听起来矛盾,其实是同一件事的两面:委派的代价是 token,收益是主上下文的干净。
这一章把委派这件事讲透。核心问题只有一个:主智能体和子智能体之间,什么东西是通的、什么是隔的?
这个问题的答案不是「隔离」两个字能概括的。实测下来是一条很清晰的分界线:对话历史完全隔离,文件系统完全共享(§4)。理解这条线,才知道该怎么设计委派。
学完你应能:
- 打开
write_todos做任务规划,并知道 v0.7 为什么把它改成了按需开启(§2) - 读懂
task工具的真实契约——包括它只有两个参数,以及那句「报告不会展示给用户」的含义(§3) - 准确说出主子之间什么通什么不通,并用文件系统传递大产出(§4)
- 逐条掌握 SubAgent 七个字段的继承规则,尤其是
permissions那条安全陷阱(§5) - 用实验性的
fork模式让子智能体继承父对话,并知道它的唯一硬限制(§6) - 用
response_format把子智能体的回传从自由文本变成 JSON(§7) - 在需要时彻底关掉
task工具(§9)
前置依赖: 第 40 章 §6(委派的基础用法和成本)、第 42 章(文件系统——§4 的共享全靠它)、第 43 章 §7(子智能体权限,§5 会用更干净的实验复现)、第 44 章 §8(技能隔离)。第 18 章的 Subagents / Handoffs 会在 §10 做对照。
参考文档:
建议阅读顺序: §4 和 §5 是本章的核心,前者定义边界、后者是配置的全部依据。§5 里 permissions 那条是安全问题,别跳。§6 的 fork 是新特性、还在 beta,但那段衔接说明的设计很值得看。
本章验证环境:deepagents 0.7.12。本章所有实验都是零 token 的——主子之间的信息流动可以用脚本化的假模型精确观察,比真模型跑更清楚。
2. write_todos:v0.7 起要自己开 #
先看任务规划。Deep Agents 提供一个 write_todos 工具让模型管理待办清单。但它默认不在:
"""零 token:确认 write_todos 默认不在工具表里。"""
import json
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 StateBackend
CAP = {}
class Fake(BaseChatModel):
@property
def _llm_type(self) -> str:
return "fake"
def bind_tools(self, tools, **kw):
CAP["tools"] = [(getattr(t, "name", "?"), getattr(t, "description", "") or "")
for t in tools]
return self
def _generate(self, messages, stop=None, run_manager=None, **kw) -> ChatResult:
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
def boot(label, **kw):
CAP.clear()
agent = create_deep_agent(model=Fake(), backend=StateBackend(),
checkpointer=InMemorySaver(), **kw)
agent.invoke({"messages": [{"role": "user", "content": "hi"}]},
{"configurable": {"thread_id": label}})
names = [n for n, _ in CAP["tools"]]
print(f"{label}: {len(names)} 个工具 {names}")
return names
base = boot("默认")
print(f"含 write_todos: {'write_todos' in base}")默认: 8 个工具 ['ls', 'read_file', 'write_file', 'edit_file', 'delete', 'glob', 'grep', 'task']
含 write_todos: False要开就自己装 TodoListMiddleware。注意它在 langchain 里,不在 deepagents:
from langchain.agents.middleware import TodoListMiddleware
agent = create_deep_agent(
model="deepseek:deepseek-v4-flash",
middleware=[TodoListMiddleware()],
)加 TodoListMiddleware: 9 个工具 [..., 'grep', 'task', 'write_todos']
含 write_todos: True2.1. 为什么改成按需 #
TodoListMiddleware 带来的开销不小:一段注入系统提示词的使用说明,加上一个 873 字符的工具描述。对短任务来说这纯属浪费。
框架自己的提示词里就写明了这个判断:
Writing todos takes time and tokens, use it when it is helpful for managing complex
many-step problems! But not for simple few-step requests.For simple objectives that only require a few steps, it is better to just complete
the objective directly and NOT use this tool.所以判断标准很直接:任务需要三步以上、且步骤之间有依赖关系时才值得开。
2.2. 三种任务状态与硬性约定 #
工具描述里定义了三个状态:
| 状态 | 含义 |
|---|---|
pending |
还没开始 |
in_progress |
正在做(互不相关、可并行的任务允许同时多个) |
completed |
已成功完成 |
配套的约定值得看,因为它们解决的都是实际问题:
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
- IMPORTANT: Unless all tasks are completed, you should always have at least one
task in_progress.
- The `write_todos` tool should never be called multiple times in parallel.「除非全部完成,否则始终至少有一个 in_progress」这条是防止清单进入「全是 pending、没人在干」的悬空状态。
完成标准写得相当严格:
- ONLY mark a task as completed when you have FULLY accomplished it
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
- Never mark a task as completed if:
- There are unresolved issues or errors
- Work is partial or incomplete
- You encountered blockers that prevent completion
- You couldn't find necessary resources or dependencies
- Quality standards haven't been met遇到阻塞时保持 in_progress、另建一个任务描述阻塞点,而不是标记完成然后往下走。这是防止「假装做完了」的关键设计。
最后一条约定容易被忽略,但对用户体验影响很大:
When you finish all work, write your final answer in the message AFTER your last
`write_todos` call — not in the same turn as that call. Start the final message with
the substantive content the user asked for.翻译过来:最终答案必须出现在最后一次 write_todos 之后的那条消息里。 提示词里还专门强调「write_todos 追踪你的工作,它不负责交付答案;把最后一个待办标记完成本身不构成对用户的回答」。
这解决的是一个很真实的失败模式:模型把清单全部标成完成,然后说一句「已完成」就结束了,用户要的东西一个字都没给。
3. task 工具:委派的入口 #
委派的入口是 task 工具,它默认就在(上面 8 个工具里最后那个)。
3.1. 只有两个参数 #
先看它的真实契约:
"""零 token:读出 task 工具的参数 schema。"""
class Peek(BaseChatModel):
@property
def _llm_type(self) -> str:
return "peek"
def bind_tools(self, tools, **kw):
for t in tools:
if getattr(t, "name", "") == "task":
CAP["schema"] = t.args_schema.model_json_schema()
return self
def _generate(self, messages, stop=None, run_manager=None, **kw) -> ChatResult:
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
agent = create_deep_agent(model=Peek(), backend=StateBackend(),
checkpointer=InMemorySaver())
agent.invoke({"messages": [{"role": "user", "content": "hi"}]},
{"configurable": {"thread_id": "t"}})
print(json.dumps(CAP["schema"], ensure_ascii=False, indent=2)){
"properties": {
"description": {
"description": "A detailed description of the task for the subagent to perform autonomously. Include all necessary context and specify the expected output format.",
"type": "string"
},
"subagent_type": {
"description": "The type of subagent to use. Must be one of the available agent types listed in the tool description.",
"type": "string"
}
},
"required": ["description", "subagent_type"],
"title": "TaskToolSchema"
}只有 description 和 subagent_type 两个参数。 工具描述里有几处写着「put full detail in the prompt」,但实际参数名是 description——没有 prompt 这个参数。看文档时容易被这个措辞误导。
description 的参数说明本身就是使用要求:「包含所有必要的上下文,并明确期望的输出格式」。这不是客套话,§4 会看到为什么这是硬要求。
3.2. 工具描述里的四条契约 #
task 的完整描述只有 419 字符,但每句都是契约:
Launch an ephemeral subagent to handle a complex, multi-step task.
Available agent types and the tools they have access to:
- general-purpose: General-purpose agent for researching complex questions,
searching for files and content, and executing multi-step tasks. ...
This agent has access to all tools as the main agent.
Specify subagent_type to select the agent. Usage notes:
- Launch multiple agents concurrently when their tasks are independent, using a
single message with multiple tool calls.
- Each invocation is stateless by default: the agent sees only the prompt you give
it and returns a single final report. Put full detail in the prompt and state
exactly what it should return — unless an agent type below says it inherits your
conversation instead.
- The agent's report is not shown to the user; relay a summary yourself.
- Tell the agent whether to create content, analyze, or only research, since it
can't necessarily see the user's intent unless it inherits your conversation.
- If an agent's description says to use it proactively, do so without waiting.四条值得单独说:
ephemeral(临时的):子智能体不是常驻服务,跑完就没了stateless by default:它只看到你给的那段文字,返回一份最终报告。§4 会精确验证这句话的边界- 「报告不会展示给用户,你要自己转述」——这条很重要。子智能体的产出不是对话的一部分,主智能体必须把它消化成给用户的回答。忘了这条,用户就会收到一句「已完成调研」而看不到调研结果
- 「独立任务可以并行扇出」:一条消息里多个
task调用
第 4 点还有个延伸:「告诉它是要创作、分析、还是只做调研」。因为子智能体看不到用户的原话,它不知道你想要什么形态的产出。
3.3. 有自定义子智能体时描述会变 #
注册一个自定义子智能体,task 的描述里会追加它:
from deepagents import SubAgent
sub = SubAgent(name="researcher", description="深度调研某个主题",
system_prompt="你是调研员。")
boot("带 researcher", subagents=[sub])Available agent types and the tools they have access to:
- general-purpose: General-purpose agent for researching complex questions, ...
- researcher: 深度调研某个主题注意它只显示 description,不显示 system_prompt。 这和第 44 章技能的 description 完全同理:
description是主智能体决定「派不派、派给谁」的唯一依据。 写得含糊,子智能体就永远不会被用到。
所以 description 要写「做什么 + 什么时候派它」,而 system_prompt 写「怎么做」。两者分工不同,别混。
4. 隔离的边界:对话隔离、文件共享 #
现在回答本章的核心问题。设计一个实验,让主子互相读写文件、同时观察各自看到的消息:
"""零 token:主子之间什么通、什么不通。"""
SEQ = []
class Scen(BaseChatModel):
"""主写文件 -> 派子 -> 子读主的文件 -> 子写自己的文件 -> 主读子的文件。"""
@property
def _llm_type(self) -> str:
return "scen"
def bind_tools(self, tools, **kw):
names = [getattr(t, "name", "?") for t in tools]
# 有 task 工具的是主智能体,没有的是子智能体
SEQ.append({"who": "main" if "task" in names else "sub", "acted": False})
return self
def _generate(self, messages, stop=None, run_manager=None, **kw) -> ChatResult:
cur = SEQ[-1]
cur["n_msgs"] = len(messages)
cur["bodies"] = [str(m.content)[:40] for m in messages]
who = cur["who"]
done_main = sum(1 for s in SEQ if s["who"] == "main" and s["acted"])
done_sub = sum(1 for s in SEQ if s["who"] == "sub" and s["acted"])
cur["acted"] = True
def call(name, args, cid):
return ChatResult(generations=[ChatGeneration(message=AIMessage(
content="", tool_calls=[{"name": name, "args": args,
"id": cid, "type": "tool_call"}]))])
if who == "main":
if done_main == 0:
return call("write_file",
{"file_path": "/from_main.txt", "content": "主智能体写的内容"}, "m1")
if done_main == 1:
return call("task", {"description": "去读主写的文件",
"subagent_type": "worker"}, "t1")
if done_main == 2:
# 子回来后,主试着读子写的文件
return call("read_file", {"file_path": "/from_sub.txt"}, "m2")
else:
if done_sub == 0:
return call("read_file", {"file_path": "/from_main.txt"}, "s1")
if done_sub == 1:
return call("write_file",
{"file_path": "/from_sub.txt", "content": "子智能体写的内容"}, "s2")
return ChatResult(generations=[ChatGeneration(message=AIMessage(
content="我读到了主写的文件,也写了自己的文件。"))])
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="全部完成"))])
SEQ.clear()
sub = SubAgent(name="worker", description="干活的", system_prompt="你是工人。")
agent = create_deep_agent(model=Scen(), backend=StateBackend(),
subagents=[sub], checkpointer=InMemorySaver())
r = agent.invoke({"messages": [{"role": "user", "content": "开始"}]},
{"configurable": {"thread_id": "x"}})主智能体的消息轨迹:
HumanMessage '开始'
AIMessage -> write_file /from_main.txt
ToolMessage 'Updated file /from_main.txt'
AIMessage -> task
ToolMessage '我读到了主写的文件,也写了自己的文件。' ← 只有这一句
AIMessage -> read_file /from_sub.txt
ToolMessage '1 子智能体写的内容' ← 读到了!
AIMessage '全部完成'子智能体每次调用收到的消息:
2 条: ['你是工人。', 'd']
4 条: ['你是工人。', 'd', '', '1 主智能体写的内容']
6 条: ['你是工人。', 'd', '', '1 主智能体写的内容', '', 'Updated file /from_sub.txt']最终 result["files"]:['/from_main.txt', '/from_sub.txt']
把结论列清楚:
| 通不通 | 证据 | |
|---|---|---|
| 主的对话历史 → 子 | 不通 | 子的消息里只有自己的 system_prompt + task 的 description,主写文件那一段完全不知道 |
| 子的对话历史 → 主 | 不通 | 主只收到子的最后一句话,子那 3 次工具调用一次都看不到 |
| 主写的文件 → 子 | 通 | 子 read_file /from_main.txt 成功 |
| 子写的文件 → 主 | 通 | 主 read_file /from_sub.txt 成功,result["files"] 里两个都在 |
一句话:
对话历史完全隔离,文件系统完全共享。
4.1. 这条边界怎么用 #
理解了边界,两个设计原则就是自然推论:
第一,description 必须自带全部上下文。 因为子智能体真的什么都不知道。这就是 §3.1 那个参数说明「Include all necessary context」的原因:
# 差:子智能体不知道「这个工单」是哪个
{"description": "分析这个工单的根因", "subagent_type": "analyst"}
# 好:把上下文都塞进去
{"description": "分析工单 T-2087(支付回调超时导致订单状态未更新,"
"2026-08-30 09:12 报障、12:47 关闭)的技术根因。"
"输出格式:三段,分别是直接原因、深层原因、可验证的证据。",
"subagent_type": "analyst"}第二,大产出走文件、不走返回值。 这正是第 46 章 §9 那个配置的机制基础:
researcher = SubAgent(
name="researcher",
description="深度调研单个主题",
system_prompt=(
"你是调研员。\n"
"把完整的调研材料写进 /research/<主题>.md,"
"然后只回一段不超过 300 字的结论摘要,并在摘要末尾给出文件路径。"
),
)返回值窄、文件宽。 这样主智能体的上下文只增加 300 字,需要细节时再 read_file 或 grep 那个文件。如果反过来让子智能体把两万字材料当返回值,那份材料就直接灌进主上下文了——委派的意义就没了。
4.2. 子智能体不能再派子智能体 #
上面轨迹里有个细节:子智能体的工具表里没有 task。
主智能体工具: ['ls', 'read_file', 'write_file', 'edit_file', 'delete', 'glob', 'grep', 'task', 'main_only_tool']
子智能体工具: ['ls', 'read_file', 'write_file', 'edit_file', 'delete', 'glob', 'grep', 'main_only_tool']委派只有一层,不能递归。这是个合理的限制——递归委派很容易失控,而且每一层都要付一次完整的上下文成本。
需要多层分工时,用主智能体做编排:它可以先派 A、拿到结果再派 B,而不是让 A 自己去派 B。
5. 七个字段的继承规则 #
SubAgent 有 11 个字段:
['name', 'description', 'tools', 'model', 'middleware', 'interrupt_on',
'skills', 'permissions', 'response_format', 'system_prompt', 'mode']类型标注上只有 name 和 description 是必填,其余都是 NotRequired。但继承行为各不相同,逐条实测。
5.1. system_prompt:不继承 #
b, g, subsys = run("子有 system_prompt",
{"system_prompt": "MARK-SUB-PROMPT 你是调研员。"},
{"system_prompt": "MARK-MAIN-PROMPT 你是主管。"})子提示词含 MARK-SUB-PROMPT: True
子提示词含 MARK-MAIN-PROMPT: False ← 不继承不写会怎样? 官方文档标它 Required,但类型是 NotRequired。实测:
子无 system_prompt: 子智能体系统提示词 0 字符
能跑起来: 是能跑,但子智能体的系统提示词是 0 字符——一个没有任何指令的智能体。所以文档说的 "Required" 是语义要求而非类型约束:不写不报错,但你得到的是个不知道自己该干什么的子智能体。
5.2. tools:默认继承,指定则完全覆盖 #
--- 子不指定 tools ---
主智能体工具: [...内置 8 个..., 'main_only_tool']
子智能体工具: [...内置 7 个..., 'main_only_tool']
-> 子继承了主的 main_only_tool: True
--- 子指定 tools=[sub_only_tool] ---
子智能体工具: [...内置 7 个..., 'sub_only_tool']
-> 含 sub_only_tool: True
-> 含 main_only_tool: False ← 完全覆盖
-> 内置文件工具还在吗: True两个要点:
- 指定就是完全覆盖,不是追加。 主智能体的自定义工具全部消失
- 内置文件工具不受影响,始终都在(要限制它们得走
middleware里塞一个带tools白名单的FilesystemMiddleware)
官方的建议是工具集最小化:只给子智能体它真正需要的工具。工具越少,它跑偏的概率越低、schema 开销也越小。
5.3. model:默认继承,可覆盖 #
各次 bind 的 tag: ['main', 'sub-own-model', 'main']中间那次用的是子智能体自己的模型。这是个很实用的优化点,官方专门提了「按任务选模型」:
# 简单的信息提取用便宜快的模型,综合写作用强模型
extractor = SubAgent(
name="extractor",
description="从文档里提取结构化字段",
system_prompt="只做字段提取,不做推理。",
model="deepseek:deepseek-v4-flash", # 便宜
)
writer = SubAgent(
name="writer",
description="把调研结论写成正式报告",
system_prompt="你是资深技术写作者。",
model="anthropic:claude-sonnet-4-6", # 贵但强
)5.4. permissions:默认继承,设了就完全替换 #
这条是安全陷阱,单独实测。父智能体设了 deny /secret:
DENY_SECRET = [FP(operations=["read", "write"], paths=["/secret{,/**}"], mode="deny")]先确认规则本身有效(主智能体直接写):
--- 对照:主智能体直接写 /secret ---
'Error: permission denied for write on /secret/a.txt'
文件: []然后让子智能体去写同一个路径:
B. 子不设 permissions: 子写 /secret/a.txt -> 被拒 ← 继承了父的规则
C. 子设宽松 permissions: 子写 /secret/a.txt -> 放行 ← 完全替换了父的规则
D. 子设自己的 deny: 子写 /secret/a.txt -> 被拒C 就是那个陷阱:子智能体只要声明了自己的 permissions,父智能体的所有规则全部失效——不是合并,是替换。一个写成 allow /** 的子智能体,能绕过父智能体所有的安全边界。
这印证了第 43 章 §7 的结论。防御办法是把权限规则抽成共享常量:
"""权限规则抽成常量,主子共用,避免子智能体成为绕过口。"""
CREDENTIAL_GUARD = [
FP(operations=["read", "write"], paths=["/{**,**/.*,.*,**/.*/**}"], mode="deny"),
]
# 子智能体要加自己的规则时,也必须带上这条护栏
researcher_perms = [
FP(operations=["read", "write"], paths=["/research{,/**}"], mode="allow"),
*CREDENTIAL_GUARD, # 兜底护栏放最后(首条命中原则,见第 43 章 §2.3)
]审查子智能体的 permissions 时,要按「它就是全部规则」来审,而不是「它是父规则的补充」。
5.5. skills / middleware / interrupt_on #
剩下三个字段:
| 字段 | 继承 | 说明 |
|---|---|---|
skills |
不继承 | 只有内置 general-purpose 子智能体继承主智能体的技能。技能状态完全隔离(第 44 章 §8 实测过) |
middleware |
不继承 | 按 .name 同名替换默认实例,其余的插在核心 middleware 之后(第 50 章讲装配) |
interrupt_on |
默认继承 | 子智能体的值覆盖继承来的。需要 checkpointer |
5.6. 继承规则总表 #
| 字段 | 默认行为 | 指定后 |
|---|---|---|
system_prompt |
不继承(不写就是空的) | 用自己的 |
tools |
继承主智能体的 | 完全覆盖(内置文件工具不受影响) |
model |
继承 | 用自己的 |
permissions |
继承 | 完全替换(安全陷阱,见 §5.4) |
skills |
不继承(GP 例外) | 用自己的,状态隔离 |
middleware |
不继承 | 同名替换默认实例 |
interrupt_on |
继承 | 覆盖 |
记忆办法:「怎么做」的不继承(system_prompt、skills、middleware),「用什么」的继承(tools、model、permissions、interrupt_on)。
6. fork 模式:继续父对话 #
mode 字段有两个值:handoff(默认)和 fork。前面测的全是 handoff。
fork 是实验性的,导入即警告:
LangChainBetaWarning: The feature `forked subagents` is in beta.
It is actively being worked on, so the API may change.它的区别用同一个实验对照最清楚。主智能体先说一句独特的话,再派子智能体:
handoff 模式:
第 3 次调用(子,有 task 工具: False)收到 2 条:
SystemMessage 'MARK-SUB-PROMPT 你是工人。'
HumanMessage 'MARK-TASK-DESC 去干活'fork 模式:
第 3 次调用(有 task 工具: True)收到 5 条:
SystemMessage 'MARK-MAIN 你是主管。\n\nMARK-SUB-PROMPT 你是工人。'
HumanMessage 'MARK-USER 用户的原始问题'
AIMessage 'MARK-MAIN-SAID 我先说一句独特的话。'
HumanMessage '继续'
HumanMessage '[The messages above are a prior conversation you are continu...'三个差异:
- 系统提示词是拼接的:主的在前、子的在后,用
\n\n连接 - 完整继承对话历史:用户原话、主智能体说过的话,全都在
- 仍然有
task工具(因为它本质上就是主智能体的延续)
6.1. 那段衔接说明 #
fork 会在消息末尾追加一条 570 字符的说明。这段文本解决的问题很妙:
[The messages above are a prior conversation you are continuing as the subagent that
was just invoked. Any mention in them of delegating to a subagent already happened —
you are that subagent, not the one being asked to delegate further. If you try to
delegate to another subagent yourself, it will be refused — complete this task
directly. Use the specific facts, figures, and identifiers already established in
that conversation when completing the task below — do not answer generically when
exact details are already available above. Your actual task is below.]
去干活想想不加这段会发生什么:模型继承了完整历史,历史里有一句「我要派个子智能体去干活」。模型读到这句,很自然会想——「对,该派个子智能体了」,于是又调一次 task,无限循环。
所以这段说明明确了三件事:
- 那次委派已经发生了,你就是那个子智能体
- 你再想委派会被拒绝,直接干活
- 用上面已有的具体事实、数字、标识符作答,别给泛泛的回答
第 3 点也很实际:继承了历史的模型容易「忘记」历史里有具体数据,转而给出通用答案。
6.2. 唯一的硬限制 #
fork 只禁一件事:
fork + skills ValueError: SubAgent 'f' cannot set skills under mode='fork';
the parent's skills are inherited instead.
fork + tools 允许
fork + permissions 允许
fork + model 允许注意错误信息的后半句:fork 模式下继承父智能体的 skills。这和 handoff 正好相反(§5.5 里 skills 是不继承的)。因为 fork 是「同一个对话的延续」,重建技能上下文没有意义。
6.3. 什么时候用 fork #
handoff 和 fork 的取舍:
handoff(默认) |
fork(beta) |
|
|---|---|---|
| 对话历史 | 隔离 | 继承 |
| 系统提示词 | 只有自己的 | 主的 + 自己的 |
| 上下文成本 | 低(只有 description) | 高(整份历史) |
task 工具 |
没有 | 有(但会被拒绝) |
skills |
不继承,可自定义 | 继承父的,不能自定义 |
| 适合 | 独立的重活、需要干净上下文 | 需要完整对话背景的子任务 |
默认应该用 handoff。 fork 放弃了上下文隔离这个核心收益——你付了一次额外模型调用的钱,却没省下任何上下文。它的价值在于「换一套系统提示词继续同一个对话」,比如切换到更严格的审查人格来复核前面的结论。
7. 结构化回传 #
默认情况下子智能体回一段自由文本,主智能体得自己解析。response_format 可以把它变成 JSON:
"""让子智能体按 schema 回传。"""
from pydantic import BaseModel, Field
class Finding(BaseModel):
"""调研结论。"""
topic: str = Field(description="调研的主题")
conclusion: str = Field(description="一句话结论")
confidence: float = Field(description="置信度 0~1")
researcher = SubAgent(
name="researcher",
description="调研员",
system_prompt="你是调研员。",
response_format=Finding,
)实测子智能体的工具表变化:
第 1 次(主): [..., 'grep', 'task']
第 2 次(子): [..., 'grep', 'Finding'] ← task 的位置被结构化工具占了
第 3 次(主): [..., 'grep', 'task']主智能体收到的 task 结果:
'{"topic":"X","conclusion":"X 可行","confidence":0.8}'从自由文本变成了 JSON 字符串。 这在两个场景下很有用:
- 主智能体要做程序化判断,比如「置信度低于 0.6 就重派一次」
- 并行扇出多个子智能体后要汇总,结构化格式便于对齐比较
response_format 也接受 ToolStrategy(...)、ProviderStrategy(...) 或原始 schema 类型(衔接第 6 章)。
8. 另外两种子智能体形式 #
subagents= 除了 SubAgent 字典,还接受两种:
CompiledSubAgent——把一个现成的 LangGraph 图当子智能体:
"""用已有的图当子智能体。"""
from deepagents.middleware.subagents import CompiledSubAgent
my_graph = some_builder.compile() # 必须先 compile()
sub = CompiledSubAgent(
name="legacy-pipeline",
description="走已有的审核流水线",
runnable=my_graph,
)只有三个字段:name、description、runnable。适合把已有的 LangGraph 工作流接进来,不用改写成声明式的 SubAgent。
AsyncSubAgent——后台跑的远程子智能体:
"""异步子智能体:跑在后台,用一组工具管理任务。"""
async_sub = {
"name": "long-crawler",
"description": "耗时很久的全站抓取",
"graph_id": "crawler", # 靠这个字段被识别成异步子智能体
# 可选:url / headers 指向远程部署
}它靠 graph_id(和可选的 url / headers)被识别,路由到 AsyncSubAgentMiddleware 而不是 SubAgentMiddleware。这类子智能体作为后台任务运行,框架会暴露一组工具用于启动、查询、更新、取消、列举任务。
适合的场景是「跑几十分钟、不能让主对话干等」的活。相应地,编排也更复杂——主智能体要自己轮询任务状态。
9. 关掉子智能体 #
不需要委派时,task 工具那份 419 字符描述纯属浪费。关掉它需要两个条件同时满足:
"""彻底去掉 task 工具。"""
from deepagents import (GeneralPurposeSubagentProfile, HarnessProfile,
register_harness_profile)
# 条件一:关掉默认的 general-purpose 子智能体
register_harness_profile(
"deepseek", # key 是 provider 名或具体模型名
HarnessProfile(
general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False),
),
)
# 条件二:不传任何同步子智能体
agent = create_deep_agent(model="deepseek:deepseek-v4-flash")GP 关闭且不传 subagents: 7 个工具
['ls', 'read_file', 'write_file', 'edit_file', 'delete', 'glob', 'grep']
含 task: False只满足一个条件不行:留着 GP、或者传了 subagents=,task 都会在。异步子智能体不受影响(它们走另一套工具)。
10. 和第 18 章的对照 #
第 18 章讲过 LangChain 的多智能体模式,容易和这里混。对照一下:
| 第 18 章 Subagents / Handoffs | Deep Agents 的 task |
|
|---|---|---|
| 实现方式 | 你自己用 LangGraph 搭图、写路由 | 框架内置工具,开箱可用 |
| 控制流 | 可以任意跳转(handoff 是真的交接控制权) | 只有「派出去、等回来」一种形态 |
| 对话历史 | 你决定传什么 | 默认隔离(fork 可继承) |
| 层数 | 任意 | 只有一层,子不能再派 |
| 状态 | 你定义 state schema | 对话隔离、文件共享 |
| 适合 | 复杂路由、循环、条件分支 | 「主管派活、下属交报告」 |
一句话区分:
第 18 章的 handoff 是「控制权转移」,Deep Agents 的
task是「函数调用」。
task 派出去之后主智能体在等结果,子智能体跑完控制权自动回来——这就是一次函数调用的语义。而第 18 章的 handoff 可以让控制权真的走掉不回来。
所以选择标准是控制流的复杂度:只要是「派活收报告」这种形态,用 task 省事得多;需要循环、条件跳转、多智能体来回协商的,还是得自己搭图(可以用 CompiledSubAgent 把搭好的图挂进来,两者不冲突)。
11. 实战约定与坑 #
write_todos默认不在,要自己装TodoListMiddleware(在langchain里,不在deepagents)。三步以上的任务才值得开(§2)- 待办清单的完成标准很严:遇到阻塞保持
in_progress并另建任务描述阻塞点,不要标记完成往下走(§2.2) - 最终答案要在最后一次
write_todos之后的消息里给。 把待办标完不等于回答了用户(§2.2) task只有两个参数:description和subagent_type。文档里的 "prompt" 措辞是误导,没有这个参数(§3.1)- 子智能体的报告不会展示给用户,主智能体必须自己转述。忘了这条,用户只会看到「已完成」(§3.2)
description是主智能体决定派不派的唯一依据,task描述里只显示它、不显示system_prompt(§3.3)- 对话历史完全隔离,文件系统完全共享。 这是设计委派的全部依据(§4)
description必须自带全部上下文——子智能体真的什么都不知道,包括用户的原话(§4.1)- 大产出走文件、返回值只回摘要。 反过来做,委派就白费了(§4.1)
- 子智能体没有
task工具,委派只有一层。多层分工靠主智能体编排(§4.2) system_prompt不继承,且不写不报错——你会得到一个系统提示词 0 字符的子智能体(§5.1)tools指定就是完全覆盖,主智能体的自定义工具全部消失(内置文件工具不受影响)(§5.2)permissions设了就完全替换父规则。 一个allow /**的子智能体能绕过父的所有安全边界。把护栏规则抽成共享常量(§5.4)- 审查子智能体权限时按「这就是全部规则」来审,不要当成父规则的补充
- 记忆继承规则的办法:「怎么做」的不继承(
system_prompt/skills/middleware),「用什么」的继承(tools/model/permissions/interrupt_on)(§5.6) fork是 beta,会打警告。它放弃了上下文隔离这个核心收益,默认应该用handoff(§6.3)fork下skills继承父的且不能自定义(设了报ValueError),和handoff相反(§6.2)response_format让回传变 JSON,适合主智能体要做程序化判断或汇总多个子智能体结果的场景(§7)- 关掉
task需要两个条件同时满足:profile 里禁用 GP + 不传subagents=(§9) - 按任务选模型:提取类任务用便宜模型,写作综合用强模型(§5.3)
- 工具集最小化:给子智能体的工具越少,跑偏概率越低、schema 开销越小(§5.2)
12. 练习 #
验证边界。 按 §4 那个脚本跑一遍,确认四条结论:主的历史子看不到、子的历史主看不到、主写的文件子能读、子写的文件主能读。
踩权限陷阱。 父设
deny /secret,子设allow /**,让子去写/secret/a.txt,确认它成功了。然后按 §5.4 把护栏抽成常量加到子的规则末尾,确认被拦住。对比 handoff 和 fork。 用同一个主智能体(先说一句独特的话再派活),分别用两种 mode,打印子智能体收到的消息列表,找出那句独特的话在哪种模式下出现了。
窄返回值。 写两个版本的调研子智能体:一个直接把材料当返回值,一个写文件只回摘要。对比主智能体的
input_tokens。结构化汇总。 定义一个带
response_format的子智能体,让主智能体并行扇出三次(三个不同主题),然后解析三份 JSON 做对比排序。待办清单。 装上
TodoListMiddleware,给一个五步任务,从轨迹里检查两件事:它是否每完成一步就立即更新状态,以及最终答案是否出现在最后一次write_todos之后。(选做)关掉 task。 按 §9 关掉
task工具,确认工具数变成 7。然后只满足一个条件(比如留着 GP),确认task又回来了。
13. 本章小结 #
write_todosv0.7 起改成按需开启,要自己装TodoListMiddleware。它带一段系统提示词和 873 字符的工具描述,短任务不值得。- 待办清单的设计重点是防「假装做完」:遇阻塞保持
in_progress、另建任务描述阻塞点;最终答案必须在最后一次write_todos之后的消息里。 task只有description和subagent_type两个参数,没有prompt。- 子智能体的报告不展示给用户,主智能体必须转述——这是最容易漏掉的一条契约。
description决定派不派(task描述里只显示它),system_prompt决定怎么做。- 核心结论:对话历史完全隔离,文件系统完全共享。 实测四个方向逐一确认。
- 两条推论:
description必须自带全部上下文;大产出走文件、返回值只回摘要。 - 委派只有一层,子智能体没有
task工具。 - 继承规则:
system_prompt/skills/middleware不继承,tools/model/permissions/interrupt_on继承。 tools和permissions指定后是「完全覆盖」而非「追加」。 前者会让主智能体的自定义工具全部消失,后者是安全陷阱——一个宽松的子智能体能绕过父的所有权限。- 护栏规则要抽成共享常量,主子共用;审查子智能体权限时按「这就是全部规则」来审。
fork模式(beta)继承完整对话历史和拼接的系统提示词,并追加一段说明防止它又去委派一次。它放弃了上下文隔离,默认还是用handoff。fork下skills继承父的且不能自定义,和handoff相反。response_format把回传变成 JSON,便于程序化判断和多结果汇总。- 另外两种形式:
CompiledSubAgent挂现成的 LangGraph 图,AsyncSubAgent跑后台长任务。 - 和第 18 章的区别:那里的 handoff 是「控制权转移」,这里的
task是「函数调用」。控制流复杂就自己搭图,「派活收报告」就用task。