档案№002日期字数约 6,600

从零训练一个 chunk interleaved 全双工语音模型

把一个按话轮问答的语音大模型改成 1 秒一拍、边听边说、还能调工具的全双工模型:协议、四阶段课程、数据合成,以及一路踩过的坑。

过去几个月我在做一件事:把一个「你说完我再说」的语音大模型,改造成能边听边说的全双工模型。它以 1 秒为一拍,每一拍听一秒用户的声音,自己决定这一秒是继续听、开口说话,还是发一个工具调用让后台 agent 去干活。

论文暂时不投了,但过程里有不少东西值得记下来。这篇按时间顺序讲:从选骨干开始,每一步模型缺什么、我们怎么补、补完的数字是多少。文末有一份清单,给想自己做一遍的人。

两条路线:并行流和 chunk interleaved

全双工模型目前大致两类做法。

并行流,代表是 Moshi:用户和助手各占一条流,按 80 ms 一帧同步往前走。好处是时间分辨率高,代价是要专门的多流结构,想在里面再塞进打字输入、后台通知、工具调用就很别扭。

chunk interleaved(也叫时分复用),代表是 OmniFlatten、MiniCPM-o 4.5:把时间切成固定长度的块(这里是 1 秒),每一块先放这一秒「进来」的东西,再放模型这一秒「出去」的东西,全部排进同一条因果序列。骨干就是一个普通的自回归语言模型,一行结构都不用改。也正因为是一条普通序列,用户打的字、后台任务进度、工具调用都能自然地放进去。

我选的是后者。它的诱人之处在于:手里已经有很强的回合制语音模型了,理论上「换个序列格式再训一训」就能变成全双工。这篇的主题其实就是:这个「训一训」到底要补哪些东西。

起点:骨干和耳朵

  • 大脑:Qwen2.5-Omni-7B 的 Thinker,只用它的文本语言模型部分(hidden size 3584)。
  • 耳朵:Qwen3-ASR-1.7B 的音频编码器 AuT(输出 2048 维)。
  • 接线:一个新建的线性层 perception.proj,把 2048 维投到 3584 维。

音频是 16 kHz 单声道,AuT 的帧率是 80 ms 一帧(12.5 Hz),所以一秒对应 13 个音频 token。

有个细节:不要把每一秒单独拿去编码。单独编码一秒钟的音频,边界上的表示很差。我们的做法是每一拍都对「最近 5 秒」做一次编码(滑动窗口),然后只把属于当前这一秒的 13 帧切出来送进语言模型。代价是重复计算,换来的是每一秒的表示都看得到足够的上文。

滑动窗口编码:AuT 每一拍编码最近 5 秒的音频,得到 5×13 帧,只把当前这一秒的 13 帧经 perception.proj 送进语言模型

协议:一个 token 同时管「在听」和「这一秒说完了」

在 Qwen 原有的 151,665 个词表之外,加三个控制 token:

token 含义
<|bos|> 话轮开始
<|turn_eos|> 这一轮说完了
<|chunk_eos|> 这一秒的输出到此为止

这几个 token 的 embedding 和输出头放在一张独立的可训练小表里,原来的大词表矩阵保持冻结。

每一秒的序列长这样(示意):

… [这一秒的 13 个音频 token] [可选:这一秒的文本事件] → 模型输出 … <|chunk_eos|>

模型这一秒如果选择不说话,就直接输出 <|chunk_eos|>,只需要一次前向;如果要说,就先吐出这一秒的文字(说完一轮再跟一个 <|turn_eos|>),最后以 <|chunk_eos|> 收尾。连续几秒放在一起:

连续五秒的序列:第 1、2 秒只有音频和 chunk_eos;第 3、4 秒输出 Sure, the capital / of France is Paris.,第 4 秒以 turn_eos 结束这一轮;第 5 秒用户打字,模型在一拍内发出完整的工具调用

MiniCPM-o 和一些同类工作会在每一块开头放一个专门的 [listen] / [speak] 二选一 token。我们省掉了它:「听」就是「第一个 token 就是 <|chunk_eos|>」。这样协议里只有一个和时钟相关的 token,模型要学的决策也只有一个:这一秒什么时候停。

推理时,每秒的说话文本限制在 8 个 token 以内(防止一秒里说出十秒的话);工具调用例外,最多 512 个 token,保证一段 JSON 在同一拍里完整发出。

文字什么时候放出来

训练数据里每个词都有强制对齐得到的起止时间。最直接、也最符合因果的规则是:一个词在它说完的那一秒才放出来(word-end release)。写成公式就是:

词 w 放在第 k 拍:k(w) = max(1, ceil((t_end(w) - τ_lead) / 1s))

