Files
2026-07-13 13:25:10 +08:00

38 KiB
Raw Permalink Blame History

FunASR vLLM 推理引擎指南


Benchmark

测试集184 文件,11541 秒,Fun-ASR-Nano / GLM-ASR-Nano。RTFx 定义、计时口径和可复现字段请见 Benchmark RTF and Reproducibility Notes

模型 引擎 VAD RTFx CER 备注
Fun-ASR-Nano PyTorch dynamic 21 8.06% 基准
Fun-ASR-Nano vLLM batch dynamic 340 8.20% 16x 加速
Fun-ASR-Nano 离线服务 (no SPK) dynamic 102 8.14%
Fun-ASR-Nano 离线服务 (+SPK) dynamic 46 8.19% SPK 默认关闭
GLM-ASR-Nano vLLM batch fixed 265 12.93% 不支持长音频推理

vLLM 与 PyTorch CER 完全一致(差 < 0.2%),速度提升 16-340x。


目录

  1. 安装与环境
  2. vLLM 推理引擎架构
  3. 离线 SDK 推理
  4. 流式 SDK 推理
  5. 离线语音识别服务
  6. 流式语音识别服务
  7. 动态 VAD
  8. API 参考
  9. FAQ

1. 安装与环境

先安装 vLLM,按 NVIDIA 驱动的 CUDA 版本选对应版本;vLLM 会自动钉定并安装匹配的 torch / torchaudio / torchvision 三件套,所以不要自己装 torch/torchaudio——三者 ABI 锁死,必须是互相编译匹配的同一组(如 torch 2.10.0 ↔ torchaudio 2.10.0 ↔ torchvision 0.25.0),只能随 vLLM 一起来。

# 1) 先装 vLLM。按 `nvidia-smi` 显示的 CUDA 版本(驱动支持的最高 CUDA,不是 runtime CUDA)选版本,
#    vLLM 会带来匹配的 torch/torchaudio/torchvision。
#    驱动 CUDA 12.x  -> pip install vllm==0.19.1   (附带 torch 2.10 / cu128)
#    驱动 CUDA >= 13 -> pip install vllm           (最新版;附带 torch 2.11 / cu130)

pip install "vllm==0.19.1"   # 按你的驱动 CUDA 调整;见下方说明

# 2) 再装 FunASR 与其余依赖。
pip install funasr>=1.3.0
pip install safetensors tiktoken websockets regex fastapi uvicorn python-multipart

cd /path/to/FunASR && pip install -e .

硬件GPU ≥ 8GB VRAMCUDA ≥ 11.8。推荐 16GB+。

为什么不要单独执行 pip install torch torchaudio ? torch/torchaudio/torchvision 的版本由 vLLM 版本决定—— 每个大版本会一起升级(见 vLLM 的 requirements/cuda.txt)。手动安装会拉到最新 wheel,可能是为比你驱动更新的 CUDA runtime 编译的;PyTorch 会在 CUDA 初始化阶段、FunASR 启动前就报 The NVIDIA driver on your system is too old。让 vLLM 统一钉定这三件套即可避免。若仍遇到该错误,请安装其 CUDA 构建与 nvidia-smi 显示的 CUDA 匹配的 vLLM 版本(如 CUDA 12.x 用 vllm==0.19.1),或先升级 NVIDIA 驱动。


2. vLLM 推理引擎架构

整体架构

FunASR 的 vLLM 集成将 ASR 模型拆分为两部分独立运行:

