《Deep Agents 实战笔记》第 5 篇 | 基于 deepagents (Python) 官方文档 原文:https://docs.langchain.com/oss/python/deepagents/subagents

第 1 篇埋过一个伏笔:子代理是"用上下文隔离换任务质量",而任务描述(description)的写法直接决定委派质量,官方文档甚至专门写了排错案例。这一篇就来还债——把 task 工具拆开看清楚:主代理怎么派活、子代理怎么干活、description 写坏会发生什么,以及几种进阶玩法。

一、task 工具:一次"外包"的完整时序

先纠正一个常见误解:子代理不是一个常驻进程。每次调用 task 工具,Deep Agents 都会临时生成一个一次性子代理,它带着自己的系统提示词、工具集和全新的上下文窗口去干活,干完只把结果摘要交回来,然后"人走茶凉"。

一次委派的完整时序是这样的:

sequenceDiagram
    participant M as 主代理
    participant T as task 工具
    participant SA as 子代理
    participant FS as 文件系统

    M->>T: task(subagent_type, description)
    Note over T: SubAgentMiddleware 根据 name<br/>找到子代理定义并实例化
    T->>SA: 启动(全新上下文,只有任务描述)
    SA->>FS: 多轮工具调用<br/>搜索、读文件、写笔记
    Note over SA: 中间过程全部留在<br/>子代理自己的上下文里
    SA-->>T: 最终回复(结果摘要)
    T-->>M: 摘要作为 ToolMessage 返回
    Note over M: 主代理窗口依然干净

正在渲染图表…

这里面有个容易忽略的实现细节:同步子代理能力是由 SubAgentMiddleware(以及它挂载的 task 工具)提供的,而且只有当至少存在一个子代理时它才会被挂载。这也意味着:

  • 你可以传一个自己命名为 general-purpose 的子代理来替换默认版本;
  • 想彻底禁用委派能力,要在 harness profile 上设 general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False);
  • 不要试图把 SubAgentMiddleware 塞进 excluded_middleware——它是必需的基础组件,这么干会直接抛 ValueError。

什么时候该派子代理、什么时候不该?官方给了一张很实用的判断清单:

该用 ✅不该用 ❌
会弄乱主代理上下文的多步骤任务简单的单步骤任务
需要自定义指令或工具的专门领域需要保留中间上下文时
需要不同模型能力的任务开销大于收益时
希望主代理专注于高层协调时

第四条最反直觉:子代理不是"人多力量大"的人海战术,它的本质是上下文分区。如果你的任务本来就不产生多少中间输出,派子代理纯粹是浪费。

二、通用型 vs 专用:两种子代理定义方式

Deep Agents 默认自带一个 general-purpose(通用型)子代理:用默认系统提示词、继承主代理的工具和技能、继承主代理的模型。它适合"只需要上下文隔离、不需要专门行为"的场景——比如主代理要做 10 次网络搜索,与其自己搜到上下文爆炸,不如打包丢给通用型子代理,取回一份摘要了事。

但真正的威力在专用子代理。什么时候用通用型?官方给了一个很干净的心法:如果你要的只是"别把主代理上下文弄脏",通用型就够了——与其让主代理执行 10 次网络搜索并用结果填满自己的上下文,不如委派一句 task(name="general-purpose", task="Research quantum computing trends"),子代理在内部完成所有搜索,只返回一份摘要。什么时候上专用型?当你发现自己在系统提示词里反复强调"研究要引用来源"、"报告不超过 500 字"这类专项纪律时,就该把它沉淀成一个专门的子代理了。

定义方式是基于字典的 SubAgent 规范,核心字段:

字段说明
name唯一标识符,主代理调 task() 时用它点名
description职责描述,主代理据此决定何时委派
system_prompt子代理的指令(isolated 模式必填,不从主代理继承)
tools默认继承主代理,一旦指定则完全覆盖
model可覆盖主代理的模型
skills子代理专属技能目录,不继承主代理
response_format结构化输出 schema,回传 JSON 而非自由文本
modeisolated(默认)或 fork,控制上下文继承,见第五节
interrupt_on为特定工具配置人工审批(HITL),默认继承主代理
permissions文件系统权限规则,设置后完全替换父代理的权限

一个最典型的"研究员"定义:

