0.32 秒延迟之外:把说话人分离与 30B 转写拼成一条流水线

发布时间:2026/10/30 0:14:05
0.32 秒延迟之外:把说话人分离与 30B 转写拼成一条流水线

0.32 秒延迟之外:把说话人分离与 30B 转写拼成一条流水线

【免费下载链接】NVIDIA-Nemotron-3.5-Transcribe-30B-A4B项目地址: https://ai.gitcode.com/hf_mirrors/nvidia/NVIDIA-Nemotron-3.5-Transcribe-30B-A4B

「0.32 秒」这个数字在最近两周的社区文章里被反复引用:8 人说话人分离、约 1 亿参数、实时流式、DIHARD III 与 NOTSOFAR-1 上错误率大幅下降。它对应的其实是 Nemotron-3-Diarization 这条专门的分离模型。而眼前这个仓库NVIDIA-Nemotron-3.5-Transcribe-30B-A4B是一个 34B 总参数、4.3B 激活参数、最高支持 1 小时录音的离线语音大模型,模型卡里有一句必须被反复强调的话:

This is an offline model.Internal audio chunking does not make it a real-time streaming ASR service.

于是真正的工程问题变成了:当一个「0.32 秒级」的流式分离能力,被装进一个「离线整段转写」的 30B 模型里,流水线该怎么拼?本文不讨论发布会的参数罗列,只讨论三件事——两条流的时间轴怎么对齐、重叠语音的归属怎么判定、端到端延迟预算怎么分配。所有结论都能在仓库的config.json、processor_config.json与模型卡找到对应证据。

一个 checkpoint,而不是两个服务

大多数「ASR + 说话人分离」方案是级联式:先跑分离拿到 speaker turns,再按 turn 切片送进 ASR,最后把两边的文本和时间戳做字符串级别的缝合。这种拼法有两个固有误差源:切片边界会切断音素,且两套模型的帧率不一致,对齐只能靠猜。

Nemotron 3.5 Transcribe 把这件事收进了同一个前向:config.json里的pe_encoder_config指向ParallelExpertEncoderPT,它并列挂着两条分支——一条 32 层、d_model=1280、10 个注意力头的 Transformer ASR 编码器,和一条来自 Nemotron-3-Diarization 的说话人编码器(31 层、hidden_size=512、8 个注意力头、num_speakers=8)。两条分支的输出经 1280 维的IdentityConnector拼接,再投影到 2688 维,喂给 Mamba-2 / Transformer 混合的 MoE 主干(128 个路由专家、每 token 激活 6 个 + 1 个共享专家、hidden_size=2688)。

这就是「一条流水线」和「两条流水线」的本质区别:说话人信息不是后处理附加的标签,而是在编码阶段就与声学特征并置进入语言模型的上下文。模型卡把这条路径描述为 "speaker-aware perception",并给出了直接后果——多说话人 ASR 的均值 cpWER 为 15.80,而此前最好的公开组合 DiCoW v3.3 + DiariZen 是 16.83。级联方案的天花板,被一次联合编码抬高了。

值得注意的是底层的规模账:语言主干约 3.5B 激活参数,加上约 800M 参数的联合音频编码器(600M ASR Transformer + 约 99M 说话人编码器),合计约 4.3B 激活。也就是说,那个「1 亿参数的分离神器」并没有消失,它成为了 30B 模型里的一个子模块——这解释了为什么它能同时做转写和分离,也解释了为什么它不再是 0.32 秒的流式服务。

时间轴对齐:80ms 一个 token,10ms 一个网格

对齐问题在联合模型里并没有自动消失,只是从「跨进程缝合」变成了「同一张网格上的三次量化」。

第一层量化来自前端。processor_config.json写明sampling_rate: 16000、hop_length: 160、win_length: 400、n_fft: 512、feature_size: 128、subsampling_factor: 8——10ms 一帧梅尔谱,再经 8 倍下采样,得到模型卡所称的「约 80ms 一个音频 token」。这是语言模型能「看见」音频的最小刻度。

