多 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 个失败模式 · 按根因聚成四类 token 类 回链 §2.1 token 账 陷阱 1 过早上多 agent 陷阱 2 token 爆炸 陷阱 7 协调轮次过多 上下文类 回链 §2.4 隔离旋钮 陷阱 3 隔离过度 陷阱 4 不隔离干扰 陷阱 8 合并冲突 协调类 回链 §2.2 / §2.3 陷阱 5 supervisor 瓶颈 陷阱 6 handoff 死循环 陷阱 11 并行写冲突 可观测 / 评估 回链 §2.5 边界 陷阱 9 错误传播放大 陷阱 10 缺可观测 陷阱 12 评估困难 共同分母 · 约 15× token 放大了整个系统的失败面
图 4.0导读:12 个失败模式不是 12 件互不相干的事,而是四类根因各自的若干表现。注意:朱红高亮的「协调类」是生产里最难复现也最难定位的一类——它的症状(瓶颈、死循环、写冲突)往往要跨多份上下文才看得清。四桶最终都汇向同一个共同分母:02 章 §2.1 那笔约 15× token 的账放大了整个系统的失败面。
怎么读这一章

不必背 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 的分界 要先建立的判断。

修复

over_eager_multiagent.py Python
# ✗ 错误写法:固定流程硬塞成多 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 花得有没有重叠」。

修复

token_budget.py Python
# ✗ 错误写法:委派 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,省不了调度往返——协调轮次过多时,省下的被往返吃回去。

修复

flatten_coordination.py Python
# ✗ 错误写法: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 中间那一档的失败。

修复

context_sharing.py Python
# ✗ 错误写法:每个 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 正好相反方向。

修复

isolate_context.py Python
# ✗ 错误写法:所有 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 那一刻才爆发。

修复

reconcile_findings.py Python
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 并行度再高也突破不了这个咽喉。这是拓扑四维取舍里「可控 ↔ 可扩展」那一维的典型失手。

修复

unblock_supervisor.py Python
# ✗ 错误写法: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 / 去中心拓扑最常见的失手。

修复

handoff_termination.py Python
# ✗ 错误写法:每个 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」通信方式的固有代价:共享带来并发,并发就要显式声明怎么合并。

修复

concurrent_state.py Python
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 默认信任上游传来的上下文,不区分「这是已验证的结论」还是「这是上游的中间猜测」。没有节点间校验,单点错误就沿链放大成系统级错误。

修复

guard_propagation.py Python
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 的设计前提。

修复

observability.py Python
# ✗ 错误写法:各 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 擅长的恰恰是没有唯一解、过程不确定的任务,而这类任务天然反「单点精确比对」式评估。评估困难不是工具缺失,是任务性质决定的:路径不确定 + 答案非唯一,要换一套评估范式。

修复

表 4.1 · 多 agent 评估的四种可落地手段
手段怎么做适合评什么
LLM-as-judge + rubric用一个评判模型按事先写好的评分量表打分(覆盖度 / 准确性 / 一致性)没有唯一答案的开放产物(研究报告、综述)
端到端结果指标只评最终产物是否满足可观测的验收标准,不管中间路径路径不确定但结果可判定(找全了几个目标)
过程 trace 断言在统一 trace 上断言关键步骤(是否做了校验、是否超预算)行为合规性(建立在陷阱 11 的可观测之上)
分层评估组件级评 subagent(可单点比对)+ 系统级评端到端(LLM-judge)多数生产系统——把可精确评的和只能整体评的分开
eval_layered.py Python
# ✗ 错误写法:对开放任务做单点精确比对
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 章哪一节。先合上教程答,写完再展开对照——直接点开等于把这一章当再读一遍。

  1. 一段 trace:LangGraph 图在 fan-out 那步抛 INVALID_CONCURRENT_GRAPH_UPDATE。这是哪个根因桶、哪个陷阱?state 该怎么改?
  2. 两个并行 subagent 给同一个产品页写文案,一个写得专业冷静、一个写得活泼口语,拼起来割裂。哪个陷阱?该共享的「够用前提」是哪一小块?
  3. swarm-handoff 系统里 a 把任务交给 b、b 又交回 a,反复横传不收敛。哪个陷阱?根因回到 02 章哪条原理?三层修复是什么?
  4. 一个「抽取 → 校验 → 转换 → 入库」的固定流程被搭成了 CrewAI hierarchical,token 翻十几倍还更慢。哪个陷阱?正确做法是什么?
  5. 多 agent 研究系统输出明显不对,但每个 subagent 单看都「正常」,排查时只有零散日志、无法定位。这同时暴露了哪两个陷阱?它们的共同设计前提是什么?
答案(先做完再展开)
  1. 协调类 · 陷阱 9(并行写冲突)。findings 这类会被多个并行节点写的字段要标 reducer:Annotated[list[str], operator.add],否则 LangGraph 对并发写未定义、直接抛异常。回到 §2.4「共享 state」通信的并发约束。
  2. 上下文类 · 陷阱 4(隔离过度)。完全隔离让两个 subagent 各做隐含决策却无共享前提。该共享的「够用前提」是那一小块会改变双方决策的东西——产品定位 / 目标受众 / 语气口径 / 关键术语;其余各自隔离。回到 §2.4 图 2.2 中间档。
  3. 协调类 · 陷阱 8(handoff 死循环 / 踢皮球)。根因:去中心后失去全局终止权,没人对整条交接链何时停负责(§2.3)。三层修复:交接次数硬上限 + 环检测 + 交接带明确去向理由。更根本的解是退回 supervisor 把终止判断收回一处(§2.2)。
  4. token 类 · 陷阱 1(过早上多 agent)。固定、可枚举、强依赖的流程不该上多 agent。正确做法:用代码编排的 workflow(普通函数串起来),只在确需模型自主判断的那一步用 LLM。回到 §2.5 的三个「别用」信号。
  5. 陷阱 11(缺可观测)+ 陷阱 10(错误传播放大)。没有跨 agent 统一 trace(陷阱 11)就无法定位是哪一步的错误沿链放大(陷阱 10)。共同设计前提:给每次运行一个 trace_id、每个 agent 步骤都挂上去——可观测是多 agent 的设计前提,不是事后补丁。回到 §2.2 调试难度维 + §2.1「15× 失败面」。
进阶挑战 · 刚好够不着

读一段真实风格的 trace,找出同时存在的失败模式

下面是一段简化的 trace(去掉细节)。识别至少 3 个同时存在的失败模式,各归到根因桶并给修复方向:

trace.log log
[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 上多个失败模式叠加」的拆解——先归桶,再逐个定位,最后才谈修复顺序。