ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MAI协议committed与intermediate:实时字幕跳字根因与前端状态机

MAI协议committed与intermediate:实时字幕跳字根因与前端状态机 1. 从一次字幕“跳字”事故说起去年帮一个做在线教育的朋友排查直播字幕问题他跟我抱怨“学生端老是看到字幕先蹦出半句话过一会儿又整段变了像有人在后台偷偷改稿。”我让他把原始事件流导出来一看问题很典型——客户端把committed事件里的文本直接当成了最终定稿渲染到屏幕上就不再管了结果后面来的intermediate增量把整句话补全时前端已经“锁死”了旧文本只能整段替换视觉上就是跳字、闪动、甚至前后矛盾。这个坑不是个例。只要涉及实时字幕、语音转写、协同编辑这类流式文本场景committed、delta、intermediate、completed这几个词就会反复出现。很多人第一反应是committed不就是“已提交、已确认”吗那它应该就是定稿了。但实际协议里committed描述的是一段已确定的前缀它保证的是“这部分不会再被推翻”而不是“整句话已经说完”。真正决定一句话是否终结的是completed这个状态位。这篇内容就围绕 MAI 协议里这套“已定稿前缀 暂定后缀”的机制展开。我会把committed、delta、intermediate、completed四个概念拆开讲清楚说明为什么不能把committed当文字定稿以及在实际工程里应该怎么处理。适合正在做实时字幕、语音转写、流式对话前端渲染的开发者也适合被“字幕跳字”折磨过的产品同学。读完你至少能判断你手里的字幕抖动到底是协议理解错了还是渲染策略写歪了。2. MAI 协议里四个关键词到底在说什么2.1 committed 不是“定稿”是“不可回退的前缀”先把最容易误解的词拎出来。committed在流式文本协议里的含义接近于“这段文本已经被确认不会再被修改”。注意它修饰的是一段前缀不是一整句。举个生活化的例子你在餐厅点菜服务员复述“您要的是宫保鸡丁、米饭……”说到“米饭”时他停了一下这部分他已经记下了不会改口但他还没说完后面可能还有“再加一个汤”。committed就是“已经记下的那部分”不是“整单已经下完”。在 MAI 协议的事件流里committed通常携带的是从句子开头到某个稳定边界之间的文本。这个边界可能是标点、可能是声学上的静音段、也可能是模型置信度足够高的一个切分点。协议保证的是这段前缀在后续事件里不会再被否定或重写。但它不保证这句话已经结束后面完全可能继续追加内容。2.2 delta 是增量不是全量delta这个词在流式场景里出现频率极高它的本质是“相比上一次新增了什么”。很多初学者会把它和全量文本搞混导致每来一个delta就重新拼一次整句拼着拼着就重复了。正确的理解是delta是一块积木你要把它按顺序垒到已有文本后面。在 MAI 的语境下delta往往和committed配合出现committed告诉你“前缀到哪儿了”delta告诉你“这次又多了哪几个字”。两者一个是位置锚点一个是增量内容缺一不可。只盯着delta拼字符串容易在断句处丢字只盯着committed不处理增量就会漏掉后续内容。2.3 intermediate 是暂定后缀随时可能被替换intermediate是这套机制里最“不稳定”的部分。它代表的是暂定的后缀——模型当前猜测的、还没被确认的文本。它可能被修正、被截断、被整体替换。你可以把它理解成输入法里的候选词你打了拼音候选栏里先蹦出一个词你还没按空格确认它随时会变。实时字幕里最典型的场景是用户说“今天天气”模型先给出intermediate“今天天气真”过一会儿又变成“今天天气真好”再后来确认成committed“今天天气真好。”如果你把intermediate直接当最终文本渲染并且不做替换屏幕上就会留下“今天天气真”这种半截话如果你每次都整段重绘又会闪得厉害。所以intermediate的正确用法是渲染但标记为可替换区域。2.4 completed 才是“这句话说完了”的信号completed是整句话的终结标记。它出现时意味着当前这句话已经完整后续不会再对这句话做增删改。注意区分committed是“前缀锁定”completed是“整句终结”。一句话可能经历多次committed比如长句被分成几个稳定段但通常只有一个completed。很多字幕跳字的根因就是把committed误当成completed。前端收到committed就以为万事大吉把文本写进“最终区”结果后面intermediate还在变两边打架。正确的状态机应该是intermediate区可替换committed区只追加不修改completed到来时才把整句归档。关键词含义是否可回退典型用途committed已确认前缀否锁定已稳定文本作为锚点delta增量内容否按序追加拼接新增文本intermediate暂定后缀是实时预览可被替换completed整句终结否归档整句触发后续处理3. 为什么“committed 当定稿”一定会出问题3.1 前缀锁定不等于整句终结这是最核心的认知偏差。committed的语义边界是“从句子开头到当前稳定点”它锁定的是已经过去的部分而不是整句话的终点。把前缀当整句等于把“已经记下的半句”当成“用户已经说完了”。在短句场景下这个错误可能不明显因为前缀往往就是整句但在长句、口语化表达、带停顿的叙述里错误会被放大。我实测过一个案例用户说“我想订一张明天下午三点从北京到上海的高铁票”。模型可能在“明天下午三点”处给出一个committed因为这里有明显的语义边界。如果前端此时把“我想订一张明天下午三点”当成定稿渲染后面“从北京到上海的高铁票”就只能另起一行或者硬插进去视觉上非常割裂。3.2 暂定后缀会持续修正前缀的“观感”intermediate虽然叫“暂定后缀”但它修正的往往不只是后缀本身还会影响整句的观感。比如前缀是“我想订一张”暂定后缀先给出“明天”再变成“明天下午”再变成“明天下午三点”。如果你把前缀当定稿、后缀当独立文本渲染用户看到的就是“我想订一张”后面跟着一个不断变长的尾巴而不是一句自然生长的话。更麻烦的是有些模型会在intermediate阶段对前缀做微调比如把“一张”改成“1张”把“明天”改成“明早”。虽然committed理论上保证前缀不变但intermediate阶段的文本是允许波动的。如果前端把committed和intermediate混在同一个渲染层就会看到已经“定稿”的字又被改了信任感直接崩掉。3.3 渲染层如果没有“可替换区”概念必然抖动前端渲染实时字幕本质上是在维护一个“文本状态机”。这个状态机至少要有两个区稳定区和暂定区。稳定区放committed的内容只追加不修改暂定区放intermediate的内容每次新事件来了就整体替换。completed到来时把暂定区的内容合并进稳定区并清空暂定区。如果渲染层只有一个文本缓冲区收到什么就写什么那committed和intermediate就会互相覆盖。表现就是先显示committed的半句然后intermediate来了把整句重写用户看到文字跳了一下下一个intermediate又重写一次再跳一下。这就是“跳字”的物理来源。注意不要把committed事件里的文本直接append到最终输出里。它应该进入稳定区而稳定区的更新策略是“只增不改”不是“来了就覆盖”。3.4 协议设计本身就在鼓励“分段确认”MAI 这套机制的设计意图其实是让流式文本更稳。它把一句话拆成“已确认前缀”和“暂定后缀”就是为了让前端可以尽早渲染稳定部分同时给暂定部分留出修正空间。如果你把committed当定稿等于主动放弃了这个设计带来的容错能力把“分段确认”退化成了“一次性确认”那协议里的intermediate和completed就形同虚设。换个角度想如果committed就是定稿那协议为什么还要单独设计completed为什么还要有intermediate这三个状态的存在本身就说明它们各司其职。committed管前缀稳定intermediate管后缀预览completed管整句终结。混用任何一个都会破坏这套分工。4. 正确的前端状态机该怎么搭4.1 两个缓冲区stable 和 pending最直接的做法是在前端维护两个字符串stableText和pendingText。stableText只接受committed和delta的内容按序追加永不回退pendingText每次收到intermediate就整体替换。最终渲染给用户的是stableText pendingText。这个模型的好处是职责清晰稳定区负责“不跳”暂定区负责“实时”。用户看到的文本始终是“已确认部分 当前猜测部分”既不会丢字也不会因为暂定部分的修正而影响已确认部分。completed到来时把pendingText合并进stableText清空pendingText整句归档。// 简化版状态机示意 let stableText ; let pendingText ; function onCommitted(text) { stableText text; // 只追加不覆盖 } function onDelta(text) { stableText text; } function onIntermediate(text) { pendingText text; // 整体替换 } function onCompleted() { stableText pendingText; pendingText ; archive(stableText); stableText ; } function render() { return stableText pendingText; }4.2 committed 和 delta 的拼接顺序不能乱committed和delta虽然都进稳定区但它们的语义不同。committed通常携带的是“从某个锚点到当前稳定边界”的文本而delta是“相比上次新增的内容”。如果协议里两者同时存在一定要按事件到达顺序处理不能先拼所有committed再拼delta。我踩过的一个坑是某次为了“优化性能”把同一批事件里的committed和delta分别收集后统一拼接结果顺序错乱文本变成了“我想订一张高铁票明天下午三点”。后来改成严格按事件时间戳顺序处理问题消失。流式文本的顺序就是语义乱序拼接等于制造乱码。4.3 intermediate 的替换要整段替换不能增量追加intermediate最容易写错的地方是把它当delta用。有人看到intermediate里只有几个字就append到pendingText后面结果暂定区越来越长重复内容堆叠。正确的做法是每次intermediate事件里的文本就是当前暂定后缀的全量内容直接替换pendingText。这一点在协议文档里通常不会写得特别显眼但实测下来是铁律。你可以做个简单验证连续打印几个intermediate事件的文本如果它们是递增的完整后缀那就该整体替换如果它们是碎片那才需要追加。MAI 语境下intermediate一般是完整后缀整体替换更稳。4.4 completed 到来时的归档动作completed不只是“这句话说完了”它还是一个触发点。归档时要做几件事把pendingText合并进stableText把整句写入历史记录清空当前缓冲区准备接收下一句。如果业务上有翻译、摘要、关键词提取等下游任务也应该在completed时触发而不是在committed时触发。我见过一个反例某系统在committed时就触发翻译结果翻译的是半句话等completed来了又翻译一次用户看到两句翻译来回闪。改成completed触发后翻译稳定了成本也降了。这个细节看似小但对下游链路影响很大。事件进入区域更新策略是否触发下游committedstable追加否deltastable追加否intermediatepending整体替换否completed归档合并并清空是5. 实操从事件流到屏幕的完整链路5.1 事件接收与排序第一步是保证事件按正确顺序到达处理层。实时字幕的事件流可能来自网络存在乱序、重复、延迟。处理层需要有一个轻量级的排序缓冲按事件序号或时间戳排序后再分发。如果协议里有sequence字段优先用它没有的话用到达顺序加时间戳兜底。这里有个经验不要在主渲染线程里做排序和拼接。字幕事件频率可能很高主线程被占满会导致界面卡顿。我通常把事件处理放在 Web Worker 或独立线程里处理完只把stableText和pendingText两个字符串传回渲染层。这样即使事件密集界面也不会掉帧。5.2 稳定区与暂定区的渲染分离渲染层最好把稳定区和暂定区分开样式。稳定区用正常字重暂定区用浅色或斜体让用户直观感受到“这部分还没最终确定”。这不是为了好看而是为了管理预期。用户看到浅色部分在变不会觉得系统出错看到深色部分稳定会对已确认内容产生信任。如果产品不允许样式区分至少要在逻辑上分开。比如暂定区的内容不参与复制、不参与搜索、不写入数据库。等completed后再统一处理。这样即使视觉上没区分数据层面也是干净的。5.3 参数选择缓冲区大小与刷新频率缓冲区不能无限增长。我一般设两个阈值单句最大长度和最大暂定时长。单句超过一定字数比如 200 字还没completed就强制归档避免内存泄漏暂定后缀超过一定时长比如 5 秒还没确认就降低刷新频率减少渲染压力。刷新频率也要控制。intermediate可能每秒来很多次如果每次都触发重绘性能吃不消。常见做法是节流到 30ms 或 50ms 一次用requestAnimationFrame对齐屏幕刷新。实测下来50ms 的节流在视觉上已经足够流畅CPU 占用能降一半以上。// 节流渲染示意 let renderScheduled false; function scheduleRender() { if (renderScheduled) return; renderScheduled true; requestAnimationFrame(() { renderScheduled false; updateDOM(stableText pendingText); }); }5.4 与后端 completed 信号的配合前端状态机要和后端的completed信号对齐。有些后端会在completed之后还发一些修正事件这时候前端要能识别并处理。我的做法是completed之后进入一个短暂的“静默期”比如 200ms期间如果还有同句的修正事件就合并处理静默期结束后再真正归档。这个静默期的长度要根据实际链路延迟调整。如果后端和前端在同一台机器上50ms 就够如果跨网络可能要 200ms 到 500ms。设得太短修正事件会被当成新句子设得太长用户会觉得字幕“卡住不动”。我一般先用 300ms 起步再根据实测调整。6. 常见问题与排查技巧实录6.1 字幕跳字、闪动、重复的排查顺序遇到字幕异常我通常按这个顺序排查先看事件流里committed和intermediate的比例如果intermediate频繁且文本波动大说明暂定区替换策略有问题再看completed是否正常到达如果长时间没有completed可能是后端断句逻辑卡住最后看渲染层是否把两个区混在一起这是最常见的低级错误。有个快速判断方法在控制台里把stableText和pendingText分别打印出来。如果stableText在completed之前就包含了整句说明你把intermediate误写进了稳定区如果pendingText越来越长且包含重复内容说明你把intermediate当delta追加了。这两个现象对应两种不同的修法别搞混。6.2 committed 之后文本还在变的几种可能理论上committed之后前缀不该变但实际中确实会遇到。常见原因有三个一是前端把intermediate写进了稳定区看起来像committed在变二是后端在committed之后又发了修正事件这属于协议实现问题三是事件乱序旧的intermediate晚于新的committed到达覆盖了稳定区。排查时先确认事件顺序再看后端是否有修正事件。如果是乱序加排序缓冲如果是后端修正需要和后端对齐协议语义。我遇到过第三种情况原因是网络抖动导致事件乱序加了序号排序后解决。这个坑不常见但一旦遇到很难查建议提前在事件里带序号。6.3 intermediate 为空或缺失时的降级策略有些场景下intermediate可能为空或者干脆不发送。这时候前端不能傻等要有降级策略。我的做法是如果一段时间内没有intermediate就只渲染stableText暂定区留空等completed来了再一次性补全。这样虽然少了实时预览但至少不会显示错误内容。还有一种情况是intermediate发送频率极低比如每秒一次。这时候节流策略要调整不能按 50ms 节流否则大部分渲染都是空转。可以根据实际频率动态调整或者干脆在intermediate到达时才触发渲染不做定时节流。6.4 长句、无标点、口语化场景的特殊处理长句和无标点场景是committed机制最容易暴露问题的地方。用户说了一长串没有标点的话模型可能很久才给一个committed暂定后缀越来越长。这时候如果暂定区渲染不当用户会看到一大段浅色文字在不停变化体验很差。我的处理方式是对暂定后缀设一个长度上限超过上限就只显示最后 N 个字符前面的用省略号代替。这样既保留了实时感又不会让屏幕被不稳定的文本占满。等completed来了再完整显示。这个策略在会议记录、直播字幕场景下特别有用。问题现象可能原因排查方法解决方向字幕跳字稳定区被覆盖打印两个缓冲区分离 stable 和 pending文本重复intermediate 被追加检查 pending 长度改为整体替换长时间不更新completed 未到达检查后端断句加超时强制归档前缀被修改事件乱序检查事件序号加排序缓冲暂定区过长长句无标点观察 intermediate 长度设长度上限6.5 实测有效的三个避坑技巧第一个技巧在开发阶段把stableText和pendingText用不同颜色渲染在页面上一眼就能看出哪个区在变。这个土办法帮我省了很多排查时间。第二个技巧给每个事件打上序号处理层按序号排序乱序问题直接消失。第三个技巧在completed之后加一个短静默期合并可能的修正事件避免一句话被拆成两句归档。这三个技巧都不复杂但都是实际踩坑后总结出来的。尤其是第一个看起来简陋但对理解状态机的行为特别直观。建议你在本地调试时先加上等逻辑稳定了再去掉。7. 我个人在实际操作中的体会这套committed前缀加intermediate后缀的机制刚接触时容易觉得绕但用顺了会发现它其实很符合流式文本的本质。文本本来就是逐步生成的硬要把它当成一次性定稿来处理反而别扭。把稳定区和暂定区分开不仅解决了跳字问题还让整个渲染链路变得可预测。我现在的习惯是任何流式文本场景先画状态机再写代码。状态机里明确标出哪些事件进稳定区、哪些进暂定区、哪个事件触发归档。画清楚了代码基本不会写歪。如果状态机画不出来说明对协议的理解还有漏洞这时候写代码大概率要返工。最后分享一个小技巧如果你不确定某个事件该进哪个区就问自己一个问题——“这个内容如果下一秒被替换用户会觉得是 bug 吗”如果会它就该进暂定区如果不会它才可以进稳定区。这个判断标准简单但很准。
RELATED READING

延伸阅读

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