说一说构建 ai-voice-platform(AI 语音交互与智能外呼平台)时,底层音频流和呼叫控制架构的演进全过程。
工作中刚刚接触 AI 电话助手的同学,开始做技术选型时通常会觉得:社区与行业里有大量关于 mod_unimrcp 的传统资料,用成熟的 UniMRCP 协议做语音集成肯定最稳妥、最规范。
早期方案也是这么走的(即使当前主流 FreeSWITCH 版本默认并不自带 mod_unimrcp,还专门花力气在编译环境中单独拉取第三方源码与复杂依赖进行构建),甚至自研了三个 C++ 插件,把 Sherpa-ONNX、Silero VAD 和 TTS 深度接进了 UniMRCP 架构中。当时,单呼场景下的 ASR/TTS/VAD 流程跑通了,业务也能正常运转。
但业务扩展扩展着,从单呼拓展到多方电话会议、转人工坐席协同、实时监听盯屏时,UniMRCP 彻底玩不了,其机制在会议场景下是完全无法使用的。
但箭在弦上,不得不发,开始找新方案,找来找去,最终找到了 mod_audio_fork 开始是unimrcp的替代方案,经过一段时间的开发和落地,发现这条路比 unimrcp 更加稳妥、可控。
纠结了一会,还是决定把 项目中关于 unimrcp 内容全部干掉,当时挺舍不得的,毕竟是跑通了的,自研的 unimrcp 插件形式的 asr,vad 和 tts ,
但怎么说呢,程序员不仅要会写代码,还要会删代码。
最终全面转向 mod_audio_fork,真正跑通了一套低延迟、高并发的工业级全双工体系。
一、起步踩坑:先拿 UniMRCP 和手搓 C++ 插件把流程跑通
在项目启动初期(v0.1 ~ v0.8),底层选型是电信与呼叫中心领域沿用二十年的经典方案 —— UniMRCP。
1 | ┌──────────────────────┐ SIP / RTSP 信令 + RTP 媒体流 ┌────────────────────────────────────────────────────────┐ |
1. 当初为什么选了 UniMRCP?
最初选择 UniMRCP,逻辑其实非常直观:
- 电信级标准协议:MRCPv2(RFC 6787)定义了非常标准的语音资源交互模型,有现成的
RECOGNIZE、SPEAK、START-OF-SPEECH等状态定义; - 传统生态与标准接口:虽然主流 FreeSWITCH 默认并不自带
mod_unimrcp(需要拉取第三方源码单独编译安装),但这是行业长久以来的经典方案,编译加载后可以在 Dialplan 里直接通过speak和detect_speech调用; - 开箱即用的事件模型:打断(Barge-in)、语法解析(NLSML XML)都有现成规范,初看非常省事。
2. 手搓 C++ 插件集成 Sherpa-ONNX(ASR / TTS / VAD)
一开始就奔着全部本地能跑通,因此直接在 unimrcp/plugins/ 目录下用 C++17 编写了三个核心插件:
sherpa-asr-vad-online:集成 Silero VAD 与 Sherpa-ONNX Zipformer 流式模型;sherpa-asr-vad-offline:集成 Paraformer 离线识别模型,用于二次质检与兜底校准;sherpa-tts:基于 VITS/Piper 的本地合成引擎。
业务层 callflow-esl 监听 FreeSWITCH 的 DETECTED_SPEECH 事件,解析 Speech-Type 与 NLSML XML 报文:
3. 单呼看似跑通了,一上并发就原形毕露
单机 Demo 跑通后,一旦接入真实高频呼叫,系统暴露出几个致命问题:
- 信令开销大、容易单通:每次识别都要走 SIP INVITE / RTSP SETUP 重新协商 SDP,并在 UDP 上动态分配 RTP 端口。遇到复杂 NAT 网络时,极易产生丢包或“单通”;
- 多路并发时的崩溃与资源竞争:早期 C++ 插件在多通道并发时,由于全局单例指针和 APR 内存池生命周期管理不当,多路通话极易出现串音、资源死锁甚至整个服务崩溃。必须将每个会话的推流句柄与推理实例彻底隔离,才能勉强维持单进程多路运行;
- 僵化的轮询机制(Turn-taking):MRCP 本质是面向 IVR 的一问一答协议。每次识别完成通道即关闭,无法满足现代大模型对“持续监听、毫秒级断句、流式吐字”的全双工诉求。
二、真正劝退的时刻:一做电话会议,UniMRCP 彻底行不通了
单呼场景下的问题尚可通过优化 C++ 代码缝缝补补,但当业务拓展至多方电话会议与坐席转人工协同场景时,UniMRCP 彻底走进了死胡同。
1 | ┌────────────────────────┐ ┌────────────────────────┐ |
1. 业务到底需要什么样的会议能力?
在 callflow-esl 中需要实现以下标准会议协同能力:
- 主持人建会:分机
1000拨打服务,callflow-esl校验权限后创建专属会议室; - 邀请参会人:ESL 发起
originate呼叫分机1001,接通后自动拉入该会议室; - AI 助手实时转写与播报:AI 需持续监听会议中所有人的发言,按发言人精准输出文本(
onParticipantTranscription),并在关键节点向全场播报纪要(conferencePlay); - 人工协同与主管强插:支持坐席在静默监听、单人耳语(Whisper)与全员强插发言之间就地切换。
2. 盘点 UniMRCP 在会议场景下的 5 个致命硬伤
在实际会议场景落地中,UniMRCP 暴露出根本无法在业务层规避的五大死结:
- 通道媒体被独占(阻塞无法并发):detect_speech 是串行阻塞式 Dialplan Application。通道入会后底层读写循环被 mod_conference 完全接管,无法并发执行 ASR 识别。
- 缺失会议推流能力:mod_conference 本身没有 MRCP 客户端协议栈,无法在会议室维度向 UniMRCP Server 推送混音音频。
- 混音破坏发音人归属(Diarization 灾难):各路音频混为单轨总线,ASR 无法区分发言角色(无法判定是谁说的),且多人同时说话时识别率断崖式下跌。
- 短命会话 vs 持续流式冲突:MRCP 面向单轮 IVR 设计(RECOGNIZE ➜ COMPLETE),多方会议需持续长流式监听;频繁重新发起 SIP 协商不仅开销极大,空窗期还会导致丢字漏音。
- 动态音频注入受限:speak 指令同样依赖通道级独占播放,无法与 mod_conference 的动态混音队列协同(难以灵活实现向全场播报 AI 纪要或单人耳语)。
三、彻底掀桌子:改用 mod_audio_fork 走纯流式 WebSocket
面对会议系统的死局,继续在 UniMRCP 上打补丁已经毫无意义。架构最终迎来了最关键的技术决断:彻底弃用 UniMRCP,全面转向 mod_audio_fork。
1 | ┌────────────────────────┐ ┌────────────────────────┐ |
1. 底层原理:不再卡 Dialplan,改用 Media Bug“挂探针”
mod_audio_fork 与 mod_unimrcp 在底层机制上完全不同:
- 带外非阻塞(Media Bug Hook):它不占用 Dialplan 执行队列,而是调用 FreeSWITCH 核心的
switch_core_media_bug_add接口,在底层通道的音频交换钩子(CS_EXCHANGE_MEDIA)上挂载一个轻量级“窃听探针”; - 随时随地动态挂载:无论当前通道处于振铃、通话、IVR、桥接(Bridge),还是已经在
mod_conference内部混音,都可以随时通过一条uuid_audio_forkAPI 指令动态挂载或卸载,即挂即用、随用随摘; - 纯粹的二进制流:Media Bug 在音频进入混音器之前,直接截取通道原始的 16kHz 16-bit 单声道 PCM 帧,封装为二进制 WebSocket 帧实时向外推送。
2. 会议多角色监听难题,这样解就顺了:ctx.conferenceHear
通过 mod_audio_fork,之前所有的死穴被全部化解:
- 天然物理隔离的发音人归属(Natural Speaker Separation):
每一个加入会议的参会人都是一条独立的 Channel,拥有全局唯一的channel_uuid。在每个成员进会时为其挂载独立的audio_fork。
音频流在各自独立的 WebSocket 连接中传输,服务端天然获得每一位参会者的纯净单轨音频,完全杜绝了混音串音,零成本实现多角色转写(Diarization)与实时盯屏; - 长连接持久监听,告别握手包袱:
WebSocket 建立后长连常驻,持续泵送音频帧,再也不需要频繁发起 SIP/MRCP 协商; - ESL 统一事件收敛:
mod_audio_fork将转写结果通过 FreeSWITCH 自定义事件CUSTOM mod_audio_fork::transcription广播给 ESL,Payload 中直接携带channelUuid、text、isFinal等结构化 JSON。
在业务编排层(callflow-esl),整个会议监听机制变得极为清爽:只需在进会时统一调用 conferenceHear 订阅流式识别事件,回调函数中就能天然拿到当前说话人的分机号码、实时 partial 文本以及断句终态;需要 AI 助手插话或全场播报纪要时,直接调用 conferencePlay 即可,业务层再也不需要跟底层的 SIP 信令和复杂声道死磕。
3. 两套方案拉出来溜溜:新老架构全方位对比
| 维度 | UniMRCP 方案 (mod_unimrcp) |
mod_audio_fork 方案 |
|---|---|---|
| 底层拦截机制 | 串行阻塞式 Dialplan Application (detect_speech) |
核心底层音频探针 (switch_core_media_bug_add) |
| 网络与传输协议 | SIP/RTSP 信令 + SDP 协商 + RTP/UDP 媒体流 | 标准长连接 WebSocket (TCP/WS) 二进制帧 |
| 会议室兼容性 | ❌ 无法挂载,通道媒体循环被 mod_conference 独占 |
✅ 完美兼容,可在任意运行状态下按 UUID 挂载 |
| 多方发言人隔离 | ❌ 混音后无法区分角色,存在严重声学干扰 | ✅ 每个通道独立推流,天然物理隔离 |
| 会话生命周期 | IVR 短命单轮 (RECOGNIZE ➜ COMPLETE) | 持续长连接流式推送,零额外握手损耗 |
| 打断 (Barge-in) | 依赖 MRCP 协议事件,时延与时序受限 | 毫秒级 VAD/Partial 事件 + 丢弃缓冲区 |
| 工程维护成本 | 需维护复杂的 C/C++ 插件与 APR 内存池 (70W+ 行代码) | 轻量 Rust/Go/TS 微服务,标准 JSON + PCM |
4. 痛下决心删代码:一口气干掉 70 万行历史包袱
在验证了 mod_audio_fork 的方案可行性后,系统分步骤完成了彻底重构与代码减重:
- 引入 WebSocket 音频分流与 VAD 判定:在呼叫流引擎中引入标准化的 audio-fork 客户端,建立长连接会话机制;
- 重构 TTS 播报链路:解耦原有基于 speak 命令的引擎,通过独立流式 TTS 服务生成音频,以 HTTP/PCM 链路进行流式播放,让呼叫流完全脱离 UniMRCP 服务运行;
- 全面落地会议级 ASR:实现多参会者独立监听(
conferenceHear)与会议动态播报,彻底打通多方通话转写; - 彻底剥离 UniMRCP 历史包袱:将原本庞杂陈旧的 70 余万行 C/C++ 依赖与插件源码整体移除,同时在业务层彻底清理 MRCP 兼容层、NLSML 解析器与 Cause 映射表。
整套系统由沉重封闭的传统 C++ 插件体系,蜕变为轻量、清晰、易于容器化部署的现代微服务架构。
四、把系统拆开:底层通信底座 vs 业务外呼中台
在彻底脱离 UniMRCP、确立 mod_audio_fork 底座后,系统的整体架构收敛为清晰的 “系统一(底座管理面)vs 系统二(外呼协同中台)” 物理与业务分野:
1 | ┌────────────────────────────────────────────────────────────────────────────────────────┐ |
1. 系统一(底座):专心搞定电话接通、推流与识别合成
- 定位:负责电信运营商与 FreeSWITCH 接入、号码路由、媒体流全双工传输、录音、低延迟 ASR 识别与 TTS 播报。
- 核心组件:
callflow-esl(端口9910):基于 FreeSWITCH Outbound ESL 模式的呼叫业务编排引擎,内置原生全双工状态机(ctx.duplex)、单呼控制(ctx.speak/ctx.hear)及会议混音控制(ctx.conference)。onnx-platform:基于纯 Rust 架构打造的本地语音推理集群,包括 FunASR 2-pass 双流识别、Sherpa-ONNX 离线/流式 ASR、Sherpa-TTS 原生 Chunked 流式合成及stream-vad-proxy前置 VAD 流式代理。callflow-server(9900)与callflow-webpage(19900):负责号码段映射、通话记录查询、分机状态监控与网页端 WebRTC 软电话控制台。
2. 系统二(中台):专心管名单、排话术和坐席协同
- 定位:负责大规模外呼名单管理、多策略定时调度、可视化话术流程编排、大模型 NLU 意图判断、转人工坐席会议协同与数据资产闭环。
- 核心组件:
callout-server(端口9920):包含高并发任务派发池、原生内嵌 AI 决策中台(/api/v1/nlu、/api/v1/chat/stream)、会议室转人工路由调度与生命周期对账看门狗。callout-webpage(端口19920):包含外呼任务大盘、三栏一体化话术策略工作台、坐席实时工作台与实时盯屏协同中心。
3. 两个系统怎么打交道?界限划清楚
两个系统共用底层硬件与语音模型,但数据与代码边界完全隔离:
- 系统二仅通过 FreeSWITCH 提供的
bgapi originate发起呼叫,通道变量中携带business_code、campaign_id、contact_id等上下文; - 电话接通后,FreeSWITCH 按照拨号计划回连系统一的
callflow-esl; - 通话过程中,
callflow-esl依据业务定义调用系统二的 NLU/Chat 策略接口; - 通话结束后,
callflow-esl调用系统二的/api/call-results回写结构化结果与双轨录音。系统二从不直接监听 FreeSWITCH 的原始底层媒体流。
五、一通电话打进来,整条语音流水线是怎么跑的?
在摆脱 UniMRCP 的历史包袱后,底层得以在纯流式体系下重构全双工语音交互流水线:
1 | ┌─────────────────────────┐ |
- 下行采流:客户接通后,FreeSWITCH 通过
mod_audio_fork模块从媒体通道复制 16kHz 16bit 单声道 PCM 音频,经由 WebSocket 全双工流式推送; - 前置 VAD 毫秒级断句:音频流首先进入
stream-vad-proxy,在毫秒级内通过 Silero VAD 捕捉用户开嗓动作,直接注入speech-start事件; - 2-Pass 流式识别:语音段实时喂入 FunASR,第一阶段流式模型(Paraformer-Online)快速吐出 partial 文本上屏,第二阶段离线大模型在句尾执行最终文本纠偏与标点回填;
- 决策与话术生成:ESL 业务引擎根据识别结果驱动流程节点,调用内嵌 AI 策略中台执行 NLU 槽位提取或大模型流式对话;
- 流式分句 TTS 合成:模型输出的文本流经过智能标点分句断句后,首句即刻推入
sherpa-tts-server,采用 HTTP Chunked 边合成边向 FreeSWITCH 推流播报; - 全双工打断(Barge-in):若用户在播放期间插话,前置 VAD 或 ASR partial 事件瞬间触发打断,网关丢弃音频缓冲区并向通道发送中断指令,实现媲美真人的流畅对答。
六、一路走来的 4 个版本演进
回顾整套架构的技术演进过程,核心经历了四个标志性阶段:
阶段 1(v0.1 ~ v0.8):手搓 C++ 插件,在 UniMRCP 里艰难求生
- 基于 UniMRCP 框架自研 C++ 插件(
sherpa-asr-vad-online、sherpa-tts); - 呼叫编排基于 ESL 监听
DETECTED_SPEECH事件,解析 NLSML XML 报文; - 致命瓶颈:遭遇多通道并发锁死崩溃,并在开发多方电话会议(Conference)时遭遇媒体控制权被抢占、无法实现多角色独立识别与长流式监听的架构死结。
阶段 2(v0.9 ~ v1.1):全面换成 mod_audio_fork,底座用 Rust 重写
- 引入
mod_audio_fork,以底层 Media Bug 模式实现单呼与多方会议的非阻塞音频采流; - 彻底剥离 UniMRCP 及其附属庞杂源码,将呼叫流中的 MRCP 兼容代码全面清理;
- 废弃 Python 运行时,自研纯 Rust 语音推理服务集群(
funasr-asr-server、sherpa-asr-server、sherpa-tts-server),内存开销降低 80%,吞吐提升 4 倍以上; - 首创纯 Rust 前置 VAD 流式代理(
stream-vad-proxy),使原本不支持 VAD 的云端/私有识别协议同样获得开嗓即打断能力。
阶段 3(v1.2 ~ v1.3):加进 Go 语音网关,把打断和延迟抠到毫秒级
- 引入 Go 开发的高性能双向语音网关
audio-fork-server,负责上行 ASR 与下行 TTS PCM 流的双向中继; - 在
callflow-esl中实现原生全双工状态机DuplexController,支持句级唯一标识(speakId)、代际打断时序控制与零长音频帧优雅收尾; - 支持
sherpa-tts-server原生 HTTP Chunked PCM16 流式合成,首包放音延迟缩短至 200ms 以内。
阶段 4(最新):干掉中间代理,做成开箱即用的外呼与人机协同中台
- 彻底剔除过渡期的独立 AI 代理服务,将大模型决策、槽位抽取与 Chat SSE 原生并入
callout-server,打造「三栏一体化话术工作台」,实现免保存草稿即时拨测; - 完善会议室桥接转人工模型,构建双阶段自治看门狗(
waiting_for_agent➜in_call_guard)与坐席离会 3 秒防抖自退机制,支持主管静默监听、私密耳语与全员强插就地切换; - 解决早期媒体(183 Session Progress)误判回铃陷阱,并在会议中注入 16k 拟真办公室底噪(
mod_local_stream),彻底消除通话机械感。