LangChain Multi-agent
代码仓库ChainReaction
LangChain 官方文档的 Multi-agent 页面开头就写了一句不太像推销的话:not every complex task requires this approach。一个 agent 配上合适的工具和 prompt,往往就能拿到差不多的结果。
我见过反过来的情况更多。工具还没写到第十个,先搭了一套 supervisor,主代理调子代理,子代理再调工具,出了错要在三层日志里找是谁说错了话。调试成本涨上去,效果没变好。
那什么时候拆是对的。官方给了三个动机,按我实际项目里的出现频率排一下:上下文隔离、职责分离、并行。这三个词听起来都像正确的废话,落到代码上是三件很具体的事。
上下文隔离是说,某个领域有几千 token 的规范和一堆专用工具,塞进主对话会把窗口顶满,而且每轮都要重新付一遍 token 钱。子代理可以只在需要的时候开一个干净窗口,干完活把结论交回来。
职责分离是说,日历、邮件、CRM、数据库四块能力由四个团队维护,各自发布各自测试,主流程只认工具名和描述这个接口。
并行是说,三个互不依赖的子任务同时跑,总延迟按最慢的那个算,而不是按三个加起来算。
反过来,如果你的工具不到十个、领域知识一个 prompt 装得下、任务本身是线性的,拆多代理只会多出一堆胶水代码。这篇文章把官方文档里的四种模式逐个跑一遍,subagents、handoffs、router 都有可运行代码和真实终端输出,custom workflow 我说明它和前三者的关系。脚本放在仓库的 MultiAgent/,正文默认用 DeepSeek;router 的分类器另外在 Gemini 上跑了一组正向对照。
官方自己算的成本账
拆之前先看一张表。官方按三种请求场景统计了各模式的模型调用次数和 token 总量,数字直接来自 Multi-agent 总览。
| 场景 | Subagents | Handoffs | Skills | Router |
|---|---|---|---|---|
| 一次性请求 | 4 次调用 | 3 次 | 3 次 | 3 次 |
| 同一请求再说一遍 | 4+4 次 | 3+2 次 | 3+2 次 | 3+3 次 |
| 多领域并行(三份各 2000 token 的文档) | 5 次,约 9K token | 7 次以上,14K+ token | 3 次,约 15K token | 5 次,约 9K token |
三行数字各说明一件事。
第一行,subagents 比其他模式多一次调用。子代理的结论要先回到主代理,主代理再组织语言回给用户,多出来的这一步是集中控制的代价。
第二行,handoffs 和 skills 这类有状态的模式在重复请求上能省掉将近一半调用,因为状态还在,不需要重新交接一次。subagents 是无状态的,每次调用子代理都从零开始,成本恒定,换来的是强隔离。
第三行最能说明问题。多领域场景下,skills 只调用三次模型,但每次调用都要重新处理已经加载进上下文的 6K token 文档,总量反而最高。subagents 和 router 靠隔离和并行,token 总量少三成以上。handoffs 在这个场景里最差,因为它必须串行交接,没法把三个语言的研究同时发出去。
官方还有一张按能力打分的表,我把它和前面这张揉在一起,做成四种模式的选择依据。
| 模式 | 控制流 | 上下文边界 | 最适合 | 代价 |
|---|---|---|---|---|
| Subagents | 主代理用工具调子代理,全部路由经过主代理 | 子代理默认只拿到任务描述,各自一个干净窗口 | 多个独立领域、需要并行、需要集中管控 | 每个请求多一次模型调用,子代理看不到主对话 |
| Handoffs | 工具写状态变量,系统读变量换配置或换 agent | 消息历史只有一份,变的是 system prompt 和工具集 | 多轮对话、有先后约束的流程、子代理要直接跟用户说话 | 串行,不能并行;状态机设计不好会绕圈 |
| Router | 一次分类调用,再分发到一个或多个处理方 | 分类器只拿 query,不带对话历史 | 输入能明确分类、需要并行查询多个来源再汇总 | 无状态,多轮对话要自己在外面套一层 |
| Custom workflow | 自己用 LangGraph 画节点和边 | 由你声明的 state schema 决定,每个节点读写哪些字段是显式的 | 要混确定性逻辑和模型调用、要复杂分支或循环 | 图的形状得自己想清楚,官方不给现成结构 |
官方对最后一行还有个补充:router 本身就是 custom workflow 的一个例子,四种模式可以互相嵌套,subagents 调用的工具里可以是一个 custom workflow,workflow 的某个节点里也可以是一个完整的 subagents 系统。
下面逐个看。
Subagents:主代理只拿结论
这是最常被拿来用的一种。核心机制只有一句话:把子代理包成一个工具。
flowchart LR
U[用户] --> M[主代理]
M -->|"调用工具 policy_expert(query)"| S[子代理]
S -->|"只回传最终结论文本"| M
M --> R[回复用户]
M -.-> H[("主代理上下文<br/>跨轮累积,越来越长")]
S -.-> C[("子代理上下文<br/>每次调用从零开始")]
实线是控制流,虚线指向的是各自的上下文。两条虚线互不相交,这是整个模式的关键。
定义与包装
子代理和主代理是同一个 create_agent,只是 prompt 和工具不同。包装成工具的写法就是官方 Basic implementation 里的那几行:
1 | import os |
@tool 的名字和描述是主代理判断要不要调它的全部依据,也是这个模式里最值得反复改的两行。名字用动词加对象,policy_expert 比 helper 强。描述要写清楚「什么时候用它」,而不只是「它能做什么」。官方在 Subagent specs 一节里把这一点单独列了出来,因为子代理该被调用时没被调用,九成是描述没写清楚。
传工具的方式就这么直白:子代理有它自己的 tools=[…],主代理只拿到 call_policy_expert 一个工具,看不到 lookup_order。这就是上下文边界的物理实现,子代理手里的工具根本不在主代理的工具列表里。
主代理和子代理各自看到什么
我在包装函数里加了一行打印,把主代理调用那一刻的消息数和子代理收到的消息数都记下来,跑两轮对话。
1 | 第 1 轮用户输入: 订单 A1024,我上周买的降噪耳机,包装拆了,用了两次,还能退吗? |
几件事在这段输出里看得见。
主代理的上下文从 2 条涨到 6 条再到 8 条,子代理每次进来永远只有 1 条消息。那 359 字符的政策原文只在子代理的 system prompt 里,从没进过主代理的上下文,主代理只拿到 44 个字的结论。
主代理并没有原样转发用户的话。它把「包装拆了,用了两次」改写成「包装已拆封,使用过两次」再交给子代理。子代理看到什么,完全由你在包装函数里决定,这是一个可以精确控制的接口,不是自动继承。
子代理内部跑了 4 条消息,中间调了一次 lookup_order,这些过程全部留在子代理自己的窗口里,主代理的 [2] 只看到最终那句话。
怎么限制子代理
限制手段有三层,从便宜到贵排。
输出层面最直接:在子代理的 system prompt 里写明返回格式和长度。我的政策专员 prompt 最后一句是「最终回答必须控制在 60 字以内,主代理只会看到你的这段最终文本」。官方在 Subagent outputs 里专门提醒过这个坑:子代理经常调完工具、推理完了,却在最终消息里没把结果说出来,因为主代理只看最后一条。把那句话写进 prompt 能省掉很多来回。
调用层面,给子代理单独设 max_tokens。我的子代理是 300,主代理是 1000。子代理是干活的不需要长篇大论,主代理要组织语言。
输入层面,用 runtime.state[“messages”] 决定传多少历史过去。官方给了两种模式的名字:isolated 只传任务描述,fork 传父代理的完整历史。默认应该是 isolated。只有子代理要接着父代理没干完的活,比如继续审查同一个 PR,才值得 fork,代价是 prompt 变大、隔离性变弱。
Handoffs:状态决定配置,不是消息决定
Handoffs 和 subagents 的区别,用一句话说:subagents 是主代理「叫」一个子代理干活然后等它回来,handoffs 是把控制权整个交出去,交接之后当前这个 agent 的 prompt 和工具集都变了。
它的核心机制是工具返回 Command 改一个状态变量,系统读这个变量换配置。
stateDiagram-v2
[*] --> triage
triage --> specialist : record_warranty_status()<br/>写入 current_step = specialist
specialist --> specialist : provide_solution() / escalate_to_human()
specialist --> [*] : 最后一条 AIMessage 没有 tool_calls
note right of triage
同一份 messages 历史
system prompt = 收集信息
工具只挂 record_warranty_status
end note
note right of specialist
同一份 messages 历史
system prompt = 给出方案
工具换成 provide_solution / escalate_to_human
end note
和 subagents 那张图对比一下就看出来了。这里只有一份 messages,对话历史从头到尾是同一条线。变的是 system prompt 和工具列表,也就是上下文边界划在 prompt 和工具上,而不是划在消息历史上。
状态怎么传
官方给了两种实现。单 agent 加中间件,或者多个 agent 子图。文档里明说优先用前者,后者只在某个 agent 节点本身就是一张复杂图(带反思、带检索)的时候才用。
单 agent 版本里,状态更新全靠工具返回 Command:
1 | class SupportState(AgentState): |
那个 ToolMessage 不能省。模型发起工具调用之后会等一个响应,缺了它消息序列就是坏的,官方文档里专门用了一个 Note 说明这件事。
中间件负责把 current_step 翻译成实际配置:
1 |
|
request.override 是官方的写法,一次调用可以同时换 system prompt 和工具。中间件按 current_step 取配置,这一步是纯 Python,没有任何模型参与,所以流程推进是确定的,不是「希望模型自己记得」。
跑起来是这样
三轮对话,每轮结束后我打印状态和中间件看到的配置。
1 | 第 1 轮用户: 我的手机屏幕摔碎了。 |
第 1 轮里 current_step 打印出来是 None。这不是 bug,是我在 AgentState 上写的类默认值不会自动写进 state,中间件靠 request.state.get(“current_step”, “triage”) 的兜底才拿到 triage。第一次调用时 provide_solution 根本不在工具列表里,模型就算想跳过收集信息这一步也没有工具可调,这就是官方说的「unlock capabilities only after preconditions are met」。
第 2 轮三次模型调用完成了整个过程:模型先调 record_warranty_status,状态变成 specialist,中间件立刻换了配置,模型在新配置下又调了 provide_solution,然后才组织语言回复。一次用户输入,配置在中途换了。
什么时候该交接而不是调用
判断标准是控制权归谁。
如果主代理拿回结论之后还要继续做别的事,用 subagents。比如客服主管拿到退货结论之后,还要决定要不要发优惠券安抚用户,这个决定得由主代理做。
如果交接之后就是另一个角色在跟用户对话,中间不需要谁来做统筹,用 handoffs。售前转售后,售后接手之后整段对话都是售后在处理,这时候留一个「主管」在中间转发没有意义,还多一次模型调用。
还有一种情况只有 handoffs 能做:给流程加先后约束。上面那个例子里,用户在保修状态没记录之前,物理上就调不到 provide_solution。subagents 做不到这一点,因为工具是挂在主代理身上的,主代理想调就调。
多个 agent 子图那版
官方给的另一种实现,用 Command(goto=…, graph=Command.PARENT) 在图的节点之间跳。这版依赖 LangGraph 的 Command.PARENT 和状态 reducer,我照着文档抄下来跑通了,两个 agent 之间成功交接:
1 | [0] HumanMessage tool_calls= :: Hi, I'm having trouble with my account login. Can you help? |
transfer_to_support 这个工具没有返回字符串,它返回了一个 Command,把 active_agent 改成 support_agent,并 goto=”support_agent”。图上一条条件边读到 active_agent,下一轮就进了 support 节点。
要注意的是 update 里的 messages 只有两条:触发交接的那条 AIMessage,和一条人工构造的 ToolMessage。官方特意解释了为什么不把子代理的全部消息历史带过去:接收方会被无关的内部推理干扰,token 也白花。交接时该带什么、不该带什么,得一条条想。
Router:先分类,再分发,最后汇总
Router 的适用场景很窄也很明确:输入能分成几个清楚的类别,每类背后是一套独立的知识。
flowchart LR
Q[用户问题] --> C{分类器<br/>一次模型调用}
C -->|categories| FA[[Send 并行扇出]]
FA --> A1[tech 处理函数]
FA --> A2[billing 处理函数]
FA --> A3[account 处理函数]
A1 --> S[汇总节点]
A2 --> S
A3 --> S
S --> R[合并后的回答]
Q -.->|每次请求重新分类,不携带上一轮历史| C
Router 和 subagents 长得像,区别在谁来路由。subagents 的主代理是一个完整的 agent,它带着对话上下文、跨多轮决定调谁。router 的分类器是一次独立调用,它只看当前这个 query,不知道之前聊过什么。官方文档用一句话划清了界限:有清楚的输入分类、想要确定或轻量的判断,用 router;需要灵活的、感知上下文的编排,用 supervisor。
分类器
分类器就是一次 with_structured_output 调用。先看跑得通的那条路,Gemini(gemini_provider_strategy.py):
1 | === 3. Multi-agent 篇:分类器 with_structured_output(Route) === |
Route 是 Pydantic 模型,destination 是 Literal["weather", "stock", "email"],reason 是自由文本。三次都选对了类别,reason 也说得通。这条路径靠的是模型的原生结构化输出,跟 Structured Output 那篇里 ProviderStrategy 跑通的是同一件事,只是这里走 with_structured_output 这一层。
DeepSeek 上就有具体问题了。with_structured_output 传 Pydantic 模型时默认 method 是 json_schema,而 DeepSeek 不认这个 response_format:
1 | === 1. DeepSeek + with_structured_output(Route) 默认走 json_schema === |
原生结构化输出这条路我另外找了 Ollama 云端试。这里要分清两条通路:前面用的 https://ollama.com/v1 是 OpenAI 兼容端点,参数收下不生效,模型照旧输出散文,报错推迟到解析那一步:
1 | === 3. Ollama 云端 gpt-oss:120b 的两条路 === |
换成 langchain-ollama 的 ChatOllama 接原生端点 https://ollama.com,直接 with_structured_output 能拿回对象,但同一个模型放进 create_agent 的 ProviderStrategy 还是失败(structured_native_ollama.py):
1 | === 3. 直接 with_structured_output,temperature=0,连续 12 次 === |
原生端点上的直接调用能成,功劳在 LangChain 的 PydanticOutputParser:它会把散文里那段围栏 JSON 剥出来。Ollama 并没有按 schema 约束住模型。ProviderStrategy 内部只有一句 json.loads,内容以散文开头就直接抛。所以 DeepSeek 和 Ollama 都没跑通,但 Gemini 跑通了,结论跟 Structured Output 那篇一致:provider 的 profile 里 structured_output 为 True 时,原生结构化输出是能用的。「厂商不支持」这个说法同样站不住,同一份 key,换个端点、换个调用层,结果就不一样。成功率也不稳,这次 12 次里 5 次,另一次 6 次的重跑只成了 2 次。
四档摊开是这样。Gemini 的 gemini-3.5-flash 属于正向跑通,profile.structured_output 为 True,三次分类全对。DeepSeek 三个模型(deepseek-chat、deepseek-flash、deepseek-v4-pro)一律 400,属于明确拒绝。Ollama 云端 OpenAI 兼容端点(https://ollama.com/v1)的 gpt-oss:120b、gpt-oss:20b、nemotron-3-super 收了参数不生效,profile 全是 None,LangChain 的能力探测认不出它们,只能退回内置的模型名正则名单,而名单里没有这些名字。换成原生端点,直接 with_structured_output 通了,但 create_agent + ProviderStrategy 仍不通。剩下的 glm-5.2、minimax-m3、mistral-large-3:675b 在免费额度外(402 this model is not included in your free usage),根本没跑起来。
DeepSeek 也不是完全不给 json。同一个模型,response_format 换成 json_object 就能用,前提是 prompt 里出现 json 这个词:
1 | === 2. DeepSeek + with_structured_output(Route, method='json_mode') === |
所以 DeepSeek 上的分类器有两种写法:留在 with_structured_output 这一层,显式写 method="json_mode" 并在 prompt 里提 json;或者按官方 Structured output 的说明走 ToolStrategy。这里选后者,理由是分类 schema 里有个 list[Literal[...]] 多选字段,ToolStrategy 至少会用 Pydantic 校验一遍,json_mode 拿到什么就是什么,没有第二次机会。换成 Gemini 就没这些事,Pydantic 模型直接传给 with_structured_output 就行。写法:
1 | class Route(BaseModel): |
跑出来是这样:
1 | 输入: 我的账单这个月多扣了 30 块,另外 App 登录一直转圈进不去。 |
第一条被分到两个类别。list[Literal[…]] 这个 schema 决定了它可以多选,这也是 router 能扇出的前提。
分发与汇总
官方给了两个原语:单个目标用 Command(goto=…),多个目标并行用 Send。我用 Send 把同一个 query 扇出到三个处理函数,处理函数是纯 Python 函数,没有模型参与:
1 | def fan_out(state: State): |
每个处理函数返回 {“findings”: […]},State 里 findings 字段带 Annotated[list[str], operator.add],并行写回来会自动拼成一个列表。汇总节点把这几个片段交给模型压成一段话。真实输出:
1 | [classify] '我的账单这个月多扣了 30 块,另外 App 登录一直转圈进不去。' -> ['billing', 'account'] |
两个处理函数各 sleep(0.3),执行区间都是 [0.62 -> 0.92],完全重叠。这就是并行扇出的证据,串行的话第二个应该从 0.92 开始。
汇总节点那句 prompt 里我写了一条约束:不要提「专员」「领域」这些词。并行分发之后如果直接把三段拼起来给用户,读起来会像三个客服同时说话,汇总这一步的质量直接决定用户看到的体验。
Router 不带记忆,要记忆就自己在外面套
上面那个 workflow 是无状态的,每次 invoke 都要重新分类。多轮对话怎么办。官方给了个轻量办法:把 router 包成一个工具,交给一个有 checkpointer 的会话代理。
1 | 第 1 轮用户: App 登录一直转圈怎么办? |
记忆全在会话代理这一层,router 每次都是干净的。官方也提醒过,如果让 router 自己维护历史、还要在多个 agent 之间切换,语气和 prompt 不一致会让用户觉得对话不连贯,那种情况下文档建议直接换 handoffs 或者 subagents。
Custom workflow:前面三种都是它的特例
第四种模式没有固定形状,就是自己用 LangGraph 的 StateGraph 画节点和边。每个节点可以是一个纯函数、一次模型调用,或者一整个 agent。
flowchart TD
S[("共享 State<br/>question / rewritten_query / documents / answer")]
I([输入]) --> N1[改写节点<br/>模型调用]
N1 --> N2[检索节点<br/>确定性,无模型]
N2 --> N3[Agent 节点<br/>带工具,可继续调模型]
N3 --> O([输出])
N1 -.->|写 rewritten_query| S
N2 -.->|写 documents| S
N3 -.->|写 answer| S
上下文边界在这张图里是显式的。你声明一个 TypedDict 当 state,每个节点读哪几个字段、写哪几个字段,代码里看得一清二楚,不需要猜。官方文档说得很直接:LangGraph 的 state 就是用来在步骤之间传信息的,每个节点读和写结构化字段。
官方那个 RAG 例子里有节点分类很值得学:改写节点是模型节点,检索节点是确定性节点,最后那个 agent 节点带工具、可以继续调模型。三种节点混在一张图里,确定性逻辑和 agent 行为各就各位。
Router 本身就是一个 custom workflow,subagents 也可以塞进某个节点。所以实际项目里很少只用一种,通常是外面一张图管流程,图里的某个节点是一个 subagents 系统。
四个坑
无限委派。主代理调子代理,子代理又有一个「再委派」的工具,两边描述写得含糊,模型就会来回踢皮球。防的办法很土:子代理不挂任何委派工具,委派只发生在主代理这一层。真需要多层,给调用深度加一个计数器,超过阈值直接抛错。
上下文重复。子代理每次从零开始,它要重新推导主代理已经知道的东西。我的例子里子代理收到的 query 是主代理改写过的,多花了主代理 37 个 token 的思考,省下的是子代理不用读前面那 4 条消息。这个交换在长对话里划算,在只有一轮的请求里就是白花。官方的性能表里,一次性请求 subagents 是 4 次调用、其他模式 3 次,多出来的就是这一笔。
成本失控。四个坑里这个最容易发生。并行扇出看着爽,但 Send 出去三个处理函数,如果每个处理函数内部又是一个 agent,一次用户输入就是三次模型调用起步。上线前拿官方那张表估一下量级:多领域场景 subagents 约 9K token,skills 约 15K,差了六成。router 的分类器每次请求都要跑一次,重复请求场景下这个开销会累积,官方的数字是两轮 6 次调用,而 handoffs 只要 5 次。
调试困难。子代理是在工具函数里被 invoke 的,LangGraph 静态发现不了它。官方的原话是 get_state 配 subgraphs 拿不到子代理的状态。要读嵌套图的状态,得把子代理放到一个 custom workflow 的节点函数里调用,而不是包成工具。我自己的做法是在包装函数里把子代理的输入输出都打到日志里,这也正是前面那段「子代理每次被调用时看到的东西」的来历。真上生产还是接 LangSmith,官方的多代理页面第一段 Tip 就是推荐开 tracing。
小结
拆之前先问一句:是因为上下文装不下、因为要并行、还是因为不同团队要各管一块。三个理由一个都不占,就不该拆。
四种模式的选择可以缩成三条判断。子代理不该跟用户说话、主代理要集中管控,用 subagents。子代理要直接跟用户多轮对话、流程有先后约束,用 handoffs。输入能分类、要并行查多个来源,用 router。三种都不合适,再用 custom workflow 自己画。
上下文边界是设计这个系统时真正要动手的地方。subagents 把它划在消息历史上,handoffs 划在 prompt 和工具上,router 划在「分类器只看 query」这条线上。想清楚每个 agent 该看到什么,比选哪个模式更重要。
子代理默认无状态,这是特性不是缺陷。它让成本可预测、隔离彻底,代价是每次重新推导。要不要把父代理的历史传过去,取决于子代理是在开新活还是在接手旧活。
官方那张性能表值得存下来。一次性请求 3 次还是 4 次调用、多领域场景 9K 还是 15K token,这些数字在评审方案的时候比架构图管用。

