1. 为什么语音质检这件事值得用 FunASR 加 FreeSWITCH 来做
做客服中心质检系统的朋友大概率都经历过这样的场景:每天几千通电话录音躺在存储里,质检员靠人耳抽听,覆盖率不到百分之三,等发现问题时客户早就流失了。传统方案要么买昂贵的商业语音识别授权,要么用开源方案但识别准确率惨不忍睹,尤其是带口音、带背景噪声的坐机录音,转写出来一堆错别字,质检规则根本没法跑。
我这次要聊的这套组合,核心思路是把FreeSWITCH当作电话接入和媒体处理的中枢,把FunASR当作语音识别引擎,中间用WebSocket做实时音频流的双向通道。FreeSWITCH 负责接电话、录音、放音、转接这些脏活累活,FunASR 负责把音频变成文字,质检规则再基于文字做关键词命中、情绪分析、语速检测。这套架构最大的好处是实时性——不用等通话结束再离线转写,而是在通话进行中就能拿到识别结果,质检从"事后抽查"变成"事中干预"。
适合谁来读这篇内容?如果你正在做呼叫中心、客服质检、电话机器人、录音分析这类项目,或者你手上有 FreeSWITCH 环境想接一个中文识别引擎,那这篇基本可以当作落地参考。如果你只是想了解 FunASR 怎么部署,前半部分也够用。我会把踩过的坑、参数怎么调、WebSocket 怎么串、热词怎么配这些细节都摊开讲,尽量让你少走弯路。
需要提前说明的是,下面涉及的具体配置和代码是基于我在实际项目中的做法整理的,不同版本的 FreeSWITCH 和 FunASR 在细节上可能有差异,你落地时以自己环境的实际表现为准。
2. FunASR 在 Linux 上的部署与模型选型
2.1 环境准备与依赖安装
FunASR 是阿里达摩院开源的一套语音识别工具链,底层依赖 PyTorch,模型仓库放在 ModelScope 上。部署第一步是把 Python 环境弄干净,我强烈建议用 conda 建独立环境,不要往系统 Python 里塞,否则后面 torch 版本冲突会让你怀疑人生。
conda create -n funasr python=3.10 -y conda activate funasr pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install funasr modelscope这里有个细节:如果你机器上有 NVIDIA 显卡,torch 一定要装 CUDA 版本,CPU 版本跑实时识别会非常吃力。我实测过,同样的 Paraformer 模型,GPU 上单路音频的实时率能到 0.1 以下(也就是 10 秒音频 1 秒内出结果),CPU 上勉强到 0.8 左右,并发一上来就顶不住。显卡显存建议 8G 起步,如果要跑多路并发,16G 更稳妥。
装完之后验证一下:
import torch print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果输出 True 和你的显卡型号,说明环境没问题。如果 False,检查驱动和 CUDA 版本是否匹配,这一步不通过后面全是白搭。
2.2 模型选择:Paraformer 还是 SenseVoice
FunASR 提供了好几个模型,常用的有 Paraformer-large、Paraformer-streaming、SenseVoice-Small 这几个。选哪个取决于你的场景:
| 模型 | 特点 | 适用场景 | 显存占用 |
|---|---|---|---|
| Paraformer-large | 非流式,精度最高 | 离线录音转写 | 约 4G |
| Paraformer-streaming | 流式,低延迟 | 实时通话识别 | 约 3G |
| SenseVoice-Small | 多语言,带情感和事件检测 | 需要情绪分析的质检 | 约 2G |
| Paraformer-zh | 中文优化,热词支持好 | 中文客服质检 | 约 3G |
做实时语音质检,我推荐Paraformer-streaming做在线识别,配合SenseVoice-Small做离线复核。原因是流式模型虽然精度略低于非流式,但延迟能控制在几百毫秒内,质检规则可以边通话边跑。通话结束后再用高精度模型重新转写一遍,做最终归档和深度分析。
模型加载代码大概长这样:
from funasr import AutoModel model = AutoModel( model="paraformer-zh-streaming", model_revision="v2.0.4", device="cuda:0", disable_update=True )disable_update=True这个参数建议加上,否则每次启动都会去 ModelScope 检查更新,网络不好的时候会卡很久。模型第一次加载会自动下载到~/.cache/modelscope目录,下载完大概几个 G,提前留好磁盘空间。
2.3 热词配置:让识别结果贴合你的业务
热词是语音质检里最容易被忽视但效果最明显的功能。客服通话里经常出现产品名、专有名词、人名,通用模型很容易识别错。比如"花呗"可能被识别成"花费","借呗"识别成"借被",质检规则一跑全是误报。
FunASR 的热词配置通过hotword参数传入,格式是"词 权重":
res = model.generate( input=audio_data, hotword="花呗 20 借呗 20 芝麻信用 15 蚂蚁森林 15", cache={}, is_final=True )权重范围一般是 10 到 100,数值越大模型越倾向于识别成这个词。但要注意,权重不是越高越好,设太高会导致其他正常词汇被强行纠正。我的经验是常用业务词给 20 到 30,生僻词给 50 左右,具体要拿真实录音测。
热词文件可以动态加载,不用重启服务。你可以把热词存在数据库或 Redis 里,每次识别请求前拉取最新的热词串。这样运营同学改热词不用找你发版,体验会好很多。
3. FreeSWITCH 侧的通话接入与媒体流处理
3.1 FreeSWITCH 安装与基础配置
FreeSWITCH 在 Linux 上的安装,官方源和编译安装我都试过。生产环境建议用官方提供的包管理方式,省事且稳定。以 Debian 系为例:
apt-get install -y gnupg2 wget lsb-release wget -O - https://files.freeswitch.org/repo/deb/debian-release/fsstretch-archive-keyring.asc | apt-key add - echo "deb http://files.freeswitch.org/repo/deb/debian-release/ `lsb_release -sc` main" > /etc/apt/sources.list.d/freeswitch.list apt-get update apt-get install -y freeswitch-meta-all装完之后systemctl start freeswitch启动,默认配置下 5060 端口是 SIP 信令,5080 是内部 SIP。如果你只是做录音质检,不需要对外暴露 SIP,内网跑就行。
Windows 上装 FreeSWITCH 也可以,但生产环境我不推荐。Windows 版本的稳定性和并发能力跟 Linux 差不少,而且很多模块在 Windows 上编译有问题。如果只是本地开发调试,Windows 版凑合能用,装完在conf目录下改配置,用freeswitch.exe启动即可。
3.2 用 mod_audio_stream 把音频推给识别引擎
FreeSWITCH 本身不带 WebSocket 音频推流功能,需要装mod_audio_stream这个模块。它的作用是把通话中的音频以 WebSocket 协议实时推送到指定服务端,同时也能接收服务端返回的音频做放音。
编译安装:
cd /usr/src git clone https://github.com/amigniter/mod_audio_stream.git cd mod_audio_stream make && make install然后在conf/autoload_configs/modules.conf.xml里加上mod_audio_stream,重启 FreeSWITCH。
在拨号方案里这样用:
<action application="audio_stream" data="ws://127.0.0.1:8000/voice_socket"/>这行配置的意思是:把这通电话的音频流通过 WebSocket 推到本机 8000 端口的/voice_socket路径。音频格式默认是 16kHz 单声道 PCM,正好是 FunASR 需要的格式,省去了重采样。
有个坑要注意:mod_audio_stream推流是单向的,默认只推不收。如果你需要服务端返回音频(比如放提示音),要在 data 里加参数,具体看模块文档。另外这个模块对 FreeSWITCH 版本有要求,太老的版本编译会报错,建议用 1.10 以上。
3.3 通话生命周期与音频分片策略
一通电话从接入到挂断,音频是连续不断的流。识别引擎需要按一定节奏切分音频,太短了识别不准,太长了延迟高。我的做法是按静音检测切分:服务端收到音频流后,用 VAD(语音活动检测)判断哪里是说话、哪里是停顿,在停顿处切一刀,把这一段的音频送去识别。
FunASR 的流式模型自带 VAD 能力,你可以在服务端维护一个音频缓冲区,每收到 600ms 的音频就喂给模型一次,模型返回增量识别结果。当检测到超过 800ms 的静音时,认为一句话结束,输出最终结果。
这个策略的好处是识别结果跟人说话的节奏对齐,质检规则可以按"句"来匹配,而不是按固定时间窗口。比如你要检测客服有没有说"您好,请问有什么可以帮您",按句匹配就比按 5 秒窗口匹配准确得多。
4. WebSocket 通道的设计与 Python 服务端实现
4.1 为什么选 WebSocket 而不是其他协议
FreeSWITCH 往外推音频,可选的方式有几种:RTP 推流、HTTP 上传、WebSocket。RTP 延迟最低但需要额外的信令控制,HTTP 上传做不到实时,WebSocket 是折中方案里最合适的——它基于 TCP,有连接状态,支持双向通信,服务端可以随时返回识别结果,FreeSWITCH 也能接收指令做放音或转接。
WebSocket 还有一个好处是穿透性好,走 80 或 443 端口基本不会被网络设备拦。如果你需要跨机房部署,WebSocket 比 RTP 省心得多。
4.2 Python 服务端骨架
服务端我用 FastAPI 加 websockets 库来实现,FastAPI 负责 HTTP 接口和健康检查,websockets 负责音频通道。核心处理函数大概是这样:
import asyncio import json import numpy as np from fastapi import FastAPI, WebSocket, WebSocketDisconnect from funasr import AutoModel app = FastAPI() model = AutoModel(model="paraformer-zh-streaming", device="cuda:0", disable_update=True) @app.websocket("/voice_socket") async def voice_socket(websocket: WebSocket) -> None: await websocket.accept() cache = {} buffer = bytearray() try: while True: data = await websocket.receive_bytes() buffer.extend(data) if len(buffer) >= 9600: # 约 300ms 的 16k 16bit 音频 audio = np.frombuffer(bytes(buffer), dtype=np.int16).astype(np.float32) / 32768.0 res = model.generate( input=audio, cache=cache, is_final=False, chunk_size=[0, 10, 5] ) if res and res[0]["text"]: await websocket.send_text(json.dumps({ "type": "partial", "text": res[0]["text"] }, ensure_ascii=False)) buffer.clear() except WebSocketDisconnect: pass finally: if buffer: audio = np.frombuffer(bytes(buffer), dtype=np.int16).astype(np.float32) / 32768.0 res = model.generate(input=audio, cache=cache, is_final=True) if res and res[0]["text"]: await websocket.send_text(json.dumps({ "type": "final", "text": res[0]["text"] }, ensure_ascii=False))这段代码有几个关键点。chunk_size=[0, 10, 5]是流式模型的参数,表示每次看 10 帧、往前看 5 帧,这个配置在延迟和精度之间比较平衡。cache是流式识别的状态缓存,同一通电话要一直复用同一个 cache,否则上下文会断。is_final=True在连接断开时调用,把缓冲区里剩余的音频做最终识别。
4.3 多路并发的资源管理
一通电话一个 WebSocket 连接,100 路并发就是 100 个连接。Python 的 asyncio 处理这种 IO 密集型场景没问题,但 GPU 推理是串行的,需要做队列管理。
我的做法是搞一个推理线程池,WebSocket 收到音频后丢进队列,推理线程从队列取任务,算完把结果通过 asyncio 的call_soon_threadsafe回传给对应的连接。这样 GPU 利用率能拉满,又不会因为并发太高把显存撑爆。
显存估算:Paraformer-streaming 单路推理大概占 500MB 到 1GB,16G 显存理论上能跑十几路并发。但实际要考虑峰值和碎片,我一般按 8 路配一台 16G 的机器,留足余量。
5. 代理、TLS 与连接稳定性那些事
5.1 Nginx 代理 WebSocket 的配置要点
生产环境一般不会让 FreeSWITCH 直接连识别服务,中间会加一层 Nginx 做负载均衡和 TLS 终止。Nginx 代理 WebSocket 需要显式配置 Upgrade 头:
location /voice_socket { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout一定要调大,默认 60 秒,通话超过 60 秒没数据传输就会被断开。设成 3600 秒基本够用。另外proxy_buffering建议关掉,音频流不需要缓冲,开了反而增加延迟。
5.2 TLS 验证策略与自签证书
如果 FreeSWITCH 和识别服务不在同一台机器,建议走 wss 加密。FreeSWITCH 侧有个tls-verify-policy参数控制是否验证服务端证书。内网自签证书的情况下,可以设成none跳过验证,但生产环境还是建议配正规证书,设成in或all。
自签证书生成:
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=voice.internal"Nginx 里配上ssl_certificate和ssl_certificate_key,FreeSWITCH 侧用wss://连接即可。注意证书的 CN 要跟实际访问的域名一致,否则验证会失败。
5.3 断线重连与心跳机制
WebSocket 长连接最怕的是中间网络设备悄悄断开连接,两边都不知道。解决办法是加心跳:服务端每隔 30 秒发一个 ping 帧,客户端收到后回 pong。如果连续 3 次没收到 pong,就认为连接已死,主动关闭并重连。
FreeSWITCH 的mod_audio_stream对断线重连的支持一般,我的做法是在拨号方案里加一个检测,如果 audio_stream 返回失败,就重新执行一次。或者在服务端做兜底,连接断了之后把已识别的部分结果先存下来,避免数据丢失。
6. 质检规则引擎与识别结果的落地
6.1 从识别文本到质检规则
识别出来的文本是一串字符串,质检规则要基于这串字符串做判断。常见的规则类型有几种:
- 关键词命中:检测是否出现"投诉""退款""曝光"等敏感词
- 话术合规:检测客服是否说了开场白、结束语、禁语
- 语速检测:统计单位时间内的字数,过快或过慢都算异常
- 静默检测:检测通话中是否有超过 10 秒的静默
- 情绪识别:结合 SenseVoice 的情感标签判断客户情绪
关键词命中用简单的字符串匹配或正则就行,话术合规需要按句切分后逐句匹配。语速检测需要记录每句话的时间戳,这个在流式识别时就要打上。
6.2 热词动态更新与规则联动
热词和质检规则是联动的。比如运营新上了一个产品叫"星云计划",那热词里要加"星云计划",质检规则里也要加对应的检测项。我的做法是把热词和规则都放在数据库里,用一个管理后台维护,识别服务定时拉取最新配置。
热词更新后不需要重启模型,下次generate调用时传入新的 hotword 字符串即可。但要注意,热词太多会影响识别速度,建议控制在 500 个词以内,按业务线分组,不同业务线用不同的热词组。
6.3 识别结果的存储与检索
每通电话的识别结果要存下来,方便后续检索和复盘。存储结构建议分两层:一层是原始识别结果,按通话 ID 存 JSON,包含每句话的文本、开始时间、结束时间、置信度;另一层是质检结果,按规则 ID 存命中记录。
检索用 Elasticsearch 比较合适,把识别文本按句索引,支持全文检索和关键词高亮。这样质检员想找"所有提到退款的通话",一个查询就出来了,比翻录音快得多。
7. 实测中遇到的几个典型问题与排查思路
7.1 识别结果断句混乱
最开始跑的时候发现识别出来的文本没有标点,一整段连在一起,质检规则没法按句匹配。原因是流式模型默认不输出标点。解决办法是接一个标点恢复模型,FunASR 提供了ct-punc模型,可以在识别结果后面再过一遍:
punc_model = AutoModel(model="ct-punc", device="cuda:0") res = punc_model.generate(input=raw_text)加上标点恢复后,文本可读性大幅提升,按句切分也准确了。代价是增加一点延迟,但质检场景对延迟不敏感,值得加。
7.2 高并发下显存溢出
压测的时候跑到第 10 路并发,服务直接 OOM 挂了。排查发现是每个 WebSocket 连接都持有独立的 cache 和模型引用,显存没释放。解决办法是把模型做成全局单例,所有连接共享同一个模型实例,cache 按连接 ID 隔离。另外加一个并发上限,超过就排队,不要硬扛。
7.3 音频格式不匹配导致识别为空
有段时间发现某些通话识别出来是空的,查了半天发现是 FreeSWITCH 推过来的音频采样率不是 16k。原因是拨号方案里某些分支用了不同的编解码。解决办法是在audio_stream配置里强制指定采样率,或者在服务端做重采样兜底。用librosa或scipy都能做,但会增加 CPU 开销,最好还是在源头统一格式。
7.4 WebSocket 连接被中间设备重置
跨机房部署时遇到过连接跑几分钟就被重置的情况,抓包发现是中间的安全设备对长连接有超时限制。解决办法是把心跳间隔从 60 秒缩短到 20 秒,让连接保持活跃。另外把 Nginx 的proxy_read_timeout和proxy_send_timeout都调大,双管齐下。
8. 一些让系统更稳的工程经验
8.1 灰度发布与 A/B 测试
识别模型更新或者热词调整后,不要一次性全量上线。我的做法是拿 10% 的流量走新配置,对比新旧配置的识别准确率和质检命中率,确认没问题再逐步放量。对比指标主要看字准确率和句准确率,字准确率反映整体识别质量,句准确率反映断句和标点恢复的效果。
8.2 监控指标要盯哪些
生产环境必须监控几个核心指标:WebSocket 连接数、识别延迟 P99、GPU 显存使用率、识别失败率、质检规则命中率。连接数突降说明有断连问题,延迟 P99 飙升说明 GPU 扛不住了,失败率上升说明模型或网络有问题。这些指标接到 Prometheus 加 Grafana,配好告警,基本能覆盖大部分故障场景。
8.3 日志要记全但要分级
识别服务的日志量很大,每句话都记会撑爆磁盘。我的做法是分级:INFO 级别只记连接建立、断开、异常;DEBUG 级别记每句话的识别结果,默认关闭,排查问题时临时打开。日志里一定要带通话 ID 和连接 ID,否则出了问题根本对不上是哪通电话。
8.4 模型预热与优雅重启
服务重启后第一次识别会特别慢,因为模型要加载到显存。解决办法是启动时先跑一段测试音频做预热,把模型和 CUDA 上下文都初始化好。重启用优雅方式,先停止接收新连接,等现有连接处理完再退出,避免通话中断。
这套系统我从零搭到稳定运行大概花了三周,其中一半时间花在调识别准确率和排查连接稳定性上。FunASR 的识别效果在中文场景下确实能打,配合热词和标点恢复,质检规则的可执行性比通用方案高一个档次。FreeSWITCH 的媒体处理能力也足够成熟,mod_audio_stream虽然小众但够用。如果你也在做类似的项目,建议先把单路跑通,再逐步加并发和规则,不要一上来就追求大而全。