☰
实时字幕开发避坑指南:MAI协议committed、delta与intermediate状态机详解
2026/10/10 4:42:39 网站建设 项目流程

实时字幕这个领域,表面看是把语音转成文字那么简单,真正动起手来才会发现,最折磨人的不是识别准确率,而是"什么时候把一行字确定下来"这件事。我做过几套字幕系统,也踩过不少坑,最典型的一个误区就是:把流式识别返回的 committed 结果直接当成最终文字写进字幕文件或者渲染到屏幕上。结果就是字幕频繁跳动、回滚、闪烁,用户体验极差。这篇内容就围绕 MAI 协议里 committed、delta、intermediate、completed 这几个状态,把"已定稿前缀"和"暂定后缀"这套机制彻底拆开讲清楚,适合正在做实时字幕、语音转写、会议记录类产品的开发者参考,也适合想理解流式识别内部状态机的人。

1. 先搞清楚 committed 到底承诺了什么

很多人第一次接触流式语音识别接口,看到返回结果里有个 committed 字段,第一反应就是"哦,这是已经确定的文字"。这个理解对了一半,但恰恰是错的那一半害死人。committed 承诺的不是"这段文字永远不会变",而是"这段文字在当前这个识别会话里,已经被识别引擎锁定为前缀,后续不会再对它做修改"。注意这两个说法的差别:前者是对最终结果的承诺,后者是对前缀稳定性的承诺。

1.1 committed 的语义边界在哪里

要理解 committed,得先理解流式识别的工作方式。语音是一段一段喂进去的,识别引擎每收到一小段音频,就会输出一次结果。但语音识别有个天然特性:后面的音频会影响前面文字的判断。比如"我想去"这三个字,单独听可能是"我想去",但如果后面接着"医院",那前面可能是"我想去";如果后面接着"海边",前面还是"我想去"。可有些词就不一样了,比如"识别"和"试别",单听前两个字很难定,必须等后面的音节进来才能确定。

所以识别引擎的策略是:把已经足够确定、后续音频不太可能推翻的部分,标记为 committed,作为稳定前缀输出;把还不确定、可能被后续音频修正的部分,作为暂定后缀,用 delta 或者 intermediate 的形式给出。committed 的本质是"前缀锁定",不是"整句定稿"。这就是为什么你不能把 committed 当文字定稿——它只保证前缀稳定,不保证这句话已经说完了。

1.2 一个具体的例子说明问题

假设用户说"今天天气不错我们出去走走吧"。识别过程可能是这样的:

第一段音频进来,引擎输出 committed 为空,intermediate 为"今天"。第二段音频进来,引擎输出 committed 为"今天",intermediate 为"天气"。第三段进来,committed 变成"今天天气",intermediate 变成"不错"。以此类推。

如果你在第二步就把 committed 的"今天"当成定稿写进字幕,那没问题,因为"今天"确实稳定了。但如果你把 intermediate 的"天气"也一起写进去,等第三步 committed 变成"今天天气"时,你就要把之前写的"天气"再确认一遍。更麻烦的是,如果引擎在第三步把 committed 修正成了"今天天汽"(假设识别出错后又被后续音频纠正),那你之前写的就全错了。

真正的问题在于:很多开发者看到 committed 有值了,就以为这一轮识别结束了,把 committed 加 intermediate 拼起来当完整句子输出。这就把"前缀稳定"误解成了"整句定稿",字幕自然会跳。

1.3 committed 和 completed 的区别

这里必须把 committed 和 completed 分清楚,这两个词太容易混了。committed 是"前缀已锁定",completed 是"这句话说完了,识别会话可以收尾了"。completed 通常出现在检测到静音、语义完整或者音频流结束时。一个 committed 的结果不一定是 completed 的,一个 completed 的结果里也可能包含还没 committed 的部分(比如结尾的语气词)。

我在实际项目里见过有人用 completed 来判断是否换行,这个思路是对的;但用 committed 来判断换行就错了,因为 committed 可能在一句话中间频繁出现。判断换行的正确依据应该是 completed 加上标点预测,而不是 committed 的有无。

2. delta 和 intermediate 不是一回事

