环境:Ubuntu 22.04 / 2 × RTX 4090(24G)/ 国内网络
方案:官方 GPU SDK Docker 镜像,每卡一个容器(无特权模式),明文 ws + Nginx 负载均衡
数据目录:/mnt/mydata/funasr-runtime-resources/models
实测性能:13 秒音频离线转写 0.87 秒(RTF ≈ 0.065)
适用场景:语音识别文件转写、移动端录音转文字后端
一、整体架构
客户端(浏览器 / 移动端 / ASP.NET Core)
│ WebSocket(明文 ws)
▼
┌────────────────────────────────────────────┐
│ Nginx 负载均衡(:10100,least_conn) │
│ ├─ funasr-gpu-0(物理 GPU 0,宿主机 :10095)│
│ └─ funasr-gpu-1(物理 GPU 1,宿主机 :10096)│
└────────────────────────────────────────────┘
设计要点:
- 双卡不要让一个容器用
cuda:all,每卡一个容器 + Nginx 负载,扩容和故障隔离都简单 - 不要使用
--privileged=true,它会导致--gpus设备隔离失效(详见坑 7) - 全链路使用明文 ws(内网场景):服务端
--certfile 0+ 客户端--ssl 0+ C#ws://,三者必须一致
性能参考:4090 上 Paraformer-large RTF ≈ 0.065,单卡可支撑 20~30 路并发,双卡 + Nginx 足够支撑上百路并发。
二、安装 NVIDIA 驱动
ubuntu-drivers devices # 查看推荐驱动
sudo apt update
sudo apt install -y nvidia-driver-550 # 4090 建议 535+
sudo reboot
nvidia-smi # 应看到 2 张 4090
三、安装 Docker(阿里云 apt 源)
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://mirrors.aliyun.com/docker-ce/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker
四、配置国内镜像加速
sudo tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://docker.1ms.run",
"https://docker.xuanyuan.me",
"https://docker.m.daocloud.io",
"https://hub.rat.dev"
],
"log-opts": {
"max-size": "100m",
"max-file": "3"
}
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker
docker info | grep -A 5 "Registry Mirrors"
镜像加速站经常失效,多配几个自动容灾。FunASR 镜像本身在阿里云仓库,不走 Docker Hub,国内可直接拉取。
五、安装 NVIDIA Container Toolkit
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt update
sudo apt install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# 验证
sudo docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi
六、拉取镜像并启动双容器(最终可用版)
# 拉取 GPU 版镜像(约 8~10GB,阿里云仓库国内速度快)
sudo docker pull registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-gpu-0.2.1
# 创建模型目录(两个容器共享,模型只下载一次)
sudo mkdir -p /mnt/mydata/funasr-runtime-resources/models
# 容器 1 → 物理 GPU 0,宿主机端口 10095
sudo docker run -itd --gpus '"device=0"' \
--name funasr-gpu-0 \
-p 10095:10095 \
--restart unless-stopped \
-v /mnt/mydata/funasr-runtime-resources/models:/workspace/models \
registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-gpu-0.2.1 /bin/bash
# 容器 2 → 物理 GPU 1,宿主机端口 10096
sudo docker run -itd --gpus '"device=1"' \
--name funasr-gpu-1 \
-p 10096:10095 \
--restart unless-stopped \
-v /mnt/mydata/funasr-runtime-resources/models:/workspace/models \
registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-gpu-0.2.1 /bin/bash
⚠️ 不要加
--privileged=true(官方文档里有,但会让 GPU 隔离失效,详见坑 7)。
立即验证 GPU 隔离(关键步骤,别跳过)
sudo docker exec funasr-gpu-0 nvidia-smi -L
sudo docker exec funasr-gpu-1 nvidia-smi -L
每个容器必须只能看到 1 张卡,且两个容器的 UUID 不同(容器内卡名都叫 GPU 0 是正常的,看 UUID 区分)。如果都能看到 2 张卡,说明隔离没生效,先解决再往下走。
七、启动 ASR 服务(两个容器各执行一次)
sudo docker exec -it funasr-gpu-0 bash
cd /workspace/FunASR/runtime
nohup bash run_server.sh \
--download-model-dir /workspace/models \
--model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch \
--vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx \
--punc-dir damo/punc_ct-transformer_cn-en-common-vocab471067-large-onnx \
--decoder-thread-num 24 \
--io-thread-num 8 \
--certfile 0 \
> log.txt 2>&1 &
exit
然后对 funasr-gpu-1 重复同样操作。
关键参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
--certfile 0 |
必填 | 关闭 SSL,使用明文 ws;不加则默认 wss 模式,Nginx/客户端连不上(坑 6) |
--decoder-thread-num |
24~32 | 解码并发线程数,影响吞吐 |
--io-thread-num |
8 | IO 线程 |
| 显存占用 | ~2.5G | Paraformer-large 驻留显存 |
模型链路:VAD(语音切分,CPU)→ Paraformer-large(识别,GPU)→ CT-Transformer(标点,CPU),返回结果自带逐字时间戳。首次启动自动从 ModelScope 下载约 2G 模型到挂载目录。
模型切换(改 --model-dir):
- 实时流式:
damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-onnx+ online 版启动脚本 - 中英混合更强:
iic/SenseVoiceSmall(需 Python 推理方式部署)
热词:编辑容器内 /workspace/FunASR/runtime/websocket/hotwords.txt,每行 热词 权重(如 小王 30),重启服务生效。
八、Nginx 负载均衡
创建 /etc/nginx/conf.d/funasr.conf:
upstream funasr_backend {
least_conn;
server 127.0.0.1:10095;
server 127.0.0.1:10096;
}
server {
listen 10100;
location / {
proxy_pass http://funasr_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # 长音频转写耗时长,必须调大
}
}
# 确认 nginx.conf 里 conf.d 的 include 没被注释
grep -n "conf.d" /etc/nginx/nginx.conf
sudo nginx -t
sudo systemctl reload nginx
ss -tlnp | grep 10100
conf.d 与 sites-enabled 功能等价,选 conf.d 即可。注意
upstream全 nginx 只能定义一次,重复定义报duplicate upstream。
九、验证测试
9.1 端口速查表(极易搞混)
| 测试位置 | 容器 1(GPU0) | 容器 2(GPU1) | Nginx 负载 |
|---|---|---|---|
| 容器内部执行 | 127.0.0.1:10095 |
127.0.0.1:10095 |
— |
| 宿主机执行 | 127.0.0.1:10095 |
127.0.0.1:10096 |
127.0.0.1:10100 |
| 局域网其他机器 | 服务器IP:10095 |
服务器IP:10096 |
服务器IP:10100 |
容器 2 映射是 -p 10096:10095,映射只对宿主机网络生效,容器内部永远连 10095。
9.2 容器内测试
GPU 镜像的客户端脚本在 /workspace/FunASR/runtime/python/websocket/(不是 CPU 版文档的 /workspace/funasr_samples/python)。先装依赖:
pip3 install websockets -i https://mirrors.aliyun.com/pypi/simple/
示例音频在挂载目录里:
cd /workspace/FunASR/runtime/python/websocket
python3 funasr_wss_client.py --host "127.0.0.1" --port 10095 --ssl 0 \
--audio_in /workspace/models/damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch/example/asr_example.wav
注意必须加
--ssl 0(服务端已是明文 ws)。
9.3 真实速度测试脚本(验收标准)
官方客户端默认 2pass 模式 + 实时推流模拟,不能用来测性能。用这个:
import websocket, json, time
audio = open("/mnt/mydata/funasr-runtime-resources/models/damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch/example/asr_example.wav", "rb").read()
for i in range(5):
ws = websocket.create_connection("ws://127.0.0.1:10100")
ws.send(json.dumps({"mode": "offline", "wav_name": "test", "is_speaking": True, "itn": True}))
t = time.time()
ws.send(audio, websocket.ABNF.OPCODE_BINARY)
ws.send(json.dumps({"is_speaking": False}))
result = ws.recv()
print(f"第{i+1}次: {time.time()-t:.2f} 秒")
ws.close()
实测基准:第 1 次可能十几秒(CUDA/cuDNN/BladeDISC 预热,属正常),第 2 次起稳定 0.86~0.89 秒(13 秒音频)。
9.4 双卡验收三件套
# ① 两张卡各有一个进程,各 ~2.5G 显存
nvidia-smi
# ② 进程参数里没有 --certfile(明文 ws 模式确认)
sudo docker exec funasr-gpu-0 ps aux | grep funasr-wss-server | grep -v grep
# ③ 全速脚本测 10100,预热后 ~1 秒出结果
9.5 服务端原始回推参考(offline 模式)
{
"is_final": false,
"mode": "offline",
"text": "正是因为存在绝对正义,所以我们接受现实的相对正义。...",
"stamp_sents": [{"start":420, "end":2260, "punc":",", "text_seg":"正 是 因 为 ...", "ts_list":[[420,640],...]}],
"timestamp": "[[420,640],...]",
"wav_name": "test"
}
两个协议要点(对接必看):
is_final恒为false,服务端发完结果不关连接(连接可复用,等你送下一段)——客户端收到带text的 offline 消息即为最终结果stamp_sents含分句边界,ts_list含逐字毫秒级时间戳,做字幕/对齐时用得上
十、ASP.NET Core 对接(已验证可用版)
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
public async Task<string> RecognizeAsync(byte[] audioBytes)
{
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri("ws://192.168.65.35:10100"), CancellationToken.None);
// 1. 配置帧
var config = JsonSerializer.Serialize(new
{
mode = "offline",
chunk_size = new[] { 5, 10, 5 },
wav_name = "test",
is_speaking = true,
itn = true
});
await ws.SendAsync(Encoding.UTF8.GetBytes(config), WebSocketMessageType.Text, true, CancellationToken.None);
// 2. 发送音频(wav 文件完整字节,可带头直接发)
foreach (var chunk in audioBytes.Chunk(1920)) // ~60ms 一帧
{
await ws.SendAsync(chunk, WebSocketMessageType.Binary, true, CancellationToken.None);
}
// 3. 结束标志(必须发,否则服务端一直等音频)
await ws.SendAsync(Encoding.UTF8.GetBytes("{\"is_speaking\": false}"),
WebSocketMessageType.Text, true, CancellationToken.None);
// 4. 接收结果:offline 模式收到带 text 的消息即为最终结果
var buffer = new byte[64 * 1024];
var sb = new StringBuilder();
while (ws.State == WebSocketState.Open)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await ws.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
return sb.ToString();
ms.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
if (result.MessageType != WebSocketMessageType.Text) continue;
using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(ms.ToArray()));
var root = doc.RootElement;
var hasText = root.TryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String;
if (hasText) sb.Append(textEl.GetString());
// ⚠️ 关键:offline 模式 is_final 恒为 false 且服务端不关连接,
// 收到带 text 的 offline 消息即为最终结果,直接返回
if (root.TryGetProperty("mode", out var modeEl) && modeEl.GetString() == "offline" && hasText)
return sb.ToString();
}
return sb.ToString();
}
生产环境建议:移动端上传的音频格式五花八门(m4a/aac/amr/各种采样率),后端接收后先用 ffmpeg 统一转成 16kHz/单声道/16bit 再送 FunASR:
ffmpeg -i input.xxx -ar 16000 -ac 1 -sample_fmt s16 output.wav
十一、踩坑记录(全部实战踩过,按遇到顺序排列)
坑 1:启动日志刷大量 [W shape_analysis.cpp:841] failed PropagateTensorShapeOnNode ... aten::pad
现象:模型加载时按 encoders.47、encoders.48 逐层刷几百行警告。
结论:Warning 不是错误,直接忽略。GPU 版用 LibTorch + TorchScript 加载模型,JIT 对 aten::pad(动态 padding)无法编译期推断形状,打印警告后跳过该节点优化。编码器 50 层每层一条,纯属刷屏。
启动成功标志:日志出现 successfully start funasr model-server on ip 0.0.0.0 port 10095,或 ss -tlnp | grep 10095 显示监听。
坑 2:日志刷到一半"卡住"不动
排查顺序:
- 宿主机
nvidia-smi:显存涨到 2~4G = 正在加载 ss -tlnp | grep 10095:已监听 = 服务已起(nohup 输出有缓冲,日志延迟写入)ps aux | grep funasr+top:进程活着吃 CPU = 正常加载du -sh /mnt/mydata/funasr-runtime-resources/models/*:目录在增长 = 还在下载模型
真卡死标准:5 分钟以上显存为 0 且 CPU 0%。多为模型下载不完整,重新下载。
坑 3:cd /workspace/funasr_samples/python 提示 No such file or directory
GPU 版镜像不带 funasr_samples(那是 CPU 在线版的目录结构)。GPU 镜像客户端在 /workspace/FunASR/runtime/python/websocket/,用 find / -name "*wss_client*" 2>/dev/null 定位。
坑 4:ModuleNotFoundError: No module named 'websockets'
镜像没装 websockets 库:pip3 install websockets -i https://mirrors.aliyun.com/pypi/simple/。
注意容器重建后装过的库全部丢失,需要重装。
坑 5:conda 环境 pip install 后仍 ModuleNotFoundError
(base) 环境下 pip 属于系统 Python,python3 指向 conda Python,两个解释器不共享包。一律用 python3 -m pip install xxx 保证装到当前解释器。
坑 6:ASP.NET Core 报 502 / 客户端报 ConnectionResetError(WSS 与 WS 混用)
现象:C# 报 The server returned status code '502' when status code '101' was expected;nginx error.log 里 upstream prematurely closed connection。
原因:FunASR 默认以 WSS(TLS)模式运行(进程参数带 --certfile server.crt),而 Nginx proxy_pass http:// 用明文连后端,握手被断开。
解决:服务端启动加 --certfile 0 关 SSL,全链路明文 ws。
铁律:服务端 --certfile 0 + 客户端 --ssl 0 + C# ws:// 必须全明文;要加密则全链路 wss(nginx proxy_pass https:// + proxy_ssl_verify off + C# 跳过自签名校验),不能混用。混用表现:nginx 侧 502,客户端侧 ConnectionResetError。
坑 7:--privileged=true 导致双卡隔离失效(最隐蔽的坑)
现象:--gpus '"device=0"' / '"device=1"' 建的容器,nvidia-smi -L 都能看到全部 2 张卡,两个服务进程全挤在物理 GPU 0 上,GPU 1 完全空闲。
原因:特权模式下 nvidia-container-toolkit 直接注入所有设备,--gpus 的设备选择被覆盖,容器内 NVIDIA_VISIBLE_DEVICES=all。
解决:去掉 --privileged=true(FunASR 实测不需要特权模式)。重建后用 nvidia-smi -L 验证:每个容器只能看到 1 张卡,两个容器 UUID 不同。
若某环境必须要特权模式:改用 -e NVIDIA_VISIBLE_DEVICES=GPU-xxxx(UUID) 强制指定。
坑 8:容器 2 内连 127.0.0.1:10096 报 ConnectionRefused
-p 10096:10095 的映射只在宿主机网络生效,容器内部永远连 10095。见 9.1 端口速查表。
坑 9:4090 转写"要 20 多秒"(假慢)
原因:官方 funasr_wss_client.py 默认模拟实时麦克风推流(60ms 一帧 + sleep),测试耗时 ≈ 音频时长;且默认 2pass 模式跑两遍模型。
佐证:测试时 nvidia-smi 功率 20W→65W 但利用率 0%——每帧微秒级脉冲,采样间隔抓不到。
正确测法:用 9.3 的全速脚本(offline + 一次性发送),实测 0.87 秒。性能验收不要依赖官方客户端。
坑 10:容器重建后模型"丢失"
旧容器内 /root/.cache/modelscope 随容器删除。模型必须下载到挂载目录(--download-model-dir /workspace/models),示例音频也从挂载目录取:/workspace/models/damo/.../example/asr_example.wav。
坑 11:测试音频报 wave.Error: file does not start with RIFF id
选了 scipy 测试数据 test-8000Hz-be-3ch-5S-24bit.wav——big-endian(RIFX)、8kHz、3 声道、24bit,全部不符合要求。FunASR 要求 16kHz/单声道/16bit,且服务端不做重采样,8kHz 音频送进去会按 16kHz 解读导致识别全乱。生产环境务必先 ffmpeg 转码。
坑 12:C# 客户端 60 秒无响应(offline 协议理解错误,最难查的坑)
现象:Python 脚本 0.87 秒出结果,C# 却挂到超时。
原因:offline 模式下服务端1 秒内返回完整结果(单条消息含全文 text),但 is_final 恒为 false 且不关连接(连接可复用设计)。C# 代码等 is_final: true 或 Close 帧,两个事件永远不会发生。
解决:收到带 text 且 mode=offline 的消息即为最终结果,直接返回。见第十章代码。
附带 bug:多段结果要用 StringBuilder 累加,不能覆盖,否则只能拿到最后一句。
十二、运维备忘
| 事项 | 命令/说明 |
|---|---|
| 查看服务状态 | `sudo docker exec funasr-gpu-0 ps aux |
| 查看服务日志 | sudo docker exec funasr-gpu-0 tail -f /workspace/FunASR/runtime/log.txt |
| 查看 GPU 占用 | nvidia-smi(两张卡各一个进程为正常) |
| 查看 nginx 错误 | sudo tail -20 /var/log/nginx/error.log |
| 重启服务 | 进容器pkill -f funasr-wss-server 后重新执行 run_server.sh |
| 容器重启注意 | --restart unless-stopped 只重启容器,内部服务需手动拉起;可将启动命令固化为容器 entrypoint 脚本 |
| 唯一有状态数据 | /mnt/mydata/funasr-runtime-resources/models,定期备份可免重新下载 |
| 防火墙 | 外部访问需放行 10100:sudo ufw allow 10100 |
十三、性能基准(实测)
| 项目 | 数值 |
|---|---|
| 测试音频 | 13 秒(asr_example.wav,408KB) |
| 单请求耗时(预热后) | 0.86~0.89 秒 |
| RTF(实时率) | ≈ 0.065 |
| 单卡显存占用 | ~2.5G |
| 单卡并发能力 | 20~30 路 |
| 首次请求(预热) | 10~20 秒(一次性,服务长期运行只预热一次) |