第二层量化来自 CTC 时间戳头。config.json的ctc_timestamps.decoder_config里,upsampling_factor: 2配upsampling_mode: conv_transpose,把 80ms 的声学网格插值回 40ms 的词片(word-piece)网格;解码器只有 2 层、16 头、pos_emb_max_len: 5000,本质是一个轻量的对齐器而非第二个 ASR。它的词表vocabulary长度 23862,前 51 个位置被结构化 token 占满:<ev:*>事件标签 10 个(breath、laughter、throat_cough 等)、<spk:0>到<spk:15>共 16 个说话人标签、<sty:stt:*>/<sty:end:*>共 22 个说话风格标签。所以「谁在何时说了什么」在输出层是同一条 token 序列里的不同符号,而不是三个服务各自的 JSON。

第三层量化来自后处理细化。ctc_timestamps.alignment_config.parameters是一组被训练/调参固化的常数,直接决定边界落在哪里:

参数取值含义
boundary_grid_seconds0.01边界搜索的最终网格精度
onset_snap_seconds0.08起始点向能量起振处吸附的窗口
boundary_max_correction_seconds0.04边界允许的最大修正量
maximum_token_len1.0364单 token 最长持续时间(秒)的约束系数
max_word_frames19单次对齐窗口的最大帧数
interpolation_enabledtrue是否启用边界插值(含 24 组系数与 offset)

这张表说明一件容易被忽略的事:模型输出的start/end并不是原始注意力权重的简单 argmax,而是「CTC 后验 → 40ms 网格 → 80ms onset 吸附 → 10ms 网格」的合成结果。做过字幕对齐的人都知道,词级时间戳的误差通常不来自识别错,而来自边界定义——这组常数正是把边界定义写死的地方。而word_timestamp_config里blank_token_id: 23862、vocab_size: 23863的 +1 关系,也印证了 CTC 词表是在 23862 类之上追加一个 blank。

重叠语音:不是二选一,而是加权归属

大纲里「重叠语音段落的归属判定」是最容易被写成玄学的一节。在这个仓库里,它其实有明确的可调参数。

对齐阶段的归属是由声学证据与文本后验共同投票的,alignment_config给出了三个权重:speaker_logprob_weight: 0.6057、speaker_activity_threshold: 0.1881、vad_threshold: 0.4641。换句话说,一个词被贴上<spk:N>,既要求说话人分支在该帧的活动概率越过 0.1881 这个较低的软阈值,又要求 CTC 后验在候选说话人之间给出区分度,两者按约 0.6 : 0.4 的比例融合。低阈值的设计意图很清楚:宁可让两个说话人同时"活动",也不要在重叠段提前判负,把消歧留给文本侧的语言模型上下文。

而说话人分支自身是流式友好的:audio_config.diarization_config里chunk_length: 500、chunk_left_context: 50、chunk_right_context: 50、fifo_length: 40,streaming_config再给出fifo_length: 264、speaker_cache_length: 188、speaker_cache_update_period: 222、prediction_score_threshold: 0.25。这套环形缓冲 + 说话人缓存的配置,正是「0.32 秒延迟」得以成立的机制来源——它只依赖固定长度的左右上下文,而不是整段音频。但在 30B Transcribe 里,这个流式配置被包在一个离线推理流程中,前后文被 30 秒的编码器分块(encoder_chunk_size_seconds: 30.0)重新组织,于是流式的低延迟被换成了整段的更高精度。

精度的代价与收益都可以量化。模型卡给出的多说话人结果里,同一会议语料在两种拾音条件下的差距,比模型差异更能说明问题:

测试集条件cpWER (%)
AMI个体头戴麦克风(IHM)10.29
AMI单远端麦克风(SDM)17.34
NOTSOFAR-1个体头戴麦克风(IHM)14.94
NOTSOFAR-1单通道远端麦克风(SC)25.91
CALLHOMECH109 电话对话11.50
Mixer 6Channel 414.80