搞清楚了 committed,接下来要处理暂定后缀。这里又有一个常见的混淆:把 delta 和 intermediate 当成同一种东西。实际上它们代表两种不同的增量语义,用错了会导致字幕重复或者丢失。

2.1 delta 是增量,intermediate 是全量

delta 顾名思义是"增量",它表示相对于上一次输出,这次新增了哪些文字。intermediate 通常是"当前这句话到目前为止的完整暂定结果"。这两个东西如果混用,后果很严重。

举个例子。第一轮输出:committed 为空,intermediate 为"今天"。第二轮输出:committed 为"今天",delta 为"天气",intermediate 为"今天天气"。如果你把 delta 当成 intermediate 用,第二轮你拿到的就是"天气",拼上之前的"今天"得到"今天天气",看起来也对。但第三轮如果引擎修正了,committed 还是"今天",delta 变成"天汽"(修正了之前的"天气"),intermediate 变成"今天天汽"。这时候你用 delta 拼接就会得到"今天天气天汽",完全乱了。

所以正确的做法是:渲染字幕时以 intermediate 为准来显示暂定部分,以 committed 为准来显示稳定部分,delta 只用来做增量更新或者日志记录,不要直接拿 delta 去拼字幕。

2.2 为什么引擎要同时给 delta 和 intermediate

有人会问,既然 intermediate 是全量,那还要 delta 干嘛?这是为了效率。在长句识别场景里,intermediate 会越来越长,每次都传全量对网络和内存都是负担。delta 只传变化的部分,客户端可以自己维护一个缓冲区,把 delta 应用上去得到最新状态。但前提是客户端要正确处理"修正"这种情况——delta 可能是新增,也可能是替换。

MAI 协议里对 delta 的定义通常包含操作类型,比如 append 还是 replace。如果你的客户端只处理 append,遇到 replace 就会出错。这也是为什么我建议字幕渲染直接用 intermediate,省得自己维护 delta 状态机,除非你有明确的性能需求。

2.3 暂定后缀的显示策略

暂定后缀怎么显示,是个产品问题也是技术问题。常见策略有三种:第一种是灰色显示,表示"这段还可能变";第二种是不显示,等 committed 了再显示,但这样字幕会有延迟;第三种是显示但加动画,变化时淡入淡出。

我个人的经验是,会议记录类产品适合第一种,因为用户能感知到识别在进行;实时字幕类产品适合第二种加一点延迟补偿,因为字幕跳动比延迟更让人难受。具体选哪种,要看你产品的核心场景。但无论哪种,技术上都要求你把 committed 和暂定后缀分开管理,不能混在一个字符串里。

3. 已定稿前缀和暂定后缀的状态机怎么设计

理解了各个字段的语义,接下来就是工程实现。实时字幕的核心是一个状态机,它要维护"当前已定稿的前缀"和"当前暂定的后缀",并在每次收到识别结果时正确更新。

3.1 状态机的基本结构

我一般会设计两个缓冲区:一个叫 committedBuffer,存已经定稿的文字;一个叫 tentativeBuffer,存暂定的文字。每次收到识别结果,先更新 committedBuffer(如果新的 committed 比旧的更长,就替换;如果一样就跳过),然后用新的 intermediate 减去 committed 部分,得到 tentativeBuffer。

这里有个细节:committed 有时候会回退。虽然理论上 committed 是稳定的,但实际工程中因为网络重传、会话恢复等原因,可能会收到比当前短的 committed。这时候不能直接替换,要做长度比较,只接受更长的 committed。这个坑我在做断线重连的时候踩过,重连后服务端可能从头开始发 committed,如果不做长度判断,字幕会突然变短。

3.2 处理修正和回滚

修正和回滚是实时字幕最麻烦的地方。修正指的是引擎把之前 committed 的内容改了,回滚指的是引擎把 committed 的内容撤回了。虽然协议上 committed 应该是稳定的,但实际中这两种情况都可能发生,尤其是在识别质量差或者音频有噪声的时候。

处理修正的策略是:如果新的 committed 和旧的 committed 有公共前缀,就保留公共前缀,替换后面的部分;如果没有公共前缀,就整体替换。处理回滚的策略是:如果新的 committed 比旧的短,且旧 committed 以新 committed 为前缀,就接受回滚;否则忽略这次更新,等下一次。

