08 - Omni 模型与全双工
02 篇第一节讲过拼接式为什么会丢信息:用户不耐烦的语气,过了「转成文字」那一步就没了。
结论是要让声音直接进模型、模型直接吐声音,中间不落回文字。能同时干这两件事的模型叫 omni 模型。
这一篇讲三件事:
| 问题 | 在哪节 |
|---|---|
| 它内部怎么组织 —— 为什么会分成「想」和「说」两半 | 二、三 |
| 一个请求怎么同时从两个出口出去 | 四 |
| 用户中途插话时,服务端要做什么 | 五、六 |
第三件是纯文本时代根本不存在的问题:用户打断时,你得同时叫停四个进程,还不能漏。
一、拼接式链路丢掉了什么
这套「ASR → 大模型 → TTS」的串法叫拼接式。它至今仍是绝大多数语音产品的实现,因为每段都能单独换、单独调、单独买。
它的问题不是慢,是每转换一次管子就窄一次。把同一句「你能再说一遍吗(不耐烦)」放进两条链路,看各自还剩下什么:
二、thinker 与 talker 的分工
一个自然的疑问:既然要端到端,为什么不干脆让一个模型交替吐文本 token 和音频 token?
因为两件事的最优步频差了一个数量级。文本一个 token 大约是 1~2 个汉字,音频一帧只有 40~80 毫秒。
同一句话,文本侧十几步就说完了,音频侧要几百步。硬塞进一个序列,要么文本被音频的步频拖慢,要么音频被文本的粒度撑粗。
拆成两个模型之后,分工变成:
- thinker:多模态输入 → 文本回答。步频按文本走,负责全部的理解和推理。
- talker:thinker 的隐状态 → 音频码本。步频按音频帧走,负责音色、韵律、语气。
关键在于talker 吃的是隐状态而不是文本。这一个设计决定带来三个连锁后果:
- talker 能拿到文本里没有的信息。同一句「好的」,thinker 的隐状态里带着上文的情绪,talker 因此能说出不同的语气。
- thinker 必须边生成边把隐状态送给 talker。等整段文本生成完再送,就白白浪费了并行的机会,首个音的延迟会变成「思考时间 + 合成时间」。
- 一个 thinker 不能同时驱动两个 talker。talker 的解码依赖 thinker 隐状态作为前缀,两个独立的 talker 共用一个前缀没有语义。SGLang-Omni 的路由配置里明确写了不支持这种扇出,理由就是这条。
Qwen 官方的总览图把这个结构画得很清楚 —— 多种模态从下方进入 thinker,talker 挂在 thinker 侧面接它的隐状态:

三、Qwen3-Omni 的八阶段
把上面的结构落到实际的服务拓扑上,SGLang-Omni 给 Qwen3-Omni 的语音路径拆了八个阶段:
两个编码器是并行的,箭头只是画图的顺序;mm_aggregate 是一个扇入点,要等三路都到齐才往下走。整条链路有两个终点:文本从 decode 出去,音频从 code2wav 出去。两边由协调器合并成一个响应。这也解释了为什么框架层必须支持「多终端阶段」这个概念:单终点的流水线抽象接不住 omni。
逐段的职责与容易踩的坑:
| 阶段 | 干什么 | 值得注意的 |
|---|---|---|
| preprocessing | 下载/解码媒体、分词、跑 HF processor | 纯 CPU 密集。放在 GPU 进程里会拖住整个调度循环 |
| image_encoder / audio_encoder | 各自模态的塔 | 可以并行;设计上留了把某个塔卸到 CPU 的余地 |
| mm_aggregate | 把三路结果按占位符位置拼成 thinker 的输入 | 扇入点,要处理「某一路失败了怎么办」 |
| thinker | MoE 骨干,生成文本 | 通常是全流水线里最大的模型,需要张量并行 |
| decode | 文本流式输出 | 终点之一 |
| talker_ar | 隐状态 → 码本 | 每步要吐多个码本,且下一步依赖上一步的反馈 |
| code2wav | 码本 → 波形,按块流式 | 终点之二,有跨块的因果状态 |
四、三条流并行推进
八阶段图看起来像串行链路,实际运行时 thinker 一开口,三条流就并行推进了。这个时序关系是 omni 低延迟的全部来源:
三个工程要点藏在这张时序里:
- 文本比音频先到。用户在听到声音之前就能看到字幕,这是 omni 产品普遍有字幕的原因 —— 不是产品设计选择,是链路的自然产物。
- 流结束必须显式发信号。
code2wav有跨块状态,最后一块要把缓冲里剩下的样本冲出来。没有结束信号,尾音会被吞掉。 - 流式响应无法用 HTTP 500 报错,因为响应头早就发出去了。具体的约定见下面这个块。
流式响应一开始就把 HTTP 头发出去了,之后再出错也没法改状态码。
SGLang-Omni 的约定是:成功的流以一个明确的完成哨兵帧结尾,失败的流在发哨兵之前直接断连。所以客户端判断失败的依据是「连接结束了,但没收到哨兵」。
这个契约必须写进客户端。不写的话,一次中途 OOM 在客户端看起来就是「音频提前结束了」,你会一直以为是模型说完了。
五、全双工与打断的清理
真正的语音对话产品要的不是「说完一句等一句」,而是能随时插话。这带来两个纯服务端的问题。
/v1/realtime WebSocket 端点,因为 HTTP 的请求/响应模型天生装不下双向流。六、omni 服务多出来的四件事
总结一下这一篇里散落的、纯文本服务不会遇到的问题:
- 多终端阶段。一个请求有两个终点(文本和音频),协调器要合并两者的完成状态,任何一个失败都要正确反映到响应上。
- 跨阶段流式。thinker 到 talker 传的是隐状态张量而不是 token id,这条流的传输开销直接进入首音延迟。
- 中止的分布式清理。见上一节。
- 两套输出长度限制。thinker 有输入长度校验,但 talker 也需要输出长度上限 —— 一个漏掉停止符的 talker 会无限解码下去,把显存吃光或把尾延迟拖垮。SGLang-Omni 的 RFC 把这条列为「对称性缺口」,明确指出目前只有输入侧有保护。
第 4 条值得单独记:任何自回归的子模块都需要一个输出上限,这在 纯文本服务里是常识(max_tokens),但在多阶段流水线里,中间那些「用户看不见」的自回归段很容易被漏掉。
下一篇:09 - 通用框架怎么 serve 多模态,从模型侧切到框架侧。