cpWER 会先把预测的说话人映射到参考说话人,再算词错误率——也就是说,归属错了会直接计入错误率,不存在「文本对了就算过」的余地。远端麦克风下的翻倍误差,指向的是重叠与混响,而非词汇量。

还有一个可直接用在产品里的实测对比:当近场指令要求「只转写离麦克风最近的说话人、不要转写其他人声」时,把 LibriSpeech test-clean 语音与 NOTSOFAR-1 远端语音混合,WER 为 4.78%;换成普通的单说话人转写 prompt,同一段音频的 WER 是 27.32%。这不是模型能力差异,而是归属判定的边界由 prompt 定义——在双人抢话的会议里,你要的可能是前者;在圆桌讨论里,你要的恰恰是后者。

端到端延迟预算:把 0.32 秒放进正确的位置

先给出一个必须承认的现实:在这个仓库的部署形态下,你拿不到 0.32 秒的端到端首字延迟。原因不是模型慢,而是三处硬约束写在了服务参数里。

第一处是入参限制。模型卡明确:时间戳请求只接受一条音频、stream=false、n=1。CTC 对齐需要把整段输出与整段音频做单调对齐,天然与增量解码冲突。

第二处是运行器互斥。要词级时间戳,必须VLLM_USE_V2_MODEL_RUNNER=0(V1 runner)并挂载ctc_timestamp_middleware;而 DFlash2 草稿模型只在 V2 runner 上运行,且--hf-overrides '{"ctc_timestamps":{"enabled":false}}'会直接关掉对齐。换句话说,「最准的时间戳」与「最高的吞吐」在 vLLM 0.32.0 上是两个部署档位,不能同时开。

第三处是编码窗口。encoder_chunk_size_seconds: 30.0意味着任何「伪流式」实现的最小等待粒度是分块级别,而不是帧级别。模型卡对此的措辞很克制:这是内部的加工选择,不是 30 秒录音上限,也不是流式延迟承诺。

所以延迟预算应该这样拆:

T_e2e = T_vad + T_enc(chunk) + T_prefill + T_decode + T_align + T_post

其中真正可被服务端优化的是T_prefill + T_decode。仓库给出的锚点是:在单张 RTX PRO 6000 Blackwell 上,NIM 路径可用 NVFP4 + DFlash2 做到 2238 秒音频/秒,30 秒录音的转写结果在 0.13 秒内返回;而 NeMo Speech 发布日提供的开源 vLLM 集成面向模型开发,预期慢约 2 倍。H100 80GB 上的批量吞吐则是 MTP2 约 410 RTFx、MTP8 约 270、DFlash2 K=7 约 340、K=15 约 260。

注意 RTFx 与「单请求延迟」是两件事:410 RTFx 指批量负载下每秒处理 410 秒音频,不代表单条 30 秒音频 73 毫秒出结果。把 RTFx 当延迟用,是这类评测文章最常见的误读。

一张可落地的预算表,按「先分级、再并行」的思路配置:

链路环节负责组件量级可调手段
端点检测 / 切片应用层 VAD数百 ms切片长度对齐到 30s 整数倍,减少 padding
声学编码Parallel Expert Encoder随切片线性16kHz 单声道,避免重采样引入额外帧
PrefillMoE 主干(chunked prefill 已开)与音频 token 数正相关--max-num-batched-tokens 4096、--mm-processor-cache-gb 0
DecodeMTP / DFlash2与输出 token 数正相关MTP2 保延迟、MTP8/DFlash2 提吞吐
CTC 对齐2 层轻量对齐器十毫秒级仅在需要时间戳时开启,配合 V1 runner
后处理cleanup_generation_loops可忽略去除字符/块级重复生成

