ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

实时字幕中 committed 不等于 final:MAI 协议的定稿边界设计

实时字幕中 committed 不等于 final:MAI 协议的定稿边界设计

实时字幕领域有个特别容易踩的坑:拿到一份带 committed 标记的字幕分片,就以为这句话是“定稿”。我做过一段时间的实时字幕流协议设计,自己写过一个叫 MAI 的内部协议(多数人不知道这个缩写,我后面会解释),它把字幕状态分成“已定稿前缀”和“暂定后缀”两个概念。这个坑如果不拆开,轻则字幕抖动,重则会把半句话发给观众、把中间结果存进资料库。这篇就把 committed、final、tentative 这几个状态的关系讲透,顺便给出能直接参考的消费端设计。

MAI 不是某个公开标准,是我自己构建的一套面向实时字幕的多级标记协议,用来解决“网络上传过来的字幕片段到底能不能直接当文字用”的问题。如果你正在做直播字幕、会议转写、AR 眼镜辅助字幕这类东西,下面这套思路值得看完。

1. 先把“到达”和“定稿”这两件事分清楚

1.1 一次线上热播节目,差点把 committed 当成听写定稿的现场

当时我接了一个实时字幕渲染模块,上游是某语音识别服务,推流协议里每个字幕包都带一个 committed 字段。业务那边想得很简单:看到 committed 就落库,就写进正片字幕,就发给观众。第一个小时看着挺正常,直到出现一次歌手念错词再纠正的场景——识别引擎先是发送了一版“午后的风吹过”,标了 committed;过了两秒又发来一版“午后的风拂过”,也标了 committed。观众看到的是两句话首尾重叠、文字互斥。业务方当时拿着协议文档问我:committed 难道不代表“这句话定了吗”?我那时候才意识到,很多人把传输层的确认语义和编辑层的最终语义混在一起了。

那次线上事故的根源不是识别引擎太差,而是消费端没有理解 committed 的边界。从 TCP 的角度,committed 表示“我收到了、我确认了、我存下来了”,仅此而已。它不代表这句话在语义上是最终版本,也不代表这句话不会被后续包修正。实时字幕流里,识别引擎会在延迟和准确率之间做权衡,它会先发一个低延迟的初步结果,再根据后面的上下文做修正。修正完成后,协议里才会给出真正意义上的 final 标记。这个 final 标记所在的区域,才构成“已定稿前缀”。

1.2 两个层级:通道层的已提交与编辑层的已定稿

很多实时字幕的问题都出在把两个层级混为一谈。通道层关心的是“包有没有到达”“字节有没有确认”;编辑层关心的是“这段文字是否可以被观众、后续程序、资料库当作最终结果使用”。MAI 协议最核心的设计,就是把这两层明确分开。

通道层的 committed 可以理解为快递柜的“签收”状态——包裹放进了柜子,你手机收到取件码,物流系统标记已签收。但签收不等于你把包裹里的东西组装好并用上了。包裹可能还有配件缺失,可能还需要二次加工。编辑层的定稿则更像是“产品说明书审核通过,可以对外发布”的状态。二者的差别在日常场景里几乎不会引起注意,但在字幕这种“边生成边消费”的场景里,每一帧都在流动,状态一旦混淆,就会出大问题。

我在 MAI 协议里做了一个约定:每个字幕流维护一条不断推进的 Timeline,Timeline 上存在一个明确的“定稿边界”。边界左边是 finalized prefix,边界右边是 tentative suffix。任何 committed 包,如果落在边界左边,那它是定稿的一部分;如果落在右边,那它只是“暂定文本”,消费端可以拿来预览,但绝对不能做落库、翻译、对外发布这类“定稿级操作”。

2. MAI 协议里到底写的是什么:四类状态标记的实际语义

2.1 MAI 的分片头与状态位

MAI 协议的名字是 Media Annotation over In-band(后补的完整解释),本质是一个跑在字幕数据流上层的轻量状态协议。每个字幕分片(segment)由两部分组成:分片头和负载。分片头里除了常规的序号、时间戳、时长,还带两个关键字段:state 和 anchor。

state 是一个枚举值,MAI 里定义了四个:

state含义是否能作为定稿使用
TENTATIVE初步识别结果,可能被修正否
COMMITTED网络通道已确认接收,存储层可缓存否,仅供展示
FINAL语义层确认不再修改是
REVOKE对之前某个分片作废否,必须处理