这些策略听起来简单,但要在代码里写对不容易。我建议把状态机的更新逻辑单独抽成一个函数,输入是旧的 committedBuffer 和新的 committed,输出是更新后的 committedBuffer 和一个"变化类型"标记(新增、修正、回滚、无变化)。这样渲染层可以根据变化类型决定怎么做动画。

3.3 和渲染层的解耦

状态机归状态机,渲染归渲染,这两层一定要解耦。我见过不少项目把状态更新和 UI 更新写在一起,结果就是每次识别结果回来都要重绘整个字幕区域,性能差不说,还容易出 bug。

正确的做法是:状态机只负责维护文字状态,输出一个"当前应该显示什么"的快照;渲染层拿到快照后,和上一次的快照做 diff,只更新变化的部分。这样即使识别结果每秒回来十次,渲染层也能保持稳定。diff 的粒度可以是字符级,也可以是词级,看你的性能要求。

4. 那些让字幕跳动的真实原因

理论讲完了,来说点实际的。字幕跳动、闪烁、回滚,这些现象背后往往不是单一原因,而是多个问题叠加。我把自己遇到过的情况整理了一下,按出现频率排序。

4.1 把 intermediate 当 committed 用

这是最常见的原因,没有之一。很多开发者图省事,直接把 intermediate 渲染出来,等 committed 更新了再替换。这样做的后果是,每次 intermediate 变化,字幕都要重绘,而 intermediate 变化非常频繁,可能每几百毫秒就变一次。用户看到的就是字幕一直在闪。

正确的做法是,committed 部分用稳定样式渲染,intermediate 部分用暂定样式渲染,两者分开。这样 committed 不变的时候,那部分字幕就不动,只有暂定部分在变,视觉上稳定很多。

4.2 没有处理 delta 的 replace 操作

前面说过 delta 可能是 append 也可能是 replace。如果你的客户端只处理 append,遇到 replace 就会把旧文字和新文字拼在一起,导致字幕越来越长,最后完全乱掉。这个 bug 在长句识别时特别明显,因为长句更容易触发修正。

排查这个问题的办法是打日志,把每次收到的 delta 操作类型和内容都记下来,看看有没有 replace。如果有,就说明你的客户端处理逻辑有问题。

4.3 会话恢复导致的 committed 重置

断线重连后,服务端可能重新开始发 committed,从空开始。如果你的客户端不做长度判断,直接替换,字幕就会突然清空然后重新出现。这个现象在移动网络下特别常见,因为移动网络容易断。

解决办法是在客户端维护一个会话 ID,重连后如果会话 ID 变了,就清空缓冲区重新开始;如果会话 ID 没变,就保留缓冲区,只接受更长的 committed。这样即使服务端重发,客户端也不会乱。

4.4 渲染层的重绘策略太激进

有时候状态机是对的,但渲染层每次收到更新就全量重绘,导致性能问题。特别是在字幕很长的时候,全量重绘会卡顿,卡顿看起来就像跳动。

优化办法是用虚拟列表或者只更新变化的 DOM 节点。如果是 Web 端,可以用 requestAnimationFrame 做节流,把多次更新合并成一次渲染。如果是移动端,要注意避免在主线程做重绘。

5. 一套可落地的字幕状态管理方案

讲了这么多问题,最后给一套我自己在用的方案。这套方案不依赖具体框架,思路是通用的,你可以根据自己的技术栈调整。

5.1 数据结构设计

核心数据结构就两个:committedText 和 tentativeText。committedText 是字符串,存已定稿的前缀;tentativeText 也是字符串,存暂定的后缀。另外还需要一个 lastCommitted 变量,存上一次的 committed,用来做长度比较和修正检测。

每次收到识别结果,先比较新的 committed 和 lastCommitted。如果新的更长,就更新 committedText 和 lastCommitted;如果一样,跳过;如果更短,检查是不是回滚,是就接受,不是就忽略。然后用新的 intermediate 去掉 committedText 前缀,剩下的就是 tentativeText。

5.2 更新逻辑的伪代码

