LangChain系列之多 Agent:拆之前,先回答这 3 个问题
多 Agent:拆之前,先回答这 3 个问题
上一篇我们把工具和章法都装进了同一个 Agent。装完之后,一个新问题冒出来了。
一个 Agent 装得下 7000 个工具,装得下无数条章法,可是它一次只长一个脑子。
于是我们把多 Agent 方案认真评估了一遍。评估完,结论是先不全上。
一、一次架构评审,我们没立刻上多 Agent
事情起于产线要演进。
现在的形态是单 Agent 加 MCP 注入,能力层已经打通,工具从货架网关按需拿。下一步想做的事是:把它拆成多个专家,各自带一块记忆。
听起来顺理成章。拆开之后每个专家只看自己那一摊,工具也少了,提示词也短了。方案画出来很漂亮。
真要动手前,我先把账算了一遍。算完就停住了。
同一套合成工具,同一件业务请求,我量了两笔账。
第一笔,一次请求只要动一个专家。 单 Agent 走 3 次模型调用,输入 3292 token。多 Agent 走 4 次调用,输入 1672 token。多 Agent 只要它的 51%。
第二笔,一次请求要动三个专家。 单 Agent 一轮并发发 3 个工具调用,2 次调用、2232 token 就完事。多 Agent 串行转交,10 次调用、3601 token,涨到 161%。
同一个架构,两种任务,结论正好相反。
差别只有一个:你的一次请求要动几个专家。
所以问题从来都不是「多 Agent 好不好」。它是「你的一次请求,要动几个专家」。
这篇就沿着这两笔账往下问。多 Agent 到底买到了什么,它凭什么值这个价,以及什么时候它其实一分钱都不值。
先说结论:多 Agent 不是能力升级,它是一种花隔离钱的手段。
二、多 Agent 到底买的是什么
先把一个误会拆掉。
拆成 4 个 Agent 之后,你可能期待模型变聪明了。事实是模型还是那个模型。每个专家手上的工具,本来就是原来那些工具的子集。提示词短了一点,选择少了一点,推理能力一点没长。
那多出来的是什么?
第一件是上下文隔离。 订单专家不需要看到知识库的检索结果,报表专家也不需要看到退货政策的原文。让每个专家只看自己那一摊,它的判断就不会被隔壁的话题带偏。
第二件是权限隔离。 一个 Agent 能改库,另一个只能读。这事在单 Agent 里做不到,因为工具是同一个模型手里的,模型只要愿意就能调。
第三件是失败域隔离。 报表专家今天接口挂了,订单链路还能照常跑。单 Agent 里一处工具报错,整条推理都可能断在半路。
我把这三件和它不卖的东西放在一张表里。
| 多 Agent 卖什么 | 多 Agent 不卖什么 |
|---|---|
| 上下文互不污染 | 更聪明的模型 |
| 权限物理可分 | 更低的 token 成本 |
| 失败域互相隔开 | 更少的调试工作 |
| 每个专家的提示词更短 | 更少的模型调用次数 |
| 职责边界写在图里,而不是写在提示词里 | 免掉记忆归属的麻烦 |
最后一格值得单独说一句。很多人以为拆开之后事情变简单了,实际上多 Agent 会新增一个单 Agent 里不存在的问题:记忆放在哪。
这个问题后面第八章专门讲。
所以判断标准其实收得很窄。它只有一句话:你有没有必须隔离的东西。
如果你答得出来,多 Agent 是一笔划算的采购。如果答不出来,你花掉的是调用次数、调试时间和一堆新的失败模式,换回来的是三个你并不需要的隔离。
三、先说清楚:默认情况下,它并不隔离
这里有个反直觉的地方,是本篇最想讲的一件。
很多人以为「把 Agent 拆开」这个动作本身就带来了隔离。实测下来,LangGraph 默认并不给你隔离。
我写了一个记录型的假模型,它会把每次收到多少条消息、什么角色,全记下来。然后用它搭最简单的场景:父图里嵌一个子 Agent,父图先攒了 24 条历史消息。
from langchain.agents import create_agent
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from typing_extensions import TypedDict
from typing import Annotated
class State(TypedDict):
messages: Annotated[list, add_messages]
sub = create_agent(model=spy, tools=[], system_prompt="你是订单专家。")
g = StateGraph(State)
g.add_node("sub", sub) # 子 Agent 直接当节点
g.add_edge(START, "sub")
g.add_edge("sub", END)
app = g.compile()
app.invoke({"messages": history + [HumanMessage(content=TASK)]})
跑完看记录。
>> 父图历史:24 条消息,1984 字符 = 612 token
>> 子 Agent 第一次调用看到 26 条消息
>> 第一次调用的输入 = 647 token
>> 其中历史占比 = 94.6%
子 Agent 收到了父图已有的全部消息,包括那 1 条 system 和 24 条历史。它的回复也会回灌进父图的 state["messages"]。
在这个姿势下,多 Agent 只是把「同一个大上下文」切成了几次请求。一分钱没省,一点没隔离。
那真正隔离要怎么做?3 种手段,都实测过。
手段一,用 Send 只传挑出来的那几样。 Send("worker", {"task": ...}) 送到 worker 节点的是一个全新的 payload,父图的历史一概进不去。
>> worker 第一次调用看到 2 条消息(system + 任务)
>> 输入 = 35 token
>> 对比默认姿势:647 → 35,省下 95%
同一条任务,647 token 变成 35 token。省掉的 612 token 全是父图那份跟本次任务无关的历史。
手段二,给子图声明自己的 state schema。 这个手段有用,但只挡得住名字不同的键。
>> 子图实际收到的 state 键 = ['messages']
>> 子图收到的 messages 条数 = 25
>> 父图的 query 有没有进去:False
父图里的 query 因为子图没声明,被挡住了。可是 messages 这个名字两边都有,于是整段历史照样进了子图。
这是个很容易踩的坑。你以为声明了独立 schema 就隔离干净了,其实只要键名撞上,数据就一路畅通。
手段三,控制回传量。 隔离是双向的。子 Agent 收到了什么要管,它交回什么也要管。这一条在第四章用 output_mode 实测。
把三者的关系画出来:
一句话收口:在 LangGraph 里,隔离不是拆出来的,是自己写出来的。
顺手说一下为什么会这样。
子 Agent 作为节点嵌进父图时,它的输入 state 是从父图的 channel 里取的。两边名字一样的键就直接对上,父图有 messages,子图也认 messages,于是整段历史顺着这个名字流了过去。名字不一样的键,子图的 schema 里没有位置,就停在外面。
这不是设计缺陷,是子图的默认语义。图嵌图的目的是复用,不是隔离。 想要隔离,就得在数据交接的地方动手。
四、官方给了两个预制件,先把它们跑起来
自己画之前,先看看官方给的东西能省多少事。
langgraph-supervisor 和 langgraph-swarm 这两个包,版本号看着有点劝退:一个是 0.0.31,一个是 0.1.0。而 langgraph 本体早就 1.x 了。
我一开始也怀疑它们是不是没跟上。翻依赖声明才发现不是这么回事。
| 包 | 版本 | 依赖声明 |
|---|---|---|
| langgraph-supervisor | 0.0.31 | langgraph>=1.0.2,<2.0.0、langchain-core>=1.0.0 |
| langgraph-swarm | 0.1.0 | langchain>=1,<2、langgraph>=1,<2 |
两个包都明确声明支持 1.x。 版号小不代表不能用,只代表它们的 API 还没冻结。
实测也确实跑得起来。
主管式:supervisor 实测
先看它编译出来的图长什么样。
from langgraph_supervisor import create_supervisor
wf = create_supervisor(
[order_agent, kb_agent, report_agent],
model=supervisor_model,
prompt="你是主管。",
)
app = wf.compile()
拓扑很规整:__start__ → supervisor,supervisor 三条条件边分别指向 __end__ 和各子 Agent,子 Agent 无条件回边到 supervisor。
主管手里的工具长这样:['transfer_to_order_expert', 'transfer_to_kb_expert', 'transfer_to_report_expert']。一个专家配一个转交工具。
接着我读了一遍转交工具的源码。有几件事跟想的不一样。
@tool(name, description=description)
def handoff_to_agent(
state: Annotated[dict, InjectedState],
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
...
return Command(
goto=agent_name,
graph=Command.PARENT,
update={**state, "messages": handoff_messages},
)
它不接收 task 参数。 签名里只有注入的 state 和 tool_call_id。也就是说,主管没法在转交的时候捎一句「你要做的是某某事」。
它把整份 state 交给目标 Agent。 update={**state, ...} 这一行说明了问题:转交过去的是完整状态,包括全部 messages。
这正好解释了第三章的实测。预制件转交这一路,走的就是「父图全历史都带过去」的模式。
还有一处更要紧的。
if description is None:
description = f"Ask agent '{agent_name}' for help"
默认描述就是这一句:Ask agent 'order_expert' for help。
模型要在几个专家之间做选择,靠的就是工具描述。而官方默认给的这句,除了专家名字之外没有任何区分信息。真上手用,这套描述必须自己写。
一次转交,账是这么走的
我抓了一次真实转交的完整轨迹,7 条消息。
human
→ ai(transfer_to_web_agent)
→ tool(Successfully transferred to web_agent)
→ ai(专家作答)
→ ai(transfer_back_to_supervisor)
→ tool(Successfully transferred back to supervisor)
→ ai(主管收口)
子 Agent 侧会自动生成一个回程工具 transfer_back_to_supervisor。专家答完,主动把控制权交回去。
一次转交来回,消息池净增 4 条。 主管问一句、专家答一句,中间夹着两条工具回执。这个成本在第七章会放大得很难看。
交回多少,官方给了个旋钮
create_supervisor 有个 output_mode,默认值是 last_message。
| 取值 | 主管第二次调用看到的 messages |
|---|---|
last_message(默认) |
7 条 |
full_history |
9 条 |
差别就在子 Agent 内部那轮工具调用。默认只把它最后的结论交回来,full_history 会把中间的 tool_call 和工具结果一起交回。
这是官方给的「回传量」控制手段,跟第三章的手段三是一回事。
交接式:swarm 实测
langgraph-swarm 的图是另一种形状。它没有中央主管节点,节点就是各 Agent 自己。
__start__ 两条条件边,按 default_active_agent 决定谁先上场。之后 Agent 之间直接互转,谁接到谁处理。
有个细节值得记一下。create_swarm 的 state 叫 SwarmState,但实测 invoke 出来以后,返回的状态键只有 ['messages'],当前活跃 Agent 那个字段不在里面。
这意味着你没法在跑完之后从结果里直接读出「最后是谁在说话」。想知道,得去翻消息或者看 trace。这是交接式形态的一个具体不便。
两个包的公开导出也放在这里,装完可以先看一眼它们给了什么。
| 包 | 导出 |
|---|---|
| supervisor | create_supervisor / create_handoff_tool / create_forward_message_tool / handoff / supervisor / agent_name |
| swarm | create_swarm / create_handoff_tool / add_active_agent_router / SwarmState / handoff / swarm |
一个我没想到的坑:并行转交会丢任务
create_supervisor 还有一个参数叫 parallel_tool_calls。我的第一反应是:打开它,主管就能一轮发几个转交,几个专家同时干活。
先看默认值。
parallel_tool_calls default=False
output_mode default='last_message'
默认关着。那打开呢?
我做了一组对照,让主管一次发出 2 到 3 个转交工具调用。结果两件事一起冒出来。
第一件,工具全都正常执行了。 我给转交工具装了探针,三个都跑了,返回值也一个没错:
transfer_to_kb_expert graph=__parent__ goto=['Send:kb_expert']
transfer_to_order_expert graph=__parent__ goto=['Send:order_expert']
transfer_to_report_expert graph=__parent__ goto=['Send:report_expert']
第二件,父图只接管了一个。 三个工具都正确地问了「派我去哪个专家」,父图最后只执行了 1 个专家的节点,另外 2 个静默消失。
我试了 parallel_tool_calls 关和开两种,试了发 2 个和发 3 个转交,一共 5 次,每次都是只落地 1 个。规律还挺整齐:生效的总是最后被调用的那个转交。
没有任何报错。日志上看不出丢了东西,只有结果少了两块。
翻了一下 langgraph 的 ToolNode._combine_tool_outputs,合并逻辑要求这些 Command 的 goto 都是纯 Send 列表才归并成一个父命令。至于为什么最后只有一个落地,我没能在这轮里追到根上。
但对你来说,根因是哪一层不重要,现象本身才是要记住的那件事:在这套版本组合下,别指望「主管一轮发多个转交」来实现并行派活。
想并行,就自己在图上用条件边加 Send 扇出去。这就是第五章要写的东西。
五、自己画:Command 与 Send 两条路
预制件装不下的场景,得自己画。原语其实只有两个。
交接:Command
from langgraph.types import Command
def order_agent(state):
...
return Command(
goto="kb_agent", # 控制流交给谁
update={"messages": [...]}, # 顺手更新什么状态
graph=Command.PARENT, # 往父图跳
)
graph=Command.PARENT 是给「我在子图里、要往父图跳」这个场景用的。实测 Command.PARENT 的值就是一个普通字符串:
>>> Command.PARENT
'__parent__'
Command 是 dataclass,不是 pydantic 模型。它的字段有 graph、update、goto、resume。
这里有个容易搞混的分工。 Command 改的是「控制流去哪」,Send 改的是「这一份活派给谁、带什么数据」。
并行分发:Send
Send 的用法不复杂,但它有个位置上的硬要求。
from langgraph.types import Send
# 正确:从条件边返回
def dispatch(state):
return [Send("worker", {"seed": "A"}),
Send("worker", {"seed": "B"}),
Send("worker", {"seed": "C"})]
g.add_conditional_edges("dispatch", dispatch, ["worker"])
# 错误:写在普通节点里
def fan_out(state):
return [Send("worker", {"seed": "A"})] # 直接抛错
g.add_node("fan_out", fan_out)
第二种写法在 1.x 上的报错原文是:
langgraph.errors.InvalidUpdateError: Expected dict, got [Send(node='worker', arg={...}), ...]
Send 必须从条件边返回。 网上不少示例把它写在节点体里,在这个版本上跑不通。
实测三个 Send 各带 {"seed": "A"/"B"/"C"},三个 worker 各拿到自己的 payload,互不干扰。这是第三章手段一在并行场景下的形态。
两个原语的对照
| 原语 | 改什么 | 从哪返回 | 典型场景 |
|---|---|---|---|
Command(goto=...) |
控制流去哪 | 节点或工具 | 交接给下一个专家 |
Send(node, payload) |
活派给谁、带什么 | 只能从条件边 | 同一个活并行发给多个 worker |
预制件和原语的关系也说一句。预制件是给人快的,原语是给人改的。 前面那两个包本质上是把这几个原语按一种固定套路拼好了。套路对得上,用预制件省事;对不上,回来用原语自己拼。
顺便说一个:destinations 防不了环
转交环是多 Agent 最典型的翻车方式,所以我特别想找一个官方开关。
add_node 在 1.x 里加了一批新参数:input_schema、destinations、error_handler、timeout、defer、cache_policy、trace_policy。其中 destinations 看起来最像「限制这个节点能跳到哪」。
我试了 4 种写法。
| 写法 | 结果 |
|---|---|
不声明 destinations,goto 自由跳 |
跳到环上,撞 GraphRecursionError |
声明 destinations=("b",),实际 goto "b" |
一样撞 GraphRecursionError |
声明 destinations=("c",),实际 goto "b" |
照样跳到 b,一样撞环 |
声明 destinations=() 空清单 |
照样跳 |
| 声明一个不存在的节点名 | 编译期报 ValueError: Found edge ending at unknown node |
destinations 不做运行时校验。 它声明的是「这个节点可能去哪」,编译时会拿它建边、检查节点存在,但管不住 Command 在运行时往哪跳。
所以防环还得靠自己。这个坑在第七章给方案。
六、三种形态怎么选
前面把两个预制件和自己画的路线都跑了一遍。现在收成一张选型表。
| 形态 | 谁在调度 | 适合什么场景 | 代价 |
|---|---|---|---|
| 主管式(supervisor) | 中央主管 Agent | 一开始不知道这事该归谁 | 每轮多一层往返,主管模型得够聪明 |
| 交接式(swarm) | Agent 之间直接互转 | 谁接到谁处理的流式协作 | 没有中央视角,出问题不好归因 |
| 自绘 | 你自己画的图 | 流程本来就确定的场景 | 回退与循环得自己兜 |
再给一张「症状对形态」的表,这个更实用。
| 你遇到的情况 | 该用的形态 |
|---|---|
| 用户提问开放,我先得判断这归哪个专家管 | 主管式 |
| 每个专家的能力边界很清楚,谁先接手可以按规则定 | 交接式 |
| 流程是死的(订单流转、审批链),分支条件可枚举 | 自绘 |
| 有一步必须并行扇出去再收回来 | 自绘,条件边加 Send |
| 我只是想让提示词短一点 | 先别拆,看第九章 |
一条经验顺序:能自绘就别用预制件,能用预制件就别硬编。
这个顺序反了会浪费很多时间。见过有人拿着一套三行代码就能表达的固定流程,硬套主管式,最后在转交描述和环检测上花了几天。
反过来也成立。如果场景就是「用户随口一问,我不知道该谁答」,自绘一张图要先写一个分类器,那还不如直接用主管式。
至于主管式和交接式的取舍,可以问自己一句话:我需要一个能看见全局的人在吗? 需要就主管式,不需要就交接式。
七、代价账单:动一个省,动三个贵
这一章是这篇的主场。把三种代价一次算清。
第一笔:固定开销
固定开销指的是跟对话无关、但要被重复注入的部分:系统提示词加工具定义。
| 形态 | 系统提示词 | 工具定义 | 合计 |
|---|---|---|---|
| 单 Agent(6 个工具) | 162 | 872 | 1034 |
| 多 Agent(1 主管 + 3 专家) | 322 | 927 | 1213 |
多 Agent 的固定开销是 1.17 倍。拆开之后,多了一份主管的提示词和 3 个转交工具。
这个倍数不算吓人,因为它不随对话变长而增长。真正难看的是后面两笔。
第二笔:窄任务,多 Agent 赢
同一件只动 1 个专家的事,比如「查一下订单,顺手试试能不能退」。
| 形态 | 模型调用 | 输入 token | 倍数 |
|---|---|---|---|
| 单 Agent | 3 次 | 3292 | 基准 |
| 多 Agent | 4 次 | 1672 | 0.51x |
多 Agent 省了一半。
原因不复杂。单 Agent 每次请求都要带上那 6 个工具的定义,哪怕这次只用得上订单相关的 2 个。多 Agent 里,订单专家手上只有自己那两三个工具,每次请求都轻。
第三笔:宽任务,多 Agent 输
同一套工具,换成一件要动 3 个专家的事:查订单、查知识库、出报表。
| 形态 | 模型调用 | 输入 token | 倍数 |
|---|---|---|---|
| 单 Agent | 2 次 | 2232 | 基准 |
| 多 Agent | 10 次 | 3601 | 1.61x |
单 Agent 一轮并发发 3 个工具调用,2 次调用就收工。多 Agent 串行转交了 3 次。
调用次数的差距比 token 更刺眼:2 次变成 10 次。
两次的账放在一起看:
分水岭就是这一句:一次请求要动几个专家。
中间那段我没测。1 个专家的时候多 Agent 赢,3 个专家的时候多 Agent 输,2 个专家落在哪一侧,取决于你的工具定义有多重。
道理不难推。工具定义越重,单 Agent 每次请求背的包越大,多 Agent 的省就越明显;工具定义轻而转交次数多,多 Agent 输得越难看。
所以别抄别人的倍数,用你自己的工具算一遍。要量的只有两个数:你的一次请求平均动几个专家,你的单工具定义平均多少 token。 前一篇文章给过单工具 220 token 左右的实测均值,那个数拿来当起点就够用。
有一处我说明一下口径。宽任务里多 Agent 走的是串行转交,10 次调用。supervisor 号称支持并行转交,但第四章实测下来,这套版本上并行转交会丢任务。所以这 10 次可以当成这套组合下的实际数字。
除了 token,还有三笔隐形账
第一笔,消息膨胀。 我故意让主管每次都转交,抓了它的轨迹。
| 轮次 | 主管侧消息数 | 专家侧消息数 |
|---|---|---|
| 第 1 次转交 | 2 | 3 |
| 第 2 次转交 | 7 | 8 |
| 第 3 次转交 | 12 | 13 |
| 第 4 次转交 | 17 | 18 |
每来回一次,双方各涨 5 条消息。 而每一轮请求都要把这些消息重新付一次钱。
第二笔,转交死循环。 上面那个实验第 8 次就撞墙了:
langgraph.errors.GraphRecursionError: Recursion limit of 8 reached without hitting
a stop condition. You can increase the limit by setting the recursion_limit config key.
主管每次都把活推出去,专家每次都推回来,图就这样转到天荒地老。这是主管式编排最典型的翻车方式,而且它在真实环境里的触发条件比实验里脏得多:专家答得含糊、主管判断不清、两个专家互相觉得该对方管。
第三笔,调试成本。 一个用户请求穿过 4 个 Agent,trace 里全是嵌套 span。单 Agent 出问题,你看一段对话就知道哪错了;多 Agent 出问题,你得先搞清楚这一段是哪个 Agent 说的,再搞清楚它为什么被叫起来。
检查点也在涨
顺手量了一个前面几篇没量过的数:一轮对话要存多少检查点。
| 形态 | 一轮消息数 | 检查点条数 |
|---|---|---|
| 单 Agent(提问 → 调工具 → 作答) | 4 | 5 |
| 多 Agent(1 主管 + 3 专家各跑一轮) | 8 | 21 |
多 Agent 的检查点是单 Agent 的 4.2 倍。它给每个子图单独分了 namespace,父图 12 条,3 个专家各 3 条。
会话不长的时候这个数字无所谓。但如果你按条数计费的存储上跑长会话,这是一笔会慢慢显形的账。
八、记忆放在哪:多 Agent 之后,位置变了
前面那篇讲记忆的时候,store 是一个很简单的东西:一个共享的记忆库,按 namespace 隔离用户。
多 Agent 之后,它变成了一个需要做决定的问题。
我先说三件事,都是实测出来的。
第一件,父图的 store 会自动透传进子 Agent。 子 Agent 里的工具用 get_store() 就能拿到父图编译时传进去的那个 store。哪怕专家是在 Send 扇出去的并行分支里跑的,也拿得到。
@tool
def save_note(key: str, value: str) -> str:
"""把一条记忆写进长期记忆库。"""
st = get_store()
if st is None:
return "store 是 None"
st.put(("probe", "u1"), key, {"value": value})
return f"已写入 {key}"
第二件,子 Agent 自己带一个 store,在父图里不生效。 create_agent 确实有 store 参数,我本来以为这就是「一个专家一个 store」的官方做法。
实测三次,结论是反的。
| 场景 | 工具里摸到的 store |
|---|---|
| 父带 store,子 Agent 也带自己的 | 父的生效,子自己的空的 |
| 父不带 store,子 Agent 自己带 | store=None,一个都没有 |
| 子 Agent 不嵌父图、独立运行 | 自己带的那个生效 |
只要专家是被父图当节点调起来的,store 就由父图说了算。 想让每个专家各拿一个独立 store,得走别的路,比如让专家作为独立服务单独部署。
第三件,检查点会自动分家。 父图的 saver 会给每个子 Agent 开一个自己的 namespace,子 Agent 自己带不带 checkpointer 都一样。
这三件事合起来,把选项收窄了。多 Agent 的记忆归属实际只有两种做法。
| 做法 | 怎么实现 | 好处 | 代价 |
|---|---|---|---|
| 共享一个 store | 父图传 store,各专家用 namespace 区分 | 同一个用户跨专家有连续认知 | namespace 设计错了会串味,权限也难分 |
| 主管持有,按需注入 | 专家无状态,主管读写记忆后写进 payload | 最可控,记忆入口唯一 | 最贵,每次都要主管做一次记忆读写 |
我产线里现在走的是混着来的做法,可以给你参考。
共享 store 里放的是那些「跨专家必须一致」的东西:用户身份、会员等级、偏好语言、历史订单归属。这些一旦不一致,用户立刻能感觉出问题。
各自持有的是各专家的专业上下文:订单专家记住这个人常买什么品类,知识专家记住他问过哪类政策问题。这两块互相冲突也没关系,因为它们本来就不该混着用。
最要小心的是「用户偏好」这一类。 如果订单专家和知识专家各记了一份这个人喜欢什么,第一次出现冲突的时候,你可能都不知道该信谁。
九、什么时候不该上多 Agent
这一章给判决。三个问题,任何一问答不出「是」,就先别拆。
第一问,有没有必须隔开的上下文? 不同职责看到的东西,本来就该不一样吗?如果所有专家都需要看同一份完整对话,那拆开只会让大家都不完整。
第二问,有没有必须分开的权限? 一个能改库、一个只能读,这个要求能不能用工具层面的校验实现?如果能,就不必为它拆 Agent。
第三问,有没有必须独立的失败域? 一个专家挂了,整条链路能不能容忍一起挂?
再拆掉三种最常见的「以为需要多 Agent」。
| 你以为的问题 | 真正的问题是 | 该用的手段 |
|---|---|---|
| 工具太多,7000 个管不动 | 工具规模化 | 货架网关,按需可见(见《MCP 与 Skills 接入》) |
| 规则太多,一个人格装不下所有章法 | 过程知识的组织 | Skills,按需读取(同上) |
| 步骤太多,怕一个 Agent 走不完 | 流程编排 | 子图与条件边(见《LangGraph 进阶》) |
| 上下文太长,塞不下 | 消息历史治理 | 裁剪、删除、摘要三招(见《Agent 的记忆》) |
| 两类信息不该互相看见 | 真正的隔离需求 | 多 Agent |
最后一行才是多 Agent 的入场券,前面四行都有更便宜的解法。
有一处我想说清楚。这些便宜解法不是「退而求其次」,而是它们本来就治那个病。拿工具太多这个例子说,你拆成 3 个专家,每个专家手上 2000 个工具,模型照样选不动。它换的只是把一个大问题切成 3 个中等问题。
收口一句:多 Agent 是为隔离付钱,不是为了看起来专业。
十、收尾
最后给两张清单。
拆之前,先做完这 4 件事:
- 算清你的一次请求平均要动几个专家。宽任务占多数的话,先别拆
- 确认要隔离的到底是什么。说不出具体的两项,先别拆
- 想清楚记忆放哪。共享还是主管持有,这个决定要写在架构图里
- 给失败兜底。
recursion_limit要给一个明确的值,别用默认
拆之后,守住这 4 条:
- 转交工具的 description 必须自己写。官方默认的
Ask agent 'xxx' for help里没有判断信息 - 转交链路要防环。计数守卫加循环检测,别指望
destinations - 回传量要控。
output_mode是官方给的旋钮,默认值未必适合你 - trace 要能一眼看出穿了哪几个 Agent。做不到这一点,多 Agent 的调试成本会翻倍
再回到上一篇的位置。上一篇我们把工具和章法装进了同一个 Agent,装得很满。这一篇回答的是,什么时候值得拆开装。
答案不在「多 Agent 先不先进」,在你手上有没有必须隔开的东西。
如果三问答不出「是」,就先别拆。不是不能拆,是没必要为一份你用不上的隔离,付一笔收不回来的钱。
下一篇,我们聊聊多 Agent 真的上线之后,怎么知道它到底跑得好不好。
回复 multiagent,领本篇完整的工程化 demo。