research_subagent = {
    "name": "research-agent",
    "description": "Conducts in-depth research using web search and synthesizes findings",
    "system_prompt": """You are a thorough researcher. Your job is to:

    1. Break down the research question into searchable queries
    2. Use internet_search to find relevant information
    3. Synthesize findings into a comprehensive but concise summary
    4. Cite sources when making claims

    Output format:
    - Summary (2-3 paragraphs)
    - Key findings (bullet points)
    - Sources (with URLs)

    Keep your response under 500 words to maintain clean context.""",
    "tools": [internet_search],
}

agent = create_deep_agent(
    model="google_genai:gemini-3.6-flash",
    subagents=[research_subagent],
)

注意三个设计决策:

  1. tools 只给了搜索。一旦指定 tools,继承的工具会被完全覆盖——这是故意的,工具越少,子代理越专注,也越安全。官方的负面示例是给 email-sender 塞进 send_email、web_search_tool、database_query、format_document 一大堆不相干工具,专注度立刻崩掉。
  2. system_prompt 写了输出格式和字数上限。"Keep your response under 500 words" 这句很关键——子代理回传的东西会直接进主代理上下文,回传越克制,主代理越持久。
  3. 模型可以按需换。主代理用 Gemini,研究员子代理可以换 openai:gpt-5.5。官方的最佳实践是按任务选模型:审合同的用大上下文模型,做数值分析的用强推理模型。

三、description 好坏对比:一行字决定的翻车现场

现在说那个官方排错案例。description 是主代理决定"何时委派给谁"的唯一依据——注意,不是系统提示词,不是工具名,就是这一行 description。写坏了会发生什么?

官方给的正反对比非常直白:

# ❌ 差的:主代理完全无法判断什么时候该派它
AsyncSubAgent(
    name="helper",
    description="helps with stuff",
    graph_id="helper",
)

# ✅ 好的:说清楚做什么、用什么手段、什么场景用
AsyncSubAgent(
    name="researcher",
    description="Conducts in-depth research using web search. Use for questions requiring multiple searches and synthesis.",
    graph_id="researcher",
)

另一组(同步子代理文档里):

✅ "Analyzes financial data and generates investment insights with confidence scores"
❌ "Does finance stuff"

戏剧性在于后果的隐蔽。你跑一个 "Does finance stuff" 版本的 Agent,它不会报错。症状是间接的:

  • 主代理收到"分析这份财报"的请求,看着 "Does finance stuff" 这行字,不知道这算不算 "finance stuff",于是决定自己上手——结果 20 次工具调用塞满了主代理上下文,子代理形同虚设;
  • 或者反过来,你定义了三个子代理,description 都写得含糊,主代理随机点名,把合同审查派给了财务分析代理,输出驴唇不对马嘴;
  • 你排查时盯着 trace 看,只看到"委派没发生"或"派错人了",没有任何异常堆栈。

这就是 Agent 工程里最磨人的一类 bug:**配置语法全对,行为全错,错误源头只有一行字符串。**我的经验是:description 要写成"动作 + 手段 + 适用场景"的三段式,像上面 researcher 那条——它是动作(conducts research)、手段(using web search)、场景(for questions requiring multiple searches)的完整组合。写完 description 做个自测:**假装你是只看得到这一行字的主代理,你能在三秒内决定"这事该不该派给它"吗?**不能,就重写。

顺便一提,调试这类问题 LangSmith 有个杀手锏:每个子代理产生的 run 都会在元数据里写入 lc_agent_name 键,你可以用元数据过滤器筛出某个子代理的所有 run,一眼看出"它到底被派过几次、干了什么":

from langsmith import Client

client = Client()
runs = client.list_runs(
    project_name="<your-project>",
    filter='has(metadata, \'{"lc_agent_name": "research-agent"}\')',
)

四、共享文件系统:子代理之间的"协作黑板"

isolated 模式下子代理看不到主代理的对话历史,那信息怎么传?除了任务描述和结果摘要这条"正门",还有一条侧门:文件系统。

Deep Agents 的文件工具背后是同一套可插拔 Backend(第 4 篇讲过),主代理和子代理默认共享这个虚拟文件系统。这就打开了一种中间层协作模式——把文件系统当黑板用:

  • 主代理把任务背景、领域材料写进 /workspace/brief.md,任务描述里只需说"先读 brief.md";
  • 多个子代理各自把产出写到不同路径,后来的子代理可以 read_file 读前人的成果,不必通过主代理中转;
  • 中间产物落盘,谁需要谁去读,任何一方的上下文都不用背着别人的原始数据。
