1. 本章目标 #

第 40 章 §6 让智能体派过一次子智能体,结论是「能用,但贵了 11 倍」。第 46 章又说子智能体是最有效的上下文手段。这两句话听起来矛盾,其实是同一件事的两面:委派的代价是 token,收益是主上下文的干净。

这一章把委派这件事讲透。核心问题只有一个:主智能体和子智能体之间,什么东西是通的、什么是隔的?

这个问题的答案不是「隔离」两个字能概括的。实测下来是一条很清晰的分界线:对话历史完全隔离,文件系统完全共享(§4)。理解这条线,才知道该怎么设计委派。

学完你应能:

前置依赖: 第 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: True

2.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.

四条值得单独说:

  1. ephemeral(临时的):子智能体不是常驻服务,跑完就没了
  2. stateless by default:它只看到你给的那段文字,返回一份最终报告。§4 会精确验证这句话的边界
  3. 「报告不会展示给用户,你要自己转述」——这条很重要。子智能体的产出不是对话的一部分,主智能体必须把它消化成给用户的回答。忘了这条,用户就会收到一句「已完成调研」而看不到调研结果
  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

两个要点:

  1. 指定就是完全覆盖,不是追加。 主智能体的自定义工具全部消失
  2. 内置文件工具不受影响,始终都在(要限制它们得走 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...'

三个差异:

  1. 系统提示词是拼接的:主的在前、子的在后,用 \n\n 连接
  2. 完整继承对话历史:用户原话、主智能体说过的话,全都在
  3. 仍然有 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,无限循环。

所以这段说明明确了三件事:

  1. 那次委派已经发生了,你就是那个子智能体
  2. 你再想委派会被拒绝,直接干活
  3. 用上面已有的具体事实、数字、标识符作答,别给泛泛的回答

第 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 字符串。 这在两个场景下很有用:

  1. 主智能体要做程序化判断,比如「置信度低于 0.6 就重派一次」
  2. 并行扇出多个子智能体后要汇总,结构化格式便于对齐比较

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. 实战约定与坑 #

  1. write_todos 默认不在,要自己装 TodoListMiddleware(在 langchain 里,不在 deepagents)。三步以上的任务才值得开(§2)
  2. 待办清单的完成标准很严:遇到阻塞保持 in_progress 并另建任务描述阻塞点,不要标记完成往下走(§2.2)
  3. 最终答案要在最后一次 write_todos 之后的消息里给。 把待办标完不等于回答了用户(§2.2)
  4. task 只有两个参数:description 和 subagent_type。文档里的 "prompt" 措辞是误导,没有这个参数(§3.1)
  5. 子智能体的报告不会展示给用户,主智能体必须自己转述。忘了这条,用户只会看到「已完成」(§3.2)
  6. description 是主智能体决定派不派的唯一依据,task 描述里只显示它、不显示 system_prompt(§3.3)
  7. 对话历史完全隔离,文件系统完全共享。 这是设计委派的全部依据(§4)
  8. description 必须自带全部上下文——子智能体真的什么都不知道,包括用户的原话(§4.1)
  9. 大产出走文件、返回值只回摘要。 反过来做,委派就白费了(§4.1)
  10. 子智能体没有 task 工具,委派只有一层。多层分工靠主智能体编排(§4.2)
  11. system_prompt 不继承,且不写不报错——你会得到一个系统提示词 0 字符的子智能体(§5.1)
  12. tools 指定就是完全覆盖,主智能体的自定义工具全部消失(内置文件工具不受影响)(§5.2)
  13. permissions 设了就完全替换父规则。 一个 allow /** 的子智能体能绕过父的所有安全边界。把护栏规则抽成共享常量(§5.4)
  14. 审查子智能体权限时按「这就是全部规则」来审,不要当成父规则的补充
  15. 记忆继承规则的办法:「怎么做」的不继承(system_prompt / skills / middleware),「用什么」的继承(tools / model / permissions / interrupt_on)(§5.6)
  16. fork 是 beta,会打警告。它放弃了上下文隔离这个核心收益,默认应该用 handoff(§6.3)
  17. fork 下 skills 继承父的且不能自定义(设了报 ValueError),和 handoff 相反(§6.2)
  18. response_format 让回传变 JSON,适合主智能体要做程序化判断或汇总多个子智能体结果的场景(§7)
  19. 关掉 task 需要两个条件同时满足:profile 里禁用 GP + 不传 subagents=(§9)
  20. 按任务选模型:提取类任务用便宜模型,写作综合用强模型(§5.3)
  21. 工具集最小化:给子智能体的工具越少,跑偏概率越低、schema 开销越小(§5.2)

12. 练习 #

  1. 验证边界。 按 §4 那个脚本跑一遍,确认四条结论:主的历史子看不到、子的历史主看不到、主写的文件子能读、子写的文件主能读。

  2. 踩权限陷阱。 父设 deny /secret,子设 allow /**,让子去写 /secret/a.txt,确认它成功了。然后按 §5.4 把护栏抽成常量加到子的规则末尾,确认被拦住。

  3. 对比 handoff 和 fork。 用同一个主智能体(先说一句独特的话再派活),分别用两种 mode,打印子智能体收到的消息列表,找出那句独特的话在哪种模式下出现了。

  4. 窄返回值。 写两个版本的调研子智能体:一个直接把材料当返回值,一个写文件只回摘要。对比主智能体的 input_tokens。

  5. 结构化汇总。 定义一个带 response_format 的子智能体,让主智能体并行扇出三次(三个不同主题),然后解析三份 JSON 做对比排序。

  6. 待办清单。 装上 TodoListMiddleware,给一个五步任务,从轨迹里检查两件事:它是否每完成一步就立即更新状态,以及最终答案是否出现在最后一次 write_todos 之后。

  7. (选做)关掉 task。 按 §9 关掉 task 工具,确认工具数变成 7。然后只满足一个条件(比如留着 GP),确认 task 又回来了。

13. 本章小结 #

  1. write_todos v0.7 起改成按需开启,要自己装 TodoListMiddleware。它带一段系统提示词和 873 字符的工具描述,短任务不值得。
  2. 待办清单的设计重点是防「假装做完」:遇阻塞保持 in_progress、另建任务描述阻塞点;最终答案必须在最后一次 write_todos 之后的消息里。
  3. task 只有 description 和 subagent_type 两个参数,没有 prompt。
  4. 子智能体的报告不展示给用户,主智能体必须转述——这是最容易漏掉的一条契约。
  5. description 决定派不派(task 描述里只显示它),system_prompt 决定怎么做。
  6. 核心结论:对话历史完全隔离,文件系统完全共享。 实测四个方向逐一确认。
  7. 两条推论:description 必须自带全部上下文;大产出走文件、返回值只回摘要。
  8. 委派只有一层,子智能体没有 task 工具。
  9. 继承规则:system_prompt / skills / middleware 不继承,tools / model / permissions / interrupt_on 继承。
  10. tools 和 permissions 指定后是「完全覆盖」而非「追加」。 前者会让主智能体的自定义工具全部消失,后者是安全陷阱——一个宽松的子智能体能绕过父的所有权限。
  11. 护栏规则要抽成共享常量,主子共用;审查子智能体权限时按「这就是全部规则」来审。
  12. fork 模式(beta)继承完整对话历史和拼接的系统提示词,并追加一段说明防止它又去委派一次。它放弃了上下文隔离,默认还是用 handoff。
  13. fork 下 skills 继承父的且不能自定义,和 handoff 相反。
  14. response_format 把回传变成 JSON,便于程序化判断和多结果汇总。
  15. 另外两种形式:CompiledSubAgent 挂现成的 LangGraph 图,AsyncSubAgent 跑后台长任务。
  16. 和第 18 章的区别:那里的 handoff 是「控制权转移」,这里的 task 是「函数调用」。控制流复杂就自己搭图,「派活收报告」就用 task。