工程上由此得到一个清晰的双档架构:在线低延迟档用 DFlash2 + V2 runner,只做纯文本转写与翻译,把说话人标签交给 prompt 让语言模型直接生成;离线精修档用 MTP + V1 runner + CTC middleware,产出词级时间戳与 diarization 分段,用于会议纪要、客服质检与字幕校准。两档共享同一个 checkpoint,不共享同一份启动参数。

把流水线跑起来:三种取时间戳的正确姿势

服务端的关键几步(完整命令见模型卡 Quick Start 与 Inference Setup):

export VLLM_PLUGINS=nemo_speechlm export VLLM_MAX_AUDIO_DECODE_DURATION_S=3600 # 默认仅 600 秒 export VLLM_MAX_AUDIO_CLIP_FILESIZE_MB=180 export VLLM_USE_V2_MODEL_RUNNER=0 # 词级时间戳依赖 V1 runner vllm serve ./NVIDIA-Nemotron-3.5-Transcribe-30B-A4B \ --served-model-name nvidia/NVIDIA-Nemotron-3.5-Transcribe-30B-A4B \ --trust-remote-code --dtype bfloat16 --gpu-memory-utilization 0.94 \ --max-model-len 65536 --max-num-batched-tokens 4096 --max-num-seqs 192 \ --enable-chunked-prefill --no-enable-prefix-caching \ --use-replayssm --mamba-backend flashinfer --mamba-cache-mode none \ --limit-mm-per-prompt '{"audio":{"count":6,"length":120000000}}' \ --speculative-config '{"method":"mtp","num_speculative_tokens":8}' \ --reasoning-parser nemotron_v3 \ --trust-request-mm-kwargs \ --middleware nemo.collections.speechlm2.vllm.salm.ctc_serving.ctc_timestamp_middleware

--use-replayssm --mamba-backend flashinfer --mamba-cache-mode none与--no-enable-prefix-caching这组参数不要随意改:Mamba 层的状态缓存与 KV 前缀复用语义不同,混用会让长音频的内存占用失控。

客户端侧,时间戳只在三个条件同时成立时返回:请求带mm_processor_kwargs: {"capture_ctc_timestamps": True}、prompt 里要求了说话人标签、且服务端挂了 middleware。

