那次验收,我先看到的是一份“看起来没问题”的字幕文件。文本能读,条数也对,播放器可以加载,格式检查没有报错。直到我把时间轴展开,才发现 144.5 秒的音频被切成了 49 条字幕,而且每一条都是 2.955 秒。
这个数字太整齐了,整齐到不像人在说话。真实语音里,有的句子只是一声回应,有的句子包含一串完整解释,停顿、语速和呼吸都不可能让它们恰好占用相同的时长。表面上的“生成成功”,掩盖的是时间轴根本没有被识别出来。
我一开始以为,是识别模型没有返回对齐信息,所以客户端只能先给出一个能播放的结果。后来沿着原始返回继续查,才发现情况更值得警惕:模型已经返回了逐字符时间戳,信息并没有在模型层丢失。真正丢失的是中间转换,上游接口没有把这些字符级数据整理成句级字段,于是客户端把空的 segments 当成缺省情况,静默走了平均分配。
字幕能生成,证明的是文本链路跑通;它不能证明时间轴是真实的。
均分为什么看起来合理
平均分配是一个很有诱惑力的错误捷径。它代码短,结果稳定,任何一段音频都能得到一组起止时间。对于只检查文件是否存在、段落是否非空的流程,它甚至比报错更容易“通过”。
但字幕时间不是排版装饰,而是对音频事实的描述。把总时长除以段落数,只能得到一个数学上的平均值,不能得到任何一句话实际开始和结束的证据。短句会被拖得很长,长句会被截断,停顿会落在文字中间,字幕切换也会和说话声错开。
如果不处理这个问题,后面会导致的不只是观看体验变差。字幕校对者会根据错误时间去判断识别内容,剪辑会把镜头切在不该切的位置,任何依赖时间范围的搜索、摘要或二次编辑都会把假时间继续当成真数据。错误越往后传播,越像一个已经确认过的事实,最后反而很难追溯它从哪里开始。
这里最容易误判的是“格式正确”。格式校验只能回答字段能不能被解析,不能回答字段是不是由真实信号推导出来的。一个全是合法数字的时间轴,可能比一个明确报错的结果更危险,因为它会让人停止追问。
先区分文本正确和时间真实
我后来把验收拆成了两条证据链。第一条检查文本:识别结果是否存在,内容是否能被正常读取,句子是否完成。第二条检查时间:每个区间是否有可追溯的来源,是否覆盖合理范围,是否与语音中实际发生的顺序一致。
这两条链路必须分开。文本正确时,说明模型听到了什么;时间真实时,才说明这些内容什么时候发生。前者通过,不能替后者背书。过去把它们塞在“字幕生成成功”这一个状态里,正是静默兜底能够混过去的原因。
我先回到模型最原始的返回,而不是继续在字幕文件上猜。原始数据里已经有逐字符时间戳,这给了一个很关键的判断:问题不是要不要换模型,而是要不要把已有证据正确地聚合到句子。只要字符和句子的边界能对应起来,句级开始时间可以取句内第一个有效字符的起点,结束时间可以取最后一个有效字符的终点,中间的停顿也不需要被平均抹平。
排查时间问题时,第一步不是设计兜底,而是确认最接近事实的原始字段是否还在。
这件事让我重新意识到,很多所谓“模型不支持”,其实是接口没有把模型支持的内容继续传递下去。若一开始就接受空 segments,把故障归因给模型,就会错过最小修复点,也会把一个转换层缺口扩大成换模型、重部署的工程问题。
修复要沿着热路径补回来
确认数据还在之后,我面临的是另一个选择:另起一个服务补时间,还是在已有识别服务里扩展返回。另起冷模型看起来边界清楚,但它意味着重新加载模型,增加大约 40 秒的冷启动,还可能让第 2 次识别和第 1 次识别使用不同的内部状态或分段结果。
我选择在自有层做进程内扩展,复用同一个热模型。理由不是“少写一点代码”,而是同一份输入应该尽量由同一条识别路径产生文本和对齐信息。这样可以避免双模型之间的词边界不一致,也不需要让调用者额外维护一个补时间的生命周期。
短音频热路径实测大约 0.83–0.96 秒,原有接口和回归测试也保持通过。这个结果并不意味着所有音频都能在这个范围内完成,而是说明修复没有因为追求真实时间,顺手引入一个不可接受的等待成本。性能数字在这里的作用,是帮助判断方案是否适合留在主流程里,而不是拿来替代正确性。
我没有把均分逻辑简单删除后就宣布完成。因为旧逻辑可能还会在其他输入上被触发,删除一行代码并不能证明错误不会从旁路回来。更稳妥的做法是让结果带上时间来源,例如明确标出来自真实对齐、来自估算,还是没有时间。调用者不必猜某组数字是怎么来的。
让假时间显式失败
时间来源被写进结果后,下一步是改变默认行为。以前只要 segments 为空,客户端就自动生成均分时间;现在应该把这件事变成可观察的状态,并允许严格调用方要求真实时间。于是结果增加了 timing_source 和 timing_real,还提供了 require-real-timing 门禁。
这个门禁的价值在于,它把“我希望是真实的”从一句口头要求变成了机器可以拒绝的条件。当调用方要求真实时间,而识别链路只能给出估算或没有时间时,流程应该失败,并说明原因。宁可让验收停在这里,也不能继续交付一份外观完整、来源不明的字幕。
可靠的兜底不是把失败藏起来,而是把估算和真实结果分开,让不允许估算的调用方能够失败。
这里还有一个边界:估算并非在所有场景都没有用。如果只是预览布局,用户明确知道时间是临时的,估算可以作为草稿。但它必须被标为估算,不能沿用真实字段,更不能在下游重新变成“已对齐”。用途不同,证据标准也应该不同。
回归样本要能击穿旧逻辑
修复完成后,我最关心的不是新增字段是否出现在响应里,而是旧的平均分配是否真的无法悄悄回来。普通测试可能只验证“有 49 条字幕”和“每条都有 start、end”,这种测试反而会把均分结果当成合格答案。
所以回归样本要故意选择能击穿旧逻辑的输入:文本段落数量不能均匀代表说话时长,原始返回必须包含逐字符时间,句级字段则保持缺失。这样,如果实现又回到平均分配,测试就应当检查出时间间隔完全相同,或者检查 timing_real 与实际来源不一致。
测试还要覆盖严格门禁:当真实时间不可用时,要求真实时间的调用必须失败;当真实时间可用时,结果必须标明来源,且时间范围不能越过音频边界。只有这样,测试才是在守住判断,而不是在守住某几个字段。
我选择这种回归方式,是因为旧逻辑的危险不在于它每次都出错,而在于它总能生成一个足够像成功的结果。能击穿旧逻辑的样本,才有机会证明这次修复改变了行为。
下次我会怎么做
下次遇到识别结果缺少句级时间,我会先问 3 个问题:文本证据和时间证据是否被分开验证?原始模型返回里有没有更细粒度的时间字段?当前数字的来源是否已经写进结果,并且能被调用方检查?
如果原始字段还在,我会优先沿热路径补齐句级时间,而不是立即换模型或另起冷服务。这样做的理由,是尽量保持同一输入、同一模型、同一分段依据,减少为了补一个字段而引入的系统差异。
如果只能得到估算,我会把它标为估算;如果调用方要求真实时间,就让流程明确失败。最后再用一条会让均分结果暴露的回归样本验收,而不是只看字幕文件能不能打开。
这件事让我重新意识到,工程里最危险的结果,往往不是空结果,而是看起来完整却没有证据的结果。一个流程成熟的标志,不是它永远能给出数字,而是它知道哪些数字不能冒充事实。真正要保留的,也不只是时间戳本身,而是时间戳从哪里来、什么时候可信、什么时候必须停下来等待真实证据的判断链。