问语音助手「巴黎现在天气怎么样」,是两种截然不同的难度。
半双工系统眼里这是一道流水线题:录完音 → 转文字 → LLM 决定调 get_weather → 等 API → 念结果。中间卡两三秒没关系——反正轮到它说话之前,天塌下来用户也只能等。
全双工系统眼里这是一道实时题。之前的系列拆过:全双工模型一直在听、随时会说,时间轴不会为任何人暂停。可工具调用偏偏是个「暂停—等待—恢复」的过程:请求发出去了,答案还没回来,这段真空期模型说什么?用户这时候又插了一句话怎么办?Moshi、OmniFlatten 这批开创性的全双工模型,没有一个碰这个问题——它们能聊天,但不会办事。
NVIDIA 在 2026 年 8 月初放出的 NemotronLabs VoiceChat 11B,给出了第一份开源答案:在全双工模型的输出侧加一条专门的工具通道,把「调用脚本」当成和「说话内容」并行的一路 token 流;等待 API 返回的真空期,则交给一句可配置的 on-hold 消息——「稍等,我帮你查一下」。模型卡的说法是:这是第一个支持工具调用的开源全双工模型。
这篇顺着 NVIDIA-NeMo/Speech 的 nemotron-labs-voicechat 分支把它拆开:先看模型本体的四条通道怎么在 80 毫秒的节拍上共舞,再看工具调用怎么塞进不停走的时间轴,最后看 NVIDIA 怎么把这套东西塞进 Triton 和两个魔改 vLLM 的生产容器——后面这半段在以往的论文式全双工工作里是看不到的。
一、总览:三个部件,一个节拍器
NemotronLabs VoiceChat 是个 11B 的组合体,三个部件全部来自 NVIDIA 自家货架:
- 听觉:FastConformer 流式编码器,取自 0.6B 的流式 ASR 模型 Nemotron-Speech-Streaming-En-0.6b,把 16 kHz 用户音频压成低帧率的连续嵌入;
- 大脑:Nemotron Nano v2,9B 的 Mamba–Transformer 混合架构 LLM,负责所有「决策」——说什么、什么时候说、要不要调工具;
- 嘴巴:一个帧同步的流式 TTS(代码里叫 EAR TTS)加神经 codec,把大脑吐出的文本 token 实时变成 22.05 kHz 的语音。
真正把三个部件拧成一个系统的,是一个全局节拍器:80 毫秒一步,12.5 Hz。 编码器每 80 ms 产出一帧音频嵌入;LLM 每 80 ms 前向一步;TTS 每 80 ms 产出一帧音频码,恰好解码出 1764 个样本(22050 × 0.08)的波形。听、想、说、调工具,四件事在同一个时钟上各走一格,谁也不等谁——这就是全双工的机械本质。
每一步里,LLM 的输入是四路信息融合成的一个向量:当前帧的用户音频嵌入,加上上一步自己吐出的三个 token(agent 文本、用户转写、工具通道)各自查完嵌入表的向量,默认做加权求和(代码还支持门控融合)。输出侧则是三个并排的头:lm_head 管 agent 说什么,asr_head 管用户在说什么(边听边转写),function_head 管要不要调工具。一进三出,全部对齐在同一根时间轴上。
放回系列的三张地图,它的坐标很清晰:双工决策完全发生在 token 流内部,标准的 L2;输入侧不做离散化、用连续嵌入直进 LLM,和 SALMONN-omni 同路;输出侧则比谁都保守——LLM 只碰文本,一个音频 token 都不碰,声学全部外包给帧同步的 TTS。上篇拆 SALMONN-omni 和 Covo-Audio 时总结过那个正在成型的新共识:智能留在文本域,声学各回各家。 NemotronLabs VoiceChat 是这个共识迄今最彻底的执行者:9B 的大脑里没有一个音频码本的位置,Moshi 那笔「codec 进词表」的智能税,它一分都不交。
这个设计还有一层出身上的意义。它的直系前身是 NVIDIA 2025 年的 SALM-Duplex(arXiv:2505.15670)——那篇论文的卖点就是:用预训练的流式 ASR 编码器接管听觉,就不需要昂贵的语音预训练,任何现成 LLM 都能改装成全双工。VoiceChat 把这条路线放大到 9B 底座,又把输出侧从「LLM 直接出 codec」换成了外挂 TTS。
顺带一提发布形态里一个诚实的细节:这个「端到端模型」其实是拼出来的。仓库里 NemotronVoiceChat 类的 training_step 直接返回 None——组合体本身不训练,大脑(含听觉)和嘴巴各自独立训完,再用 combine_ckpt_conda.sh 把 STT、TTS、RNNT 三份权重合并成一个 HuggingFace checkpoint。两个自回归模型,在文本通道上对接成一个系统。
二、通道即时间线:轮次是「预测」出来的
理解这个模型的钥匙,是想清楚「通道」到底是什么。源码 duplex_stt_model.py 里有一段注释画得极好:
flow: |---user---||-------assistant--------||-user-|
text channel: 0000000000 1xxxxxxx0000000000000002 000000
agent 文本通道和时间严格对齐:用户说话时,它输出 PAD(0);模型决定开口,先吐一个 BOS(1),跟着吐正文 token;说完吐 EOS(2),回到 PAD。「轮次」不再是对话协议里的一个回合,而是通道上 BOS 与 EOS 之间的一段区间。 什么时候开口、什么时候闭嘴,就是「在哪一帧吐出 BOS/EOS」——轮次切换从一个工程问题(VAD 判停、超时阈值)变成了一个预测问题(下一个 token 是不是 BOS)。
用户转写通道同理,只是记号换成了两个便宜字符:^ 表示用户开口,$ 表示用户说完(直接复用词表里的现成 token,省掉两个特殊 token)。四条通道摆在一起,一次对话长这样:
推理循环的核心一步(_step_inference,简化):
# 每 80ms 执行一次
agent_text_emb = self.embed_tokens(gen_text[:, t-1]) # 上一步 agent 说的
user_text_emb = self.embed_asr_tokens(gen_asr[:, t-1]) # 上一步用户转写
user_audio_emb = audio_embeds[:, t] # 当前帧用户音频
function_emb = self.embed_tokens(gen_function[:, t-1]) # 上一步工具通道
fused = self.fusion_module(agent_text_emb, user_audio_emb,
user_text_emb, function_emb)
out = self.llm(inputs_embeds=fused, past_key_values=cache) # 9B 前向一步
gen_text[:, t] = sample(out.text_logits) # agent 下一个字
gen_asr[:, t] = out.asr_logits.argmax() # 用户转写下一个字
gen_function[:, t] = out.function_logits.argmax() # 工具通道下一个字
于是全双工的各种行为都成了这个循环的自然推论:
- 响应快:用户话音刚落,模型看到的音频嵌入已经包含完整语义,下一帧就可以吐 BOS,官方数字是约 450 ms 的轮次响应;
- 打断:agent 说到一半,音频通道和转写通道同时报告「用户开口了」,模型学过这种局面的正确应对——吐 EOS 让位,官方数字 480 ms;
- 判停靠语义:「嗯……让我想想」和「说完了」在声学上都是静音,但在 LLM 眼里语义完全不同。模型不依赖外挂 VAD 的静音时长阈值,理论上句中停顿不会被抢话(实际表现后面再说,这里先立个 flag)。
模型自己的预测之外,推理代码里还埋了一套保险丝(force_turn_taking):转写通道连续约 3 秒全是 PAD 且模型迟迟不开口,就强行往 agent 通道写一个 BOS;反过来 agent 说话时转写通道冒出 ^,若一段窗口内还没有 EOS,就强写一个 EOS。生产配置里还有第二套基于 RNNT 解码器的同款守护(下文第五节)。值得注意的是出厂参数把这些门限调得很宽——3 秒量级,而模型卡宣传的响应是 450 ms 量级。快速反应靠模型自己学到的行为,保险丝只兜「模型卡住了」的底。
三、嘴巴:两个自回归模型的锁步舞
大脑只出文本,声音从哪来?来自第二个自回归模型——Duplex EAR TTS,它和 LLM 以完全相同的 12.5 Hz 节拍锁步运行:LLM 每吐一个文本 token,TTS 就把它吃进去,同步产出一帧音频码。
# nemotron_voicechat.py · offline_inference(简化)
for t in range(1, T):
ans = self.stt_model._step_inference(t, ...) # 大脑走一步
code, past_key_values = self.tts_model.infer_codes_one_step(
current_subword_id=inference_state["gen_text"][:, t], # 本步文本
prev_subword_id=inference_state["gen_text"][:, t-1],
prev_audio_tokens=code, # 上一帧音频码
past_key_values=past_key_values,
) # 嘴巴跟一步
这个 TTS 值得单独一节,因为它不是普通 TTS 的流式化,而是为全双工特调的。代码注释交代了出身:架构基于 Audio Flamingo 3(arXiv:2507.08128)提出的流式 TTS,外加三处改造——文本与音频表示的门控融合、改善多 token 单词发音的 subword 感知嵌入,以及一对支持双工交互的定制 BOS/EOS 嵌入,让「说到一半被掐断」成为模型见过的正常局面。
内部结构几个有意思的点:
- 主干很小:28 层、隐宽 1152 的 Gemma3 结构 Transformer——嘴巴不需要智能,跟得上节拍就行;
- 字符感知编码(CAS):文本 token 先过一个字符级编码器再进主干。BPE 把 “weather” 切成什么样是分词器的事,发音却取决于拼写,让 TTS 看见字符能明显减少读错生僻词;
- 连续潜变量 + RVQ 贴码:每帧的生成不是在 31 个码本上各跑一次分类,而是先由混合高斯头(1024 个分量)采样连续潜变量,再分组「贴」回 RVQ 码本得到 31 个离散码——把逐码本分类换成连续空间的回归加投影,这是它能锁步跑实时的关键之一;
- CFG 也在场:推理时对「有文本条件/无条件」做双路前向,classifier-free guidance 的 scale 是 0.2,让咬字更稳。
全双工特调的部分藏在一个不起眼的分支里——文本通道一出 EOS,回喂给 TTS 的「上一帧声音」立即被换成静音帧的码:
# duplex_ear_tts.py · infer_codes_one_step
if self.cfg.get('inference_force_speech_silence_on_eos', True):
prev_audio_tokens = torch.where(
current_subword_id.unsqueeze(-1) == self.text_eos_id,
silence_codes, # EOS 一出现,下一帧起立即归于安静
prev_audio_tokens,
)
配合大脑侧「被打断就吐 EOS」的行为,打断的全链路就通了:用户开口 → LLM 吐 EOS → TTS 静音——没有任何一处需要「截断音频流」的外科手术,声音是顺着通道语义自然消失的。
音色方面,TTS 用 3 秒参考音频的 latent 做说话人条件。但发布的 checkpoint 做了一手「voice lock」:预设说话人的 latent 直接烘焙进权重,而把音频参考的投影层重新随机化——你给它任何新的参考音频,得到的都是无意义的条件向量。仓库里那个 _voice_lock 后缀的转换脚本注释写得很直白:只有预烘焙的音色能用。防语音克隆做进了权重而不是服务条款,这个思路值得记一笔。
四、第四条通道:工具调用怎么塞进不停的时间轴
到这里,前三条通道(音频进、agent 文本出、用户转写出)在之前的全双工工作里都能找到影子,第四条才是这个模型的独门菜。
先看训练时怎么教。工具调用在数据里被表示成函数通道上的一段 token:调用脚本是模型要学着「说」的,格式是纯文本的 <TOOLCALL> 包 JSON;三个 Nemotron 词表里的保留 token 划出边界——<SPECIAL_20> 开始调用(代码里叫 SOTC)、<SPECIAL_21> 调用结束(EOTC)、<SPECIAL_22> 响应结束(EOTR)。关键设计是插入式时间轴扩展:函数调用和响应的 token 序列被「插」进时间轴,序列因此变长,插入区间里 agent 文本通道全补 PAD、用户音频通道全补静音。翻译成人话:调工具的那几拍,对话时间被暂停键拉长了,但每条通道依然对齐——模型学到的是「说完 on-hold 消息后闭嘴等待,看到响应注入后再开口」。
推理时的一次完整调用长这样:
拆开说四个环节:
- 工具怎么注册:老老实实写进系统提示词。README 给的默认 Jinja 模板把工具列表(JSON schema)放进
<AVAILABLE_TOOLS>标签,再附上一大段决策规则——「匹配工具必须调用、常识问题直接回答、都不沾就礼貌拒绝」,甚至包括「参数必须来自用户说出的内容,缺参数要追问,不许瞎猜」; - 调用怎么发出:
function_head在工具通道上逐帧吐出<TOOLCALL>[{"name": "get_weather", "arguments": {"city": "Paris"}}]</TOOLCALL>。工具通道与文本通道共用词表和嵌入表,各挂一个输出头,说话和调工具互不抢字——这正是「第四条通道」而非「文本里夹工具指令」的意义; - 等待怎么填:每个工具可以配一组
ack_messages(on-hold 消息),服务端检测到调用一发出,就把预设的那句「Sure, let me check the weather for you.」注入 agent 文本通道,TTS 照常发声。用户听到的不是冷场,而是一句自然的过渡; - 响应怎么回来:外部执行完,结果被包成
<TOOL_RESPONSE>[...]</TOOL_RESPONSE>的 token 序列,逐帧注入工具通道(这也是为什么官方要求响应必须是 ASCII 且「TTS 友好」——它要原样喂进模型,模型多半会照着念)。响应注入完毕,模型回到正常节拍,开口播报结果。
效果如何?模型卡给的数字:BFCL-v3(经 AU Harness)平均 56.1%,其中 simple 58.5%、multiple 62.5%;Full-Duplex-Bench v3 的工具调用项,工具选对率 82.5%,参数准确率 44.2%。参数准确率明显是短板——语音链路里「用户说的城市名」要先被听对、再被拼对,比文本 agent 难一截。官方建议每会话不超过 5 个工具,且工具调用执行期间用户无法打断 agent——全双工在这几拍里暂时退化成了半双工,这是当前实现的诚实边界。
五、上线:把 80 ms 节拍塞进 Triton 和 vLLM
论文式的全双工工作到第四节就该收尾了,但这个仓库最独特的部分才刚开始:一条完整的生产链路。NVIDIA 把整套东西打进一个推理容器,跑在单张 80 GB 卡上(文档给的实际占用约 66 GB),对外是 WebSocket 接口——而且刻意兼容 OpenAI Realtime API 协议:session.update 配工具、input_audio_buffer.append 推音频、response.output_audio.delta 收语音,现成的 Realtime 客户端和 Pipecat 都能直接对接。
几个工程决定值得细看。
把「每帧一步」塞进 vLLM 的三板斧。 vLLM 的世界观是「一段 prompt,生成到 EOS 为止」,和 duplex「永远在生成、每步输入都是新的多模态嵌入」的世界观八字不合。适配的办法:
# streaming_llm_engine.py —— 让 vLLM 永不自行收尾
default_sampling = {
"max_tokens": 100000, # 设到天上去;结束一律靠显式 abort
"stop": [], "stop_token_ids": [],
"ignore_eos": True,
}
一是走自定义输入规格(custom_input_specs),让请求携带的不是 token 而是融合后的 embedding;二是会话开始时用一段假 prompt 开一个「永不结束」的请求,此后每帧调一次 append_request 续上新 embedding——这个 API 是 stock vLLM 没有的,容器启动脚本里那句「patches vLLM」指的就是它;三是断言每步恰好产出一个 token,多一个都算 bug。采样也拆成了两半:LLM 的 top-p、重复惩罚在 vLLM 外面对 logits 做(特殊 token 直通不采样,保证 BOS/EOS 决策不被采样噪声搅局),TTS 则用 skip_sampling 把混合高斯采样整个塞进 vLLM 内部。
精度是道菜谱,不是滑块。 LLM 跑 bf16、占 52% 显存池;EAR TTS 必须 fp32、占 18%——配置注释直说 bf16 会让语音出幻觉。整条链路没有任何权重量化,66 GB 就是这么来的。感知编码器则吃了另一副药:cache-aware 流式编码(每步只编码新增音频)加两张手工捕获的 CUDA graph——为什么是两张?因为首个 chunk 和后续 chunk 的 mel 长度不同,各需要一张静态图。codec 侧用因果卷积缓存做增量解码,注释里的原话是顺便消掉了拼接处的咔哒声。
打断在服务层的最后形态。 生产配置默认用 RNNT 解码器(合并 checkpoint 时塞进来的第三份权重)做轮次守护:它逐帧解码用户音频,连续 N 帧非空白且 agent 正在说话,就往 agent 文本通道当前帧写一个 EOS——和第三节的机制殊途同归,打断永远不是「掐断音频流」,而是「往通道里写一个 EOS,让声音顺着语义消失」。 代码注释里甚至警告:TTS 读取文本 token 的时机必须在轮次守护写入之后,顺序挪早一行,这套机制就静默失效。
工具的等待期是一台小状态机。 服务端内置了九个演示工具(天气、股价、汇率、新闻、GPU 占用……全是朴素的 HTTP 调用,没有 MCP),异步模式下调用一发出,LLM 就脱离 80 ms 节拍在后台全速生成,前台按分层策略播语音:先播 per-tool 的 on-hold 消息,API 慢了再播通用的「还在查」,超时兜底道歉。有个细节能看出调试的血泪:注入的每句 on-hold 消息都要包上 BOS、补至少 17 个 PAD 再加 EOS——不补的话 TTS 的自回归状态停在「话说一半」,会污染工具调用结束后的正式回答。
六、训练配方与体检报告
训练侧的信息散在模型卡和代码里,拼起来大概是:约 55 万小时音频(Fisher、LibriVox、LibriTTS 等真实语音加多套 TTS 合成的对话),文本侧有 Nemotron、UltraChat 等语料;上下文窗口最长 2 分钟音频。得益于 SALM-Duplex 路线——听觉交给预训练好的流式 ASR 编码器——整个系统不需要从头做语音预训练。
代码里能看到不少针对真实世界的加固:背景噪声、房间混响、麦克风脉冲响应、有损编解码四层音频增强;训练时随机往「agent 说话、用户沉默」的段落里叠加用户的附和声(backchannel),教模型别把「嗯嗯」当打断;还有早打断增强,专门制造用户提前插话的局面。
体检报告(模型卡数字):Full-Duplex-Bench 1.0 上轮次切换时延 448 ms、用户打断处理满分 1.0;VoiceBench 在开源全双工模型里排第二。但停顿处理只有 0.153——「嗯……让我想想」还是容易被抢话,第二节立的 flag 没能立住:架构上判停靠语义,不代表行为上真能等你想完。这恰好呼应综述那篇的核心论点:架构可达不等于行为可得,缺的那格永远是数据的格子。 官方局限清单也异常坦诚,摘几条:多轮之后可能退化成胡言乱语;说完一轮后可能自说自话刹不住车;backchannel 处理还不系统;嘈杂或强混响环境、尤其有背景人声时不适用。
七、放回地图
给这个系统在系列坐标系里定个位。
| Moshi | SALMONN-omni | NemotronLabs VoiceChat | |
|---|---|---|---|
| 听觉输入 | 离散 codec token | 连续嵌入 | 连续嵌入 |
| LLM 输出 | 文本 + 音频 token | 文本(含状态 token) | 三路 token:文本 + 转写 + 工具 |
| 发声 | codec 解码进词表流 | 外挂流式合成器 | 外挂帧同步自回归 TTS |
| 工具调用 | 无 | 无 | 专用通道,TOOLCALL 脚本 |
| 生产链路 | 开源 demo | 论文原型 | Triton + 双 vLLM 容器 |
三点观察收尾:
其一,「智能留在文本域」的钟摆继续摆。从 Moshi 的 17 条并行流,到 SALMONN-omni 的连续直连,再到这里「LLM 一个音频 token 都不碰」,声学被推得离大脑越来越远;大脑省下来的容量,这次变现成了 9B 模型的知识和工具调用能力。
其二,全双工开始「agent 化」。此前的全双工竞赛卷的是对话流畅度——响应快不快、打断灵不灵;工具通道的出现把赛道拓宽成「边聊边办事」。56.1% 的 BFCL 和调用期间不可打断说明它离成熟还远,但方向已经立住:语音 agent 不必在「会聊」和「会干」之间二选一。
其三,也是这个发布最被低估的部分:它把全双工的工程账本摊开了。论文会告诉你 448 ms 的时延,不会告诉你 bf16 会让 TTS 出幻觉、vLLM 要打补丁才能每帧续写、on-hold 消息不补 17 个 PAD 会污染下一轮回答。这些细节对复现者和自建者的价值,不比模型权重小。
参考资料
- 代码与文档:NVIDIA-NeMo/Speech · nemotron-labs-voicechat 分支(模型实现
nemo/collections/speechlm2/,实时容器文档voicechat_realtime_instructions/) - 模型卡:NVIDIA-NemotronLabs-VoiceChat-11B(OpenMDW 1.1 许可,2026-08-03 发布)
- 前身论文:SALM-Duplex: Efficient and Direct Duplex Modeling for Speech-to-Speech Language Model
- TTS 出处:Audio Flamingo 3(流式 TTS 架构)
- LLM 底座:NVIDIA-Nemotron-Nano-9B-v2
- 站内相关:三张地图读懂语音对话系统、深入拆解 Moshi、深入拆解 LSLM、深入拆解 OmniFlatten、双拆 SALMONN-omni 与 Covo-Audio