38 KiB
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. 安装与环境
先安装 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 VRAM,CUDA ≥ 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 |
关键实现细节
- 权重分离:从
model.pt提取 LLM 权重,转为 HuggingFace 格式供 vLLM 加载 - EmbedsPrompt:直接把已算好的 embedding 向量(而非通常的 token ID)作为 prompt 送入 vLLM(开关
enable_prompt_embeds=True)。Fun-ASR-Nano 必须用它,因为音频经 adaptor 得到的是连续向量、不是 token,需把音频 embedding 与文本 embedding 在序列维拼接后整体送入 vLLM - use_low_frame_rate:Fun-ASR-Nano 的 adaptor 输出需按公式截断到正确 token 数(一致性关键)
- batch encode:多条音频通过
extract_fbank→audio_encoder→audio_adaptor一次前向 - 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] │
│ │ │
│ ▼ │
│ EmbedsPrompt(N 条) │
└──────────────────────────────────────────────┼──────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 阶段 2: LLM 解码(vLLM, 多 GPU Tensor Parallel) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ EmbedsPrompt × N ──→ vLLM Continuous Batching │
│ (PagedAttention + CUDA Graph) │
│ │ │
│ ▼ │
│ Generated token_ids × N │
│ │ │
│ ▼ │
│ Decode + 后处理(去特殊标记、清洗) │
│ │ │
│ ▼ │
│ (可选) CTC Forced Alignment → 字级别时间戳 │
└─────────────────────────────────────────────────────────────────────┘
关键设计决策:
- 权重分离:首次运行时从
model.pt提取llm.*前缀的权重,保存为 HuggingFace safetensors 格式供 vLLM 加载(缓存到Qwen3-0.6B-vllm/目录) - Embedding 拼接:文本 prompt 通过 LLM 的
embed_tokens层编码为 embedding,与音频 adaptor 输出在序列维度拼接:[prefix_emb | audio_emb | suffix_emb],以EmbedsPrompt形式送入 vLLM - Low Frame Rate 截断:adaptor 输出需按公式
fake_token_len = ((((fbank_len - 3 + 2) // 2 - 3 + 2) // 2) - 1) // 2 + 1截断到正确长度,确保与 PyTorch 训练时一致 - 批量音频编码:多条音频按 batch_size=8 分组通过 encoder + adaptor 前向,减少 GPU kernel launch 开销
- 文本 prompt 共享:同一批次内 hotwords/language 相同时,prefix_emb 和 suffix_emb 只计算一次
- CTC 时间戳:保留 encoder_out,LLM 生成文本后做 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])
如果同一个音频第一次请求正常、第二次请求开始重复:
- 先把 API 层拿掉,用相同 VAD 分段跑上面的最小脚本。
- 如果最小脚本稳定,优先检查 API 封装是否复用了请求级变量、上一轮 VAD 分段列表、上一轮
results或累积文本。 - 如果最小脚本也重复,再记录完整的
funasr、vllm、torch版本,以及第一次和第二次输出文本,再调整其它解码参数。
不要通过调大 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,持续发送) │
│ │
│ ┌────┴─────────────────────────┐
│ │ 实时循环: │
│ │ ├─ 动态 VAD(60ms 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-sec,partial_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.0、top_p=1.0和skip_special_tokens=True。在 prompt-embeds 模式下,repetition_penalty保持中性的1.0,除非你走的是 token prompt 路径;FunASR 的 vLLM helper 会把其他值归一化,避免 vLLM CUDA scatter 错误。 - 确认
model_dir和vllm_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 或音频读取问题。