环境: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)│
└────────────────────────────────────────────┘

设计要点

  1. 双卡不要让一个容器用 cuda:all,每卡一个容器 + Nginx 负载,扩容和故障隔离都简单
  2. 不要使用 --privileged=true,它会导致 --gpus 设备隔离失效(详见坑 7)
  3. 全链路使用明文 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"
}

两个协议要点(对接必看)

  1. is_final 恒为 false服务端发完结果不关连接(连接可复用,等你送下一段)——客户端收到带 text 的 offline 消息即为最终结果
  2. 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.47encoders.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:日志刷到一半"卡住"不动

排查顺序

  1. 宿主机 nvidia-smi:显存涨到 2~4G = 正在加载
  2. ss -tlnp | grep 10095:已监听 = 服务已起(nohup 输出有缓冲,日志延迟写入)
  3. ps aux | grep funasr + top:进程活着吃 CPU = 正常加载
  4. 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 帧,两个事件永远不会发生。
解决:收到带 textmode=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 秒(一次性,服务长期运行只预热一次)