一、引言:多 Agent 系统里的"责任真空"
把单 Agent 拆成 Planner、Retriever、Coder、Reviewer、Executor 之后,很多团队会遇到一个反直觉的现象:**系统的单点能力提升了,端到端的成功率反而下降,而且失败后没人能说清楚是哪里错了。
**
翻日志,你会看到熟悉的对话:
- Coder:“我是严格按照 Planner 给的 schema 写的,字段名对不上不是我的问题。”
- Reviewer:“我审的是 SQL 逻辑,没义务核对上游表结构是否存在。”
- Executor:“我只负责执行上游交付的脚本,脚本报错说明上游不合格。”
- Planner:“我给的只是方向性描述,具体实现细节需要 Coder 自己确认。”
每一句都成立,但任务失败了。这不是 LLM 的"道德问题",而是协作工作流的工程缺陷:系统里没有一个角色被赋予"最后一道闸门"的责任,也没有任何机制强制把"我完成了"变成"我完成了,且证据可复现"。
本文从任务分配和结果验证两个维度,拆解多 Agent 甩锅的技术成因,并给出一套可落地的契约化 handoff + 分层验证工作流。
在展开之前,先明确一个观察口径:这里的"甩锅"不是拟人化的道德指责,而是三类可观测的技术现象。
第一类是显性甩锅:某个 Agent 明确输出"我做不到,因为上游没给我 X",但由于系统没有回退路径,任务就此挂起。第二类是隐性甩锅:某个 Agent 输出了模棱两可的结论,比如"逻辑基本正确,建议注意边界条件",把判断责任重新推回给人类或下游。第三类最隐蔽,是静默甩锅:下游 Agent 发现上游产物有问题,但没有上报,而是"顺手修正"后继续执行,导致失败被推迟、归因链被切断。
如果用一句话概括本文的工程主张:把多 Agent 协作看成一个带契约和守卫条件的状态机,而不是一群用自然语言聊天的专家。状态机的每一次状态转移都必须有前置条件(precondition)、产物(artifact)和证据(evidence),任何一次转移失败都必须在原地暴露,而不是带着错误继续往下走。下面四节先讲成因,再讲设计。
二、甩锅的四种技术成因
2.1 责任扩散:没有人被指派"最后一道闸门"
社会心理学里的"责任扩散"在 Agent 系统里是精确成立的:当 N 个 Agent 都能"看到"某个错误、但没有任何一个 Agent 的 prompt 明确写"检查这一项是你的失败条件"时,检查就没人做。更糟的是,LLM 天然倾向于配合上游——它把上游输出当作既定事实,而不是待验证的假设。
用概率模型可以把这个现象说清楚。假设每个 Agent 在"没有明确指派"的情况下,主动检查某项约束的概率是 p,而且各 Agent 的判断近似独立,那么至少有一个 Agent 检查的概率是 1-(1-p)^n,看起来会随 Agent 数量增加而上升。但问题在于,p 本身会随 Agent 数量增加而下降:当 Agent 看到链路里已经有"Reviewer"这个角色时,它会合理地认为"审查是别人的职责",于是 p 趋近于 0。结果不是责任被分摊,而是责任被稀释到消失。
更微妙的一层是 LLM 的顺从性(sycophancy):模型在上下文里看到一段语气自信、结构完整的上游输出时,倾向于认同其前提,而不是从零重算。上游说"表结构已经确认过",下游的注意力就会从"验证表结构"转移到"如何基于这个结论继续加工"。这不是模型能力不足,而是训练目标(有用、配合、不抬杠)与验证任务(怀疑、证伪、找反例)之间的天然张力。
补救办法是把"检查"从一个模糊的集体义务,改造成一个写在某个具体 Agent 失败条件里的硬性职责。工程上可以直接借用 RACI 矩阵:
- Responsible:实际产出该产物的 Agent(Coder)。
- Accountable:为产物正确性最终负责、且失败条件被写进 prompt 的 Agent(在本设计里通常不是 Coder 自己,而是某个 Gate)。
- Consulted:提供输入但不承担 correctness 的一方(Retriever 提供候选表)。
- Informed:只接收结果、不做判断的一方(日志、看板)。
当一列 RACI 里出现两个 Accountable,或者某个产物的 Accountable 是空的,就等价于"最后一道闸门"不存在——这正是甩锅的结构性根源。
2.2 接口是自然语言:软约束无法被校验
Agent 之间的消息如果是"用户要求生成一份月度报表,请实现",那这条消息就是不可校验的。下游无法判断自己是否满足要求,也无法证明自己满足了。自然语言接口的另一个后果是:错误可以无限期地以"理解偏差"的形式潜伏,直到最下游执行时才爆发。
把这一点讲透,需要区分软约束和硬约束:
- 软约束是"报表要合理、字段要完整、口径要一致"这类人类能懂、机器难判的表述;
- 硬约束是"输出 JSON,必须包含 month(格式 YYYY-MM)、revenue(decimal)、order_count(int),且 month 取值范围在 2023-01 到 2025-12 之间"这类可以被程序判定的表述。
不可校验的接口会带来三个连锁后果。其一,无法自动重试:系统不知道"差多少算差",因此无法判断重试是否有意义,只能整体重跑,成本翻倍。其二,无法自动告警:因为没有判定函数,监控只能看到"任务失败",看不到"哪条约束被违反"。其三,无法批量回归:当 prompt 或模型升级后,你无法构造一组断言来自动验证"这次改动有没有让老问题复发"。
一个常见的误解是"自然语言更灵活,所以更好"。在探索性阶段这没错,但在已经被验证过的稳定链路上,自然语言的灵活性恰恰是错误传播的载体。实践中的收敛路径是:先用自然语言把任务跑通,再把其中被反复证明重要的约束抽成 JSON Schema / Pydantic 模型,最后把该 schema 作为交接的强制格式。这个过程本质上是把 prompt 工程逐步沉淀为接口工程。
2.3 验证者与生成者同源:确认偏差被放大
最常见的做法是"让同一个模型检查自己的输出"。这在 LLM 上有三重偏差叠加:self-preference bias(偏好自己的措辞)、确认偏差(倾向于确认已有结论)、以及上下文污染——如果 Reviewer 看到了 Coder 的推理过程,它大概率会被那段推理说服,而不是独立重算一遍。
三重偏差的危害程度并不相同。self-preference bias 影响的是"打分尺度",它会让通过率整体偏高,但不会系统性漏掉某一类错误。确认偏差影响的是"证据搜索方向":Reviewer 会去搜索支持"产物正确"的证据,而不是尝试构造反例。上下文污染最危险,它会把验证任务从"独立计算"降级为"评价推理过程是否流畅"——一段写得漂亮但前提错误的推理,反而更容易通过审查。
因此在设计验证环节时,有三个正交的隔离维度值得同时使用:
- 模型隔离:Reviewer 与 producer 使用不同模型或不同版本,让偏差不相关。
- 上下文隔离:只给 Reviewer 验收标准和最终产物,不给上游的自我辩解、推理链和"我已经检查过了"的声明。
- Prompt 隔离:给 Reviewer 的指令不应是"请检查是否正确",而应是"默认这个产物有缺陷,请逐条给出能证伪它的证据;只有当你在给定标准下找不到任何反例时,才允许放行"。后者的默认假设是"有罪推定",与前者的"有罪推定缺失"完全不同。
2.4 错误在传递中衰减:归因链断裂
上游一个 5% 的字段名错误,到下游可能变成"表不存在"的硬失败,也可能被下游"顺手修正"而静默吞掉。前者暴露了问题但指错了方向,后者更危险:系统的行为与任何单个 Agent 的意图都不一致,出了问题谁都不认。归因的前提是每一步都有不可篡改的输入输出快照和可复现证据。
"静默修正"值得单独举例,因为它几乎总是以"善意"的形式出现。Coder 输出了WHERE user_id = ...,而真实表里列名是uid。Executor 在生成执行计划时发现列名不存在,于是"智能地"把它改写成了uid,任务成功。表面上看这是一次成功的自适应,但它埋下了两个隐患:第一,这条链路的成功依赖于 Executor 恰好执行了修正,一旦 Executor 被换成严格模式(或该 Agent 被下线),同样的输入就会失败;第二,Coder 从这次运行中得不到任何负反馈,错误会持续产生,而系统的成功率看起来一直正常——直到某天环境变化,所有被掩盖的错误同时爆发。
因此,归因链的建设必须包含三样东西:每一步的输入输出快照(不可被下游修改)、贯穿全链路的 trace_id(让一次失败可以按时间顺序回放)、以及修正必须显式上报的约定(下游可以修正,但修正行为本身要成为一条可查询的事件,而不是隐式副作用)。没有这三样,"是谁的错"永远只能靠翻聊天记录猜。
三、把协作当"带契约的状态机":任务分配设计
核心思路只有一句话:把 Agent 之间的自然语言承诺,压缩成机器可校验的结构化契约。
不要让上游说"我写好了 SQL",而要让它交付一个包含产物、证据、前提假设、未知项的 Handoff 对象:
from__future__importannotationsfromdataclassesimportdataclassfromenumimportEnumfromtypingimportAnyclassStatus(str,Enum):DONE="done"# 声称完成,必须附证据PARTIAL="partial"# 部分完成,必须写明未覆盖范围BLOCKED="blocked"# 拒绝继续,必须写明缺什么@dataclassclassHandoff:task_id:strproducer:strclaim:str# 一句可被证伪的完成声明artifacts:dict[str,Any]# 产物:SQL / 文件路径 / diffevidence:list[str]# 可复现证据:命令 + 输出摘要assumptions:list[str]# 我依赖但没验证的前提unknowns:list[str]# 我明确不知道的部分status:Status confidence:float=0.0关键字段是evidence和assumptions。evidence把"我做了"变成"你可以自己验";assumptions把隐性依赖显性化——假设一旦写出来,就可以被下游定向检查,也可以被归因。当下游失败时,你能立刻判断是"假设不成立"还是"实现有误",而不是陷入互相指责。
逐个字段看,每个都有明确的工程意图:
claim:要求"可被证伪",是为了防止"已尽力完成"这类无法反驳的表述。好的 claim 形如"已生成 migration SQL,覆盖 spec 中列出的 4 个字段"。artifacts:产物与声明分离,允许"声明完成但产物缺失"被自动检出。evidence:证据必须是可复现的命令加输出摘要,而不是"我运行过了"。这是把 Agent 的自我报告降级为附录、把客观输出升级为凭据。assumptions:显性化的隐性依赖,是归因的关键。它同时充当了下游的检查清单。unknowns:与 assumptions 互补——assumptions 是"我以为成立",unknowns 是"我知道自己不掌握"。confidence:不是用来炫耀把握的,而是用来触发不同处置策略(高置信走自动链路,低置信强制人工确认)。
配套的 gate 函数负责在交接点拦截不合格交付:
FATAL="FATAL"defgate(h:Handoff,spec:dict)->list[str]:errors:list[str]=[]ifh.statusisStatus.BLOCKED:return[f"{FATAL}: 上游拒绝继续,缺输入 ->{h.unknowns}"]ifnoth.evidence:errors.append(f"{FATAL}:{h.producer}无证据,claim 不可证伪")ifh.statusisStatus.PARTIALandnoth.unknowns:errors.append(f"{FATAL}: PARTIAL 但未声明未覆盖范围")# 契约层:产物必须覆盖 spec 声明的必选字段forfield_nameinspec["required_artifacts"]:iffield_namenotinh.artifacts:errors.append(f"{FATAL}: 缺少产物{field_name}")returnerrors这套设计带来一个直接效果:"我不知道"变成了一个合法且被鼓励的输出。BLOCKED状态比"硬着头皮猜一个"便宜得多——后者才是错误传播的源头。
在此基础上,可以把 gate 扩展成一个真正的状态机守卫,包含超时、重试与回退路由:
fromdataclassesimportdataclass,field@dataclassclassGatePolicy:max_retry:int=2# 同一 producer 的重试上限,防止死循环timeout_s:int=300# 超时视为 BLOCKED,交回上游on_exhausted:str="escalate"# 重试耗尽后的路由:escalate / human / abortallow_silent_fix:bool=False# 显式禁止静默修正@dataclassclassGateResult:passed:boolroute:str# "next" / "retry:<agent>" / "escalate" / "human"reasons:list[str]=field(default_factory=list)defguarded_handoff(h:Handoff,spec:dict,policy:GatePolicy,retry_count:dict[str,int])->GateResult:errors=gate(h,spec)ifnoterrors:returnGateResult(passed=True,route="next")# PARTIAL 的处置不同于 FATAL:前者是补做,后者是回退ifh.statusisStatus.PARTIALandall(e.startswith("FATAL: PARTIAL")foreinerrors):returnGateResult(passed=False,route="retry:"+h.producer,reasons=errors)n=retry_count.get(h.producer,0)ifn<policy.max_retry:retry_count[h.producer]=n+1returnGateResult(passed=False,route="retry:"+h.producer,reasons=errors)# 重试耗尽:不再让同一个 producer 无限循环,升级处理returnGateResult(passed=False,route=policy.on_exhausted,reasons=errors)这里有一条容易被忽略的原则:重试必须有上限,且上限必须绑定到具体 Agent。否则一个持续产出错误契约的 Coder 会把整条流水线拖入"重试—拦截—再重试"的活锁,从甩锅演变为停滞。
另一个常见误区是契约的粒度。契约过细,Agent 会因为大量边界条件无法满足而频繁返回BLOCKED,系统可用性下降;契约过粗,等于没有契约。经验做法是从验收标准反推必选字段:先把"什么样算完成"写成可判定的条目,再把这些条目映射成required_artifacts,凡是无法映射到产物的验收条目不进入契约,而是进入 L2 模型评审。
四、独立验证:从"模型评审"到分层 Gate
只做契约检查还不够,产物本身可能"格式正确但内容错误"。验证必须分层,且确定性检查优先于模型评审。
defverify_chain(spec,h:Handoff,artifact)->dict:# L1 确定性检查:解析、schema 比对、dry-run,成本最低、结论最硬parse_ok,parse_msg=sqlglot_parse(artifact["sql"])ifnotparse_ok:return{"verdict":"REJECT","layer":"L1","reason":parse_msg}schema_diff=compare_schema(artifact["sql"],load_schema(spec["table"]))ifschema_diff:return{"verdict":"REJECT","layer":"L1","reason":f"引用不存在的列:{schema_diff}"}# L2 对抗性盲审:Reviewer 只看 spec + artifact,# 不接收 producer 的 reasoning,避免被说服review=llm_review(role="adversarial_reviewer",model=spec["reviewer_model"],# 与 producer 异构payload={"spec":spec["acceptance_criteria"],"artifact":artifact,"assumptions":h.assumptions},instruction="默认产物有缺陷,逐条找出能证伪它的证据;找不到再放行",)# L3 只有 L1/L2 冲突时才升级仲裁,避免无谓成本ifreview["verdict"]=="REJECT":returnarbitrate(spec,h,artifact,review)return{"verdict":"ACCEPT","layer":"L2","notes":review["notes"]}三个设计要点:
- 异构验证者。Reviewer 用不同模型、不同 prompt、不同上下文,才能降低同源偏差。
- 盲审。只给 Reviewer 结论性产物和验收标准,不给上游的自我辩解。
- 成本分层。语法解析几乎免费,LLM 评审昂贵,人工仲裁更贵。让便宜的先跑,把预算集中在 L2/L3。
把 L1 展开,一个成熟的确定性检查清单通常包括:语法/格式解析、schema 与列名比对、dry-run(不落库的执行计划)、权限检查、资源配额(预估扫描行数、是否全表扫描)、幂等性检查(重复执行是否产生副作用)、以及危险操作黑名单(无 WHERE 的 UPDATE/DELETE、DDL 变更)。这些检查的共同特征是结论确定、成本极低、可以批量回归,因此应该尽可能多地放在 L1,把 L2 留给真正需要语义判断的问题。
L2 的关键在于"结构化输出 + 证据绑定"。不要接受 Reviewer 的"通过/不通过"单值结论,而要让它输出形如{"verdict": ..., "counterexamples": [...], "checked_items": [...]}的结构,其中每条反对意见都必须引用产物中的具体位置。这样做有两个好处:一是把主观判断逼成可复核的断言;二是当 L2 结论后来被证明是误判时,你可以用这些断言构造回归样本,反过来评估 Reviewer 的召回率。
L3 的成本最高,因此升级条件必须收紧。合理的升级触发包括:L1 与 L2 结论冲突、L2 置信度低于阈值、产物涉及高权限或不可逆操作、以及历史上同类产物曾出现过逃逸(漏放)。仲裁结果不应只解决当次任务,还应当被回写成 L2 的对抗样本,让下一次同类错误能在更便宜的层级被拦下——这是让验证体系随运行不断变强的唯一自然机制。
还有一点常被忽略:验证器本身也需要被验证。你可以准备一组"已知错误样本"(故意把列名写错、故意漏掉必填字段、故意在边界条件上出错),定期跑一遍 Reviewer,统计它的召回率与误杀率。如果 Reviewer 对明显错误也给出"通过",那它不是验证者,而是新的甩锅对象。
五、实战:一条会甩锅的 SQL 生成流水线
场景:Planner 解析需求 → Coder 生成迁移 SQL → Reviewer 审逻辑 → Executor 在预发库执行。
改造前:Planner 输出一段自然语言方案,Coder 输出 SQL 代码块,Reviewer 输出"逻辑基本正确,建议注意边界",Executor 直接跑。典型失败是 SQL 引用了user_id,而实际表里叫uid。Coder 说"Planner 没给 schema",Planner 说"Retriever 给了表名",Retriever 说"我返回的是候选表列表"。整条链路无人担责。
改造后:
- Retriever 的 Handoff 里
evidence必须包含DESCRIBE table的真实输出,unknowns列出未取到 schema 的表; - Coder 的
assumptions必须写明"假设表字段与 Retriever 快照一致",且 SQL 必须通过sqlglot解析; - L1 用本地 schema 快照做列名比对,不依赖 LLM 判断——字段名对不上直接 REJECT,附上
schema_diff作为归因证据; - 只有 L1 通过才进入盲审,Executor 只接受
verdict == ACCEPT的产物。
效果不是"Agent 变聪明了",而是失败从"最下游爆炸"提前到了"交接点被拦下",且拦截理由是可复现的字符串。在多数团队的内测里,这类改造的收益主要体现在返工成本和排障时间上,而不是单次成功率——因为错误总量没变,变的是错误被发现的时机和归因成本。
把改造前后对照起来看,差异会更清楚:
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 错误发现位置 | 最下游执行时 | 交接点(L1/L2) |
| 失败理由 | 自然语言描述,需人工复现 | 结构化字符串,可自动告警 |
| 归因方式 | 翻聊天记录、互相推断 | 读assumptions+evidence |
| 重试策略 | 整体重跑 | 定点重试到具体 producer |
| 静默修正 | 允许且不可见 | 显式禁止或强制上报 |
| 回归能力 | 依赖人工经验 | 契约 + 已知错误样本自动回归 |
一个改造后的失败日志大致长这样,它本身就是一份归因报告:
[gate] task=sql-8842 producer=coder status=DONE [gate] L1 parse=ok [gate] L1 schema_diff=["orders.uid not in snapshot", "orders.created_at not in snapshot"] [gate] route=retry:coder retry_count=1 [gate] reason: 产物引用了快照中不存在的列,疑似假设"字段与快照一致"不成立注意最后一行:系统并没有说"是 Coder 的错",而是指出哪条假设不成立。这是契约化 handoff 最重要的产出——它把"谁的错"这个无法回答的问题,替换成了"哪条假设被推翻"这个可验证的问题。前者引发争论,后者直接指向修复动作。
落地路径建议分三步走:第一步,只做 Handoff 契约和 L1 确定性检查,先把"字段名对不上"这类硬错误拦住,投入最小、收益最直接;第二步,在稳定链路上引入 L2 盲审,并配套"已知错误样本"回归;第三步,再补 L3 仲裁与升级路由。反过来做——先上最贵的 LLM 评审——往往既贵又难评估效果,因为缺乏 L1 提供的基线。
六、踩坑与优化建议
- 别让 Reviewer 兼任 Generator。
更多硬核网安与AI工具包,请扫码获取完整源码!
即使同一模型,也要换 prompt、换上下文,最好换模型。
- 验证标准要来自需求方,不能由 Generator 自己定义。自己定标准自己判卷,等于没有验证。
confidence字段不要当装饰。低置信度交付应触发强制人工确认,而不是让它停留在 prompt 里自娱自乐。更实用的做法是把 confidence 映射到处置策略:高于 0.8 走自动链路,0.5 至 0.8 允许一次额外盲审,低于 0.5 直接升级人工。- 禁止静默修正,但允许显式回退。下游可以拒绝不符合契约的输入并请求重做,但不应该"顺手改一改"然后继续。修正行为若必须发生,也要作为事件落盘。
- 不要用"更长的 prompt"解决协作问题。职责边界、验收标准、失败条件这三件事如果没有被结构化成数据,写进 prompt 只会让模型更难判断优先级。
- 警惕契约的过度设计。字段越多,Agent 返回
BLOCKED的概率越高。先加必选产物,再逐步增加假设声明与证据要求,观察拦截率是否合理。 - 为归因链预留 trace_id。从 Planner 到 Executor 的每一次 handoff 都应携带同一个任务标识,否则事后回放只能靠时间戳拼接,容易错位。
- 把 Reviewer 的拒收理由结构化。自然语言的"这里有点问题"无法统计、无法回归、也无法交给上游自动修复。
- 控制升级成本。L3 仲裁和人工介入都要有预算上限,否则系统在疑难任务上会被低频高成本的仲裁拖垮。
- 不要只看端到端成功率。更有价值的指标是拦截率、误杀率、逃逸率、平均归因时间和重试次数分布。端到端成功率掩盖了错误何时被发现这一关键信息。
- **让"我不知道"有出口