端到端语音对话(Qwen2.5-Omni)真打不过级联ASR+LLM+TTS?RTX 4090 单卡实测全记录

关键词:Qwen2.5-Omni、端到端语音对话、speech-to-speech、SenseVoice、CosyVoice2、级联语音、RTX 4090、本地部署、显存 OOM、RTF

一句话结论:在 非流式 口径下,Qwen2.5-Omni-7B 端到端 speech-in→speech-out 完整回复要 11.8 秒 ,而 SenseVoice + LLM + CosyVoice2 级联只要 5.66 秒 ------端到端反而更慢。但端到端"真的听懂了你的情绪",而且 24G 单卡跑它 几乎没有余量 。本文把两套方案在同一块 RTX 4090 上从环境、下载、代码到踩坑全部复现一遍,所有数字均为脚本实测(计时 + nvidia-smi 采显存),非估算。


0. 为什么要做这个实测

端到端语音大模型(speech-in → speech-out,一个模型直接听音频、直接吐语音)这两年很火:Qwen2.5-Omni、GLM-4-Voice、Moshi、Step-Audio......宣传里都是"低延迟""全双工""听得懂情绪"。

但做陪伴类产品的人最关心的其实是两个朴素问题:

  1. 它到底比我现在的"ASR + LLM + TTS"三段式快多少?
  2. 一块 24G 的 4090 到底跑不跑得动?能不能上生产?

网上纸面对比一大堆,真机数字很少。于是我在一块 RTX 4090 24G 上,把 Qwen2.5-Omni-7B 端到端SenseVoice(ASR) + LLM + CosyVoice2(TTS) 级联 喂同一段中文语音问题,逐段计时、采显存,得到一组挺反直觉的数据。

测试硬件:RTX 4090 24G / 128G RAM,模型权重放在机械盘 /mnt/sda


1. 先看结论(TL;DR)

同一段 10 秒中文语音问题("用一句话介绍你自己 + 我今天心情低落,做点什么开心起来"),喂给两套方案:

维度 Qwen2.5-Omni-7B(端到端) 级联 ASR+LLM+TTS
听输入的方式 直接听音频(含语气/情绪/语速) 先 ASR 转成文字(丢掉语气情绪)
文字回复时延 1.4s(warm;cold 首次 4.5s) ASR 0.62s + LLM 1.4s ≈ 2.0s
完整语音回复时延 11.8s(非流式,等整段音频) ASR 0.62 + LLM 1.4 + TTS 3.64 = 5.66s
输出音频时长 16.1s 16.5s
生成 RTF 0.73(Talker+code2wav) TTS 段 0.22(CosyVoice2)
显存峰值 21.3--22.0GB(近满 24G,OOM 边缘) 三段各 <4GB
模型数 1 个,架构统一 3 个,模块化可换
加载耗时 15--79s(7B + 音频塔,~21GB) 各段秒级
中文质量 ✅ 自然口语,带语气词"嗯..." ✅ ASR 转写零错、TTS 克隆自然

三条最值得记住的发现:

  • 反直觉:非流式下级联更快(5.66s vs 11.8s) 。因为 Qwen2.5-Omni 的 Talker + code2wav 声码器一次性吐整段音频,比 CosyVoice2 慢(Omni RTF 0.73 vs CosyVoice2 RTF 0.22)。端到端的真正价值不在"批量出整段更快",而在 懂输入语气情绪 + 架构统一 + 可流式低延迟首响
  • 端到端真的听懂了。Qwen2.5-Omni 直接"听"音频,回复里自带"你今天心情低落啊,那你可以看看喜剧电影、散散步、听听音乐"------情绪 + 语义都接住了,这是级联(先转文字)丢不掉也补不回的体验。
  • 24G 是硬瓶颈 。Qwen2.5-Omni-7B(bf16)光权重就吃 21.3GB ,出语音时 Talker 再要 ~300MB 就 直接 OOM,本机必须先腾出 ComfyUI 占的 560MB 才跑通。生产部署在 24G 单卡上几乎没有并发余量。级联三段每段都 <4GB,轻得多、好扩容。

下面是完整复现步骤,CSDN 老规矩------你照着抄就能跑起来。


2. 环境准备(复用 vllm conda 环境,省一套 env)

这是第一个省事的点:不用新建环境 。本机已有的 vllm conda 环境里,transformers 已经是 5.12.1 ,自带 Qwen2_5OmniForConditionalGeneration,所以只补几个音频依赖即可:

bash 复制代码
conda activate vllm

# 只补音频处理 + Omni 工具包,transformers 已经够新不动它
pip install soundfile librosa qwen-omni-utils accelerate

环境关键版本:

  • torch 2.11 + cu130(推理正常;flash-attn 未装,走 sdpa attention,够用)
  • transformers 5.12.1(已含 Qwen2_5OmniForConditionalGeneration / Qwen2_5OmniProcessor)

提示:如果你的 transformers 版本低于 5.x、没有 Qwen2_5OmniForConditionalGeneration 这个类,升级一下即可:pip install -U "transformers>=4.52"(Omni 类在较新版本才合入)。

级联那边用的是另一套 audio-bench 环境(FunASR + CosyVoice2),第 5 节再说。


3. 模型下载(hf-mirror + huggingface-cli,约 21GB)

国内直连 HuggingFace 基本下不动,走 hf-mirror.com 镜像。注意:下载时如果你的环境里挂了 socks 代理,反而会把镜像搞崩(后面踩坑 §6.2 详述),所以下载前先把代理变量 unset 掉,只认镜像:

bash 复制代码
# 清掉可能干扰的代理,只走镜像
unset http_proxy https_proxy all_proxy
export HF_ENDPOINT=https://hf-mirror.com
export HF_HOME=/mnt/sda/hf-cache    # 权重放机械盘,21GB 别塞系统盘

# 下载 Qwen2.5-Omni-7B(bf16 全量,约 21GB)
huggingface-cli download Qwen/Qwen2.5-Omni-7B --local-dir-use-symlinks False

下完后权重落在 HF_HOME 指定的缓存目录。21GB,机械盘首次加载会慢(后面 §6 会看到冷启动 ~79s),有条件放 SSD 更舒服。


4. Qwen2.5-Omni-7B 端到端:加载与推理代码

端到端的核心 pipeline:一段音频进 → 模型直接听 → thinker 出文字 + Talker/code2wav 出语音 。完整可跑脚本如下(bench_qwen_omni.py):

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Qwen2.5-Omni-7B 端到端语音对话实测(RTX 4090 24G)
   speech-in -> text+speech-out,量两个时延:①只出文字 ②出文字+语音"""
import os, time, json, gc
os.environ.setdefault("HF_HOME", "/mnt/sda/hf-cache")
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")

import torch
import soundfile as sf
from transformers import Qwen2_5OmniForConditionalGeneration, Qwen2_5OmniProcessor
from qwen_omni_utils import process_mm_info

MODEL   = "Qwen/Qwen2.5-Omni-7B"
QWAV    = "data/user_question_zh.wav"   # 你的中文语音问题
OUT     = "results/qwen_omni"
SPEAKER = "Chelsie"                     # 内置发音人:Chelsie(女) / Ethan(男)

# ☆ 出语音必须带这段"指定 system prompt",否则 Talker 不正常工作(踩坑 §6.3)
SYS = ("You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
       "capable of perceiving auditory and visual inputs, as well as generating text and speech. "
       "请用简体中文、温暖自然的口语回答。")

os.makedirs(OUT, exist_ok=True)

def vram():
    return torch.cuda.max_memory_allocated() / 1024 / 1024

def build_inputs(processor, conv):
    text = processor.apply_chat_template(conv, add_generation_prompt=True, tokenize=False)
    # process_mm_info 负责把对话里的 audio/image/video 路径解析成张量
    audios, images, videos = process_mm_info(conv, use_audio_in_video=False)
    inputs = processor(text=text, audio=audios, images=images, videos=videos,
                       return_tensors="pt", padding=True, use_audio_in_video=False)
    return inputs.to(model.device).to(model.dtype)

print("== loading Qwen2.5-Omni-7B ==")
torch.cuda.reset_peak_memory_stats(); t0 = time.time()
model = Qwen2_5OmniForConditionalGeneration.from_pretrained(
    MODEL, torch_dtype=torch.bfloat16, device_map="cuda", attn_implementation="sdpa")
processor = Qwen2_5OmniProcessor.from_pretrained(MODEL)
print(f"   loaded in {time.time()-t0:.1f}s, vram {vram():.0f}MB")

# 一段音频进,system + user(audio)
conv = [
    {"role": "system", "content": [{"type": "text", "text": SYS}]},
    {"role": "user",   "content": [{"type": "audio", "audio": QWAV}]},
]
inputs = build_inputs(processor, conv)

# ---- 模式 A:只出文字(thinker 路径,最快)----
torch.cuda.reset_peak_memory_stats(); torch.cuda.synchronize(); t = time.time()
with torch.no_grad():
    text_ids = model.generate(**inputs, return_audio=False,
                              thinker_max_new_tokens=256, use_audio_in_video=False)
torch.cuda.synchronize(); text_only_s = time.time() - t
reply = processor.batch_decode(text_ids, skip_special_tokens=True)[0].split("assistant")[-1].strip()
print(f"[A] text-only {text_only_s:.1f}s | reply: {reply[:120]}")

# ---- 模式 B:出文字 + 语音(thinker + talker + code2wav)----
del text_ids; gc.collect(); torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats(); torch.cuda.synchronize(); t = time.time()
with torch.no_grad():
    text_ids2, audio = model.generate(**inputs, return_audio=True, speaker=SPEAKER,
                                      thinker_max_new_tokens=256, talker_max_new_tokens=2048,
                                      use_audio_in_video=False)
torch.cuda.synchronize(); full_s = time.time() - t
wav = audio.reshape(-1).detach().cpu().numpy(); sr = 24000
sf.write(os.path.join(OUT, "reply.wav"), wav, samplerate=sr)
dur = len(wav) / sr
print(f"[B] text+speech {full_s:.1f}s | out audio {dur:.1f}s | rtf {full_s/dur:.2f}")

几个 关键参数 解释:

  • return_audio=True ------ 真正触发出语音(Talker + code2wav 声码器);False 只走 thinker 出文字,快很多。
  • speaker="Chelsie" ------ 内置发音人,女声 Chelsie / 男声 Ethan,固定音色。
  • SYS 这段 system prompt 是出语音的硬性前提,少了它 Talker 不正常工作(踩坑 §6.3)。
  • process_mm_info(...) ------ qwen-omni-utils 提供的多模态解析函数,负责把对话里的 audio 路径读成模型能吃的张量。
  • 输出采样率固定 24000

实测结果(warm):

复制代码
[A] text-only      1.4s
[B] text+speech   11.8s | out audio 16.1s | rtf 0.73
显存峰值           21.3--22.0GB
回复文字:你今天心情低落啊,那你可以看看喜剧电影、散散步、听听音乐......(带口语语气词)

文字 1.4 秒就出,但完整语音要 11.8 秒------慢就慢在 Talker + code2wav 一次性吐整段音频(RTF 0.73,即生成 16 秒音频要花约 11.8 秒)。


5. 级联基线 ASR + LLM + TTS:三段代码

级联思路就是隐界(以及大多数陪伴产品)现在的形态:SenseVoice 把语音转文字 → LLM 想回复 → CosyVoice2 把回复合成语音。三段串行,各自可换、各自可控。

环境用另一套 audio-bench(FunASR + CosyVoice2)。核心脚本 bench_cascade.py:

5.1 第一段 ASR:SenseVoice(0.62s,转写零错)

python 复制代码
import time, re
from funasr import AutoModel

QWAV = "data/user_question_zh.wav"

def run_asr():
    m = AutoModel(model="iic/SenseVoiceSmall", trust_remote_code=False, disable_update=True)
    t = time.time()
    r = m.generate(input=QWAV, language="auto", use_itn=True)
    dt = time.time() - t
    # SenseVoice 输出带 <|zh|><|EMO|> 等标签,正则清掉
    txt = re.sub(r"<\|[^|]*\|>", "", r[0]["text"]).strip()
    return dt, txt

asr_s, transcript = run_asr()
print(f"ASR {asr_s:.2f}s :: {transcript}")   # -> ASR 0.62s,转写零错

SenseVoiceSmall 实测 0.62 秒/句、转写零错 ,而且它本身就能输出情绪标签(<|HAPPY|>/<|SAD|> 等)------这点很关键,它是少数"能在 ASR 阶段就把情绪带出来"的模型,后面给隐界的落点会用到。

5.2 第二段 LLM:思考(本测用同机 7B thinker 的 1.4s 作参照)

这一段是"想出回复文字"。本测为了和端到端可比,直接复用 Qwen2.5-Omni thinker 的纯文字时延 1.4s 作为"本地 LLM"参照值 (通过 --llm_s 传入);真实产品里这一段是远程 API(隐界用的是远程 Claude,约 2--4s TTFT),另算。

python 复制代码
# LLM 阶段不在本脚本内实测,作为参数传入(秒)
# 本地 7B 参照 = 1.4s;远程 Claude 真实 ≈ 2--4s TTFT
llm_s = 1.4
reply = "今天心情低落的话,可以先给自己泡一杯热茶,听几首喜欢的歌,再出门散散步晒晒太阳,慢慢就会好起来的。"

5.3 第三段 TTS:CosyVoice2 zero-shot(3.64s,RTF 0.22)

python 复制代码
import sys, os, time
sys.path.insert(0, "/mnt/sda/audio-bench/CosyVoice")
sys.path.insert(0, "/mnt/sda/audio-bench/CosyVoice/third_party/Matcha-TTS")
import torch, torchaudio
from cosyvoice.cli.cosyvoice import CosyVoice2

OUT        = "results/cascade"
PROMPT_WAV = "/mnt/sda/audio-bench/CosyVoice/asset/zero_shot_prompt.wav"  # ☆ 收"路径"非 tensor
PROMPT_TXT = "希望你以后能够做的比我还好呦。"

def run_tts(text):
    cv = CosyVoice2("/mnt/sda/audio-bench/hf-cache/cosyvoice2",
                    load_jit=False, load_trt=False, fp16=False)
    out = os.path.join(OUT, "reply_tts.wav")
    t = time.time()
    # ☆ 短中文用 zero_shot,别用 cross_lingual(会幻觉),见踩坑 §6.4
    outs = list(cv.inference_zero_shot(text, PROMPT_TXT, PROMPT_WAV, stream=False))
    wav = torch.cat([o["tts_speech"] for o in outs], dim=1)
    torchaudio.save(out, wav, cv.sample_rate)
    return time.time() - t, out, wav.shape[1] / cv.sample_rate

tts_s, tts_wav, tts_dur = run_tts(reply)
print(f"TTS {tts_s:.2f}s -> {tts_wav} ({tts_dur:.1f}s)")   # -> TTS 3.64s,RTF 0.22

5.4 级联总时延

python 复制代码
total = asr_s + llm_s + tts_s
print(f"级联总时延 ≈ ASR {asr_s:.2f} + LLM {llm_s:.2f} + TTS {tts_s:.2f} = {total:.2f}s")
# -> 级联总时延 ≈ ASR 0.62 + LLM 1.40 + TTS 3.64 = 5.66s

三段串行累加 = 5.66 秒,显存每段都 <4GB,4090 上还能并发、还能同时干别的。


6. 完整踩坑记录(照着复现请逐条看)

这部分是本文最值钱的地方。每一条都是真机踩出来的,不看大概率会卡住。

6.1 Qwen2.5-Omni 出语音直接 OOM(21.3GB + Talker 撑爆 24G)

现象 :只出文字没事,一旦 return_audio=True 出语音就 CUDA out of memory

原因 :7B bf16 权重本身就占 21.3GB,出语音时 Talker + code2wav 还要再要 ~300MB,而此时显卡只剩 ~120MB free,直接爆。

修复(两步叠加才跑通):

bash 复制代码
# ① 减少显存碎片,让分配器能弹性扩展段
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
bash 复制代码
# ② 腾出其他进程占的显存(本机 ComfyUI 常驻占 560MB),先停掉再跑 Omni
#    跑完语音实测再把 ComfyUI 拉起来
#    用 nvidia-smi 确认显存真的释放了:
nvidia-smi --query-gpu=memory.used,memory.free --format=csv

这条本身就是结论:24G 单卡跑 Qwen2.5-Omni 出语音没有部署余量。bf16 不量化的话,基本是"一个模型独占整张卡",别说并发了,连后台挂个 ComfyUI 都嫌挤。要上生产,要么量化(INT4/AWQ,本轮未做),要么换更大显存卡 / 多卡。

6.2 transformers 走 socks 代理崩 + 必须离线加载

现象 :权重明明全量下到本地缓存了,加载时还是报 OSError: Unknown scheme for proxy URL

原因 :transformers 加载时仍会尝试联网做版本校验,撞上环境里的 socks:// 代理------而 httpx 不认这个 scheme,直接崩。

修复:unset 所有代理 + 强制离线加载,让它只读本地缓存:

bash 复制代码
unset http_proxy https_proxy all_proxy
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1

注意这和 §3 下载阶段是矛盾的:下载时要镜像(联网)、推理时要离线。下完模型再开离线开关即可。

6.3 出语音必须带"指定 system prompt"

现象:不带 system prompt,或 system prompt 内容随便写,Talker 出来的语音不正常 / 不出声。

原因 :Qwen2.5-Omni 的 Talker 路径依赖那段固定的官方 system 提示(You are Qwen ... capable of ... generating text and speech)才能正常工作。

修复 :把官方那段 system prompt 固化进对话(见 §4 代码里的 SYS),后面可以再追加你自己的中文风格要求("请用简体中文、温暖自然的口语回答")。只出文字时这条不强制,出语音时必须有。

6.4 造输入音频:CosyVoice2 zero-shot 收"路径"非 tensor + 短中文别用 cross_lingual

为了有一个干净、可控、音色独立的中文问题(避免模型自说自话),我用 CosyVoice2 zero-shot 先合成一段 10 秒中文问题。两个坑:

  • inference_zero_shot 的 prompt 参数收文件路径,不收加载好的 tensor 。传 tensor 会报 Invalid file: tensor(...)。直接传 .wav 路径即可(见 §5.3 的 PROMPT_WAV)。
  • 短中文务必用 inference_zero_shot,别用 inference_cross_lingual------会幻觉(吐出莫名其妙的内容)。zero-shot 合成干净,用 SenseVoice 回读校验零错。

6.5 用 vllm 环境跑 Omni,省一套环境

如 §2 所说,本机 vllm conda 环境的 transformers 已经 5.12.1 自带 Omni 类,只补 soundfile / librosa / qwen-omni-utils / accelerate 就能跑,不必新建独立环境。torch 2.11+cu130、走 sdpa(没装 flash-attn)推理也正常。能省则省。


7. 延迟拆解:谁慢在哪

把两套方案按阶段拆开看,问题就很清楚了:

阶段 端到端 Omni 级联
听懂输入 + 想出文字 1.4s(thinker 一步到位) ASR 0.62s → LLM 1.4s
合成语音 ~10.4s(Talker + code2wav,) TTS 3.64s(CosyVoice2,)
合计(非流式) 11.8s 5.66s

端到端在"理解"上很优雅 ------一步听懂、连情绪一起接住;但在"非流式吐整段语音"上被自带声码器拖慢。级联靠 CosyVoice2 更快的 TTS(RTF 0.22 vs Omni 0.73)在总延迟上反超。

要真正发挥端到端的优势,得上 流式 :边想边说、首字音频 <1s 就开口,而不是等整段 16 秒音频全生成完。Qwen2.5-Omni 本身支持流式分块出音频,真实时首字延迟应远低于 11.8s ------但本轮为了和级联的"等完整音频"口径可比,测的是批量整段,流式未测(诚实标注)。


8. 结论与选型建议

Qwen2.5-Omni-7B(端到端):

  • ✅ 直接听音频,连"我心情低落"的语义 + 情绪一起接住,回复贴心自然带口语语气词------这是级联丢不掉也补不回的体验。
  • ✅ 架构统一,一个模型 multimodal(文/图/音/视频)进、文字 + 语音出,省去拼三个模型的工程。
  • ✅ 文字回复极快(warm 1.4s),适合"先出字幕、再补语音"的体验。
  • ❌ 显存 21.3GB 近满 24G,出语音 OOM 边缘,单卡无并发余量
  • ❌ 非流式整段语音慢(11.8s / RTF 0.73);低延迟得靠流式(本轮未测)。
  • ❌ 首次冷加载 ~79s(7B + 音频塔从机械盘读),warm 15s。

级联 ASR + LLM + TTS:

  • ✅ 模块化,ASR/LLM/TTS 各自可换可控;LLM 可接现有远程 provider,不必本地扛 21GB。
  • ✅ 轻,SenseVoice + CosyVoice2 都 <4GB,4090 上能并发、能同时干别的。
  • ✅ 快(本测 5.66s 总),ASR 0.62s 转写零错、TTS RTF 0.22。
  • ✅ 中间就是文字,对日志/审核/记忆天然友好。
  • ❌ 丢输入语气情绪(ASR 只给文字)。
  • ❌ 误差累积 + 串行延迟(ASR 错→LLM 跟着错;三段时延相加)。
  • ❌ 不天然支持全双工(边听边说/被打断),要额外做 VAD + 打断逻辑。

一句话选型:

现在就要上生产、要可控要省显存 → 级联(SenseVoice + LLM + CosyVoice2),并给 ASR 选一个能带情绪标签的(SenseVoice 就行),把"丢情绪"这个最大短板补回来。

要做"能听出你情绪的实时语音通话"、愿意上流式、有更大显存 → Qwen2.5-Omni 端到端,赌的是流式首响 + 情绪理解,不是赌总延迟,而且先得解决 24G 没余量的问题(量化 / 大卡 / 多卡 / 只在高级功能里开)。

对陪伴类产品最实际的路线:短期保持"LLM + TTS"两段式,先补一个能听情绪的 ASR(SenseVoice 0.62s、零错、<4GB)把语音输入接进现有文本链路 ;端到端作为"流式语音通话"的中期差异化实验,而不是现在就拿非流式端到端替换整条链路------既慢(11.8s 整段)、又挤(21GB 近满 24G)、还更难审核/记忆(没有中间文字)。


9. 诚实边界(未测项,别被本文误导)

  • 只实测了 Qwen2.5-Omni-7B 一个端到端模型 ;GLM-4-Voice-9B / Moshi(全双工)/ Step-Audio 等 未真机测(大模型另需下载 + 复杂环境),它们的纸面生态对比另文分析。
  • 未测流式 :本轮为可比性测的是"等完整音频"的批量口径;Qwen2.5-Omni 支持流式分块出音频,真实时首字音频延迟应远低于 11.8s,待补流式实测。
  • 未测全双工 / 打断:Moshi 这类 full-duplex 能力本轮没碰。
  • 级联 LLM 阶段为参照值:用同机 7B thinker 的 1.4s 代表"本地 LLM";真实远程 API(如 Claude)约 2--4s TTFT,故真实级联总延迟约 7--9s,仍与端到端同量级,但工程可控性 + 显存成本明显更优。
  • 输入是合成语音非真人录音:用 CosyVoice2 造的干净问题,真人带口音/噪声/口语会更难,鲁棒性差异待真人样本验证。
  • 显存口径:21.3GB 为 bf16 不量化;INT4/AWQ 量化可压显存,但本轮未做。

如果这篇对你复现有帮助,欢迎收藏点赞。所有数字都是同一块 4090 上脚本实测出来的,可复现。下一篇打算补 Qwen2.5-Omni 流式出音频的首字延迟实测------那才是端到端真正该比的赛道。