τ_lead 一开始是 0。后来为了让下游 TTS 早一点拿到文字,改成了 0.4 s(原因见后面 Talker 一节)。<|turn_eos|> 放在这一轮结束的那一拍;用户说完到助手开口之间留 0.6 s 的反应时间。

loss 要按全局 token 数归一化

流式序列里,「听」的那一拍只有 1 个监督 token,工具调用那一拍可能有几十个。多卡训练时各卡分到的有效 token 数差别很大,如果每张卡先各自求平均,梯度就会偏向 token 少的那张卡。

做法是:每张卡上的 loss 加权求和后,除以所有卡的有效 token 总数,再乘以卡数抵消 DDP 的平均。这样多卡的梯度和「单进程一次跑完整个 batch」完全一致(我们在 4 张 A800 上核对过,相对误差 2.45×10⁻¹¹)。token 权重是:<|bos|>、<|turn_eos|>、工具调用 5.0,<|chunk_eos|> 和普通文字 1.0。

四阶段课程

直接拿回合制模型去训交错的流式对话,投影层和话轮控制都会一起乱掉。所以分阶段走:

阶段 序列模式 训练什么 数据 步数 / 峰值学习率
Stage 1 离线 只训 perception.proj + 3 个控制行(7.35M 参数),不上 LoRA 英文 ASR 20,000 / 1e-4
Stage 2 离线 + LLM LoRA(r=16, α=32) 243 万条口语问答 + ASR 48,001 / 2e-5
Stage 3 1 s 流式 合并 Stage 2,装新 LoRA,解冻控制 embedding ASR + 问答 130,000 / 2e-5
Stage 4 1 s 流式 全双工对话、工具调用,最后转全参数训练 合成双工会话 + 委托会话 + ASR 见下文

「离线」指的是 提示 → 整段音频 → 完整答案 → <|turn_eos|>,也就是普通回合制的格式。

Stage 1 只是把耳朵接上。刚初始化时模型根本不会停,64 条测试里 61 条一直生成到上限,WER 超过 1000%;只训投影层和 3 个控制行之后,截断全部消失,LibriSpeech test-clean 降到 3.49%(这组数字来自我们在 4 卡上做的完整复现)。

Stage 2 在离线格式下教它回答问题。注意答案的音频只用来提取词的时间戳,模型的输入始终只有用户音频。

坑一:模型不知道「一秒」在哪里结束

Stage 2 训完,离线模式下 LibriSpeech test-clean 的 WER 是 2.99%。同一份权重,什么都不改,只是把音频改成一秒一秒地喂、要求它每一秒都停一次,WER 变成了 140%(四个测试集上 123%–180%)。

四个 ASR 测试集上的 WER:离线解码 2–9%,同一权重改成每秒解码后 123–180%,流式训练后回到 3–16%

原因很简单:离线训练里从来没有出现过「一秒的边界」,模型没有任何关于什么时候该在一拍里收手的先验,只会在每一拍里拼命往外吐字。

这就是 Stage 3 存在的意义:切换到 1 秒交错的流式格式,让模型在每个安静的拍子上学会输出 <|chunk_eos|>,并且学会把要说的话按因果顺序分摊到各个拍子里。

结果出乎意料地好:Stage 3 的流式模型在四个 ASR 测试集(共 22,599 条)上的综合 WER 是 10.48%,反而低于离线前身的 10.72%(LibriSpeech test-clean 2.71%)。差距主要来自 FLEURS 的长句:离线自回归解码偶尔会陷进重复插入的死循环(WER 20.62%),而 1 秒一拍的结构天然把每一拍的生成限制在这一秒听到的范围内。

流式也有代价:知识类问答掉了一些,Llama Questions 从 78.2% 掉到 70.1%,TriviaQA 从 56.9% 掉到 53.0%。

全双工数据:为什么拼接对话不够

Stage 3 只见过单轮问答。在复现实验里,拿它跑多轮双工会话,「该说话的拍子」只有 51.8% 说对了。所以 Stage 4 要喂真正的双工对话。

问题是:带精确时间标注、带重叠和打断的真实双声道对话几乎没有。通行做法(参考 OmniFlatten)是拼接:把单轮问答 TTS 出来,拼成双声道会话,覆盖三种时序:

  • 正常接话:用户说完 0.6 s 后助手开口;
  • 用户打断:助手回复播出至少 25% 后,用户在截断点前 0.6 s 插话;
  • 说完以后等待;

再给一半的用户音轨加 15–30 dB 的 MUSAN 噪声。

拼接数据确实有效:复现里「该说话的拍子」从 51.8% 涨到 85.5%。在 Full-Duplex-Bench 上,这个模型的综合分是 0.953,高于 MiniCPM-o 4.5 的 0.863;真打断时 98.5% 都能停下来回应。

坑二:它学会的其实是一条「声音检测」规则