这里最容易被忽视的是 REVOKE。它不携带正文字幕,只携带一个 target_segment_id,表示“我之前发出的 xxx 分片内容作废,请把它从显示和存储里删掉”。如果消费端只看 committed 不看 REVOKE,那错误文本就会永远留在界面上,只能等下一句字幕来覆盖。实时字幕场景里,这种覆盖有时不明显,因为观众默认字幕会跳。但如果你把 committed 内容交给翻译引擎,或者写进会议纪要,REVOKE 被忽略就真的是事故了。

anchor 字段则用来标记“当前分片是否落在已定稿前缀内”。一个分片即使 state 是 COMMITTED,如果它的事件时间小于当前定稿边界,那么它实际上属于定稿前缀。这个字段让消费端不需要自己维护复杂的边界算法,直接按照 anchor 判断优先级。

2.2 四个状态如何流转

从一个语音片段被识别开始,状态一般会经历这样的过程:

  1. 识别器生成第一版结果,state = TENTATIVE。
  2. 语音流继续往后走,识别器结合后续上下文修正前文,把新区间标记为 COMMITTED。此时旧的分片可能被新分片覆盖,也可能被 REVOKE 掉。
  3. 当某个时间点的所有修正都完成,识别器推进“定稿边界”,把这些分片标记为 FINAL。
  4. 如果某个已经定为 FINAL 的内容因为噪音或纠错需要调整,识别器会发出 REVOKE,同时附带一个新的修正分片,再重新推进边界。

从状态机角度看,FINAL 是一种“不可回退”的承诺,但协议并不强制物理上不可回退——REVOKE 就是回退通道。真正定稿的含义是“在正常运营流程下,后续不再修改它”。这与“通道层的已确认”有本质区别:committed 的包如果你不处理 REVOKE,下游就一定会出错;final 的包即使后续被 REVOKE,也是极少数异常情况,通常伴随运营介入。

我在实现里对 FINAL 做了额外约束:同一个 segment_id 只能被 FINAL 标记一次,后续只能 REVOKE 整个 segment,不能直接改内容。这样下游可以做幂等处理,避免“同一个 id 多个版本”的混乱。

2.3 已定稿前缀和暂定后缀在协议里的位置

MAI 协议不要求把已定稿前缀和暂定后缀从物理流里分开传输,而是通过一个叫 boundary 的控制消息来维护。boundary 消息里有唯一的 boundary_id 和它对应的 media_time。所有位于该 media_time 之前的分片,无论状态如何,都统一定义为“已定稿前缀”;位于该时间之后的分片,为“暂定后缀”。

这么做有一个直接好处:消费端不需要等待每个包都变成 FINAL,只要 boundary 推进了,它就知道边界前的所有内容可以被落库、展示、投递。boundary 通常由识别引擎综合置信度、上下文稳定性、静音检测等信号后发出。比如一段连续语音中间出现 800ms 静音,引擎大概率把静音前的所有文本一次性推到 FINAL 并发出 boundary。这样既降低了字幕延迟,又保证了边界前的稳定性。

这里有个重要细节:boundary 和具体分片不是一一对应的。boundary 可能一秒发一次,也可能十分钟不发一次。识别器对当前说话人没有把握时,会一直推迟推进 boundary。这段时间内,所有字幕无论 committed 与否,都只能是暂定后缀。消费端需要撑得住这种“长时间没有定稿”的状态——用户界面上要继续滚动显示,但页面上必须有一个明确的“预显示”区域,不能和已定稿区域混在一起。

3. 为什么 committed 永远不能当作文字定稿来消费

3.1 committed 只代表“这一批字节被接收方确认存下来了”

从协议语义来看,committed 是通道层的状态。发送方发出一个分片,接收方回 ACK,发送方把它标为 committed——仅此而已。它不包含“这句话不会改”的承诺。更直白地说,committed 是指“数据包层面我没丢”,而不是“文本层面我没变”。

我用一个生活类比解释给团队听:你让助理去记会议内容,助理先把潦草速记发给你,微信显示“已送达”,那就是 committed。但这份速记可能少字、有歧义,需要助理整理后再发一版。整理后的版本才叫 final。你如果把“已送达”的速记直接打印出来发给全公司,那就等于把 committed 当定稿用了。语音识别实时字幕比速记更特殊:它没有一个天然停顿的“整段结束”节点,所以 committed 到 final 之间的窗口可能非常长。

