复刻多 Agent 诊断系统:47 个失败模式复盘
一个项目目标听起来不复杂:把一个已经在线上跑的「客资诊断」agent 系统,用新的技术栈复刻一遍。原系统能根据一个业务 ID 把整条链路的状态查清楚:数据有没有入库、短信发没发、有没有回访,然后给一线同学一份诊断报告。
真正的难度集中在一处:新系统接的工具语义接近、定位接近,但签名不保证一致。项目里专门记失败的那个文件 failuremode.md,最终攒了 47 个不同的 FM 编号。这不是 bug 列表,而是「同一个错误以后还会换个皮再犯」的结构性失败模式。下面把它们摊开看:这些坑的形状高度相似,认出形状比记住每一个坑更有用。
为公开发布,所有内部平台名、数据集名、工号、手机号、文档 ID 均已脱敏,换成功能描述。技术含义不变。
先说那个数字
文档头、README、架构说明里都白纸黑字写着「32 FM-* 失败模式库」。实际 grep 一下:
1 | |
43 个独立的 ## 段落,加上某个大类底下挂的 4 个子类型,一共 47 个不同的编号。文档里的「32」是某次大扫除冻结下来的快照,之后又新增了一整个系列,没回填到那个总数上。
这本身就是第一个失败模式的活样本:一个写死在文档里的数字,会随着代码演进悄悄变成谎言。这种「描述与现实脱钩」是贯穿整个项目最高频的母题。
下面按形状分类。
一、签名漂移:声称的签名与真实签名不一致
这个项目的底子是「语义复刻」:老系统用一套工具,新系统接的是一批定位接近、语义接近、但签名不保证一样的新工具(都通过 MCP 暴露)。项目早期需求文档里有一句关键描述:
复刻版用的是语义接近、定位接近,但签名有可能发生变化的工具。所以每个环节的参数设定和内部提示词,可能都匹配不上。
这句话后来变成了一整个家族的失败模式,也是编号最密的一类。典型表现有三种:
- 工具的入参 schema 用了旧版草案的写法,被新的严格校验直接拒掉,400 报错根本调不动;
- 日志查询工具,老系统裸传一个 ID 就能命中,新工具要求
<事件名> AND <ID>的语法,结果连续 10 多次返回「0 命中」; - 某个 trace 工具返回的关键字段恒为
null,而提示词里写着「这个字段非空 = 成功」,于是 agent 一路把「字段是 null」翻译成「业务失败」。
最坑的不是报错,是不报错。工具返回 success=True,结果是空,agent 就心安理得地把「空」当成「真的没有数据」,生成一份「全链路未触发」的漂亮报告。审计这份报告时,初次评估也很容易判它通过,理由是「报告字面上是合规的」。
直到用户手动拿一个已知一定有数据的输入去打,当场命中,才证明之前所有的「0 命中」全是参数拼错。
从这一类里榨出来的硬规则,成了整个项目的第一原则:
- 「返回 success=True」≠「调用对了」:协议通 ≠ 参数对 ≠ 结果本就该是空;
- 拿到空结果,第一反应是怀疑参数拼错,不是接受「真无数据」;
- 接任何新工具之前,先用一个已知有数据的正样本对照打一次,建立基线;
- 提示词里写的工具签名,是「老系统时代的假设」,落到真工具之前必须用真实调用验证。
这几条听起来像废话,但每一条背后都对应着一次真实的误诊报告。
二、Mock 渗漏:确定性地制造幻觉
用户连发了四次同一个业务 ID,每次都收到一份「✅ 全链路正常」的报告,但四份报告细节还不太一样。用户截图来问:重启 agent 之后总是得到漂亮但不一样的结果,是不是大模型在幻觉?
排查下来,根因和大模型一点关系都没有:
- 那条链路上的某个工具,在缺少环境配置时会静默回退到 Mock 后端,1 毫秒返回一份内置的假数据;
- 真实工具对同一个 ID 返回的是「阶段①失败、状态 RETRY2」,和 Mock 给的「全链路正常」完全相反;
- 而 Mock 的输出结构和真后端 100% 一致,没有任何
[MOCK]标记,模型收不到任何「这是假数据」的信号; - 于是模型基于假数据加上自己的措辞拼装,产出了一份确定性产生的幻觉。
这里最危险的是那句甩锅。用「大模型是非确定性的」去解释「为什么前后报告不一样」,是个下意识但错误的归因。前后不一样不是因为模型随机,是因为 Mock 数据混进了生产路径。把一个确定性的工程 bug 甩锅给「模型行为」,是这类问题最危险的地方:甩锅成功之后就没人去查真因了。
后来定下的铁律很简单粗暴:
- 真 agent 进程里零个 Mock 后端实例;
- Mock 只允许在测试进程里显式注入;
- 实在要留的 Mock,输出必须带强制可见标记;
- 缺配置时不许静默回退,直接抛配置错误。
配套还有一条审计命令,每次加新后端都要跑一遍,确认没有任何模块级或回退路径会实例化 Mock。遇到「前后结果不一样」,先怀疑 Mock 渗漏,再怀疑模型;看到报告「漂亮得不真实」,立刻怀疑假数据。
三、幻觉自引用:AI 编造的事实会自我扩散
有一个数据集的名字,在文档里、探针脚本里反复出现,标注还写着「来自需求文档的老假设」。真去查时,平台直接报「project not found」。
把需求文档的全部历史版本用 git log -p 翻一遍,结论是:这个名字从来没在任何版本里出现过。它是某个早期 AI 会话凭空编出来的,后续的会话没验证就抄进了文档,还贴心地标上「老假设」三个字,让它看起来像是有出处的。再后面的会话引用这份文档,就更像真的了。
更离谱的是,这个幻觉还衍生出了一条假任务:既然「文档说了有这个数据集但查不到」,那就建一个待办,「去找业务方要真实的名字」。这条待办挂了好几个会话,前提(「文档说了」)从一开始就不成立,但因为它「blocked on 业务方」,反而获得了一种免检的护身符。
这一类的教训:
- AI 幻觉的传播链是「幻觉 → 写进文档 → 下个会话引用文档 → 看起来像真的」,每一环单看都合理,但源头是假的;
- 任何待办的前提如果写着「文档说了 X」,捡起它之前先
grep确认文档真说了; - 「blocked on 业务方」不能当免验金牌,对方没回应不等于前提是对的;
- 验证手段很便宜:
git log -p -- <文档> | grep <名字>,确认 SSOT 里到底有没有。
和它同源的还有一种:模型写提示词时,看到「查某某分发状态」,潜意识就把「分发」等同于「结构化查询」,自动联想去查数据仓库,而那个字段其实是从日志事件里采的,根本不在数仓里。模型对外部命名的「直觉投射」,被现实反复打脸。
四、架构拆分:一次拓扑变更引爆 8 个失败模式
项目中段做了一次大动作:把「1 个调度 + 2 个 worker」的拓扑,拆成「1 个调度 + 6 个 worker」。这次拆分一开始被当成一个「配置分发」任务,核对一下每个 worker 的工具清单和设计文档对得上就行。
预发环境一上,接连炸了。光这一次拆分就贡献了 8 个独立编号的 FM:
- 鉴权不对等:老的那个 worker 走的是手写装配路径,凭证是当作工具参数注入的;新拆出来的 5 个 worker 走通用工厂路径,凭证要从 HTTP 请求头走,而工厂之前根本没有注入请求头的机制。结果新 worker 的工具集体「AK/SK is not valid」。
- 日志全丢:老 worker 那个 wrapper 是多轮迭代沉淀下来的,带钩子、调用链追踪、重试、分阶段日志;新写的通用 wrapper 只做了最朴素的一次调用,钩子、追踪、重试、日志全没有。用户的原话是「之前每个助手的每个环节都能看到日志,现在完全看不到了」。线上排障直接瞎。
- 横切场景被切断:能力本来是按「用户场景」横切组织的(比如「只给手机号 → 全文搜日志 → 反查出 ID → 再走诊断链」)。但提示词是按「工具/角色边界」纵切提取的。一纵切,这条横跨多个工具的链就断了。用户只给手机号,agent 直接回「不支持按手机号反查」,而其实底层工具完全支持全文搜。
- 修一个引出下一个:为了修上面那条,给某个 worker 接上了数仓查询工具。接通了,调用也 200 了,然后框架在处理返回值时抛
TypeError,因为那个函数返回的是 dict,而 agent 框架要求返回特定的结果对象。同一个函数,在不同的调用层有不同的类型契约,跨层复用就崩。 - 路由层级错了:拆分时,本该由某个主 worker 内部编排的 4 个子 agent,被错误地提升成了调度层的顶层路由目标,变成「六选一」,而设计明确是「二选一」。用户问「短信发了吗」,调度直接路由到子 agent,绕过了主 worker 提供的上下文。
- 死代码 + 零日志:预发时发现日志稀少得反常,深挖才知道,项目里有两套编排实现,带 5 个关键日志点的那套只被单元测试调用,生产流量走的是另一套,一个路由日志都没有。注释还写着「调用了 X」,实际从来没调过。
这一连串里最关键的一条:
「拆 agent」不是「分发配置」。工具可用、鉴权能通、日志完整、重试有效、指标上报,这是五个互相独立的维度,每一个都要单独验证。少验一个,就漏一个 FM。而且这五个维度里,只有第一个是「功能性」的,后面四个全是「非功能性但生产必需」的,恰恰最容易在拆分时被丢掉。
还有一条元层面的:单元测试覆盖的代码路径 ≠ 生产代码路径。测试把那套带日志的编排跑通过,完全不代表生产环境有日志。信代码,不信注释。
五、工程操作纪律
这一类不性感,但出现频率高,而且每一个都能用一条机械规则堵死:
- 改名要原子。想把一个配置项改名,结果只改了一半,留下一个加了
_DEPRECATED_前缀但没真删的半成品。规则:用编辑器改「整块」(带上下几行锚点),改完立刻grep 旧名兜底。 - 删 legacy 要连描述一起删。源码删了,但 README、配置注释、字段注释里还写着「legacy 是默认 / 保留两周」。新人按文档去找,发现源码没了,反复困惑。源码、配置、文档要同步删。
- 改凭证字段后立刻跑全套测试,别指望 review 兜底。主路径改完忘了改 5 个下游,是测试一跑就能抓到的。
- 环境名靠实证,不靠直觉。「日常环境」的环境变量值长期被假设成
daily,并为此加了一堆兼容代码。实际 ssh 进容器一看,值是testing。直觉值和平台实证值不一致时,实证值一定对。 - 大动作必须在 git 留痕,单一 commit 加详细 message,保证能整体回滚。
这些规则有个共同点:它们都是把「一次踩坑的疼」固化成了「一条 grep 或一条命令」。纪律的本质就是把疼痛编译成可执行的检查。
六、完成度判定:怎么确认一件事真做完了
一轮文档大扫除按计划清单跑完:几十个文件删除、几十处引用回流、测试零回归,随后宣告「全部收口,可以进下一阶段」。
用户问了一句关键的话:「你如何确定这件事真做完了?」
这个问题当时没有现成答案。因为「测试零回归 + 文件树干净 + git diff 干净」回答的是「计划清单跑完了」,不是「现实和目标对齐了」。反向审计的做法是把已经沉淀的每一条铁律转成一条 grep 命令跑一遍,当场就揪出好几处直接违反已有铁律的残留:违禁的数据集名还在提示词里出现 8 次,被废弃的环境名还在配置里躺着。
这件事逼出了两条元规则:
- commit 视角的「完」≠ audit 视角的「完」。前者是「计划里列的步骤都做了」,后者是「所有已知反模式 grep 下来零命中」。宣告完成前,必须做后者。
- 反向警告会过期。为了压制「某模型是失效残留」的认知,曾在十几处写了反向警告。等残留真清干净了,这些警告自己变成了新的误导:它们让人以为项目「绑死了某个模型」,而实际是可切换的。「X 是 stale」是一句过程性描述,不是永久约束;清完之后,这句描述本身也 stale 了。
这些失败模式的共同形状
47 个编号,翻来覆去其实是同一个故事的不同版本:某个被默认成立的假设,和「现实就是那样」之间,有一道没人去验证的缝。
假设工具签名没变,实际签名漂移了。把空结果当成「真没数据」,根因往往是参数拼错。文档里看着有出处的名字,可能是上一个 AI 会话编的。拆 agent 被当成分发配置,其实是五个维度的整体迁移。计划清单跑完不等于做完,做完的判据是 grep 零命中。当初为防误判写下的反向警告,残留清干净后反而成了新的误导。
把这道缝补上的动作,几乎永远是同一个,而且都很便宜:用一次真实的、最小的验证去打掉一个假设。一次正样本调用、一次 grep、一次 git log -p、一次 ssh 进容器看真实值、一次预发端到端 curl,都属于这一类。
做 AI agent 系统这件事,放大了这道缝的危险性。系统里有两个特别擅长「自信地填空」的角色:一个是大模型,它会把缺失的字段、断掉的链路用通顺的语言圆回来;另一个是赶时间的人,容易把「跑通了」当成「做对了」。两个填空高手凑在一起,就需要一套刻意的、机械的、不相信直觉的验证纪律来兜底。
这 47 个失败模式,本质上就是这套纪律的 47 块补丁。它不可能覆盖所有的缝,大概率第 48 个已经在路上。但每块补丁背后的判据是统一的:一个结论在交付前,要分清它是被验证过的,还是只是被假设成立的。


