# FunASR vLLM 推理引擎指南 --- ## Benchmark **测试集**:184 文件,11541 秒,Fun-ASR-Nano / GLM-ASR-Nano。RTFx 定义、计时口径和可复现字段请见 [Benchmark RTF and Reproducibility Notes](./benchmark/rtf_reproducibility.md)。 | 模型 | 引擎 | 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. [安装与环境](#1-安装与环境) 2. [vLLM 推理引擎架构](#2-vllm-推理引擎架构) 3. [离线 SDK 推理](#3-离线-sdk-推理) 4. [流式 SDK 推理](#4-流式-sdk-推理) 5. [离线语音识别服务](#5-离线语音识别服务) 6. [流式语音识别服务](#6-流式语音识别服务) 7. [动态 VAD](#7-动态-vad) 8. [API 参考](#8-api-参考) 9. [FAQ](#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 一起来。 ```bash # 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](https://github.com/vllm-project/vllm/blob/main/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_rate**:Fun-ASR-Nano 的 adaptor 输出需按公式截断到正确 token 数(一致性关键) 4. **batch encode**:多条音频通过 `extract_fbank` → `audio_encoder` → `audio_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] │ │ │ │ │ ▼ │ │ EmbedsPrompt(N 条) │ └──────────────────────────────────────────────┼──────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ 阶段 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_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倍加速) | ### 通用接口(推荐) ```python 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']}") ``` ### 直接接口 ```python 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, ) ``` ### 命令行 ```bash 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(可能变)] ``` ### 用法 ```python 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 服务时,请隔离每次请求的状态,并固定安全的解码默认值: ```python 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. 如果最小脚本也重复,再记录完整的 `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 启动服务 ```bash 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的一个环境变量](https://docs.vllm.ai/en/v0.4.3/serving/env_vars.html) ,示例中的 `=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 | 是否输出字级别时间戳 | **响应**: ```json { "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 } ``` **客户端示例**: ```bash # cURL curl -X POST http://localhost:8899/asr \ -F "file=@meeting.wav" -F "language=中文" -F "spk=true" ``` ```python # 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 // 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`): ```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}, ...] } ] } ``` **客户端示例**: ```python # 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) ``` ```bash # 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"` | 结束,请求识别结果 | **服务端 → 客户端**: ```json {"event": "started"} {"event": "language_set", "language": "中文"} {"sentences": [{"text":"...","start":..,"end":..}], "is_final": true, "duration_ms": 5170} {"event": "stopped"} ``` **客户端示例**: ```python 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 启动服务 ```bash CUDA_VISIBLE_DEVICES=0 python examples/industrial_data_pretraining/fun_asr_nano/serve_realtime_ws.py \ --port 10095 --language 中文 --hotword-file 热词列表 ``` 多客户端或长时间连续语音场景,建议先限制 partial 预览窗口并适当降低刷新频率: ```bash 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 调到能覆盖短暂网络抖动: ```bash 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 重聚类 | **服务端 → 客户端**: ```json {"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**: ```bash python client_python.py --server ws://localhost:10095 --mic python client_python.py --server ws://localhost:10095 --file audio.wav ``` **实时压测**: ```bash 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](./benchmark/realtime_ws_benchmark.md)。 **浏览器**:打开 `client_mic.html` **自定义 Python**: ```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` 直接追加到最终文本里。推荐把已锁定文本和临时预览分开: ```js 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 | 离线倾向保留长段减少边界损失;流式更快收紧以降低延迟。 ### 自定义 ```python 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 或音频读取问题。