┌──────────────────────────────────────────────────────────────┐
│                    FunASR + vLLM 推理架构                      │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌─────────────── PyTorch (单 GPU) ───────────────┐          │
│  │                                                │          │
│  │  Audio ──→ Frontend ──→ Audio Encoder ──→ Adaptor         │
│  │            (fbank)      (SenseVoice/     (Transformer/    │
│  │                          Whisper)         MLP)            │
│  │                              │                            │
│  │                              ▼                            │
│  │                     Audio Embeddings                      │
│  │                              │                            │
│  │  Text Prompt ──→ Tokenize ──→ Embed                       │
│  │  (system/user/                  │                         │
│  │   hotwords/language)            │                         │
│  │                                 ▼                         │
│  │                          [Concat Embeddings]              │
│  └─────────────────────────────────┼─────────────┘           │
│                                    │                         │
│                                    ▼ EmbedsPrompt            │
│  ┌─────────────── vLLM Engine ────────────────────┐          │
│  │                                                │          │
│  │   PagedAttention + Continuous Batching         │          │
│  │   KV Cache 管理 + CUDA Graph                   │          │
│  │   Tensor Parallel (多卡)                       │          │
│  │                                                │          │
│  │   Qwen3-0.6B / Llama-2B (LLM 解码)              │          │
│  │                                                │          │
│  └────────────────────┬───────────────────────────┘          │
│                       │                                      │
│                       ▼                                      │
│                Generated Text                                │
│                       │                                      │
│  ┌────────────────────┼──────────────────────────┐           │
│  │  (可选) CTC Decoder ──→ Forced Alignment      │            │
│  │           ──→ 字级别时间戳                     │            │
│  └───────────────────────────────────────────────┘           │
└──────────────────────────────────────────────────────────────┘

为什么用 vLLM

特性 PyTorch generate() vLLM
KV Cache 管理 固定分配,浪费显存 PagedAttention,按需分配
批处理 需手动 padding Continuous Batching,自动调度
CUDA 优化 CUDA Graph + 算子融合
多卡并行 手动实现 Tensor Parallel 一行配置
吞吐量 RTFx ~20 RTFx 340+

支持模型

模型 LLM 部分 audio encoder vLLM 加速
Fun-ASR-Nano Qwen3-0.6B SenseVoice ✓ 21.7x
GLM-ASR-Nano Llama-2B Whisper-like ✓ 7.6x
LLMASR Qwen/Vicuna Whisper
Paraformer 无 LLM ✗ 非自回归
SenseVoice 无 LLM ✗ encoder-decoder

关键实现细节

  1. 权重分离:从 model.pt 提取 LLM 权重,转为 HuggingFace 格式供 vLLM 加载
  2. EmbedsPrompt:直接把已算好的 embedding 向量(而非通常的 token ID)作为 prompt 送入 vLLM(开关 enable_prompt_embeds=True)。Fun-ASR-Nano 必须用它,因为音频经 adaptor 得到的是连续向量、不是 token,需把音频 embedding 与文本 embedding 在序列维拼接后整体送入 vLLM
  3. use_low_frame_rateFun-ASR-Nano 的 adaptor 输出需按公式截断到正确 token 数(一致性关键)
  4. batch encode:多条音频通过 extract_fbankaudio_encoderaudio_adaptor 一次前向
  5. CTC 时间戳:保留 encoder_out,生成文本后做 forced alignment 得到字级别时间

3. 离线 SDK 推理

适用于大规模音频转写、离线批量处理。vLLM 的批处理能力在此场景优势最大。

设计原理

离线 SDK 推理将 ASR 流水线拆分为两阶段独立执行:

┌─────────────────────────────────────────────────────────────────────┐
│                  阶段 1: 音频编码(PyTorch, 单 GPU                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  音频文件列表 ──→ 分组(每 8 条)──→ Frontend(Fbank)                    │
│       │                                     │                       │
│       │                                     ▼                       │
│       │                            SenseVoice Encoder               │
│       │                                     │                       │
│       │                                     ▼                       │
│       │                            Audio Adaptor                    │
│       │                            (dim 转换 + low_frame_rate 截断)  │
│       │                                     │                       │
│       └─── 共享文本 prompt 预编码 ─────┐      ▼                       │
│            (system/hotwords/language)  │  audio_embeds               │
│                     │                 │      │                       │
│                     ▼                 │      ▼                       │
│                prefix_emb ──→ [concat: prefix | audio | suffix]      │
│                                              │                       │
│                                              ▼                       │
│                                     EmbedsPromptN 条)             │
└──────────────────────────────────────────────┼──────────────────────┘
                                               │
                                               ▼