flowchart TB
    M["主代理<br/>写任务简报 brief.md"]
    subgraph BOARD["共享虚拟文件系统(协作黑板)"]
        B1["brief.md<br/>任务简报与背景材料"]
        B2["notes-research.md<br/>研究员的中间产出"]
        B3["notes-data.md<br/>数据组的中间产出"]
    end
    M -->|"write brief.md"| B1
    SA["子代理 A(研究)"] -->|"read"| B1
    SA -->|"write"| B2
    SB["子代理 B(分析)"] -->|"read"| B1
    SB -->|"read"| B2
    SB -->|"write"| B3
    M -->|"read 摘要与结论"| B3
    style M fill:#eef4ff,color:#1a3a8b,stroke:#4a72d9
    style BOARD fill:#f4effb,color:#4a2170,stroke:#8b52c9

正在渲染图表…

不过有两点边界要拎清楚。第一,技能状态是双向完全隔离的:子代理配置了 skills 后运行自己独立的 SkillsMiddleware,它加载的技能对父代理不可见,反之亦然;只有通用型子代理会继承主代理的技能。第二,如果子代理的任务本质上是"延续主代理已经开始的工作",硬靠黑板传上下文就很绕——这正是 fork 模式的用武之地。

五、fork 模式:把父代理的上下文"分叉"给子代理

默认的 isolated 模式下,子代理只看得到任务描述,从零开始。而 mode: "fork" 的子代理会继承父代理的对话历史和状态——机制很精巧:fork 收到的就是父代理自己的消息列表,唯一改动是把末尾那次 task 调用替换成一句简短前言("Continuing as the subagent that was just invoked..."),标记"以下是延续而非新请求"。fork 干完后答案作为普通工具结果返回,父代理无缝续接。

comment_writer = {
    "name": "comment-writer",
    "description": "Continues an in-progress PR review and drafts review comments",
    "mode": "fork",
    "tools": [read_diff],
}

官方给的适用场景很形象:主代理已经审查了 PR、发现了两个安全问题(token 明文落日志、refresh 无过期校验),这时候派一个 fork 子代理去"针对已发现的问题起草评审意见"——isolated 子代理会傻乎乎地把 diff 重新审一遍,而 fork 子代理已经知道问题在哪,直接动笔。isolated 与 fork 的取舍:

维度isolated(默认)fork
上下文只有任务描述父代理的对话历史和状态
调用其他子代理可以用 task不能,必须自己干活
最适合几乎不需要先前上下文的聚焦型工作延续父代理已经开始的调查

一个工程细节:fork 可以复用父代理的提示词缓存(匹配前缀),但前提是你别乱改系统提示词、工具定义这些共享前缀——改了缓存命中率就掉了,成本跟着涨。另外 fork 目前是 beta 功能,要求 deepagents>=0.7.13。

六、中间层子代理:流水线式分工

子代理不该只有"主代理 → 子代理"一层。官方的常见模式展示了三级流水线:每个环节都是专门化子代理,前一环的产出是后一环的输入。

flowchart LR
    U["用户请求"] --> M["主代理<br/>只做协调"]
    M -->|"task: 收集数据"| C["data-collector<br/>搜索 / API / 数据库"]
    C -->|"原始数据"| M
    M -->|"task: 分析"| A["data-analyzer<br/>统计分析"]
    A -->|"关键洞察"| M
    M -->|"task: 写报告"| W["report-writer<br/>格式化输出"]
    W -->|"成稿"| M
    M --> O["最终报告"]
    style M fill:#eef4ff,color:#1a3a8b,stroke:#4a72d9
    style C fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a
    style A fill:#fbf3e6,color:#6b4a12,stroke:#c99a3d
    style W fill:#fdeef0,color:#701d2a,stroke:#c94a5e

正在渲染图表…

subagents = [
    {
        "name": "data-collector",
        "description": "Gathers raw data from various sources",
        "system_prompt": "Collect comprehensive data on the topic",
        "tools": [web_search_tool, api_call, database_query],
    },
    {
        "name": "data-analyzer",
        "description": "Analyzes collected data for insights",
        "system_prompt": "Analyze data and extract key insights",
        "tools": [statistical_analysis],
    },
    {
        "name": "report-writer",
        "description": "Writes polished reports from analysis",
        "system_prompt": "Create professional reports from insights",
        "tools": [format_document],
    },
]

agent = create_deep_agent(
    model="google_genai:gemini-3.6-flash",
    system_prompt="You coordinate data analysis and reporting. Use subagents for specialized tasks.",
    subagents=subagents,
)

