实时字幕领域有个特别容易踩的坑:拿到一份带 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 四个状态如何流转
从一个语音片段被识别开始,状态一般会经历这样的过程:
- 识别器生成第一版结果,state = TENTATIVE。
- 语音流继续往后走,识别器结合后续上下文修正前文,把新区间标记为 COMMITTED。此时旧的分片可能被新分片覆盖,也可能被 REVOKE 掉。
- 当某个时间点的所有修正都完成,识别器推进“定稿边界”,把这些分片标记为 FINAL。
- 如果某个已经定为 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 的已定稿前缀设计,本质上就是给下游一个明确答复:在这个边界之前,你尽管拿去用;在这个边界之后,你只能用来预览。搞清楚这条线,比优化识别准确率更优先。