但 Full-Duplex-Bench v1.5 的干扰项一测就露馅了:用户转头跟旁边的人说话,它 97% 的情况会接话回答;背景里有人声,96% 会回答。

回头看数据就明白了:TTS 合成的单轮问题里,句子中间几乎没有超过 0.3 s 的停顿(p90 只有 0.24–0.29 s)。在这样的数据里,下面这条规则从来不会被违反:

安静超过 0.6 s 就接话;助手说话时只要有人出声,就停下来回答。

模型学到的就是这条规则,跟话是谁说的、对谁说的没有关系。

于是我们重新合成了一份多条件双工数据:343,763 个训练会话,共 7,991 小时,中英文都有。在拼接的基础上叠加四种情况:

  1. 句中犹豫停顿:用强制对齐找到句法上会犹豫的位置(虚词后、逗号处、前提和问题之间),插入 0.3–3.0 s 的对数正态停顿(至少 40% 跨过一整拍),填上低电平的房间底噪并做交叉淡化;还有带「um / uh / let me think」的变体。停顿期间只监督 <|chunk_eos|>。
  2. 用户附和与背景人声:助手说话时,在用户音轨叠加「yeah」「mm-hmm」「right」或背景人声,监督助手继续把话说完。
  3. 旁人插话,先停后续:用户说着说着转头跟别人讲话,监督助手先停下,等旁人的话说完再把没讲完的内容接着讲。
  4. 助手附和:用户一段话超过 6 秒时,在合适的词间空隙插入助手简短的「嗯」「对」。

用这份数据继续训练后,Full-Duplex-Bench 上的变化:

Full-Duplex-Bench 前后对比:句中停顿被抢话 19.9%→6.5%(Candor)、11.7%→2.2%(合成);对旁人说话误回应 97.0%→6.0%;对背景人声误回应 96.0%→17.0%;对真打断的回应 98.5%→55.5%

最后一行是代价。FDB v1.5 把模型分成两类:responsive(有重叠就让,真打断处理得好,但会被无关的声音带跑)和 floor-holding(不轻易让出话轮,抗干扰,但响应真打断会慢)。我们从前者走到了后者。在不认识用户声音的前提下,这本质上是灵敏度和特异度的取舍,没有白给的午餐。

还有一个小坑:只用双工数据训、不混 ASR 数据,复现里 Common Voice 的 WER 从 16.3% 涨到 32.7%,不该说话时开口的拍子也变多了。所以后面的数据配方里一直混着 ASR 和问答。

工具调用:死掉的 embedding 行

我们还想让模型在对话中把活儿派给后台 agent(比如写代码、跑测试)。协议上:

  • 每一拍的 13 个音频 token 之后,可以附上这一秒发生的文本事件:用户打的字、正在输入的提示、后台任务的进度和结果;
  • 模型通过两个工具行动,都包在 <tool_call>…</tool_call> 里:delegate{text, kind}(kind 是新任务 / 中途修改 / 取消)和 memory{op, text}。

坑三:验证 loss 很低,但一次都没调用过

第一版在冻结 embedding 的前提下用 LoRA 训练。验证集 teacher-forced loss 是 0.656,看起来很健康。但自由生成一测:258 个会话、506 次该调用的时机,一次工具调用都没发出。

查 Qwen2.5-Omni-7B Thinker 的词表才发现:<tool_call>(ID 151657)和 </tool_call>(ID 151658)在预训练里从来没被训练过,输入 embedding 的范数是 0.000,跟没用过的 padding 行一模一样(正常 token 的平均范数是 0.771)。embedding 冻住、LoRA 只动注意力和 MLP,模型在 15 万个候选里给 <tool_call> 的概率一直在 10⁻⁴ 左右。

左:tool_call 两行和 padding 行的输入 embedding 范数都是 0.000,正常 token 平均 0.771。右:自由生成时工具调用准时召回,控制行冻结 0.0%,可训练 81.0%,加多条件数据 88.8%

修法:把这两个 token 也升级成可训练的控制行(重新用正态分布初始化),loss 权重设为 5.0。同时改成全参数训练(AuT 冻结,FSDP,fp32 master 权重),加上 0.4 s 的文本提前和按进度的余弦学习率。500 步之内 teacher-forced 调用概率就升到了 0.77;最终模型在留出会话上准时召回 88.8%,1 秒内召回 98.8%,每个会话平均误调用 0.15 次,流式 ASR 的 WER 保持在 2.0%。

两条教训:

  • 任何新加的、或者「看起来词表里有」的特殊 token,先查一下它的 embedding 范数。
  • **teacher-forced loss 不能代表自由生成时会不会发出某个 token。**一定要做自由生成评测。

让它开口:解耦的 Talker

Thinker 只输出文字,还需要把文字变成声音。

