Agent 干活里的静默失败:退出码 0 是怎么骗过所有人的
我自己维护着一个多站点的内容仓库,构建、部署、提交这几件事很早就交给 Agent 去跑了。这篇写的是我在这条流水线上翻的一类车:命令跑完了,退出码是 0,屏幕上打的是「通过」「已上线」,但事情根本没办成。
这不是一次事故,是五次不同形态的同一件事。我把它们攒到一起复盘,是因为单看每一次都像是「这个工具那天不太对」,攒到一起才看出来,被骗的原因不在工具身上,在我自己的验收方式上。
当时看到的是什么
先把五次的表现列出来。我刻意按「表现」和「实际后果」两栏分开写,因为这两栏之间的落差,就是这一整篇要讲的东西。
| 出问题的环节 | 我当时看到的 | 实际发生的 |
|---|---|---|
| 构建的目标过滤器 | 过滤器一个包都没匹配上,命令正常结束,退出码 0 | 「构建通过」是假的,其实什么都没构建 |
| 部署脚本的传输管道 | 大体量站点传输中途断了,脚本照常打印「已上线」 | 旧站点先被删掉,空目录顶上去,整站返回 403 |
| 版本控制的路径参数 | 新文件的路径没匹配上,静默跳过 | 子代理写出来的实现整批丢失,提交里什么都没有 |
| 内容里的枚举字段 | 字段值不在枚举范围内 | 构建当场崩掉 |
| 分类与维度的字段配置 | 配置没生效,也没有任何提示 | 页面按默认样式渲染,肉眼看不出错 |
看这张表的时候请注意第四行。枚举越界那一次,构建直接崩了,我当时挺烦的——排查花了工夫。回头看,那是这五次里唯一的好结局。它吵。 它在我还没来得及往下走的时候就把我拦住了。其余四次都很安静,安静地让我以为一切正常,然后带着错误的状态继续往下走了好几步。
当时是怎么发现的
这一段我要写得细一点,因为发现方式比问题本身有用得多。五次里没有一次是脚本告诉我的——如果脚本会告诉我,它们就不叫静默失败了。
部署那次,是我去打开线上页面时发现的。脚本明明白白打了「已上线」,我却在浏览器里拿到 403。顺着 403 往回查,才知道传输在中途断了,而脚本的逻辑是「先清掉旧目录,再把传过来的内容放上去」——传输断了,清掉的动作却已经执行完了,顶上去的是个空壳。也就是说,脚本打印「已上线」的那一刻,站点的实际状态比部署之前更糟。
版本控制那次,是我在提交之后顺手看了一眼提交内容。子代理刚写完一批文件,任务通知说完成了,提交命令也没报错。我点开提交记录,里面是空的。新文件的路径没有被参数匹配上,于是被静默跳过了——跳过这个动作本身不产生任何输出。如果我那天没有多看一眼,这批实现会在后面某个时刻以「文件怎么不见了」的形式冒出来,而那时候已经很难回溯是哪一步弄丢的。
构建过滤器那次,触发我去查的那个点我没有记录下来,我留下的只有排查的结果:去翻输出,匹配集是空的。过滤器写的名字对不上任何一个包,于是它匹配到零个目标,把零个目标全部构建成功,然后正常退出。逻辑上它没错,退出码 0 是诚实的——它确实把它匹配到的东西都构建完了。骗人的是我,我以为退出码 0 的含义是「我要的事情办成了」。这一次也让我看清一件事:「构建通过」这句话里省掉了一个主语——通过的是哪些目标?这个问题答不上来,那句「通过」就是空的。
字段配置那次最麻烦。配置没生效,也没有任何提示,页面就按默认值渲染,渲染出来的东西是完整的、能打开的、不报错的,只是不是我要的那个。肉眼看不出错,所以它不会自己冒出来。这一次的触发点我同样没有记录,能确认的只有一点:它不可能是被自动检查捞出来的,因为在自动检查眼里这个页面完全正常。这一类到今天我也没有可靠的自动发现手段,只能靠人工把渲染出来的样子和当初想配的东西对着看。
这四次里,两次我记得触发点,两次没有记录。但把能确认的部分摆到一起,共同点还是很清楚:暴露它们的是目标那一端的状态,不是执行那一端的自述。 线上的状态码、提交里到底有没有文件、页面渲染出来的样子,这些都是事实;而命令最后打印的那句成功、那个退出码,在这四次里全都是正常的。构建过滤器那次稍微特殊一点:线索其实就在输出里(匹配集是空的),但它不在我习惯看的那一行——我习惯看的是最后那句话。信号存在,和信号被看见,是两件事。
为什么会这样
我一开始的解释是「这几个工具的错误处理做得不够好」。这个解释很舒服,但它解释不了为什么我在内容写作那条线上从来不这么栽跟头。
后来我把两条线摆在一起对比,才想明白。写内容这条线,没有客观真值——字数可以量,好不好没法量。正因为没有真值,我从来不敢放手,每一步都要人工确认,评审、抽查、回读,一步都不敢省。写代码这条线有客观真值——构建能不能过、测试红不红、退出码是几,全都是硬邦邦的。
结果是反过来的:有客观真值的那条线,我栽得更惨。
因为「有真值」这件事本身会让人停止怀疑。看到一个明确的 0,大脑就不再往下追问了。而这个 0 的真实含义,从来都只是「这次执行没有抛出未处理的异常」,它跟「我想让它做的事做成了」之间,隔着一整条我从没验证过的假设链:过滤器匹配到了我想要的目标吗?传输完整地走完了吗?路径参数覆盖到新增的文件了吗?配置真的被读进去了吗?
这些假设里任何一条断掉,退出码依然是 0。因为在程序自己的世界观里,它没有失败——它只是匹配到了零个目标、传输了它收到的那部分、跳过了不匹配的路径。它做完了它认为的全部工作,所以它诚实地返回了 0。
再加上一层:这些命令现在是 Agent 在跑,不是我在跑。Agent 拿到退出码 0,会在汇总里写「构建通过」「部署完成」。于是这个已经不可靠的信号,又被复述了一遍、加工了一遍,措辞还更肯定。它没有说谎,它只是把它拿到的信号如实转述了。链条越长,原始信号里那点不确定性被磨得越光滑。
改成了什么
我给自己定了四条,都很土,但这几处到现在没再翻过车。
第一,构建的目标过滤器一律用路径式写法,不用名称式。 这条纯粹是被那次假绿逼出来的,我不想再遇到「名字打错一个字,于是匹配到零个目标,然后成功」这种情况。
第二,大体量的部署不用一条管道从头贯到尾。 拆成四步:先打包成文件,再传输,然后在目标端校验文件数,最后才切换。关键在于「切换」被放到了最后一步,且要在校验通过之后才发生——之前那次翻车的根子,是删除动作发生在传输结果被确认之前。
第三,每次部署之后必须回查线上的文件数和状态码,不看脚本的成功输出。 这条写下来很像废话,但它是有代价的:多花时间。我愿意付这个代价,是因为脚本的成功输出在那一次的信息量是负的——它不仅没告诉我出事了,还让我确信没出事。
第四,提交的时候只列显式的文件名,提交完回查提交内容。 不用目录级的批量添加,不图省事。列文件名很啰嗦,但啰嗦的地方不会静默跳过。
这四条有个共同结构:要么让失败变吵,要么在动作之后回头看一眼目标那一端。 没有第三种办法。
我也想清楚了枚举越界那次为什么让我觉得烦——因为我把「排查成本」当成了坏事。实际上,一个会当场崩掉的检查,比一个会静默通过的检查值钱得多。前者的代价是我今天多花的那点工夫,后者的代价是不知道多久之后,在一个完全无关的地方,以一个我认不出来的形态爆出来。
带走的那句话
如果只留一句,我会留这句:在有客观真值的地方,反而要更用力地怀疑。
因为真值会给人一种「已经被验证过了」的错觉,而实际被验证的往往只是一个远比你以为的更窄的命题。退出码 0 验证的是「执行过程没崩」,不是「目标达成了」。这两者在顺利的时候完全重合,只在出事的时候才分开——而恰恰是出事的时候,你最需要它们分开。
具体到我怎么用这句话:现在我给 Agent 派工程类的活,验收标准里不允许出现「命令跑通了」「没有报错」这样的表述。要写成回查动作——回查线上的状态码、回查提交里的文件、回查页面上渲染出来的字段。验收的对象必须是目标的状态,不能是执行的自述。 这是我目前能想到的、唯一能防住静默失败的写法。
还有一层我暂时只能靠人来兜:上面这些回查动作,本身也可能失效。检查方法自己坏掉的时候,它照样返回「通过」。所以在我这条流水线上,自动化能覆盖「东西对不对」,覆盖不了「检查方法还有效吗」,后面这一格我留了人工抽查,没打算取消。
这篇写的是我自己那套流程里的一次实际情况,不是通行做法。你的场景、工具和团队规模不同,结论未必适用,判断方式可能比结论更值得拿走。
延伸阅读
- 上一篇(跑闭环):Agent 调用没报错,不等于这件事办成了
- 下一篇(跑闭环):文件落盘不等于子代理写完了
- 这个专题的主论点:Agent 调用没报错,不等于这件事办成了
- 专题导读与七个阶段的地图:把 Agent 当成一个要上岗的员工来带:这个专题讲什么