一次 AI 音频改动搞崩了线上,我回滚了 2,724 行代码
我给播客平台的语音系统推了一个本该是升级的改动。 六天、几个 commit 之后,我删掉了 2,724 行代码,回滚到了之前能用的版本。 这是事情经过,以及它教给我的关于测试生产环境 AI 改动的教训。
我本来想早点写这篇的。但整件事比我预期的花了更长时间才理清,我想确保自己把故事理顺了再分享出来。
2026 年 4 月 28 日,我部署了一个自认为是对 DIALØGUE 文本转语音系统的改进。一个不同的语音模型在纸面上看起来很有前景。我一直在听说——自己也听到了——当前模型的语音在较长的播客中会漂移。所以我切换了过去。
到了 4 月 30 日,我删掉了 2,724 行代码,回滚到了 4 月 22 日的稳定版本。
一个真实用户的播客卡住了。它无法生成音频。系统标记了 AUDIO_FAILED,然后在重试时抛出了 409 Conflict——这起初看起来像是真正的 bug。但后来发现那只是一个症状——重试发生时播客已经处于失败状态。真正的问题更简单也更严重:一个 segment 在新配置上超时了。fallback 也超时了。然后系统就放弃了。
更糟糕的是,这是一段即将达到 65 分钟语音时长量的脚本。平台上播客的中位数时长是 26 分钟。我让一个超过这个体量两倍多的脚本通过了一个从未成功生成过接近这个体量内容的系统。这才是真正的风险——而模型变更暴露了它。
我得承认,作为一个 builder,这不是我最好的时刻。我推了一个配置变更,却没有对一个生产系统应有的压力测试。这是事情的经过、哪里出了问题,以及我学到了什么。
DIALØGUE 做什么
如果你没见过,DIALØGUE 是一个 AI 播客生成器。你给它一个话题、一份 PDF 或一期节目素材,它就会生成一段双人对谈式的播客。整个过程基于 Google 的 Gemini 文本转语音合成。
音频 pipeline 是这样工作的:
- 从素材生成一个 outline
- 把 outline 扩展成完整脚本
- 把脚本拆分成 segments
- 用 Gemini TTS 为每个 segment 合成音频
- 拼接所有 segments、标准化音量,上传最终的 MP3
线上默认的 Gemini TTS 模型在大多数场景下表现不错。但脚本越长,你越能听到 segments 之间的语音漂移。segment 一中的主持人 A 和 segment 六中的主持人 A 听起来不完全一样。对于 25 分钟的播客,你能注意到但会习惯。对于接近 40 分钟的,就比较难受了。
我决定尝试一个不同的模型配置。目的是改善较长播客中语音的一致性。
我做了什么
在四月底大约一周的时间里,我推了一系列 commit 来尝试不同的模型配置。这不是一行代码的随手改动。我搭建了一个完整的架构迁移:
- 将默认 TTS 模型切换到新配置
- 添加了 fallback 链——如果主模型超时,就 fallback 到稳定的生产模型
- 搭建了 chunk 级别的 QA 系统:把文字稿拆分成更小的单元,逐个合成,用 ffmpeg 分析验证音频质量
- 添加了 workflow 进度追踪,让 UI 能展示每个 segment 的合成状态
- 加固了重试逻辑——每个 chunk 三次尝试,指数退避
- 添加了一个长音频质量门禁,只有最终拼接好的 MP3 通过了音频 QA 才会标记为
COMPLETE
思路是从"每个 segment 生成音频然后祈祷听起来一致"转变为"拆分文字稿、每个 chunk 做 QA、失败重试、验证最终文件"。
到了 4 月 28 日,代码部署到了线上。单元测试过了。集成测试过了。我缺少的——也是本该有的——是一个针对新配置运行完整 50 分钟以上导出任务的 load test。我跳过了唯一一个真正重要的测试。
哪里出了问题
几乎立刻,一个线上播客失败了。
那是一个长任务——分析 Big Tech capex 的脚本,即将达到 65 分钟的语音时长量。系统执行到音频步骤,返回了 AUDIO_FAILED。
起初,我看到了 409 Conflict,以为 orchestrator 有 bug。后来发现 409 是一个次生症状——播客已经被标记为失败后的重试。第一次失败的原因简单得多。
Segment 0 在新模型配置上超时了。超时设置是 60 秒。fallback 模型——也就是稳定路径使用的那个模型——也超时了。系统把播客标记为失败,然后继续。
fallback 本来应该能工作。但它是在同样的 60 秒超时预算下调用的,同样的大 segment,同样的请求上下文——而且这个上下文已经在失败的主调用上消耗了时间。相比之下,稳定路径是在全新的调用中以完整的超时预算来处理每个 segment 的。同样的模型,不同的条件——这就是为什么稳定版本能正常处理而 fallback 不行的原因。
我用一行改动在线上禁用了 chunk QA 系统。但这没有解决超时问题。新配置仍然在那些稳定模型能正常处理的 segment 上超时。
好吧,这就是那种你必须做选择的时刻:继续调试新路径,还是回到能用的版本。
回滚
4 月 30 日,我做了决定。
回滚 commit(4a5bfc8)删除了 2,724 行代码:
- 整个 TTS chunker 模块
- 音频质量分析门禁
- workflow 进度追踪
- chunk QA 重试测试套件
- fallback 模型链配置
并添加了 321 行来保留重要的部分——本地化语音提示措辞、前端兼容性,以及稳定路径的回归覆盖。
删掉这么多行代码感觉不好。老实说,感觉像是在承认失败。但不是。这是选择了正确的长期路径,而不是一个看起来像进步但还没准备好的东西。
回滚之后,我:
- 重新构建了共享基础 Docker 镜像
- 重新部署了
generate-speech服务 - 把失败的播客重置到脚本审批状态
- 通过正常 UI 点击"生成音频"
结果:
- 模型:Gemini TTS(稳定生产配置)
- Segments:6 个全部完成
- 时长:1,527 秒——大约 25 分钟
- 最终 MP3:30.5 MB
- 状态:
COMPLETE
那个卡了两天的播客,在回滚后大约 11 分钟内就完成了。用户的节目上线了——比计划晚了两天,但完整交付了。
关于那个 65 分钟的数字:那是基于原始脚本长度的估算时长,不是最终输出。重置后,脚本经过了正常的缩短 pipeline,最终拼接出来的音频大约 25 分钟。原始脚本本来还会更长——这也是它失败的部分原因。
数据
在决定下一步怎么做之前,我想要实际的数字。不是感觉——是数据。我从线上数据库查询了播客时长的信息。
| Cohort | 数量 | 中位数时长 | p90 | 最长 |
|---|---|---|---|---|
| 过去 30 天 | 8 | 26 分钟 | 31 分钟 | 34 分钟 |
| 过去 90 天 | 72 | 27 分钟 | 36 分钟 | 45 分钟 |
| 全部 | 120 | 26 分钟 | 30 分钟 | 45 分钟 |
所以播客的中位数时长大约是 26 分钟。p90 大约在 34-36 分钟。有史以来最长完成的播客是 45 分钟。
还有更难看的数据——进入音频阶段的播客,按估算时长分类:
| 估算时长 | 进入音频阶段 | 完成 | 失败 | 进行中 |
|---|---|---|---|---|
| 15 分钟以下 | 18 | 17 | 0 | 1 |
| 15-30 分钟 | 36 | 31 | 2 | 3 |
| 30-40 分钟 | 24 | 23 | 0 | 1 |
| 40-50 分钟 | 3 | 1 | 2 | 0 |
| 50 分钟以上 | 2 | 0 | 2 | 0 |
"进行中"指的是开始了但从未完成的播客——用户放弃了、生成中途取消的,或者停留在 pending 状态的。两个尝试在 50 分钟以上的播客,全部在音频阶段失败,零个完成。
没有一个完成的线上播客达到过 50 分钟。系统在 15-40 分钟范围内运行良好。超过 40 分钟,风险急剧上升。超过 50 分钟,基本上是没有经过测试的领域。
我学到了什么
1. 配置变更需要生产级别的压力测试
我得诚实面对这一点。新的模型配置在短测试提示词下可能表现很好。但在线上,面对真实的脚本和真实的超时约束,它在稳定配置毫无问题的 segment 上失败了。
我在部署之前没有做好基准测试。一个短的测试提示词会成功。一个 65 分钟、多个 segment 在 60 秒超时下的播客是完全不同的上下文。这两种场景之间的差距,就是生产事故生活的地方。
我可能对配置本身判断有误——也许它只是需要更多调优或者更长的超时。但重点不变:我改变了一个核心的生产依赖,却没有对人们真正依赖的系统进行应有的压力测试。
2. 语音不一致才是真正的问题,不是超时
超时是可见的失败。但我一开始尝试不同配置的原因就是语音不一致。即使在稳定模型上,语音也会在多次合成调用之间漂移。segment 一中的主持人 A 和 segment 六中的主持人 A 听起来不完全一样。
对于短播客,这几乎注意不到。对于长的,会累积。而对于 50 分钟以上的播客——再说一遍,线上没有人成功完成过——可能会非常明显。
chunk 化的思路是通过让每个 chunk 更小、更可控来解决这个问题。我认为方向是对的。只是实现还没准备好上线。
3. 我需要在事故发生前而不是之后就有 telemetry
当故障发生时,我无法把 TTS 成本或性能映射到具体的播客 ID 上。日志中没有有用的条目。workflow 事件中没有 TTS 生成记录。
我只能从播客的状态字段、令人困惑的 409 错误、以及本地复现超时行为来诊断故障。最终能定位问题,但那不是我想再经历一次的调试体验。
事后我添加了 TTS 成本 telemetry——使用的模型、fallback 模型、重试次数、每次尝试的状态、文字稿字符数、输出音频字节数、音频时长。这些本应该在事故发生前就存在的。根据我的经验,向来如此。你总是在火灾之后才建 observability,而不是之前。
4. 回滚不是失败
删掉 2,724 行代码感觉不好。我不会假装无所谓。你花了一周时间搭建东西,对架构感到自豪,然后你又把它们全部拆掉,因为还没准备好。
但这是正确的决定。chunk QA 系统设计得好。它会回来——以一个更小的、隔离的变更,加上合适的 canary 验证。只是不会作为模型配置变更的一部分回来。也不会在新配置仍然在稳定路径能正常处理的 segment 上超时的情况下回来。
5. 50 分钟的节目是另一个产品
这个让我意外了。我原本以为系统能处理任意长度的脚本。数据说不行。
如果有人真的想要一个 50 分钟的播客,那是不同的生成 profile。它可能需要在音频生成之前把脚本压缩到 45 分钟或更短。TTS 之前需要一个人工审核门禁。更强的每个 segment 超时预算。甚至可能需要完全不同的合成策略。
把默认路径优化在 15-40 分钟范围内是正确的选择。50 分钟以上的节目应该被当作异常路径来处理,而不是常态。我认为这是一个产品决策,不仅仅是工程决策。
现在的情况
线上已经回到稳定的 TTS 配置。它不完美。较长播客中的语音不一致仍然存在,我也能听到。但它足够稳定,平台在绝大多数场景下能正常工作。
如果你想听听 DIALOGUE 生成的是什么,可以自己试试:https://podcast.chandlernguyen.com。
事故报告已经提交到代码仓库。回滚已经记录了重置失败播客的具体 SQL 写法。telemetry 现在已经到位,为下一次尝试做准备。
而这个教训比我希望的更清晰:
在生产环境 AI 中,在没有适当压力测试的情况下改变核心依赖的代价,不是一个失败的实验。而是一个失败的用户体验。
我会再次尝试不同的模型配置。但下一次,会有更好的 telemetry、一个 canary 部署路径,以及一个在触碰线上之前运行完整 50 分钟以上导出的压力测试。
在改动线上 AI 依赖之前,这是我本该遵循的 checklist:
- 压力测试最坏情况的任务。 运行你预期的最长、最重的工作负载——不只是短的测试提示词。如果你的系统能处理 40 分钟的播客,就用一个 50 分钟的来测试。
- 从第一天起就记录每次尝试的 telemetry。 模型名称、fallback 模型、重试次数、超时、文字稿字符数、输出音频字节数、音频时长。这些本应该在事故发生前就存在的。向来如此。
- Canary 运行一个真实任务。 在对所有人切换之前,在新配置上端到端运行一个真实的生产任务并验证输出。
- 在同样的超时预算下验证 fallback。 一个和主模型共享同样超时的 fallback 不是 fallback——它是在同样条件下第二次失败的机会。
- 在部署之前就定义好回滚 SQL 和 runbook。 确切知道怎么重置一个卡住的任务。把命令写好文档。如果你没有这个,你将会在事故中即兴发挥。
如果你也在构建生产环境 AI 系统,你是怎么在模型配置变更时不破坏用户的?我真的很想知道。你有没有经历过类似的"改了一个东西然后全都崩了"的时刻?
Cheers,
Chandler