我们没有训练端到端的声学编解码器,原因是合成数据只用了 7 个预设音色、存成 16 kHz,端到端训练只会让解码器记住这 7 个声音。做法是解耦:

  • 冻结训练好的 Thinker;
  • 复用 MiniCPM-o 4.5 的 TTS 解码器(20 层 Llama,768 维),让它预测 25 Hz 的 S3 语音 code;
  • 再用冻结的 token2wav,配合任意一段参考音频,合成 24 kHz 波形。音色完全由参考音频决定,训练不会改变它。

按词尾切,不按秒切

因为文字是按「词说完的那一拍」放出的,如果 S3 code 也按每秒固定 25 个去切,跨越拍子边界的词,它的声音就会被分到错误的单元里。所以我们按每一拍最后一个词的结束时间来切 S3 code:中间单元平均 25.3 个 code(约 1.01 s),没有空单元。被打断的那一轮,最后一个单元不加结束符,让 Talker 学会「话说一半被掐断」,而不是收出一个完整句尾的语调。

两种切分 S3 code 的方式:按秒固定切会把跨拍的词 capital 的声音切成两半;按每拍最后一个词的结尾切,每个单元正好对应这一拍放出的词

坑四:词尾放出要付延迟

文字要等词说完才放出,Talker 要连续播放不卡顿,就得先缓冲一段。实测中位数要缓冲 1.44 s(p95 2.07 s)。这就是后期把文本提前量 τ_lead 设成 0.4 s 的原因。

不依赖 Thinker 的 Talker

Talker 的条件有两种做法:

  • hidden:每个文字 token 的 embedding 加上 Thinker 最后一层隐状态的投影(L2 归一化);
  • user_audio:只用文字 embedding,前面接用户最近 8 秒的 AuT 表示作为韵律前缀,完全不需要跑 7B 的 Thinker。

在 430 条用没见过的参考音色合成的验证样本上,两者几乎一样:UTMOS 都是 4.27(真值 S3 code 的上限是 4.20),说话人相似度 0.940 vs 0.937,英文 WER 5.3% vs 5.9%(差异在 bootstrap 置信区间内)。但训练成本从 84 降到 27 个 GPU 时,而且任何共享 Qwen 词表的 Thinker 都能直接驱动它。

怎么评测

几条我觉得重要的原则:

  • **全量测,不挑样本。**ASR 和口语问答在全部清单上跑(ASR 22,599 条,问答 4,955 条)。空输出、生成到上限的样本全部计入分母。
  • **流式就按流式测。**ASR 一秒一秒地喂、不加尾部静音;问答在问题后最多加 60 秒静音,以 <|turn_eos|> 停止。
  • **话轮行为用 Full-Duplex-Bench v1.0 和 v1.5。**v1.0 测停顿、附和、接话、打断,v1.5 测附和、旁人说话、背景人声这些干扰。比较不同模型的话轮决策时,我们用统一的「文本时间轴」把文字换算成时间,排除各家 TTS 的差异。
  • 工具调用用自由生成召回率,同时统计准时(同一拍)和 1 秒内两个口径,以及误调用次数。

还没解决的问题

  • **真人麦克风。**训练数据里的用户音轨全部是合成或重新配音的,模型唯一听过的真实录音是朗读的 ASR 语料。用笔记本麦克风实测三段对话,表现很差:一段完全没有回应,ASR 模式 CER 0.62;真实录音里接近静音的间隙,要人为混进一点底噪,模型才会把它当成「用户说完了」。
  • **工具调用的延迟。**一段几十个 token 的 JSON 串行解码,可能超过 1 秒的预算。投机解码是一个方向。
  • 打断响应率下降,见上文的取舍。
  • 每一步修复都同时改了好几个因素(数据、训练方式、控制行、文本提前),所以上面的数字都是「之前 / 之后」的对比,不是严格的单因素消融。比如工具调用能修好,到底是可训练行的功劳还是全参数训练的功劳,没有拆开测过。

清单:如果你也要做一遍

  1. 先用离线格式只训投影层,把耳朵接上,再上 LoRA,最后才切流式。
  2. 一定要有单独的流式阶段。离线训好的模型直接按块解码会彻底崩掉。
  3. 用一个 <|chunk_eos|> 同时表示「在听」和「这一拍结束」就够了。
  4. 双工数据里必须有句中停顿、用户附和、旁人说话、背景人声,否则模型学到的只是声音检测。
  5. 训双工时一直混着 ASR 和问答数据,防止识别能力退化。
  6. 新增的特殊 token 先查 embedding 范数;必要时做成可训练的控制行并加大 loss 权重。
  7. 多卡训练时,loss 按全局有效 token 数归一化。
  8. 用自由生成评测,别只看 teacher-forced loss。
  9. 尽早用真麦克风、真环境测一次。