def update_state(new_committed, new_intermediate, state): old_committed = state.last_committed if len(new_committed) > len(old_committed): # 新增或修正 if new_committed.startswith(old_committed): state.committed_text = new_committed else: # 修正,找公共前缀 common = common_prefix(old_committed, new_committed) state.committed_text = new_committed state.last_committed = new_committed elif len(new_committed) < len(old_committed): # 可能的回滚 if old_committed.startswith(new_committed): state.committed_text = new_committed state.last_committed = new_committed # 否则忽略 # 计算暂定后缀 if new_intermediate.startswith(state.committed_text): state.tentative_text = new_intermediate[len(state.committed_text):] else: state.tentative_text = new_intermediate

这段逻辑看起来简单,但覆盖了新增、修正、回滚、无变化四种情况。实际用的时候还要加上异常处理和日志,方便排查问题。

5.3 渲染层的 diff 策略

渲染层拿到 committedText 和 tentativeText 后,不要直接拼接渲染,而是和上一次的渲染结果做 diff。diff 的粒度建议是字符级,因为中文没有词边界,词级 diff 反而复杂。

具体做法是维护一个"当前渲染的字符串",每次更新时计算新字符串和旧字符串的最长公共前缀,只更新公共前缀之后的部分。这样即使 committedText 和 tentativeText 都变了,只要前缀没变,渲染层就只更新后缀,性能很好。

5.4 实测效果和调优建议

我用这套方案做过一个会议记录产品,实测下来字幕稳定性提升很明显。之前用户反馈字幕闪,用了这套方案后基本没有闪的反馈了。调优方面有几个建议:第一,committed 的更新频率不要太高,如果引擎支持,可以设置一个最小更新间隔;第二,tentativeText 的渲染可以加一点延迟,比如 100 毫秒,避免频繁变化;第三,如果产品允许,可以在 tentativeText 变化时加淡入动画,视觉上更平滑。

6. 从 MAI 协议看流式识别的设计哲学

最后聊点偏原理的东西。MAI 协议把 committed、delta、intermediate、completed 这几个概念分开,背后其实是一种"渐进式确定"的设计哲学。语音识别本质上是一个不断修正的过程,越往后听,判断越准。协议的设计就是把这个过程显式地暴露给客户端,让客户端自己决定怎么处理不确定性。

6.1 为什么不做成一次性返回

有人会问,为什么不干脆等整句话说完再返回结果,这样就没有不确定性了。答案是延迟。实时字幕的核心价值就是实时,如果等整句说完再返回,延迟可能好几秒,体验就没了。所以必须在识别过程中就返回部分结果,用 committed 和 intermediate 来区分确定程度。

这个设计思路其实在很多流式系统里都有,比如流式翻译、流式摘要。核心都是"先给部分结果,再逐步修正"。理解了这一点,就能理解为什么 committed 不能当定稿用——它本来就是为"部分结果"设计的。

6.2 客户端应该承担什么责任

MAI 协议把不确定性暴露给客户端,意味着客户端要承担一部分状态管理的责任。这不是协议设计得不好,而是实时系统的必然。服务端不知道客户端要怎么用这些结果,是做字幕、做翻译还是做别的,所以只能把原始状态给出来,让客户端自己决定。

作为客户端开发者,要做的就是理解这些状态的语义,设计好状态机,把不确定性处理好。这也是为什么我花这么多篇幅讲状态机——它才是实时字幕的核心,识别引擎只是提供原料。

6.3 常见的协议误读

最后列几个我见过的协议误读,供大家避坑。第一,把 committed 当最终结果,前面讲过了。第二,把 completed 当换行依据,其实 completed 只表示这句话说完了,换行还要看标点和语义。第三,忽略 delta 的操作类型,导致拼接错误。第四,不做会话管理,重连后状态混乱。第五,把 intermediate 直接渲染,导致字幕闪。

这些误读本质上都是对协议语义理解不到位。我的建议是,拿到一个流式协议,先把每个字段的语义搞清楚,特别是那些看起来相似的字段,一定要区分清楚。然后再设计状态机,最后才是渲染。顺序反了,后面全是坑。

我在实际项目里最大的体会是,实时字幕的难点不在识别,而在状态管理。识别引擎的准确率再高,如果状态管理做不好,字幕照样跳。反过来,即使识别准确率一般,只要状态管理做得好,字幕看起来也会很稳。所以如果你在做实时字幕,建议把精力多花在状态机上,这块做扎实了,整个产品的体验就上来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询