这个模式的精髓是主代理的 system_prompt:**"You coordinate..."——明确告诉它自己是协调者,不是执行者。**配合每个环节的克制回传(官方建议让子代理只返回关键洞察、置信度、建议动作,不要原始数据和中间计算),整条流水线跑下来,主代理的上下文增量只有三段摘要。"克制回传"本身可以固化成系统提示词模板,官方给的数据分析员版本就很值得直接抄用:

data_analyst = {
    "system_prompt": """Analyze the data and return:
    1. Key insights (3-5 bullet points)
    2. Overall confidence score
    3. Recommended next actions

    Do NOT include:
    - Raw data
    - Intermediate calculations
    - Detailed tool outputs

    Keep response under 300 words."""
}

配合 response_format 还能更进一步:给子代理传一个 Pydantic schema,父代理收到的就是可解析的 JSON 而非自由文本(要求 deepagents>=0.5.3):

from pydantic import BaseModel, Field

class ResearchFindings(BaseModel):
    """Structured findings from a research task."""
    summary: str = Field(description="Summary of findings")
    confidence: float = Field(description="Confidence score from 0 to 1")
    sources: list[str] = Field(description="List of source URLs")

research_subagent = {
    "name": "researcher",
    "description": "Researches topics and returns structured findings",
    "system_prompt": "Research the given topic thoroughly. Return your findings.",
    "tools": [web_search],
    "response_format": ResearchFindings,
}

七、编译型子代理:把整张 LangGraph 图塞进去

字典式定义最终都会被包装成标准的子代理循环。如果你需要完全控制执行流程——多节点图、条件分支、自定义状态——可以用 CompiledSubAgent 把一张已编译的 LangGraph 图直接注册为子代理:

from deepagents import CompiledSubAgent, create_deep_agent
from langchain.agents import create_agent

custom_graph = create_agent(
    model=your_model,
    tools=specialized_tools,
    system_prompt="You are a specialized agent for data analysis...",
)

custom_subagent = CompiledSubAgent(
    name="data-analyzer",
    description="Specialized agent for complex data analysis tasks",
    runnable=custom_graph,
)

唯一的技术约束:自定义图必须包含名为 messages 的 state key,否则父代理拿不到它的输出。CompiledSubAgent 同样支持 mode: "fork",但由于图在构建时就定型了,它会保留自己的系统提示词。

什么时候值得上编译型?我的判断标准是:当"子代理"需要的是确定性的流程控制而不是"一个带工具的 LLM 循环"时——比如多节点审批链、带重试分支的数据管道、需要自定义 state 字段的图。字典式定义做不到这些,硬塞进系统提示词里让模型"自觉遵守"只会得到概率性的服从;而图是代码,代码不会耍赖。反过来说,如果只是换套提示词加几个工具,字典式定义三行搞定,别为了用图而用图。

到这里,同步子代理的完整版图就拼齐了:通用型兜底、专用型分工、fork 继承上下文、黑板共享文件、流水线多级协作、编译型自由定制。但你可能已经发现一个别扭的地方——同步子代理是阻塞的:主代理派出去一个就得干等一个,三个子代理也只能排队跑。想让它们真正并发起来、派出去就不管?下一篇讲异步子代理和动态编排。

本篇踩坑点

  • description 含糊不报错,只烂掉:"Does finance stuff" 式的描述会让主代理不派或乱派,用 LangSmith 的 lc_agent_name 元数据过滤器验证委派是否真的发生;
  • 指定 tools 是覆盖不是追加:想保留主代理工具再添几个?做不到,列表一旦指定就完全替换继承的工具;
  • 自定义 isolated 子代理必须写 system_prompt:它不从主代理继承,漏了会直接报错;fork 模式则相反,system_prompt 要尽量省略(追加会破坏提示词缓存);
  • 回传不克制等于白隔离:子代理系统提示词里要明确"返回摘要、不要原始数据、限制字数",否则主代理的上下文照样被撑爆;
  • 别用 excluded_middleware 禁用子代理:SubAgentMiddleware 是必需组件,排除它会抛 ValueError,正确姿势是 profile 里 enabled=False。

下一篇预告:《06 | 子代理(下):异步与动态编排》——异步子代理让主代理"派出去就不管",后台跑任务、完成自动通知、中途改需求、随时叫停;动态子代理更进一步,让代理在解释器代码里用循环和并行批量派发,解锁扇出、锦标赛、对抗评审等编排模式。还会手把手写一个"3 个子代理对抗评审"的实战 demo。


参考资料: