复刻一个多 Agent 诊断系统,我攒下了 47 个失败模式
这半个月我在做一件听起来不难的事:把一个已经在线上跑的「客资诊断」agent 系统,用新的技术栈复刻一遍。原系统能根据一个业务 ID 把整条链路的状态查清楚——数据有没有入库、短信发没发、有没有回访——然后给一线同学一份诊断报告。
复刻而已,能有多少坑?
结果是:到今天为止,项目里专门记失败的那个文件 failuremode.md 已经攒了 47 个不同的 FM 编号。不是 bug 列表,是「同一个错误以后还会换个皮再犯」的那种结构性失败模式。这篇就是把它们摊开看一看——不为炫耀踩坑多,而是因为这些坑的形状高度相似,认出形状比记住每一个坑更有用。
为了能公开发,所有内部平台名、数据集名、工号、手机号、文档 ID 都做了脱敏,换成功能描述。技术含义不变。
先说那个数字
最开始我以为是 32。因为文档头、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 个已经在路上了。但至少现在,每次想说「应该没问题」的时候,我会先问自己一句:这是验证过的,还是我以为的?