3.2 从真实场景看 committed 之后还会发生什么

为了说明 committed 之后还会发生什么,我整理了一个真实出现过的事件序列。这是一场嘉宾访谈,识别引擎输出的字幕片段大致如下:

时间点事件状态说明
T1发送“我们认为这个方向是可行”TENTATIVE初步结果
T2发送“我们认为这个方向是可行的”COMMITTED补了个“的”
T3发送“我们认为这个方向是可行的…”COMMITTED继续补标点
T4发送“我们认为这个方向其实可行”COMMITTED删掉“是”,加“其实”
T5发出 boundary,推进定稿边界到 T4 对应时间FINAL全句定稿

同一个意思,在 4 秒内被修改了 3 次,每次都是 committed。如果消费端在 T2 就把“这个方向是可行的”存库,那么库里会留下一个错误版本;如果把它发给下游翻译,翻译团队拿到的是半成品。这就是为什么“已定稿前缀”这个设计如此重要——它不关心单个包的状态,只关心边界是否已经越过这个包。

还有更极端的例子。一次现场音乐节目的字幕,识别器把歌手的即兴哼唱识别成了“啦啦啦 哦呜”,committed;后来音频里出现和声,识别器直接发了 REVOKE 删掉这个片段。观众端如果已经渲染了“啦啦啦 哦呜”,删掉后字幕区会突然空白。这不是 bug,而是协议要求消费端必须支持的“回溯动作”。排版层面上,这种空白可以被下一句字幕快速填补;业务逻辑上,它保证了“凡是留在定稿区的,都是经过验证的”。

3.3 对齐偏移:committed 不解决“这句话从哪一秒开始”的问题

除了文字内容会变,committed 还有一个隐性问题:它的开始时间戳可能也会变。语音识别引擎在做 VAD(语音活动检测)时,对说话起始点的判断经常在后续几帧里修正。第一版 committed 可能写着 start=1200ms,下一版修正成 start=1350ms。如果你用第一版的时间戳去做音频对齐、歌词滚动,界面就会明显跳动。

MAI 处理这个问题的方式是:boundary 推进时,除了标定文字边界,还会携带一个 offset_correction 字段,表示“定稿边界内所有分片的时间戳统一修正量”。消费端在渲染“已定稿前缀”时,不需要关心每个分片原始时间戳,直接用 boundary 提供的修正后时间即可。这是把“文字稳定”和“时间稳定”一起纳入定稿语义的做法。

4. 正确做法:把 MAI 的已定稿前缀当唯一可引用锚点

4.1 消费端要维护的三段式缓冲

我在设计消费端时,最初只维护一个“当前显示句子”的变量,结果被 REVOKE 打个措手不及。后来改成三段式缓冲,问题基本绝迹。三段分别是:

  • finalized buffer:存放所有已被 boundary 确认的定稿分片,按时间排序,是整个系统唯一可对外输出的数据源。
  • pending buffer:存放已收到但尚未被 boundary 覆盖的 committed/tentative 分片,只用于预览,禁止外发。
  • revoke queue:收到 REVOKE 时先进入这个队列,延迟一个短周期(比如 300ms)再执行删除,避免网络乱序导致误删未来得及应用的新版本。

这个三段式结构解决了一个实际问题:网络层是乱序的。MAI 协议里,REVOKE 和被修正的新分片可能不是按顺序到达的。如果消费端收到 REVOKE 立刻执行,但新分片还在路上,那界面上就会短暂出现一个“缺失段”。用 revoke queue 延迟执行,可以让新分片先落地,再删除旧的,视觉效果几乎无感。

我在实际测试中发现,300ms 的延迟窗口比 0ms 好得多。线上网络抖动在 200-500ms 时,0ms 的 revoke 会让字幕闪烁明显;300ms 时基本稳定。有同行问为什么要设 300ms 而不是更长,因为再长的话,REVOKE 对应的旧文本会在界面上多停留一瞬,观众看完错误信息才看到修正,这违背了低延迟字幕的初衷。

4.2 一个最小可用的合并策略

当 boundary 推进时,消费端需要把 pending buffer 里到期的分片“提升”进 finalized buffer。这一步看起来只是改状态,实际上要处理合并。语音识别的分片粒度很碎,可能两三个分片才组成一句完整的话。如果 finalized buffer 里存的是碎片,下游做字数统计、翻译对齐、字幕分段都会很痛苦。

