通过三个真实案例说明快速修复的危害,介绍如何约束AI代理进行根因分析而非临时补丁,提升代码质量。
📝 最初发表于(日语版)forge.workstyle.tech。
遇到 bug 时,最快的修复方法是"消除症状"。如果发生错误,就用 try-catch 包裹并吞掉它。如果只在某个特定值下才会崩溃,就通过硬编码来规避这个值。如果精度不对,就用启发式关键词来伪造结果。这些方法似乎都能暂时奏效。
然而,这些权宜之计迟早会反噬你。因为根因依然存在,同一个问题会通过不同的入口再次浮现。被吞掉的错误会以更加隐晦的形式泄露到下游。硬编码的条件会成为下一个开发者的地雷。
在与 AI 编程 Agent(如 Claude Code)协作时,这种诱惑实际上会加剧。Agent 可以高速提出"目前能用的修复方案"。正因为如此,向 Agent 明确施加一条原则是有效的:"禁止快速修复;始终追求根因解决。"在本文中,我将用开发语音转换应用时实际遇到的三个 bug,介绍一种调查模式——不隐藏症状,直达根因。
核心原则:消除根因,而非症状
首先,我来建立贯穿本文的决策标准。当有人提出一个修复方案时,问自己以下几点:
你是在消除症状还是在消除根因?
这个修复是否也能消除来自同一根因的其他症状?
你能用一句话解释为什么这个修复有效吗?
第三点尤为关键。一个无法解释的修复通常只是在隐藏症状。"如果我们规避这个值,就不会崩溃"不是解释。"因为当提供这个值时,[X] 的假设会失效;因此我让这个假设始终成立"才是解释。
让我们看三个真实案例。
案例 1:转换后的音频"说话慢"——比例关系指向根因
第一个症状是,只有经过语音转换的音频会以不自然的拉伸节奏播放。输入的录音是正常速度,但输出听起来像缓慢的醉话。
人们可以想到无穷无尽的快速修复方法。例如,对输出应用时间拉伸,强行将其恢复到正常速度。然而,这根本无法解释为什么它一开始会变慢。
这里有效的方法是观察比例关系。问题不在短音频文件上发生,只在长录音上出现。而且,输入越长,输出就越慢。131 秒的录音播放速度比正常慢 4 倍多——这个线索"问题随长度成比例恶化",直接指向了根因所在位置。
如果是采样率不匹配,音频会以固定比例持续变慢,与长度无关。时间拉伸 bug 也是如此。"问题随长度缩放"这种比例关系,只在将固定长度的片段拉伸以适应总时长时才会出现。
根因是用于特征提取的 Whisper 编码器的"30 秒限制"。当我传入 131 秒的录音时,它只获取了前 30 秒的内容。因为那 30 秒的片段被拉伸到了 131 秒,所以速度变成了 131 ÷ 30 ≒ 4.4 倍慢。这与我"慢 4 倍多"的感觉完全吻合。
解决方案是将音频分割成重叠的 30 秒块,分别通过 Whisper 处理,然后拼接结果。这不是症状性的时间拉伸;而是在源头上修复——确保在特征提取阶段获取正确的信息。我在一篇单独的文章中详细记录了这次调查的技术细节:"语音转换'语速低'bug 的罪魁祸首是 Whisper 的 30 秒限制。"
这里的教训很简单:比例关系是通往根因的箭。如果你测量症状随什么缩放,就能机械地缩小嫌疑范围。
案例 2:长 ML 推理期间的 502 错误——不要"延长"超时,要"改变机制"
下一个 bug 是:尝试用 44.1kHz 宽带模型生成长音频时,会在前端导致 502 错误。短音频正常工作,但如果生成时间过长,就不可避免地出现 502。
最简单的快速修复是将超时值设置为一个巨大的数字。然而,这是一种典型的症状性处理:在不理解连接为何断开的情况下调整数字。即使你增加了这个数字,一旦输入超过新的限制,它就会再次崩溃。你只是把地雷推迟了而已。
通过追查根因,我发现当 Next.js 服务器将请求转发到推理后端时,内部 fetch 实现(undici)有默认超时。它因为无法等待长时间运行的响应而关闭了连接。502 是代理层在上游服务器仍然存活时放弃的结果。
这正是决策路径分叉的地方。"禁用 undici 超时"在技术上可行,但更稳健的解决方案是用 Node.js 标准的 http/https 替换代理转发,允许无限等待响应。考虑到长时间推理的特性,"超过一定时间就断开"这一前提与这个端点根本不兼容。因此,修复方法是改变实现,从而移除这个前提——直击根因。
这里的教训是:当你想要调整超时时间或重试次数这类"数字"时,停下来问问自己这是否只是症状性处理。在很多情况下,你不应该调整数字;而应该质疑"为什么架构设计成存在这个限制?"
案例 3:大型模型下载卡死——不要"忽略并继续",要"确保放置"
第三个问题涉及从 HuggingFace 获取多 GB 模型权重的过程进行到一半就卡住了。在网络不稳定的环境中,下载会悄无声息地停止并挂起。
快速修复的冲动是"吞掉下载失败,尝试继续启动应用"。然而,如果你尝试用不完整的模型权重进行推理,稍后会在流程中遇到更加令人困惑的错误。吞掉错误只是在隐藏问题,而不是解决它。
根因是标准下载器无法检测"卡顿"(悄无声息地停止);一旦卡住,它就无法自行恢复。它不会崩溃,也不会返回错误;只是静静地停在那里。这意味着没有什么可"吞"的——根本没有异常被抛出。
解决方案是使用支持卡顿检测和断点续传的下载器(使用特定的 curl 选项),并确保产物可靠地放置在 HuggingFace 缓存目录中。如果一段时间内没有数据流动,进程将其视为失败,中断并从断点处恢复。这保证了即使在不稳定网络上,最终也会有完整文件落入缓存。
这里的教训是:"悄无声息的卡顿"比"抛出错误的崩溃"更麻烦。你无法吞掉一个从未抛出的异常。正确的方法是提供一个可靠的获取机制,并将"完成"定义为产物成功且准确地放置到位的时刻。
三个案例中发现的共同模式
虽然这是三个不同的 bug,但用来追溯根因的模式是相同的:
测量症状的"有效性"——如案例 1("与长度成比例"),观察症状随什么缩放。比例关系、边界条件和复现条件都是通往根因的直接线索。
首先排除"嫌疑对象"——像测试采样率或时间拉伸一样,根据观察到的事实排除可疑候选。剩下的就指向根因。
用"你能用一句话解释为什么有效吗?"作为守门人——如果一个修复无法解释,怀疑它只是在隐藏症状。在案例 2 中,"增加超时"不是解释,所以它没有通过检验。
警惕调整数字和吞掉错误——增加超时值(案例 2)或吞掉错误(案例 3)是症状性处理的典型信号。如果你想伸手去调整,停下来想一想。
与 AI Agent 协作时的实践性
这个原则在与 AI 编程 Agent 协作时特别值得制度化。因为 Agent 可以快速大量生产快速修复,如果不加控制,你最终会得到一堆"能工作、但只是隐藏症状"的代码。
有效的是为 Agent 定义一条永久指令:"禁止快速修复。遵循此顺序:识别根因 → 适当的技术选型 → 提出设计级解决方案 → 实现。"然后,当有人提出方案时,人类必须用它来把关:"这是症状还是根因?"以及"你能用一句话解释为什么有效吗?"Agent 的生产力与根因解决的纪律可以共存。
快速修复(用 try-catch 吞掉错误 / 用硬编码逃避 / 用启发式伪造)保留了根因,注定会复发。
用两个关卡评估修复提案:"这是在消除症状还是根因?"以及"你能用一句话解释为什么有效吗?"
案例 1:症状的比例关系指向了根因(Whisper 的 30 秒限制)。比例关系是通往根因的箭。
案例 2:长推理期间的 502 不是通过增加超时解决的,而是通过改为不按时间截断的转发机制解决的。
案例 3:大型下载中的卡顿不是通过吞掉错误解决的,而是通过使用卡顿检测 + 断点续传来确保产物可靠放置解决的。
AI Agent 容易大量生产快速修复。将"追求根因解决"设为永久指令,并让人类充当守门人。