asrserver_deploy_guide
FunASR ASRServer Docker 部署与联调文档(含离线环境排障)
关键词:FunASR、asrserver、WebSocket、ModelScope、离线环境、Docker 部署、排障
本文以 funasr-wss-server-2pass 为例,整理一套 ASR Server 部署 + 联调 + 排障 的完整文档,适用于你目前的场景:
- 使用 Docker 部署 FunASR WebSocket ASR 服务(后文统一称为 asrserver)
- 生产环境 无外网(无法访问
www.modelscope.cn) - 需要通过 C++ 官方客户端 和 自定义 Python WebSocket 客户端 进行联调
- 启动和联调过程中遇到过:
- 容器启动后自动退出
- TLS 握手超时
- WAV 头格式报错
sended data len=0却返回空文本结果
1. 服务说明
1.1 服务组件
- 服务端二进制:
funasr-wss-server-2pass - 启动脚本(镜像内):
FunASR/runtime/run_server_2pass.sh - 常见启动参数:
--download-model-dir:模型下载/缓存目录,一般为/workspace/models--vad-dir:VAD 模型(ModelScope 模型名或本地路径)--model-dir:离线 ASR 模型--online-model-dir:在线/流式 ASR 模型--punc-dir:标点模型--itn-dir:数字/时间等反规范化模型--lm-dir:语言模型--hotword:热词文件路径
示例(镜像内 ENTRYPOINT 大致如下):
cd FunASR/runtime
bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --itn-dir thuduj12/fst_itn_zh --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --hotword /workspace/models/hotwords.txt
说明:
damo/...和thuduj12/...是 ModelScope 模型 ID,在有外网时会自动从www.modelscope.cn下载。
2. 目录规划与端口规划
2.1 宿主机目录规划
建议在宿主机上统一存放模型文件,例如:
/data/funasr/
└── models/ # 挂载到容器的 /workspace/models
2.2 端口规划
- 容器内默认监听端口:
10095 - 宿主机映射端口示例:
-p 10097:10095 # 宿主机 10097 → 容器 10095
3. 在线环境一次性下载模型(为离线环境做准备)
离线环境无法访问 ModelScope,因此需要在一台 有外网 的机器上,使用同版本镜像预先下载模型到宿主机目录。
3.1 准备挂载目录
mkdir -p /data/funasr/models
3.2 启动容器,让 FunASR 自动下载模型
docker run --rm -it -v /data/funasr/models:/workspace/models <你的-funasr镜像> bash -c "cd /workspace/FunASR/runtime; bash run_server_2pass.sh"
第一次启动时,日志中会看到类似信息:
Download model: damo/speech_fsmn_vad_zh-cn-16k-common-onnx from modelscope
...
Successfully load model from /workspace/models/damo/speech_fsmn_vad_zh-cn-16k-common-onnx/model_quant.onnx
...
asr model init finished. listen on port:10095
说明 /workspace/models 中需要的模型已经下载完整,并且服务成功启动。
3.3 确认模型目录结构
退出容器后,在宿主机查看目录结构:
ls -R /data/funasr/models
理想结构(示意):
/data/funasr/models
├── damo
│ ├── speech_fsmn_vad_zh-cn-16k-common-onnx/
│ ├── speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx/
│ ├── speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx/
│ ├── punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx/
│ └── speech_ngram_lm_zh-cn-ai-wesp-fst/
├── thuduj12
│ └── fst_itn_zh/
└── hotwords.txt # 若无,可手工创建一个空文件
3.4 打包模型目录
cd /data/funasr
tar czf funasr_models.tar.gz models
将
funasr_models.tar.gz拷贝到生产/离线服务器,供后续部署使用。
4. 离线环境部署 asrserver
4.1 解压模型并挂载
在离线服务器上:
mkdir -p /data/funasr
tar xzf funasr_models.tar.gz -C /data/funasr
然后启动 asrserver:
docker run -d --name asr-server -v /data/funasr/models:/workspace/models -p 10097:10095 <同一版本-funasr镜像>
此时:
- Docker 中仍然会使用原来的 ENTRYPOINT(
run_server_2pass.sh); - 虽然仍然会尝试访问 ModelScope,但 本地
/workspace/models已经有模型,即使下载失败,只要能找到本地模型,服务仍然会启动成功。
4.2 日志确认
查看日志:
docker logs -f asr-server
会看到类似:
Failed to resolve 'www.modelscope.cn' ...
Failed to download model from modelscope. If you set local vad model path, you can ignore the errors.
Successfully load model from /workspace/models/damo/speech_fsmn_vad_zh-cn-16k-common-onnx/model_quant.onnx
...
asr model init finished. listen on port:10095
关键判断点:
- 前面的下载失败报错在离线环境是正常现象;
- 只要后面出现 “Successfully load model from …” 和 “listen on port:10095” 即可认定服务成功启动。
5. 确认服务状态(容器 + 端口)
5.1 容器状态
docker ps -a | grep asr-server
期望状态:Up ... 而不是 Exited (1)。
5.2 容器内检查端口监听
docker exec -it asr-server bash
ss -lntp | grep 10095
输出类似:
LISTEN 0 128 0.0.0.0:10095 0.0.0.0:* users:(("funasr-wss-server-2pass",pid=...,fd=...))
说明 WebSocket 服务已在 10095 监听。
6. 使用 C++ 官方客户端联调
FunASR 自带 C++ 客户端 funasr-wss-client-2pass,位于镜像内:
/workspace/FunASR/runtime/websocket/build/bin/funasr-wss-client-2pass
6.1 准备测试音频
要求:
- WAV 格式;
- 16 kHz 采样率;
- 单声道(Mono);
- 16 bit PCM。
如需转换,可使用 ffmpeg:
ffmpeg -y -i input.wav -ar 16000 -ac 1 -sample_fmt s16 tmp_16k_mono_s16.wav
将 tmp_16k_mono_s16.wav 拷入容器相应目录,例如:
/workspace/FunASR/runtime/csharp/ws-client/confg/tmp_16k_mono_s16.wav
6.2 在容器内发起测试
docker exec -it asr-server bash
cd /workspace/FunASR/runtime/websocket
./build/bin/funasr-wss-client-2pass --mode 2pass --server-ip 127.0.0.1 --port 10095 --wav-path ../csharp/ws-client/confg/tmp_16k_mono_s16.wav --is-ssl 0
关键参数说明:
--mode 2pass:与服务端 2pass 模式对应;--server-ip 127.0.0.1:容器内直接访问自身;--port 10095:服务监听端口;--is-ssl 0:关闭 SSL,以明文ws://模式连接(默认是 SSL)。
正常情况下会看到类似日志:
I... sended data len=123456
I... on_message = {"is_final":true,"text":"今天天气不错,我正在测试语音识别服务","wav_name":"wav_default_id"}
此时说明:
- WebSocket 已经连通;
- 服务端收到了音频数据;
- 成功返回了识别文本。
7. 常见问题与排障记录
这一节记录你本次部署过程中遇到的几个典型问题及解决思路,方便以后查阅。
7.1 启动即退出:ModelScope 下载失败 + 模型缺失
现象:
Failed to resolve 'www.modelscope.cn' ([Errno -3] Temporary failure in name resolution)
...
Failed to download model from modelscope. If you set local vad model path, you can ignore the errors.
...
/workspace/models/damo/speech_fsmn_vad_zh-cn-16k-common-onnx/model_quant.onnx do not exists.
原因:
- 离线环境无 DNS / 无外网;
/workspace/models中没有提前准备好的模型文件;- FunASR 无法从本地或 ModelScope 获取模型,启动失败。
解决:
- 在有外网环境预下载
/workspace/models并打包(参考第 3 章); - 在离线环境解压并挂载到
/workspace/models(参考第 4 章); - 再启动容器时,即使仍报下载失败,只要后面出现 “Successfully load model from …”,服务就可以正常运行。
可选进阶:在 ENTRYPOINT/脚本中去掉 --download-model-dir,并将 damo/... 等参数改成本地路径,彻底关闭在线下载逻辑。
7.2 C++ 客户端 TLS 报错:TLS handshake timed out
现象:
[error] handle_transport_init received error: TLS handshake timed out
[info] asio async_shutdown error: asio.ssl:336462231 (shutdown while in init (SSL routines, SSL_shutdown))
原因:
- 服务端默认运行在明文 ws 模式(未配置 SSL 证书);
- C++ 客户端未指定
--is-ssl 0,默认按 wss/SSL 模式 握手; - 导致 TLS 握手超时。
解决:
使用以下命令进行测试:
./build/bin/funasr-wss-client-2pass --mode 2pass --server-ip 127.0.0.1 --port 10095 --wav-path ../csharp/ws-client/confg/tmp_16k_mono_s16.wav --is-ssl 0
即显式关闭 SSL,使用 ws:// 连到 10095。
7.3 WAV 头格式错误:Expected subchunk1_size 16. Given: 18
现象:
Expected subchunk1_size 16. Given: 18
原因:
- C++ 客户端对 WAV 头格式要求非常严格,只接受 fmt 子块长度为 16 的标准 PCM WAV;
- 部分录音工具生成的是扩展 WAV(
subchunk1_size=18等),导致客户端拒绝解析。
解决:
使用 ffmpeg 将音频转换为标准的 16k/单声道/16bit PCM WAV:
ffmpeg -y -i input.wav -ar 16000 -ac 1 -sample_fmt s16 tmp_16k_mono_s16.wav
再用 tmp_16k_mono_s16.wav 作为 --wav-path。
7.4 sended data len=0 + 空文本结果
现象:
I... sended data len=0
I... on_message = {"is_final":true,"text":"","wav_name":"wav_default_id"}
含义:
- WebSocket 连接成功,服务端返回了最终结果(
is_final=true); - 但客户端总共发送的音频字节数为 0,因此服务端没有可用音频,只能返回空文本。
常见原因:
--wav-path路径拼写错误(例如confg→config);- 文件不存在或大小为 0;
- 客户端读取文件失败。
排查步骤:
在容器内确认文件路径和大小:
cd /workspace/FunASR/runtime/websocket
ls -lh ../csharp/ws-client/confg/tmp_16k_mono_s16.wav
file ../csharp/ws-client/confg/tmp_16k_mono_s16.wav
确认:
- 路径无误;
- 文件大小 > 0;
file命令输出为标准 WAV。
修正之后再次运行 C++ 客户端,sended data len 应变为一个非零值,服务端返回的 text 字段也会包含识别结果。
8. Python WebSocket 客户端示例(可直接复用)
下面是一个可直接用于生产联调的 Python WebSocket 客户端示例,已经处理了:
mode = "2pass";- 自动将 WAV 转为 16k/mono/16bit;
- 分块发送 4000 字节 PCM;
- 打印并解析服务端 JSON 结果。
请根据实际情况修改
ASR_HOST、ASR_PORT和test_audio路径。
import asyncio
import json
import uuid
import websockets
import wave
import os
import subprocess
import tempfile
import logging
from typing import Optional
# ==========================
# 配置区
# ==========================
ASR_HOST = "10.131.40.18" # ASR 服务所在机器 IP
ASR_PORT = 10097 # 宿主机映射端口,如 -p 10097:10095
CHUNK_BYTES = 4000 # 每次发送 4000 字节
RECV_TIMEOUT = 120 # 接收识别结果超时时间(秒)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def build_ws_uri() -> str:
session_id = str(uuid.uuid4())
return f"ws://{ASR_HOST}:{ASR_PORT}/{session_id}"
def build_init_request() -> dict:
"""构造初始化 JSON 请求"""
return {
"mode": "2pass",
"chunk_size": [5, 10, 5],
"chunk_interval": 10,
"encoder_chunk_look_back": 4,
"decoder_chunk_look_back": 0,
"wav_name": str(uuid.uuid4()),
"is_speaking": True,
"audio_fs": 16000,
"wav_format": "pcm",
"hotwords": "",
"itn": True,
}
async def asr_client(audio_file_path: Optional[str] = None) -> None:
uri = build_ws_uri()
max_retries = 3
for attempt in range(1, max_retries + 1):
logger.info(f"[asr_client] Attempt {attempt}/{max_retries}, connecting to {uri}")
try:
async with websockets.connect(uri, ping_interval=None) as websocket:
logger.info(f"[asr_client] Connected to {uri}")
# 1) 发送初始化请求
init_request = build_init_request()
init_json = json.dumps(init_request, ensure_ascii=False)
logger.info(f"[asr_client] Sending init_request: {init_json}")
await websocket.send(init_json)
logger.info("[asr_client] Initialization request sent")
# 2) 发送音频
if audio_file_path and os.path.exists(audio_file_path):
await send_audio_data(websocket, audio_file_path)
logger.info("[asr_client] Audio data finished, you can now wait for results")
else:
logger.warning(f"[asr_client] Audio file not found: {audio_file_path}")
# 可选:发送空帧作为结束提示
try:
await websocket.send(b"")
logger.info("[asr_client] Sent empty binary frame as end-of-stream hint")
except Exception as e:
logger.warning(f"[asr_client] Failed to send end hint (can be ignored): {e!r}")
# 3) 接收识别结果
logger.info("[asr_client] Waiting for recognition results ...")
await receive_messages(websocket)
break
except Exception as e:
logger.error(f"[asr_client] Error on attempt {attempt}: {e!r}", exc_info=True)
if attempt < max_retries:
logger.warning("[asr_client] Retry in 2 seconds ...")
await asyncio.sleep(2)
else:
logger.error("[asr_client] Max retries reached, exit.")
async def receive_messages(websocket: websockets.WebSocketClientProtocol) -> None:
"""循环接收识别结果"""
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=RECV_TIMEOUT)
except asyncio.TimeoutError:
logger.warning(f"[receive_messages] No message for {RECV_TIMEOUT} seconds, stop waiting.")
break
except websockets.exceptions.ConnectionClosed as e:
logger.warning(f"[receive_messages] WebSocket closed: code={e.code}, reason={e.reason}")
break
except Exception as e:
logger.error(f"[receive_messages] Error while receiving message: {e!r}", exc_info=True)
break
if isinstance(message, bytes):
logger.info(f"[receive_messages] Received binary message, len={len(message)}")
preview = message[:200]
logger.info(f"[receive_messages] Binary preview: {preview!r}")
try:
text = message.decode("utf-8")
logger.info(f"[receive_messages] Decoded as UTF-8: {text}")
except UnicodeDecodeError:
text = None
logger.info("[receive_messages] Binary is not valid UTF-8")
else:
text = message
logger.info(f"[receive_messages] Received text message: {text}")
if text:
try:
data = json.loads(text)
logger.info("[receive_messages] Parsed JSON result:")
logger.info(json.dumps(data, indent=2, ensure_ascii=False))
except json.JSONDecodeError as e:
logger.warning(f"[receive_messages] Message is not valid JSON: {e}")
async def send_audio_data(websocket: websockets.WebSocketClientProtocol, audio_file_path: str) -> None:
"""按 4000 字节一块发送 16k/mono/16bit PCM 音频"""
logger.info(f"[send_audio_data] Sending audio from: {audio_file_path}")
if audio_file_path.lower().endswith(".wav"):
fixed_path = convert_wav_to_required_format(audio_file_path)
if fixed_path != audio_file_path:
logger.info(f"[send_audio_data] Using converted file: {fixed_path}")
audio_file_path = fixed_path
with wave.open(audio_file_path, "rb") as wf:
framerate = wf.getframerate()
nchannels = wf.getnchannels()
sampwidth = wf.getsampwidth()
logger.info(f"[send_audio_data] WAV params: framerate={framerate}, channels={nchannels}, sampwidth={sampwidth}")
if framerate != 16000 or nchannels != 1 or sampwidth != 2:
logger.error("[send_audio_data] ERROR: WAV format is still not 16k/mono/16bit after conversion, skip sending.")
return
frames_per_chunk = CHUNK_BYTES // 2
chunk_idx = 0
while True:
data = wf.readframes(frames_per_chunk)
if not data:
break
await websocket.send(data)
chunk_idx += 1
logger.info(f"[send_audio_data] Sent WAV chunk #{chunk_idx}, bytes={len(data)}")
await asyncio.sleep(0.125)
else:
chunk_idx = 0
with open(audio_file_path, "rb") as f:
while True:
data = f.read(CHUNK_BYTES)
if not data:
break
await websocket.send(data)
chunk_idx += 1
logger.info(f"[send_audio_data] Sent PCM chunk #{chunk_idx}, bytes={len(data)}")
await asyncio.sleep(0.125)
logger.info("[send_audio_data] Audio data transmission completed")
def convert_wav_to_required_format(input_file: str) -> str:
"""将 WAV 转成 16000Hz / 单声道 / 16bit PCM"""
try:
with wave.open(input_file, "rb") as wf:
framerate = wf.getframerate()
nchannels = wf.getnchannels()
sampwidth = wf.getsampwidth()
if framerate == 16000 and nchannels == 1 and sampwidth == 2:
logger.info("[convert_wav_to_required_format] Input WAV already in required format")
return input_file
logger.info("[convert_wav_to_required_format] Converting WAV to 16k/mono/16bit using ffmpeg ...")
output_file = tempfile.mktemp(suffix=".wav")
cmd = [
"ffmpeg",
"-y",
"-i", input_file,
"-ar", "16000",
"-ac", "1",
"-sample_fmt", "s16",
output_file,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.error(f"[convert_wav_to_required_format] ffmpeg failed: {result.stderr}")
return input_file
logger.info(f"[convert_wav_to_required_format] Conversion successful: {output_file}")
return output_file
except Exception as e:
logger.error(f"[convert_wav_to_required_format] Error: {e!r}", exc_info=True)
return input_file
if __name__ == "__main__":
# 请替换为你实际要测试的文件路径
test_audio = "/path/to/tmp_16k_mono_s16.wav"
asyncio.run(asr_client(test_audio))
更多推荐



所有评论(0)