┌─────────────────────────────────────────────────────────────────────┐
│              阶段 2: LLM 解码(vLLM, 多 GPU Tensor Parallel         │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  EmbedsPrompt × N ──→ vLLM Continuous Batching                      │
│                        (PagedAttention + CUDA Graph)                │
│                              │                                      │
│                              ▼                                      │
│                     Generated token_ids × N                         │
│                              │                                      │
│                              ▼                                      │
│                     Decode + 后处理(去特殊标记、清洗)                 │
│                              │                                      │
│                              ▼                                      │
│                     (可选) CTC Forced Alignment → 字级别时间戳         │
└─────────────────────────────────────────────────────────────────────┘

关键设计决策:

  1. 权重分离:首次运行时从 model.pt 提取 llm.* 前缀的权重,保存为 HuggingFace safetensors 格式供 vLLM 加载(缓存到 Qwen3-0.6B-vllm/ 目录)
  2. Embedding 拼接:文本 prompt 通过 LLM 的 embed_tokens 层编码为 embedding,与音频 adaptor 输出在序列维度拼接:[prefix_emb | audio_emb | suffix_emb],以 EmbedsPrompt 形式送入 vLLM
  3. Low Frame Rate 截断adaptor 输出需按公式 fake_token_len = ((((fbank_len - 3 + 2) // 2 - 3 + 2) // 2) - 1) // 2 + 1 截断到正确长度,确保与 PyTorch 训练时一致
  4. 批量音频编码:多条音频按 batch_size=8 分组通过 encoder + adaptor 前向,减少 GPU kernel launch 开销
  5. 文本 prompt 共享:同一批次内 hotwords/language 相同时,prefix_emb 和 suffix_emb 只计算一次
  6. CTC 时间戳:保留 encoder_outLLM 生成文本后做 forced alignment 得到字级别时间

为什么比 PyTorch generate() 快?

维度 PyTorch vLLM
KV Cache 固定预分配(浪费显存) PagedAttention 按需分配
批处理 需手动 padding 对齐 Continuous Batching 自动调度
CUDA 逐 sample 串行 CUDA Graph + 算子融合
多卡 需手动实现 Tensor Parallel 一行配置
结果 RTFx ~20 RTFx 340+16倍加速)

通用接口(推荐)

from funasr.auto.auto_model_vllm import AutoModelVLLM

model = AutoModelVLLM(
    model="FunAudioLLM/Fun-ASR-Nano-2512",
    hub="ms",                    # 或 "hf"
    tensor_parallel_size=2,      # 多卡并行
    gpu_memory_utilization=0.8,
)

results = model.generate(
    ["audio1.wav", "audio2.wav"],
    language="中文",
    hotwords=["张三", "北京"],
)
for r in results:
    print(f"[{r['key']}] {r['text']}")

直接接口

from funasr.models.fun_asr_nano.inference_vllm import FunASRNanoVLLM

engine = FunASRNanoVLLM.from_pretrained(
    model="FunAudioLLM/Fun-ASR-Nano-2512",
    tensor_parallel_size=4,
)

results = engine.generate(
    inputs="wav.scp",  # 支持 scp/jsonl/文件列表
    hotwords=["开放时间"],
    language="中文",
    max_new_tokens=512,
)

命令行

cd examples/industrial_data_pretraining/fun_asr_nano

# 单文件
python demo_vllm.py --input audio.wav --language 中文

# 批量 + 多卡
python demo_vllm.py --input wav.scp --tensor-parallel-size 4 --batch-size 32

# 带热词 + 保存结果
python demo_vllm.py --input audio.wav --hotwords 张三 北京 --output results.jsonl

4. 流式 SDK 推理

将音频按 720ms chunk 逐步处理,输出逐步稳定的识别结果。适用于 SDK 集成实时字幕场景。

设计原理

音频流(720ms chunks
    │ 累积重编码(每个 chunk 包含从头到当前的全部音频)
    ▼
┌──────────────────────┐
│ Stage 1: 前 10 chunk │  ← 无 prev_text,批量生成
│ 找到稳定输出           │
└──────────┬───────────┘
           ▼
┌──────────────────────┐
│ Stage 2: 后续 chunk   │  ← 用稳定输出作 prev_text
└──────────┬───────────┘
           ▼
每个 chunk: [fixed 区域(确认)] + [8字 unfixed(可能变)]

用法

from funasr.models.fun_asr_nano.inference_vllm_streaming import FunASRNanoStreamingVLLM

engine = FunASRNanoStreamingVLLM.from_pretrained(
    model="FunAudioLLM/Fun-ASR-Nano-2512",
    chunk_ms=720,
    rollback_chars=8,
)

for result in engine.streaming_generate("audio.wav", language="中文"):
    if result["is_final"]:
        print(f"最终: {result['text']}")
    else:
        print(f"[{result['audio_duration_ms']:.0f}ms] 确认: {result['fixed_text']}")

注意:EmbedsPrompt 下不能用 repetition_penalty 此时 prompt 是 embedding 向量、没有对应的 token ID,而 repetition_penalty 要靠 prompt 的 token ID 在 logits 上给已出现的词降分;用在 EmbedsPrompt 上会索引越界、触发 CUDA device-side assert

生产 API 稳定性清单

AutoModelVLLM 封装成长驻 API 服务时,请隔离每次请求的状态,并固定安全的解码默认值:

common = dict(
    language="auto",
    temperature=0.0,
    repetition_penalty=1.0,
    max_new_tokens=200,
)

for _ in range(2):
    results = model.generate(["vad_segment_01.wav", "vad_segment_02.wav"], **common)
    print([r["text"] for r in results])

如果同一个音频第一次请求正常、第二次请求开始重复:

  1. 先把 API 层拿掉,用相同 VAD 分段跑上面的最小脚本。
  2. 如果最小脚本稳定,优先检查 API 封装是否复用了请求级变量、上一轮 VAD 分段列表、上一轮 results 或累积文本。
  3. 如果最小脚本也重复,再记录完整的 funasrvllmtorch 版本,以及第一次和第二次输出文本,再调整其它解码参数。

不要通过调大 repetition_penalty 来压制 Fun-ASR-Nano vLLM 重复输出;prompt-embeds 路径应保持中性值 1.0

输出特性

累积音频 输出质量
< 1.5s 空或噪声
1.5-3.0s 部分正确
> 3.0s 准确输出

5. 离线语音识别服务

5.1 服务架构

客户端                                  serve_vllm.py
  │                                        │
  │── HTTP/OpenAI/WebSocket ──────────────→│
  │                                        │
  │                                   ┌────┴────────────────────────┐
  │                                   │ 1. 接收完整音频文件            │
  │                                   │ 2. 动态 VAD 分段(≤60s/段)    │
  │                                   │ 3. vLLM batch 推理所有段      │
  │                                   │ 4. CTC 时间戳(逐字)          │
  │                                   │ 5. 说话人分离(可选)          │
  │                                   └────┬────────────────────────┘
  │                                        │
  │←── JSON 结果 ─────────────────────────│

特点

  • 音频完整到达后处理,适合文件转写
  • 动态 VAD 保留长段(≤60s),减少边界切割损失
  • batch 推理所有 VAD 段,吞吐量高
  • 自动输出字级别时间戳
  • SPK 说话人分离默认关闭,客户端可开启

5.2 启动服务

CUDA_VISIBLE_DEVICES=0 python examples/industrial_data_pretraining/fun_asr_nano/serve_vllm.py \
    --port 8899 \
    --model FunAudioLLM/Fun-ASR-Nano-2512 \
    --gpu-memory-utilization 0.5

关于 CUDA_VISIBLE_DEVICES:这是vllm的一个环境变量 ,示例中的 =0 只是"用第 0 张卡"的示例值,不是固定写法,它选择本进程可见的 GPU(编号同 nvidia-smi),单卡机器也不需要设置。

单卡多实例0.6B / 1.7B 这类小模型一张卡可起多个实例,多进程可都指向同一张卡(如都 =0+ MPS 共享;分卡则进程 A =0、B =1(见 §6.7)。

5.3 协议一:HTTP REST — POST /asr

功能最全的接口,支持 SPK、时间戳、热词。

请求multipart/form-data

参数 类型 默认值 说明
file file 必填 音频文件(wav/mp3/flac
language string None 语种("中文"/"English"/...),None 为自动
hotwords string "" 热词,逗号分隔
spk bool false 是否开启说话人分离
timestamp bool true 是否输出字级别时间戳

响应

{
    "text": "完整识别文本",
    "segments": [
        {
            "text": "段文本",
            "start": 1.7,
            "end": 14.8,
            "speaker": "SPK0",
            "words": [
                {"word": "砸", "start": 2.02, "end": 2.08},
                {"word": "了", "start": 2.26, "end": 2.32}
            ]
        }
    ],
    "duration": 227.4,
    "processing_time": 3.422,
    "rtf": 0.015
}

客户端示例

# cURL
curl -X POST http://localhost:8899/asr \
    -F "file=@meeting.wav" -F "language=中文" -F "spk=true"
# Python requests
import requests
resp = requests.post("http://localhost:8899/asr",
    files={"file": open("audio.wav", "rb")},
    data={"language": "中文", "spk": "true"})
result = resp.json()
// JavaScript fetch
const form = new FormData();
form.append("file", audioBlob, "audio.wav");
form.append("language", "中文");
form.append("spk", "true");
const resp = await fetch("http://localhost:8899/asr", { method: "POST", body: form });
const result = await resp.json();

5.4 协议二:OpenAI Whisper 兼容 — POST /v1/audio/transcriptions

兼容 OpenAI Whisper API 标准,可直接用 OpenAI SDK 接入。

请求multipart/form-data

参数 类型 默认值 说明
file file 必填 音频文件
model string "fun-asr-nano" 模型名(兼容字段)
language string None 语种
response_format string "json" "json" / "text" / "verbose_json"
timestamp_granularities string "word" "word" / "segment"
spk bool false 说话人分离(FunASR 扩展字段)

响应verbose_json):

{
    "task": "transcribe",
    "language": "zh",
    "duration": 5.17,
    "text": "我一直没有照顾孩子,但是我想要抚养权。",
    "segments": [
        {
            "id": 0, "start": 0.0, "end": 5.15,
            "text": "我一直没有照顾孩子,但是我想要抚养权。",
            "words": [{"word": "我", "start": 0.42, "end": 0.48}, ...]
        }
    ]
}

客户端示例

# OpenAI SDK(推荐)
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8899/v1", api_key="none")
result = client.audio.transcriptions.create(
    model="fun-asr-nano",
    file=open("audio.wav", "rb"),
    response_format="verbose_json",
)
print(result.text)
# cURL
curl -X POST http://localhost:8899/v1/audio/transcriptions \
    -F "file=@audio.wav" -F "model=fun-asr-nano" -F "response_format=verbose_json"

5.5 协议三:WebSocket — ws://host:port/ws

离线服务的 WebSocket 接口,发送完整音频后获取结果。STOP 时自动进行说话人聚类,结果中包含 spk 字段。

客户端 → 服务端

消息 说明
"START" 开始会话
"LANGUAGE:中文" 设置语种(可选)
"HOTWORDS:词1,词2" 设置热词(可选)
[binary] PCM16 16kHz mono 音频数据
"STOP" 结束,请求识别结果

服务端 → 客户端

{"event": "started"}
{"event": "language_set", "language": "中文"}
{"sentences": [{"text":"...","start":..,"end":..}], "is_final": true, "duration_ms": 5170}
{"event": "stopped"}

客户端示例

import asyncio, websockets, json, numpy as np, soundfile as sf

async def offline_ws(audio_path):
    audio, sr = sf.read(audio_path)
    pcm = (audio * 32768).astype(np.int16)

    async with websockets.connect("ws://localhost:8899/ws") as ws:
        await ws.send("START")
        await ws.recv()
        await ws.send("LANGUAGE:中文")
        await ws.recv()

        # 发送完整音频
        await ws.send(pcm.tobytes())
        await ws.send("STOP")

        # 接收结果
        async for msg in ws:
            data = json.loads(msg)
            if data.get("is_final"):
                for s in data["sentences"]:
                    print(f"[{s['start']/1000:.1f}s] {s['text']}")
                break

asyncio.run(offline_ws("audio.wav"))

6. 流式语音识别服务

6.1 服务架构

客户端(麦克风/音频流)              serve_realtime_ws.py
  │                                      │
  │── WebSocket PCM16 16kHz ────────────→│
  │   (每帧 ~100ms,持续发送)             │
  │                                      │
  │                                 ┌────┴─────────────────────────┐
  │                                 │ 实时循环:                     │
  │                                 │  ├─ 动态 VAD60ms chunk    │
  │                                 │  ├─ 检测到端点 → vLLM 解码     │
  │                                 │  ├─ 未结束 → partial 预览     │
  │                                 │  └─ 说话人流式分配             │
  │                                 └────┬─────────────────────────┘
  │                                      │
  │←── JSON 实时推送 ───────────────────│

特点

  • 音频逐帧到达,边收边处理
  • 基于 VAD 端点自然分句
  • 确认段文字锁定不变,partial 实时更新
  • 可选流式说话人分配(--enable-spk+ STOP 时全局重聚类
  • 首字延迟 ~480ms

6.2 启动服务

CUDA_VISIBLE_DEVICES=0 python examples/industrial_data_pretraining/fun_asr_nano/serve_realtime_ws.py \
    --port 10095 --language 中文 --hotword-file 热词列表

多客户端或长时间连续语音场景,建议先限制 partial 预览窗口并适当降低刷新频率:

CUDA_VISIBLE_DEVICES=0 python examples/industrial_data_pretraining/fun_asr_nano/serve_realtime_ws.py \
    --port 10095 --language 中文 \
    --partial-window-sec 8 --decode-interval 0.8

说话人分离默认关闭;只有确实需要 spk 字段时再加 --enable-spk

如果麦克风长连接经过 Docker、nginx 或云负载均衡,建议保持 WebSocket ping/pong 开启,并把 timeout 调到能覆盖短暂网络抖动:

CUDA_VISIBLE_DEVICES=0 python examples/industrial_data_pretraining/fun_asr_nano/serve_realtime_ws.py \
    --port 10095 --language 中文 \
    --ws-ping-interval 20 --ws-ping-timeout 60

只有在外部网关已经统一负责 keepalive / reconnect 策略时,才考虑设置 --ws-ping-interval 0 关闭服务端 ping。

6.3 WebSocket 协议

连接ws://host:10095

客户端 → 服务端

消息 格式 说明
开始 "START" 初始化 session
热词 "HOTWORDS:词1,词2" 可选
语种 "LANGUAGE:中文" 可选
音频 binary PCM16 16kHz mono
结束 "STOP" 最终解码;启用 --enable-spk 时会做 SPK 重聚类

服务端 → 客户端

{"event": "started"}
{"sentences": [{"text":"你好","start":300,"end":1200}], "partial": "世界", "is_final": false}
{"sentences": [...], "is_final": true}
{"event": "stopped"}

字段sentences[] = 已锁定句子,partial = 当前正在说的临时文本(可能变化),partial_start_ms = 当前 partial 对应音频窗口的起点,is_final = STOP 后为 true。启用 --enable-spk 后,sentences[] 会包含 spk

时序

Client              Server
  │── START ───────→│
  │←─ started ──────│
  │── [audio] ─────→│
  │←─ {partial} ────│    # partial 的原理是注意事项见 6.5
  │── [audio] ─────→│
  │←─ {sentences+partial} ─│  (VAD 切了一句)
  │── STOP ────────→│
  │←─ {is_final:true} ────│
  │←─ stopped ─────│

6.4 客户端调用

Python CLI

python client_python.py --server ws://localhost:10095 --mic
python client_python.py --server ws://localhost:10095 --file audio.wav

实时压测

python examples/industrial_data_pretraining/fun_asr_nano/realtime_ws_benchmark.py \
    audio_16k_mono_pcm16.wav --server ws://localhost:10095 --clients 4 \
    --output-jsonl realtime_ws_4c.jsonl

指标定义和报告字段见 Realtime WebSocket Benchmark

浏览器:打开 client_mic.html

自定义 Python

import asyncio, websockets, numpy as np, json

async def stream(audio_path):
    import soundfile as sf
    audio, sr = sf.read(audio_path)
    pcm = (audio * 32768).astype(np.int16)

    async with websockets.connect("ws://localhost:10095") as ws:
        await ws.send("START")
        await ws.recv()

        for i in range(0, len(pcm), 1600):
            await ws.send(pcm[i:i+1600].tobytes())
            await asyncio.sleep(0.05)

        await ws.send("STOP")
        async for msg in ws:
            data = json.loads(msg)
            if data.get("is_final"):
                for s in data["sentences"]:
                    print(f"[{s['start']/1000:.1f}s] {s['text']}")
                break

asyncio.run(stream("audio.wav"))

6.5 partial 预览机制与长句特性

partial 是什么、怎么产生的 流式服务在用户说话过程中会周期性地(serve_realtime_ws.py 默认 decode_interval≈0.48s)对"当前这句话从句首到现在"的音频解码一次,输出临时文字(即协议里的 partial 字段,可被后续刷新覆盖),直到 VAD 判定句尾才锁定进 sentences。这让用户边说边看到字。

注:serve_vllm.py(§5)的 /ws 没有 partial、只在句尾返回;要实时预览请用 serve_realtime_ws.py

前端渲染原则 partial 只能当作“可替换预览”,不要把连续两次 partial 直接追加到最终文本里。推荐把已锁定文本和临时预览分开:

const committed = data.sentences.map((s) => s.text).join("");
const preview = data.partial || "";
render(committed + preview);

如果启用了 --partial-window-secpartial_start_ms 可能随着窗口向前滑动;这时 partial 只描述当前受限窗口内的临时识别结果。前端应每次替换 preview 区域,只把 VAD 已锁定的 sentences 或最终 is_final=true 结果追加到正式转写区。

原理:为什么每次 partial 都从句首整段重编 Fun-ASR-Nano 的声学编码器(SenseVoice)是全上下文、非流式编码器——每一帧的表示都依赖整段音频的前后文。当这句话又往下说了一截、音频变长时,先前那些帧的上下文随之改变,之前算出的编码不再成立,因此无法像流式 / 因果编码器那样"缓存历史、只算新增帧",只能把"句首→当前"的整段重新过一遍编码器。

由此带来的特性:长句的 partial 会越来越慢(O(L²) 正因每次都从句首重编,一句话越长,单次 partial 要编的音频越长、刷新次数也越多——总编码量随句长二次增长。实测一句约 29s 的连续发言会被完整重编十余次,单次 encoder 耗时从几十毫秒爬到数百毫秒。(§4 SDK 流式"每个 chunk 包含从头到当前的全部音频"是同一机制,长文件同理。)

使用建议

  • 正常对话语音有自然停顿,VAD 会把它切成一句句较短的语音,每句 partial 的开销自然受限,通常无需关注
  • 只有超长、不停顿的连续语音(如长篇朗读)会让单句不断变长、partial 预览逐渐变慢。serve_realtime_ws.py 默认用 --partial-window-sec 15 限制临时预览窗口;多客户端或连续独白压测时可降到 8-10,并把 --decode-interval 提高到 0.8-1.0。这只影响临时 partial,VAD 锁定句和 STOP 最终结果仍走完整音频。

6.6 说话人分离(SPK)的代价与开关

serve_realtime_ws.py 默认不加载 SPK 模型。只有启动时显式加 --enable-spk,才会加载 --spk-model(默认 iic/speech_eres2netv2_sv_zh-cn_16k-common)并在流式中对每个 VAD 完成句调用一次说话人分配。需要注意:

  • Fun-ASR-Nano 上 SPK 效果有限(见 #2944),多数实时 ASR 场景并不需要说话人分离。
  • 流式 SPK 代价高且随会话变长:每句对全部历史 embedding 做一次全量重聚类(O(N²),会话越长每句越贵),且同步阻塞事件循环;而会话结束时还会全量重聚一遍,流式期间每句的聚类结果会被最终结果覆盖——对最终输出而言属于重复计算。长会话 + 高并发下尤其明显。
  • 建议:多客户端实时转写优先保持默认关闭;确需 diarization 时再加 --enable-spk,并以 STOP 后的最终 spk 标签为准。

6.7 生产并发与多进程部署

serve_realtime_ws.py单 asyncio 事件循环服务:decode()(定时 partial)与 add_audio()VAD 句尾触发解码)都同步阻塞整个事件循环——任一路在解码时,其余连接全部暂停收发。因此:

  • 单进程并发墙来自事件循环串行,不是 GPU 算力
  • 目前扩展可行方案 = 单卡多个独立进程 + CUDA MPS + nginx 轮询:每个进程有独立的 GIL 与 CUDA 上下文,绕开单循环串行;MPS 让多进程真正并发共享 GPU、填满空闲算力;nginx 在多个 WebSocket 后端间轮询。超过单卡余量后,再横向加卡(每卡一实例 + 负载均衡)。
  • 小并发实时流不一定比多个 PyTorch 进程更适合 vLLM。vLLM 的优势主要来自批处理和 LLM token decode 调度;而当前实时 WebSocket 路径会把多路小请求通过单事件循环同步送进解码,无法自然形成大 batch,所以可能显存占用更高但 GPU 利用率仍不高。对于少量连续麦克风流,多个轻量 PyTorch 进程有时更容易在一张卡上排布;使用 vLLM 时请按真实话务压测,先配合较低的 --gpu-memory-utilization 和多进程服务,而不是假设一个 vLLM 进程就应该承载所有连接。
  • 可持续并发没有通用的"支持 N 路"数字:决定上限的不是在线连接数,而是同一时刻有多少路正在"说话"——每路只要在说,就每约 1 秒触发一次 partial 解码,全部串行在那个单事件循环上。它主要随两点变化:① 停顿 / 静音占比——真实一问一答中用户大半时间在听、不出声,同时解码的路数远少于在线连接数;连续独白则几乎每路都在持续解码,负载高得多。② 句长——句子越长,单次 partial 的编码越贵(见 6.5 的 O(L²)),同样路数下负载更高。因此同一套"单卡 L20 + 多进程 + MPS",在接近真实 turn-taking 的负载下可稳定支撑数十路,而在长句、连续不停顿的负载下会显著更低。任何"支持 X 路"的数字都只在它被测出来的那种话务下成立——请按自己的真实话务(句长、停顿、是否连续说话)压测确定,别把别处测出的某个并发数当成自己的规格。

7. 动态 VAD

fsmn-vad 默认启用动态静音阈值。离线和流式使用不同配置。

累积时长 离线(保留长段 ≤60s 流式(平衡延迟)
≤ 5s 2000ms 2000ms
5-10s 2000ms 1500ms
10-15s 1000ms 1000ms
15-20s 1000ms 800ms
20-30s 800ms 800ms
30-45s 600ms 400ms
45-60s 200-400ms 100ms
> 60s 100ms 100ms

离线倾向保留长段减少边界损失;流式更快收紧以降低延迟。

自定义

model.generate(input="audio.wav", silence_schedule=[(5000,1500), (20000,800), (float('inf'),300)])

GLM-ASR 不支持长段,使用时传 dynamic_silence=False


8. API 参考

参数 AutoModelVLLM serve_vllm.py serve_realtime_ws.py
model --model --model
gpu_memory_utilization --gpu-memory-utilization --gpu-memory-utilization
tensor_parallel_size --tensor-parallel-size
max_model_len --max-model-len --max-model-len
language generate() 参数 API 参数 --language / LANGUAGE:
hotwords generate() 参数 API 参数 --hotword-file / HOTWORDS:

9. FAQ

Q: 离线还是流式? 完整文件 → 离线(高吞吐)。麦克风/直播 → 流式(低延迟)。

Q: GLM-ASR 用动态 VAD 不支持长段推理,用 dynamic_silence=False

Q: SPK 性能影响? RTFx 102 → 46。CER 不变。默认关闭。

Q: 二次开发入口? 离线:serve_vllm.process_audio() / FunASRNanoVLLM.generate() 流式:serve_realtime_ws.RealtimeASRSession

Q: 首次慢? vLLM 初始化 60-90s,之后即时。

Q: vLLM 输出连续标点(例如 !!!!!!!!),但 PyTorch/HF generate 正常,应该先查什么? 这通常说明音频 frontend 和 checkpoint 本身能工作,但 vLLM prompt-embedding 路径或解码参数和 upstream runner 不一致。改模型前先检查这些项:

  • 传给 vLLM 的 prompt embeddings 要显式转成 float32 EmbedsPrompt(prompt_embeds=input_embeds.float())
  • 使用 ASR 更合适的确定性解码。Fun-ASR-Nano vLLM 路径默认使用 temperature=0.0top_p=1.0skip_special_tokens=True。在 prompt-embeds 模式下,repetition_penalty 保持中性的 1.0,除非你走的是 token prompt 路径;FunASR 的 vLLM helper 会把其他值归一化,避免 vLLM CUDA scatter 错误。
  • 确认 model_dirvllm_model_dir 是匹配的一组 Fun-ASR-Nano 模型。如果清空 vllm_model_dir 后同一音频走 HF generate 正常,就继续排查 vLLM 路径,而不是音频文件。
  • 对一个失败样本记录 vLLM finish_reason、生成 token ids、prompt embedding dtype 和 shape。连续标点且 finish_reason="length" 时,通常更像解码/prompt 不匹配,而不是 VAD 或音频读取问题。