我用的合并策略是:以标点符号和静音间隔为自然断句点,把位于同一断句区间内的分片拼接成完整句,再写入 finalized buffer。boundary 边界一般落在断句点之后,所以很少出现“半句定稿”的情况。但如果真的出现半句定稿(比如演讲者突然停顿很久,识别器以为说完了),我会在 finalized buffer 里保留一个 incomplete 标记,让下游知道这句话语义未闭合。

合并逻辑的伪码大致如下,实际工程里可以在此基础上扩展:

def on_boundary(boundary): # 收集 boundary 之前所有未定稿的分片 segments = pending_buffer.take_until(boundary.media_time) # 按时间排序,准备合并 segments.sort_by('start_time') current_sentence = '' current_start = None for seg in segments: if current_start is None: current_start = seg.start_time current_sentence += seg.text if is_sentence_end(seg.text): finalized_buffer.append( Sentence(start=current_start, end=seg.end_time, text=current_sentence, correction=boundary.offset_correction) ) current_sentence = '' current_start = None

这里最关键的逻辑是take_until。它不只是按时间取数据,还要检查每个分片的 anchor 字段。如果某个分片 anchor 表示它已经在定稿区,那它应该直接被忽略或已经处理过,防止重复合并。MAI 协议的 anchor 设计就是为了减少消费端这层判断的复杂度。

4.3 与播放器时间轴对齐的细节

字幕最终要渲染到画面上,和播放器时间轴对齐是绕不开的。很多实现直接把字幕文本的 start_time 跟播放器 current_time 比较,但在 MAI 场景里这不够。因为 recognizer 和播放器可能分别维护一条时间线,语音识别的处理延迟会导致字幕时间比播放器时间慢几百毫秒。

我推荐的做法是引入一个 timeline_offset 变量,在收到 boundary 时用修正量和播放器当前的时间戳差动态校准。具体来说,当播放器播放到某个位置时,字幕应从 finalized buffer 中查找 start_time 落在[play_time - tolerance, play_time + tolerance]范围内的句子,tolerance 设置为 300ms 比较合理。这个容差窗口既能吸收校准误差,又不会导致字幕乱跳。

另外一个容易被忽略的点:已定稿前缀可能跨越多个 boundary。比如上一句在 boundary A 时定稿,下一句在 boundary B 时定稿,中间的 gap 里没有任何 pending 数据。这种情况下,finalized buffer 中应该允许存在“连续的空档”。不要把空档当成错误,识别器可能只是没检测到有效语音。如果强制补空字幕,观众会看到幽灵字幕。

5. 实操中踩过的坑与排查清单

5.1 问题一:把 committed 发给翻译引擎,翻译结果整段偏移

我第一次做多语言实时字幕时,直接把所有 committed 分片送进翻译服务,结果英文翻译比中文原声慢半拍,而且某些句子的翻译在 2 秒后突然变了,观众看到英文字幕来回跳。后来我把翻译触发条件改成“仅当分片进入 finalized buffer 才发送”,同时给翻译服务增加了一个 dedupe key:句子在 finalized buffer 中的全局序号。这样即使同一句话因为 REVOKE 被重新定稿,翻译端也知道用新版本覆盖,而不是并行产出两份结果。

调这个问题的核心在于:翻译引擎是无状态的,它不会知道哪条是暂定结果。如果你把 committed 发过去,它默认翻译的就是“最终文本”。最终用户看到的字幕是“中文已定稿 + 英文未定稿”的混合状态,体验极其割裂。

5.2 问题二:显示线程直接渲染 tentative 后缀,字幕闪烁

一开始为了“零延迟”,我让显示线程把 pending buffer 里的最新文本直接渲染上屏。结果就是识别器每修正一次,字幕就闪一下——观众以为字幕坏了。后来我规定:界面只渲染两种内容,一是 finalized buffer 里的定稿句,二是当前正在进行的 tentative 句。后者用灰色、半透明或者边框样式区分,让观众明确知道“这句话还在识别中”。

这个交互设计非常有效,观众能理解“灰色字幕是实时识别的中间结果”,反而对偶尔的修正更宽容。从心理学角度讲,人更容易接受“有明确状态标识的不完美”,而不是“看起来完美但随机变化”。

5.3 问题三:清空对齐表之后,回退文本把正文字幕盖掉

