Skip to main content

08 - Omni 模型与全双工

02 篇第一节讲过拼接式为什么会丢信息:用户不耐烦的语气,过了「转成文字」那一步就没了。

结论是要让声音直接进模型、模型直接吐声音,中间不落回文字。能同时干这两件事的模型叫 omni 模型

这一篇讲三件事:

问题在哪节
它内部怎么组织 —— 为什么会分成「想」和「说」两半二、三
一个请求怎么同时从两个出口出去
用户中途插话时,服务端要做什么五、六

第三件是纯文本时代根本不存在的问题:用户打断时,你得同时叫停四个进程,还不能漏。

一、拼接式链路丢掉了什么

这套「ASR → 大模型 → TTS」的串法叫拼接式。它至今仍是绝大多数语音产品的实现,因为每段都能单独换、单独调、单独买。

它的问题不是慢,是每转换一次管子就窄一次。把同一句「你能再说一遍吗(不耐烦)」放进两条链路,看各自还剩下什么:

同一句「你能再说一遍吗?(不耐烦的语气)」走两条链路拼接式 · ASR → LLM → TTSASR输出纯文字LLM只看得到文字TTS语气自己猜丢掉的:语气、情绪、口音、背景声、说话人身份、重音位置累加的:三次模型调用的延迟,且必须串行 —— 前一个不出完后一个不能开始原生 omni · 一个模型内部完成音频直接进 thinker连语气一起被编码talker 接的是 thinker 的隐状态不是文字保留的:全部副语言信息,talker 能据此选语气省下的:两次「转成文字再转回来」的往返,可以边想边说代价也很明确:原生 omni 是一个大得多的模型,部署门槛更高;而且它把三段能力绑死了 —— 想单独换掉 ASR 或换个音色,做不到。
选型上没有绝对答案。如果产品只要「能听会说」,拼接式更灵活、更便宜、每一段都能单独替换和调优;只有当「听出情绪并据此调整语气」这类能力成为核心体验时,原生 omni 的代价才划算。

二、thinker 与 talker 的分工

一个自然的疑问:既然要端到端,为什么不干脆让一个模型交替吐文本 token 和音频 token?

因为两件事的最优步频差了一个数量级。文本一个 token 大约是 1~2 个汉字,音频一帧只有 40~80 毫秒。

同一句话,文本侧十几步就说完了,音频侧要几百步。硬塞进一个序列,要么文本被音频的步频拖慢,要么音频被文本的粒度撑粗。

拆成两个模型之后,分工变成:

  • thinker:多模态输入 → 文本回答。步频按文本走,负责全部的理解和推理。
  • talker:thinker 的隐状态 → 音频码本。步频按音频帧走,负责音色、韵律、语气。

关键在于talker 吃的是隐状态而不是文本。这一个设计决定带来三个连锁后果:

  1. talker 能拿到文本里没有的信息。同一句「好的」,thinker 的隐状态里带着上文的情绪,talker 因此能说出不同的语气。
  2. thinker 必须边生成边把隐状态送给 talker。等整段文本生成完再送,就白白浪费了并行的机会,首个音的延迟会变成「思考时间 + 合成时间」。
  3. 一个 thinker 不能同时驱动两个 talker。talker 的解码依赖 thinker 隐状态作为前缀,两个独立的 talker 共用一个前缀没有语义。SGLang-Omni 的路由配置里明确写了不支持这种扇出,理由就是这条。

Qwen 官方的总览图把这个结构画得很清楚 —— 多种模态从下方进入 thinker,talker 挂在 thinker 侧面接它的隐状态:

Qwen3-Omni 的模型结构总览:文本、图像、音频、视频经各自编码器进入 MoE thinker,talker 接 thinker 的隐状态并输出多码本音频
出处:Qwen3-Omni 官方仓库(技术报告)。官方把音频与图像编码器叫「塔」(tower),本专题沿用这个说法。

三、Qwen3-Omni 的八阶段

把上面的结构落到实际的服务拓扑上,SGLang-Omni 给 Qwen3-Omni 的语音路径拆了八个阶段:

两个编码器是并行的,箭头只是画图的顺序;mm_aggregate 是一个扇入点,要等三路都到齐才往下走。整条链路有两个终点:文本从 decode 出去,音频从 code2wav 出去。两边由协调器合并成一个响应。这也解释了为什么框架层必须支持「多终端阶段」这个概念:单终点的流水线抽象接不住 omni。