import base64 from pathlib import Path from openai import OpenAI client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="EMPTY") PROMPT = ( "Transcribe this audio with punctuation and capitalization. " "Include all spoken words and verbal fillers. " "Write numbers in their standard written form. " "Put speaker tags <spk:0> <spk:1> <spk:2> <spk:3> on the transcription." ) audio = base64.b64encode(Path("meeting.wav").read_bytes()).decode() resp = client.chat.completions.create( model="nvidia/NVIDIA-Nemotron-3.5-Transcribe-30B-A4B", messages=[{"role": "user", "content": [ {"type": "text", "text": PROMPT}, {"type": "audio_url", "audio_url": {"url": f"data:audio/wav;base64,{audio}"}}, ]}], temperature=0, max_tokens=16384, extra_body={ "chat_template_kwargs": {"enable_thinking": False}, "skip_special_tokens": False, # 必须保留 <spk:> 标签 "spaces_between_special_tokens": False, "seed": 0, "mm_processor_kwargs": {"capture_ctc_timestamps": True}, }, ) align = resp.model_dump()["ctc_timestamps"] if align["error"] is not None: raise RuntimeError(align["error"]) # 对齐失败原因要先检查 tag_map = align["speaker_tag_to_diarization_speaker"] for seg in align["diarization"]: print(f"[{seg['start']:.2f} -> {seg['end']:.2f}] diar_speaker={seg['speaker']}") for w in align["words"]: print(f"{w['start']:7.2f} {w['end']:7.2f} {w['speaker']:>7} {w['word']}")

三个工程细节值得展开:

其一,error字段必须前置检查。对齐是单调匹配,一旦文本与音频不匹配(例如幻觉出一段不存在的语音、或音频被截断),结果不是「略微偏移」而是整段失效。把它当异常而不是警告处理,能避免下游字幕错位几十秒。

其二,speaker_tag_to_diarization_speaker是两层系统的接缝。文本侧的<spk:N>由语言模型自回归生成,它在长音频里可能重复、跳号、甚至在两人对话中复用同一个标签;而diarization分段来自声学分支,说话人数量与出现顺序都是稳定的。用这张映射表把文本标签回落到声学说话人,是纠正「标签漂移」的唯一可靠方式。模型卡对此有一句关键提醒:评估分离效果时应当用diarization分段,而不是词级时间戳——因为词级时间戳的 speaker 字段是把两者融合后的产物,不适合作为分离的独立度量。

其三,采样设置不能想当然。转写、翻译、语种识别用temperature=0且关闭思考;音频理解类任务才用 0.6 / top_p 0.95 / top_k 关闭并开启推理。把enable_thinking打开去做转写,除了变慢,还会让模型在文本里夹带推理痕迹。

别忽略的四个现实约束

说话人上限是 8,不是 16。CTC 词表里确实有<spk:0>到<spk:15>共 16 个标签,但说话人编码器的head_config.num_speakers与流式配置的num_speakers都是 8。标签空间留了余量,声学分支并没有。9 人以上的圆桌,需要外接分离模型或者按时间段切分。

中文是多说话人"支持"而非"领先"。语言矩阵里 Chinese Mandarin 的 ASR、翻译、多说话人、code-switching 全部打勾,但质量档位是 Supported(需要微调才能达到生产水准),英语、法语、德语、印地语、意大利语、韩语、葡萄牙语、俄语、西班牙语等才是 Leading。中文会议的 cpCER 参考值是 AISHELL-4 32.91、AliMeeting 34.95——这是含说话人归属的字符错误率,与纯文本 CER 不可直接比较,但足以说明中文多说话人仍有明确的优化空间。

长音频要吃满输出预算。模型卡直说:一小时密集对话可能需要约 20000 个输出 token。默认max_tokens=16384会把尾部截断,而对齐失败又往往表现为「文本短于音频」,两者很容易被误判成同一个 bug。

生成循环要有兜底。模型卡给出了一个后处理函数,把连续重复 ≥21 次的单字符、以及 1–4096 字符块重复 ≥20 次的情况折叠为一份。它只在转写、翻译、语种识别链路上建议启用;推理类长答案里,这个正则容易误伤 Markdown 表格的分隔线。

结语:0.32 秒是一个模块指标,不是系统指标

把这件事讲清楚,比复述参数更有价值:0.32 秒属于那个约 1 亿参数的流式说话人分离模块,它靠固定长度左右上下文与说话人缓存换取低延迟;而 Nemotron-3.5-Transcribe-30B-A4B 把它收进 Parallel Expert Encoder,用 30 秒编码分块与 80ms 音频 token 换取整段一致性,再用 CTC 头把 23862 类词表对齐回 40ms 网格,最终以 cpWER 15.80 的成绩压过级联方案。

所以「把说话人分离与 30B 转写拼成一条流水线」的正确拼法,不是让 30B 模型去追 0.32 秒,而是承认两档存在:需要秒级响应的场景,让流式分离 + 轻量 ASR 顶在前面;需要「谁说了什么、精确到词」的场景,把整段录音交给这个联合模型,用diarization分段做归属、用speaker_tag_to_diarization_speaker做校正、用boundary_grid_seconds: 0.01的网格做边界。

延迟预算从来不是被某一个数字决定的,而是被「你愿意为哪一段误差付多少钱」决定的。

【免费下载链接】NVIDIA-Nemotron-3.5-Transcribe-30B-A4B项目地址: https://ai.gitcode.com/hf_mirrors/nvidia/NVIDIA-Nemotron-3.5-Transcribe-30B-A4B

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考