有一次我升级消费端逻辑,为了调试方便,做了一个“清空所有缓冲”的操作。结果下一个 REVOKE 到达时,消费端发现目标分片不在缓冲区里,就忽略了它。但紧接着一个新的 committed 分片到达,内容是旧文本的修正版。由于旧文本的句子在 finalized buffer 里被清空了,新分片没有可覆盖的目标,只能作为独立新句子追加。最终字幕区同时出现旧句和新句,观众看到的是同一句话的两个版本并排显示。

排查后发现,问题根源是清空操作没有清理 revoke queue 里的待处理项。清空缓冲区的正确顺序是:先清 revoke queue,再清 pending buffer,最后清 finalized buffer。或者更稳妥的做法是:不直接清 buffer,而是通过发送一个全局 RESET 控制消息,让消费端进入“重新同步”状态。MAI 协议支持这种控制消息,消费端收到后等待下一个 boundary 再恢复显示。

5.4 常见问题速查表

现象可能原因处理方式
字幕文本突然消失收到 REVOKE 且没有新分片补充确认 revoke queue 延迟窗口是否正常
同一句话反复变化直接渲染了 pending buffer 内容改为仅渲染 finalized buffer,tentative 用特殊样式
翻译速度慢且经常变化翻译服务接收了 committed 数据设置翻译触发器为 finalized buffer 变化
字幕时间轴漂移未处理 boundary 的 offset_correction在播放器对齐逻辑中统一应用修正量
定稿区出现半句话boundary 提前推进,断句未完成保留 incomplete 标记,合并下一句时修正
新句覆盖不了旧句清空缓冲顺序错误先清 revoke queue,再清 pending,最后清 finalized

还有一条排查经验:任何时候都不要直接相信分片自带的顺序号。语音识别可能并行处理两个音频通道,推送顺序和逻辑顺序不一定一致。我在消费端对每个进入 pending buffer 的分片都做一次“按 start_time 排序”的稳定化处理,再执行后续逻辑。这个排序操作能避免大量“后续分片先到、前续分片后到”引发的渲染错乱。

6. 这层设计还能往下扩展什么

6.1 从“定稿前缀”延伸到多语言翻译的版本冻结

做完 MAI 的定稿机制后,我把它延伸到多语言字幕的版本管理系统里。思路是:每一个 finalized buffer 里的句子都有一个全局递增的 version 号。多语言翻译服务只认 version 号,不认文本内容。当原文因为 REVOKE 被替换时,新版本号递增,翻译服务对比版本号后决定是覆盖还是新增。这个机制避免了“内容相同但版本不同导致重复翻译”的浪费。

更进一步,我还在版本号里塞了一个稳定度字段,表示“这个句子在被定稿前经历过几次修正”。如果稳定度低,说明当时音频质量差、识别置信度低,下游可以决定降低这条字幕的排版权重,或者在会议纪要里标记为“需人工复核”。这个字段对质检团队特别有用,他们不需要重新听一遍音频,就能知道哪几句最不可靠。

6.2 接上逐词时间戳后的整包对齐

实时字幕最终要支持“逐词高亮”效果时,MAI 的定稿机制会更重要。每个词的时间戳在 tentative 阶段往往不稳定,多次修正后可能整体偏移。我现在的做法是:整包对齐只在 boundary 推进时执行,每个词的时间戳以 boundary 的 offset_correction 为基准统一平移。这样即使在 tentative 阶段看到词和音频对不齐,定稿后也会自动归位。消费端在渲染逐词高亮时,只使用 finalized buffer 里已对齐的词时间戳;pending buffer 里的词时间戳仅供预览,不做音频联动。

逐词对齐还带来一个额外要求:REVOKE 可能只作废一个分片里的某个词区间,而不是整个分片。这种情况下我会把 REVOKE 的粒度细化到 word_span,而不是 segment。MAI 协议扩展消息里支持 word_span 级别的 REVOKE,消费端把它应用到一个已经渲染的句子上,只删除对应词区间,再插入修正后的新词。这个操作听起来复杂,但从用户观感上看,效果接近“原地改正”,比整句闪烁舒服得多。

做实时字幕这么多年,我个人的体会是:协议设计里最值得花时间的就是“状态边界”。多花一个小时定义清楚“什么内容是消费者可以信赖的”,能省下你后面无数个排查线上字幕事故的夜晚。MAI 的已定稿前缀设计,本质上就是给下游一个明确答复:在这个边界之前,你尽管拿去用;在这个边界之后,你只能用来预览。搞清楚这条线,比优化识别准确率更优先。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进