逐段的职责与容易踩的坑:

阶段干什么值得注意的
preprocessing下载/解码媒体、分词、跑 HF processor纯 CPU 密集。放在 GPU 进程里会拖住整个调度循环
image_encoder / audio_encoder各自模态的塔可以并行;设计上留了把某个塔卸到 CPU 的余地
mm_aggregate把三路结果按占位符位置拼成 thinker 的输入扇入点,要处理「某一路失败了怎么办」
thinkerMoE 骨干,生成文本通常是全流水线里最大的模型,需要张量并行
decode文本流式输出终点之一
talker_ar隐状态 → 码本每步要吐多个码本,且下一步依赖上一步的反馈
code2wav码本 → 波形,按块流式终点之二,有跨块的因果状态

四、三条流并行推进

八阶段图看起来像串行链路,实际运行时 thinker 一开口,三条流就并行推进了。这个时序关系是 omni 低延迟的全部来源:

三个工程要点藏在这张时序里:

  • 文本比音频先到。用户在听到声音之前就能看到字幕,这是 omni 产品普遍有字幕的原因 —— 不是产品设计选择,是链路的自然产物。
  • 流结束必须显式发信号code2wav 有跨块状态,最后一块要把缓冲里剩下的样本冲出来。没有结束信号,尾音会被吞掉。
  • 流式响应无法用 HTTP 500 报错,因为响应头早就发出去了。具体的约定见下面这个块。
流式失败的信号是「少了个哨兵」,不是 500

流式响应一开始就把 HTTP 头发出去了,之后再出错也没法改状态码。

SGLang-Omni 的约定是:成功的流以一个明确的完成哨兵帧结尾,失败的流在发哨兵之前直接断连。所以客户端判断失败的依据是「连接结束了,但没收到哨兵」。

这个契约必须写进客户端。不写的话,一次中途 OOM 在客户端看起来就是「音频提前结束了」,你会一直以为是模型说完了。

五、全双工与打断的清理

真正的语音对话产品要的不是「说完一句等一句」,而是能随时插话。这带来两个纯服务端的问题。

用户在第 3 秒插话时,流水线里的四个阶段各自持有什么thinker正在解码第 40 个 token占着一整块 KV cachetalker_ar正在解码第 90 帧另一块 KV cachecode2wav持有跨块因果状态缓冲里还有半块音频传输层在途的张量缓冲发出去还没被确认中止要广播不是只发给第一个阶段每段各自清理KV、缓冲、传输句柄清理必须幂等同一个 id 会被清多次还要处理竞态上游算完时已被中止最后那格是真实踩过的坑:预处理阶段跑完把结果交出去时,这个请求早就被中止了,结果对象无人接收 —— 如果预处理里存了以 request_id 为键的共享状态(比如准备好的参考张量),这条路径上不清理就是内存泄漏。SGLang-Omni 的接入文档把这三条中止路径逐条列了出来,并要求清理函数幂等。打断在纯文本服务里只是「取消一个请求」;在多阶段 omni 里,它是一次需要在四个进程之间协调的分布式清理。
全双工的另一半是输入侧:用户说话的同时模型也在说话,服务端要一边接收音频流一边推送音频流。SGLang-Omni 为此单开了 /v1/realtime WebSocket 端点,因为 HTTP 的请求/响应模型天生装不下双向流。

六、omni 服务多出来的四件事

总结一下这一篇里散落的、纯文本服务不会遇到的问题:

  1. 多终端阶段。一个请求有两个终点(文本和音频),协调器要合并两者的完成状态,任何一个失败都要正确反映到响应上。
  2. 跨阶段流式。thinker 到 talker 传的是隐状态张量而不是 token id,这条流的传输开销直接进入首音延迟。
  3. 中止的分布式清理。见上一节。
  4. 两套输出长度限制。thinker 有输入长度校验,但 talker 也需要输出长度上限 —— 一个漏掉停止符的 talker 会无限解码下去,把显存吃光或把尾延迟拖垮。SGLang-Omni 的 RFC 把这条列为「对称性缺口」,明确指出目前只有输入侧有保护。

第 4 条值得单独记:任何自回归的子模块都需要一个输出上限,这在纯文本服务里是常识(max_tokens),但在多阶段流水线里,中间那些「用户看不见」的自回归段很容易被漏掉。

下一篇09 - 通用框架怎么 serve 多模态,从模型侧切到框架侧。