多 Agent · 04 失败模式
常见失败模式
上一章把多 agent 跑通了——这章按根因排出真实生产里最常击中多 agent 的 12 个失败模式。每个失败模式给出症状、回到 02 原理的根因、错误写法对正确写法的修复、以及设计层面如何避免再次触发。基于 2026-06 的框架格局(LangGraph / AutoGen-AG2 / CrewAI / OpenAI Agents SDK)。下文所有代码用来演示设计判断,均未在本机执行(标 2026-06)。
本章你要建立的心智模型
- 多 agent 的失败几乎都能追到四类根因:token 账没算、上下文隔离档位拧错、协调结构本身的代价、可观测与评估缺位——记根因比记症状省力
- 每个失败模式都有同一副骨架:症状(看得见的现象)→ 根因(02 章哪条原理被违反)→ 修复(错误写法换成正确写法)→ 如何避免再次触发(设计层面)
- 看到一段异常 trace,先归类到四个根因桶,再定位到具体失败模式——这是 06 章 trace 诊断题的核心动作
- 多数失败模式的最终修复不是「改一行代码」,而是「这个任务本就不该上多 agent」——诚实承认这一点是本章的底色
不必背 12 个失败模式——这是一份对症 reference,不是 quiz。读法只有一条:看到每个失败模式,先不看修复,先问自己「它属于哪个根因桶,回到 02 章哪一节」。把症状反向追到原理,是 06 章 trace 诊断题真正考的能力;而记住「错误写法长什么样」远不如记住「哪条原理被违反」省力。
4.1陷阱 1:过早上多 agent(该用单 agent / workflow)
把一个本可单 agent 或固定 workflow 解决的任务拆成多 agent——白白背上约 15× token,没换来任何并行价值。
症状:一个查询走单 agent 串行只要几千 token、几秒钟,改成「supervisor + 3 个 subagent」后,token 账翻了十几倍、wall-clock 反而更慢(多了拆任务和汇总两次调用),结果质量却没变好。盯 trace 会看到 subagent 之间没有真正并行——后一个在等前一个的输出,或者它们查的是同一批东西。
根因:违反 02 章 §2.5 何时不该用多 agent 的三个「别用」信号——子任务强依赖、要共享大量中间态、步骤可预先枚举。多 agent 唯一值约 15× token(§2.1)的理由是把 token 兑换成探索广度;强依赖或固定流程的任务并行不起来,这笔账就是纯亏损。这也是 01 章 §1.5 单 agent + 工具 vs 多 agent 的分界 要先建立的判断。
修复
# ✗ 错误写法:固定流程硬塞成多 agent
# 抽取 → 校验 → 转换 → 入库 是可预先枚举的步骤,
# 每步依赖上一步,根本没有可并行的子任务
crew = Crew(
agents=[extractor, validator, transformer, loader],
process=Process.hierarchical, # 多一个 manager 调度,多两次 LLM 调用
manager_llm=ChatAnthropic(model="claude-opus-4-7"),
)
# ✓ 正确写法:固定路径用 workflow,模型只在确需判断处介入
def pipeline(raw: str) -> Record:
data = extract(raw) # 普通函数,零 LLM
if not validate(data): # 普通函数
raise ValueError("校验不通过")
enriched = llm_transform(data) # 仅这一步要模型自主判断
return load(enriched) # 普通函数
如何避免再次触发:上多 agent 前先过 §2.5 的三问——子任务能并行吗、要不要持续共享大量中间态、步骤能不能提前画成流程图。三问里只要有一个指向「不能并行 / 要共享 / 可枚举」,默认退回单 agent 或固定 workflow。Anthropic 在《Building Effective Agents》里的原则是同一句:能用更简单的方案就别上 agent,能用单 agent 就别上多 agent。
4.2陷阱 2:token 爆炸(没算约 15× 的账)
该用多 agent,但没给 subagent 数量和上下文设预算——token 账从约 15× 失控成几十倍。
症状:一个研究类任务用多 agent 是对的,但月底账单远超预期。盯 trace 会看到两类浪费叠在一起:subagent 数量随查询无脑增长(一个简单问题 spawn 出十几个 subagent),且每个 subagent 之间探索严重重叠(三个 subagent 查回几乎相同的资料)。
根因:违反 02 章 §2.1 为什么多 agent 贵 的三笔开销结构——每个 subagent 一份独立上下文、协调轮次本身要 token、探索重叠是纯浪费。约 15× 是「切得不重叠、数量受控」时的基准;一旦 subagent 数量不设上限、任务又没先切成不重叠的块,重叠开销会把这个倍数顶到几十倍。这正是 §2.1 预测题戳破的直觉:真正的杠杆不是「加 subagent 数量」,而是「token 花得有没有重叠」。
修复
# ✗ 错误写法:委派 prompt 不约束规模,subagent 数量随心所欲
ORCHESTRATOR_PROMPT_BAD = """
把这个问题拆成若干子任务,每个交给一个 subagent 去查。
"""
# ✓ 正确写法:按任务复杂度给 subagent 数量定上限 + 先切成不重叠的块
ORCHESTRATOR_PROMPT = """
先判断任务复杂度,再决定 subagent 数量:
- 简单事实查询:1 个 subagent。
- 多来源研究:最多 3 个 subagent。
- 任何单一查询都不要超过 5 个 subagent。
切分时给每个 subagent 明确、互不重叠的子范围,
不要让两个 subagent 查同一块——重叠的 token 是纯浪费。
"""
# 工程兜底:把 token 预算做成硬上限,超了就停而不是继续烧
TOKEN_BUDGET = 200_000
def spawn_subagents(tasks: list[Task]) -> list[Finding]:
if estimate_tokens(tasks) > TOKEN_BUDGET:
raise BudgetExceeded("拆得过细,先合并子任务再跑")
return run_parallel(tasks)
如何避免再次触发:把约 15× token 当成一条要持续盯的预算线,而不是事后才看的账单。两个设计动作:一是委派 prompt 里写死 subagent 数量上限(按复杂度分档),二是切分时强制「不重叠」——先把任务切成互不相交的块,再谈加并行度。把 token 预算做成代码里的硬上限(超了抛异常),比依赖模型自觉省钱可靠。
4.3陷阱 3:协调轮次过多 → 延迟(先放这里,因为它也是 token 类)
每多一轮中心调度都是一次串行的 LLM 调用——轮次堆起来,延迟和 token 一起涨,并行省下的 wall-clock 被吃回去。
症状:多 agent 系统单看每个 subagent 都不慢,但端到端延迟高得离谱。盯 trace 会看到大量时间花在 supervisor 反复「拆一点 → 等结果 → 再拆一点 → 再等」的串行往返上——subagent 的并行只省了执行段,调度段却被拉成一条长串行链。
根因:违反 02 章 §2.1 的第二笔开销「协调轮次本身要 token」与 §2.3 协调机制权衡。中心调度的每一轮都是一次串行模型调用,且要把各路结果读进同一个上下文;轮次越多,token 和延迟同步线性增长。多 agent 的并行省的是 subagent 执行段的 wall-clock,省不了调度往返——协调轮次过多时,省下的被往返吃回去。
修复
# ✗ 错误写法:supervisor 一次只派一个,串行往返 N 轮
def supervisor_serial(state):
next_task = pick_one_unfinished(state) # 每轮只挑一个
return Command(goto=route_for(next_task)) # 派一个,等回来,再来
# ✓ 正确写法:一次性 fan-out 全部独立子任务,单轮调度
from langgraph.types import Send
def supervisor_fanout(state):
tasks = decompose(state["query"]) # 一次拆完
# 用 Send 在单轮里并行派发所有独立子任务,避免逐个往返
return [Send("worker", {"task": t}) for t in tasks]
# 设计层:把「调度轮次上限」也当成预算
MAX_COORDINATION_ROUNDS = 3
如何避免再次触发:能一次性 fan-out 的独立子任务,就不要逐个串行往返——LangGraph 的 Send 让 supervisor 在单轮里并行派发。给协调轮次设一个上限(如 3 轮),超了就汇总现有结果或上报,而不是无限往返。深层判断回到 §2.3:如果一个任务天然需要很多轮中心来回,往往说明子任务其实强依赖——该考虑的是 §2.5 的退回单 agent,而不是优化调度。
4.4陷阱 4:上下文隔离过度 → 子 agent 缺信息
隔离旋钮拧到「完全隔离」——subagent 各做各的,缺了改变彼此决策的那一小块共享前提。
症状:并行 subagent 各自看自己那块都合理,拼到一起却互相打架。Cognition 的 Flappy Bird 例子是经典:一个 subagent 画了 Mario 像素风背景,另一个画了风格完全不搭的飞鸟——两个产物各自没错,合起来就是割裂的。汇总 agent 拿到风格冲突的 finding 无法调和。
根因:违反 02 章 §2.4 通信与上下文隔离 描述的「隔离过度」那一档。subagent 独立工作时会基于自己看到的那一小块做出隐含决策,而隐含决策需要共享前提才能彼此一致。隔离过度,subagent 在缺共享前提的情况下各做各的,汇总时无法对齐——这正是图 2.2 中间那一档的失败。
修复
# ✗ 错误写法:每个 subagent 只拿到自己那一小块任务,完全隔离
def dispatch_bad(subtasks):
return [worker.invoke({"task": t}) for t in subtasks] # 无共享前提
# ✓ 正确写法:把「会改变隐含决策」的那一小块前提,共享给每个 subagent
SHARED_PREMISE = {
"整体目标": "做一个 Flappy Bird 风格的小游戏",
"美术风格": "Mario 像素风", # ← 改变每个 subagent 隐含决策
"目标受众": "8-12 岁",
"语气口径": "活泼",
}
def dispatch(subtasks):
return [
worker.invoke({"task": t, "premise": SHARED_PREMISE}) # 前提随任务一起传
for t in subtasks
]
# 注意:只传「会改变决策」的前提;subagent 各自的中间草稿、原始材料
# 仍留在隔离里,不互相传——那是私有上下文,传了就回到 token 爆炸。
如何避免再次触发:用 §2.4 那条判别——会改变 subagent 隐含决策的,是共享前提,必须传(整体目标、统一口径、要对齐的接口约定、已定的全局约束);只是某个 subagent 的工作过程,是私有上下文,不该传。把这一小段共享前提随任务一起下发,其余各自隔离。这一小块前提的 token 成本极低,却挡掉了最贵的失败——返工重写。
4.5陷阱 5:不隔离 → context clash 互相干扰
隔离旋钮拧到「全员共享一份历史」——A 的中间猜测污染 B 的判断,且 token 随历史平方级膨胀。
症状:所有 subagent 共享同一份不断膨胀的对话历史,跑着跑着行为开始互相串味——B 突然采纳了 A 还没验证的中间结论,C 被前面 agent 的错误假设带偏。同时每个 agent 每一步都要把全部历史读一遍,token 用量随轮次平方级上涨。
根因:违反 02 章 §2.4 描述的「隔离过松」那一档,撞上 context clash(上下文互相干扰)。共享同一份历史时,A 的中间态会污染 B 的判断;且共享历史意味着每个 agent 每步都重读全部历史,token 平方级增长,直接放大 §2.1 那笔账。这是隔离旋钮的另一端失手——和陷阱 4 正好相反方向。
修复
# ✗ 错误写法:所有 subagent 读写同一份膨胀的 messages 历史
def run_shared(state):
for agent in agents:
# 每个 agent 都看到、也追加到同一份全局历史
state["messages"] = agent.invoke(state["messages"]) # 平方级膨胀 + 互相污染
return state
# ✓ 正确写法:每个 subagent 关进自己干净的上下文,只回传结论
def run_isolated(state):
findings = []
for agent in agents:
# 各自一份隔离上下文:共享前提 + 自己的子任务,不读别人历史
local_ctx = {"premise": state["premise"], "task": agent.task}
result = agent.invoke(local_ctx)
findings.append(extract_conclusion(result)) # 只回传结论,不回灌过程
return {"findings": findings}
如何避免再次触发:默认让每个 subagent 在自己干净的上下文里工作,回传的是结论而非整段过程——别把所有 agent 挂到同一份全局历史上。隔离不是「开 / 关」开关,是一根要调到「够用且不过量」的旋钮(§2.4):先全隔离,再只把改变决策的那一小块前提加回去。陷阱 4 和陷阱 5 是同一根旋钮的两端,调试时永远先问「现在这档偏哪一端」。
4.6陷阱 6:子 agent 结果合并冲突
汇总阶段拿到多路 finding,但它们格式不一、口径不一、甚至互相矛盾——reducer 无法干净合并。
症状:fan-out 的 subagent 各自返回结果,汇总 agent 试图合并时发现:同一个实体被五种不同写法列出(大小写、简称 vs 全称各不相同),或两路 finding 给出互相矛盾的结论,reducer 要么报错、要么硬拼出一份自相矛盾的输出。
根因:这是陷阱 4(§2.4 隔离过度)在汇总端的显形——subagent 各自对未声明的 implicit 决策(命名规范、结论口径)做了不同选择,没有共享 normalization 前提。深一层还连着 §2.3:中心汇总点要承担「把异构结果对齐成一致输出」的职责,这个职责若没人显式设计,冲突就堆到 reducer 那一刻才爆发。
修复
from pydantic import BaseModel
# ✓ 1. 给 subagent 输出定结构,强制统一格式(消除「五种写法」)
class Finding(BaseModel):
entity: str # 统一用全称小写,prompt 里写死规范
claim: str
sources: list[str]
confidence: float # 0-1,便于冲突时按可信度取舍
# ✗ 错误写法:reducer 盲拼 free-form 文本
def reduce_bad(findings: list[str]) -> str:
return "\n".join(findings) # 矛盾和重复原样堆进去
# ✓ 2. reducer 显式处理冲突,而不是假装没有
def reduce(findings: list[Finding]) -> list[Finding]:
by_entity = group_by(findings, key=lambda f: f.entity.lower())
merged = []
for entity, group in by_entity.items():
if has_contradiction(group):
merged.append(pick_highest_confidence(group)) # 按 confidence 取舍
else:
merged.append(synthesize(group))
return merged
如何避免再次触发:在 fan-out 之前就把两件事定下来——一是 subagent 输出的结构(用 Pydantic 之类定 Finding 形状,命名规范写进 prompt),二是冲突解决策略(矛盾时按 confidence 取舍、还是回到 subagent 复核)。把「对齐」当成汇总 agent 的显式职责去设计,而不是寄望 reducer 在最后一刻自动调和。源头仍是 §2.4 的共享前提:统一口径本就该作为前提下发。
4.7陷阱 7:supervisor 成瓶颈
所有控制流都过中心节点——supervisor 自己成了串行瓶颈,subagent 再多也卡在它一个人手里。
症状:supervisor 拓扑下,subagent 数量加上去了,吞吐却不涨。盯 trace 会看到 subagent 大量时间在「等 supervisor 派活 / 等 supervisor 收结果」上排队——中心节点每次只能处理一路交互,成了整个系统的串行咽喉。极端情况下 supervisor 还自己把 worker 的活干了(CrewAI hierarchical 的一个真实 bug:manager 借走 worker 的 tool 自己执行,worker 永不被调用)。
根因:违反 02 章 §2.2 拓扑选择权衡 揭示的 supervisor 拓扑代价——「所有控制流经过中心节点」既是它可控性强的来源,也是它可扩展性差的根。中心节点的处理能力是天花板,subagent 并行度再高也突破不了这个咽喉。这是拓扑四维取舍里「可控 ↔ 可扩展」那一维的典型失手。
修复
# ✗ 错误写法:supervisor 既调度又亲自干活,还逐路串行
def supervisor_bad(state):
data = web_search(state["q"]) # 自己借用 worker 的 tool 干活
return summarize(data) # worker 形同虚设
# ✓ 正确写法 1:supervisor 只调度、不持有 worker 的执行 tool
crew = Crew(
agents=[researcher, writer, qa], # worker 必须都登记进来
process=Process.hierarchical,
manager_llm=ChatAnthropic(model="claude-opus-4-7"),
# 不给 manager 自带执行类 tool——它只负责 delegate
)
# ✓ 正确写法 2:让 supervisor 一次 fan-out,把串行咽喉摊平
from langgraph.types import Send
def supervisor(state):
return [Send("worker", {"task": t}) for t in decompose(state["q"])]
# ✓ 正确写法 3:中心真成瓶颈时,部分交互改去中心 handoff(换拓扑)
如何避免再次触发:先确保 supervisor 只做调度、不抢 worker 的活(CrewAI 里别让 manager 继承执行类 tool)。再用一次性 fan-out 把「逐路串行」摊平成「单轮并行」。如果中心节点确实是吞吐天花板,按 §2.2 的取舍考虑把部分交互改成去中心 handoff——但记住这是换拓扑(02 §2.2 说的「最贵改的决定」),且会引出下一个陷阱。
4.8陷阱 8:去中心 handoff 死循环 / 踢皮球
去中心交接里没人为整条链设统一终止条件——「a 交给 b、b 交回 a」的环没有任何一方负责打断。
症状:swarm-handoff 拓扑下,控制权在 agent 之间反复横传却不收敛:a 觉得该交给 b,b 看了一眼觉得不归自己、交回 a,a 又交给 b……形成踢皮球的死循环;或者交接链跑飞、轮次无上限地涨,直到外部 quota 把它掐断。
根因:违反 02 章 §2.3 协调机制权衡 指出的去中心代价——中心调度里终止判断天然只有一处;去中心后,终止变成每个 agent 各自的局部判断,没有任何一方对「整条交接链何时停」负责。这正是 §2.3「去中心 → 失去全局终止权」的代价落地,也是 §2.2 里 mesh / 去中心拓扑最常见的失手。
修复
# ✗ 错误写法:每个 agent 各自决定交给谁,无全局终止、无环检测
def agent_a(state):
if not_my_job(state):
return Command(goto="agent_b") # 没人记录交接历史,b 又能交回来
# ✓ 正确写法:给整条交接链装三层终止
def make_agent(name, peers):
def node(state):
state["handoff_count"] = state.get("handoff_count", 0) + 1
# 层 1:全局交接次数硬上限
if state["handoff_count"] > 8:
return Command(goto="finalize") # 兜底收尾,不再横传
# 层 2:环检测——同一对 agent 反复互踢就打断
if seen_cycle(state["handoff_trace"], name):
return Command(goto="finalize")
target = decide_target(state, peers)
# 层 3:交接要带明确理由,避免「不归自己」式甩锅
state["handoff_trace"].append((name, target))
return Command(goto=target)
return node
如何避免再次触发:去中心拓扑必须显式补上中心拓扑天然就有的那一处全局终止权——三层叠加:交接次数硬上限(兜底)、环检测(同一对 agent 反复互踢就打断)、交接要带明确去向理由(堵住「不归自己」的甩锅)。更根本的判断回到 §2.2:如果一个任务总是踢皮球,往往说明职责边界没划清,去中心拓扑本就不合适——该退回 supervisor 让终止判断回到一处。
4.9陷阱 9:并行竞态 / 共享 state 写冲突
两个并行节点同时写同一个 state 字段——LangGraph 默认对并发写未定义,直接抛 INVALID_CONCURRENT_GRAPH_UPDATE。
症状:LangGraph 图编译通过,运行时在 fan-out 那一步抛 INVALID_CONCURRENT_GRAPH_UPDATE,报错指向两个并行 node 写了同一个 state key(最常见的是 findings 这类列表字段)。或者更隐蔽:没报错,但并行写互相覆盖,最后只剩一路结果(last-write-wins)。
根因:违反 02 章 §2.4 通信落到「共享 state」这种消息形状时的并发约束。LangGraph 对「多个节点并发写同一字段」默认不定义合并方式——字段必须用 Annotated 标一个 reducer(合并策略)才能并行写。这是「共享 state」通信方式的固有代价:共享带来并发,并发就要显式声明怎么合并。
修复
import operator
from typing import Annotated, TypedDict
# ✗ 错误写法:两个并行 node 都写 findings,没声明合并策略
class BadState(TypedDict):
findings: list[str] # 并行写 → INVALID_CONCURRENT_GRAPH_UPDATE
# ✓ 正确写法:用 Annotated 标 reducer,声明并发写如何合并
class GoodState(TypedDict):
findings: Annotated[list[str], operator.add] # 多路结果做列表拼接
# 需要更复杂的合并就自定义 reducer
def merge_dicts(a: dict, b: dict) -> dict:
return {**a, **b}
class CustomState(TypedDict):
config: Annotated[dict, merge_dicts]
如何避免再次触发:凡是会被多个并行节点写的 state 字段,定义时就用 Annotated[T, reducer] 标清合并策略——列表拼接用 operator.add,需要去重 / 取最新 / 字典合并就自定义 reducer。设计层面把它当成「共享 state 通信」的必填项:只要选了 fan-out + 共享 state(§2.4),并发写就是确定会发生的事,合并策略不能等运行时报错才补。
4.10陷阱 10:错误在 agent 间传播放大
上游 agent 的一个小错被下游当成可靠输入接着用——错误沿调用链逐级放大,末端的产物离谱却查不出源头。
症状:sequential 或 supervisor 链跑完,最终输出明显错误,但每个 agent 单看都「正常工作」。盯 trace 会发现上游某个 agent 输出了一个带瑕疵的中间结果(甚至是幻觉补的数据),下游 agent 没有任何校验就把它当事实,在它之上继续推理、继续放大——到末端已经面目全非。
根因:这是 §2.1 「15× token = 15× 失败面」的直接后果——更长的调用链意味着错误有更多级可以传播。更具体地连着 §2.4:下游 agent 默认信任上游传来的上下文,不区分「这是已验证的结论」还是「这是上游的中间猜测」。没有节点间校验,单点错误就沿链放大成系统级错误。
修复
from pydantic import BaseModel, ValidationError
# ✗ 错误写法:下游无条件信任上游输出
def downstream_bad(upstream_output: str):
return build_on(upstream_output) # 上游错了,这里跟着错并放大
# ✓ 正确写法:节点间设校验关卡,错误就地拦截而非向下传
class UpstreamResult(BaseModel):
value: float
confidence: float
def downstream(raw: dict):
try:
result = UpstreamResult(**raw) # 1. 结构校验
except ValidationError:
return escalate("上游输出结构不合法") # 拦在这里,不向下传
if result.confidence < 0.6: # 2. 可信度门槛
return escalate("上游可信度过低,需复核")
return build_on(result) # 通过校验才继续
如何避免再次触发:在 agent 之间设校验关卡——下游接收上游输出时先做结构校验和可信度门槛,不合格就就地拦截或上报,而不是接着用。让每个 agent 的输出带上可信度信号(见陷阱 6 的 Finding.confidence),下游据此决定信不信。设计原则:链越长,越要在节点间「断点」校验,把单点错误挡在它发生的那一段,别让 15× 的失败面变成 15× 的放大器。
4.11陷阱 11:缺可观测 → 无法定位哪个 agent 出错
多 agent 跑出错误结果,却没有跨 agent 的统一 trace——根本看不出是哪个 subagent、哪一步、哪次工具调用出的问题。
症状:系统输出明显不对,但排查时只能看到最终结果和零散的几条日志。每个 subagent 的输入、输出、工具调用、决策点分散在各自的上下文里,没有一条把它们串起来的 trace。定位一个错误要靠猜,复现更是碰运气——这在去中心拓扑里尤其致命,控制流是一张网而非一条线。
根因:这是多 agent 相对单 agent 新增的固有难度,根在 §2.1 的「调试要跨多份上下文」与 §2.2 拓扑代价表里的「调试难度」那一维。单 agent 的行为可线性追踪;多 agent 把一次执行摊到 N 份独立上下文 + 多轮协调上,没有统一 trace 就等于把一个分布式系统当黑箱调试。可观测不是事后加的运维项,是多 agent 的设计前提。
修复
# ✗ 错误写法:各 agent 各打各的 print,无关联
def worker_bad(task):
print("working...") # 哪个 worker?哪次运行?无从对应
return do(task)
# ✓ 正确写法:给每次运行一个 trace_id,每个 agent 步骤都挂上去
import uuid
def run(query):
trace_id = str(uuid.uuid4())
log_event(trace_id, "orchestrator", "decompose", {"query": query})
findings = []
for i, task in enumerate(decompose(query)):
# 每个 subagent 的输入 / 输出 / 工具调用都带 (trace_id, agent_id, step)
log_event(trace_id, f"worker-{i}", "start", {"task": task})
result = worker.invoke(task)
log_event(trace_id, f"worker-{i}", "finish", {"output": result})
findings.append(result)
return findings
# 生产里接 LangSmith / OpenTelemetry 这类,把分散步骤拼成一条可查的链
如何避免再次触发:在搭多 agent 的第一天就把可观测当前提——给每次运行一个 trace_id,每个 subagent 的输入、输出、工具调用、路由决策都挂这个 id,让一次执行能在事后被还原成一条完整的链(生产里接 LangSmith / OpenTelemetry 之类)。判断标准很硬:如果你不能从 trace 里指出「哪个 agent、哪一步、为什么这么决定」,这个多 agent 系统就还不具备上线的可调试性。
4.12陷阱 12:多 agent 评估困难
多 agent 没有唯一正确答案、路径还不确定——用「比对标准输出」那套单点评估根本套不上。
症状:想给多 agent 系统建评测,却发现无从下手:研究型任务没有唯一标准答案,同一个查询两次运行的路径和中间产物都不同,传统「输出 == expected」的断言全都失效。质量回归靠人工抽查,改一处 prompt 不知道整体是好了还是坏了。
根因:这是 §2.1 多 agent 本质(一台「token → 广度」兑换机)和 §2.5 适用边界(研究 / 检索 / 覆盖面优先的开放任务)共同带来的——多 agent 擅长的恰恰是没有唯一解、过程不确定的任务,而这类任务天然反「单点精确比对」式评估。评估困难不是工具缺失,是任务性质决定的:路径不确定 + 答案非唯一,要换一套评估范式。
修复
| 手段 | 怎么做 | 适合评什么 |
|---|---|---|
| LLM-as-judge + rubric | 用一个评判模型按事先写好的评分量表打分(覆盖度 / 准确性 / 一致性) | 没有唯一答案的开放产物(研究报告、综述) |
| 端到端结果指标 | 只评最终产物是否满足可观测的验收标准,不管中间路径 | 路径不确定但结果可判定(找全了几个目标) |
| 过程 trace 断言 | 在统一 trace 上断言关键步骤(是否做了校验、是否超预算) | 行为合规性(建立在陷阱 11 的可观测之上) |
| 分层评估 | 组件级评 subagent(可单点比对)+ 系统级评端到端(LLM-judge) | 多数生产系统——把可精确评的和只能整体评的分开 |
# ✗ 错误写法:对开放任务做单点精确比对
def evaluate_bad(output, expected):
assert output == expected # 路径 / 答案非唯一,必然脆断
# ✓ 正确写法:分层——组件级精确比对 + 系统级 rubric 打分
def evaluate(system, dataset):
# 1. 组件级:subagent 有确定输出的部分,照常断言
for case in dataset.component_cases:
assert system.subagent(case.input) == case.expected
# 2. 系统级:端到端开放产物,用 LLM-as-judge 按 rubric 打分
scores = []
for case in dataset.e2e_cases:
output = system.run(case.query)
scores.append(llm_judge(output, rubric=case.rubric)) # 覆盖度/准确/一致
return {"component_pass": True, "e2e_mean_score": mean(scores)}
如何避免再次触发:别拿单 agent 的「比对标准输出」去套多 agent。分层评估——能单点精确比对的组件(subagent 的确定性子能力)就照常断言;只能整体判断的端到端产物用 LLM-as-judge + rubric。这一切建立在陷阱 11 的可观测之上:没有统一 trace,过程断言无从谈起。评估范式要匹配任务性质(§2.5),而不是反过来逼任务去适应评估工具。
·几个反模式
上面 12 个失败模式是具体症状;下面是更高一层、贯穿多个失败模式的设计反模式——它们不是单个 bug,而是一类会反复制造 bug 的思维习惯。
- 反模式 A · 「多 agent 总比单 agent 强」——把多 agent 当默认升级。这是陷阱 1 / 2 的共同源头。多 agent 是一台 token 兑换广度的机器,不是更聪明的脑子;不需要广度的任务上多 agent 纯亏 15×。
- 反模式 B · 「subagent 越多越好」——把加并行度当增强手段。真正的杠杆是「token 花得有没有重叠」(§2.1),先切不重叠再谈加数量,否则只是为重复探索付钱(陷阱 2)。
- 反模式 C · 「隔离 = 开 / 关」——把上下文隔离当布尔开关。它是一根连续旋钮(§2.4),两端都是失败:过松撞 context clash(陷阱 5),过度撞决策冲突(陷阱 4 / 8)。
- 反模式 D · 「先跑通,可观测以后再加」——把 trace 当运维补丁。多 agent 是分布式系统,没有跨 agent 统一 trace 就等于黑箱调试(陷阱 11 / 12);可观测是设计前提,不是事后项。
- 反模式 E · 「下游默认信任上游」——节点间不设校验。链越长失败面越大(§2.1),单点错误会沿链放大(陷阱 10 / 6);节点间断点校验不是冗余,是必需。
·什么时候不要用多 agent(诚实段)
这一章排了 12 个失败模式,但要诚实说一句:它们里有相当一部分的最优解不是「修好多 agent」,而是「这个任务本就不该上多 agent」。把这条放在章末,是因为它比任何单个修复都重要。
回到 02 章 §2.5 的三个「别用」信号,遇到下面这些情况,第一反应是退回单 agent + 工具或一条固定 workflow,而不是去调拓扑、调隔离、调终止:
- 子任务强依赖——后一步必须等前一步结果。并行不起来,多 agent 唯一值 15× 的理由就不成立。走单 agent 串行,一条线程顺序累积上下文,后段天然看得见前段。
- 要持续共享大量中间态——联合写一份前后必须严格一致的长文档、增量编辑同一份代码。这把隔离旋钮逼到两端都坏的死角(陷阱 4 与 5 同时压上来)。单 agent 一份累积上下文反而最自然。
- 步骤可预先枚举——流程固定、可提前画成流程图。不需要模型在运行时自主决定下一步,一个代码编排的 workflow 更可控、可测、便宜(陷阱 1)。
- 高频、低价值、答案唯一的查询——Anthropic 明确把这类列为不该用多 agent 的场景。烧 15× token 去并行一个有唯一答案的高频查询,经济上说不通。
判断顺序固定:能用单次调用就不上 workflow,能用 workflow 就不上 agent,能用单 agent 就不上多 agent。多 agent 的复杂度只在「子任务独立可并行、且价值高到吃得下约 15× token」时才值得加。能识别「不该用」的这条边界,比会修这一章任何一个失败模式都更省钱。
合上教程,把图 4.0 的四个根因桶默画出来——只画 4 个框 + 框里各塞 2-3 个失败模式编号就行。画完回到本章对照:你画的图里,「协调类」那个框有没有被你标成最难定位的一类?四个桶有没有都汇到「约 15× token」这个共同分母上?
§本章 self-check
每题先把它归到四个根因桶之一,再定位到具体失败模式编号、说出回到 02 章哪一节。先合上教程答,写完再展开对照——直接点开等于把这一章当再读一遍。
- 一段 trace:LangGraph 图在 fan-out 那步抛
INVALID_CONCURRENT_GRAPH_UPDATE。这是哪个根因桶、哪个陷阱?state该怎么改? - 两个并行 subagent 给同一个产品页写文案,一个写得专业冷静、一个写得活泼口语,拼起来割裂。哪个陷阱?该共享的「够用前提」是哪一小块?
- swarm-handoff 系统里 a 把任务交给 b、b 又交回 a,反复横传不收敛。哪个陷阱?根因回到 02 章哪条原理?三层修复是什么?
- 一个「抽取 → 校验 → 转换 → 入库」的固定流程被搭成了 CrewAI hierarchical,token 翻十几倍还更慢。哪个陷阱?正确做法是什么?
- 多 agent 研究系统输出明显不对,但每个 subagent 单看都「正常」,排查时只有零散日志、无法定位。这同时暴露了哪两个陷阱?它们的共同设计前提是什么?
答案(先做完再展开)
- 协调类 · 陷阱 9(并行写冲突)。
findings这类会被多个并行节点写的字段要标 reducer:Annotated[list[str], operator.add],否则 LangGraph 对并发写未定义、直接抛异常。回到 §2.4「共享 state」通信的并发约束。 - 上下文类 · 陷阱 4(隔离过度)。完全隔离让两个 subagent 各做隐含决策却无共享前提。该共享的「够用前提」是那一小块会改变双方决策的东西——产品定位 / 目标受众 / 语气口径 / 关键术语;其余各自隔离。回到 §2.4 图 2.2 中间档。
- 协调类 · 陷阱 8(handoff 死循环 / 踢皮球)。根因:去中心后失去全局终止权,没人对整条交接链何时停负责(§2.3)。三层修复:交接次数硬上限 + 环检测 + 交接带明确去向理由。更根本的解是退回 supervisor 把终止判断收回一处(§2.2)。
- token 类 · 陷阱 1(过早上多 agent)。固定、可枚举、强依赖的流程不该上多 agent。正确做法:用代码编排的 workflow(普通函数串起来),只在确需模型自主判断的那一步用 LLM。回到 §2.5 的三个「别用」信号。
- 陷阱 11(缺可观测)+ 陷阱 10(错误传播放大)。没有跨 agent 统一 trace(陷阱 11)就无法定位是哪一步的错误沿链放大(陷阱 10)。共同设计前提:给每次运行一个
trace_id、每个 agent 步骤都挂上去——可观测是多 agent 的设计前提,不是事后补丁。回到 §2.2 调试难度维 + §2.1「15× 失败面」。
读一段真实风格的 trace,找出同时存在的失败模式
下面是一段简化的 trace(去掉细节)。识别至少 3 个同时存在的失败模式,各归到根因桶并给修复方向:
[t=0] orchestrator: 对查询「列出主流 JS 框架」spawn 了 9 个 subagent
[t=1] worker-1: 搜 Google,返回 finding(5k token 原始 HTML 原样塞回)
[t=2] worker-2: 搜 Bing,返回 finding(4k token 原始 HTML)
[t=3] worker-3: 搜 Google,返回 finding(与 worker-1 几乎相同的结果)
...
[t=9] worker-9: 返回结果
[t=10] reducer: 合并 9 路 finding —— 同一个框架被五种不同写法列出
[t=11] graph: 在 findings 字段抛 INVALID_CONCURRENT_GRAPH_UPDATE
提示(卡住再展开)
至少四个失败模式同时压在这段 trace 上,分属三个根因桶:
- token 类 · 陷阱 2(token 爆炸):「列出主流 JS 框架」是简单查询,9 个 subagent 远超需要;worker-1 和 worker-3 查回几乎相同结果,是纯重叠浪费。修复:按复杂度限 subagent 数量(这种查询 1-3 个够)+ 切不重叠。
- 上下文类 · 陷阱 6(合并冲突):同一框架五种写法——subagent 各自决定命名规范,无共享 normalization 前提。修复:定
Finding结构 + 命名规范写进 prompt。 - 协调类 · 陷阱 9(并行写冲突):
findings字段没标 reducer,并行写抛异常。修复:Annotated[list[str], operator.add]。 - 潜在 token 类(牵涉上下文):5k token 原始 HTML 原样回传,是 finding 没压缩成结论——放大了 §2.1 的账,也喂给 reducer 更多噪声。修复:subagent 只回传结论(框架名 + 来源),不回灌原始 HTML。
这道题的意义不在数出几个,而在练「同一段 trace 上多个失败模式叠加」的拆解——先归桶,再逐个定位,最后才谈修复顺序。