
去年给一家客服外包公司做智能外呼改造需求很明确电话接通后机器人先说话用户说什么要实时转成文字文字交给对话系统生成回复回复内容再用语音播回去。上层听着是完整的智能客服闭环落到实施上就是三件事FreeSwitch负责电话接入科大讯飞负责ASR和TTS中间这些设备通过MRCP协议串起来。项目走上正轨之后我把实施里踩过的坑和关键配置都整理成文给后面要接同类型项目的人做个参照。先说结论这套方案的核心链路并不复杂复杂的是把呼叫流程、媒体流、识别语法和业务状态机捏合在一起时那些细小的地方。全文围绕真实落地过程展开涉及协议原理、FreeSwitch编译配置、dialplan编写、对话系统接入和生产排障适合正在做呼叫中心智能化和外呼机器人改造的朋友参考。1. 呼叫场景为什么绕不开MRCP协议1.1 音频流从哪来到哪去做这个项目之前团队里有人提出过一个思路不走MRCP直接用讯飞开放平台的WebSocket流式接口把FreeSwitch一侧的RTP音频抽出来转发出去。听起来很美实际一推演就发现全是坑。电话场景里的音频是实时双向流。用户说话的时候FreeSwitch正在把媒体包从SIP/RTP通道一路送进来这时候需要有一套机制把这些PCM音频持续推给识别引擎同时还能接收引擎返回的识别中间结果和最终结果。用WebSocket接口当然可以做到流式识别但FreeSwitch这一侧怎么办要么写一个模块去扒媒体流要么用一个伪通道转码再要么让业务系统自己处理回声和打断逻辑。本质上等于自己重新发明一遍媒体控制协议而且还做不标准。MRCP解决的正是这个问题。MRCP的全称是Media Resource Control Protocol它把“识别一段语音”和“合成一段语音”定义成了标准化的控制指令底层用SIP管理会话用RTP传输实时音频。FreeSwitch只需要作为MRCP客户端把呼叫侧的媒体流交给MRCP服务器识别请求和合成请求都走标准方法剩下的时序、超时、事件通知都在协议里定义好了。对接科大讯飞时讯飞一侧提供MRCP网关服务我们不需要考虑音频如何“塞进”识别引擎协议栈已经处理完了。1.2 MRCP本质上是什么控制指令加一路音频第一次接触MRCP的人容易把它想象成一个复杂的怪物其实拆开看就两层。第一层是SIP会话控制。MRCPv2基于SIP建立独立的控制通道FreeSwitch和MRCP服务器之间先协商出一个SIP会话MRCP指令在这一路通道上传送。第二层是RTP媒体流。识别和合成用到的音频走另外一路RTP编码格式、IP端口、采样率都通过SDP协商。MRCP协议本身定义的方法很像一组“AT指令”SPEAK用于TTS合成播放RECOGNIZE用于启动一次语音识别DEFINE GRAMMAR用于加载/更新识别语法STOP用于终止正在进行的操作。每个方法还带一堆头部参数比如识别超时时间、无输入超时时间、音频格式、语法名称等。讯飞网关接收到RECOGNIZE之后会把通话音频实时喂给ASR引擎识别结果通过MRCP事件回传FreeSwitch的mod_unimrcp再把这些事件映射成通道变量和FS事件。理解了这个结构后面所有配置都顺了profile负责SIP和RTP参数dialplan里的play_and_detect_speech本质上就是“先发一个SPEAK再发一个RECOGNIZE中间保持RTP媒体流双向流动”。1.3 对话系统放在链路里的什么位置标题里提到的对话系统在链路中是独立于FreeSwitch和讯飞之外的业务模块。它不直接参与媒体流只接收ASR转写出来的文本通过业务逻辑或大模型接口生成回复文本再交回给TTS合成。实际的调度顺序是FreeSwitch先播放欢迎语同时开启识别用户说话讯飞ASR返回转写文本FreeSwitch把文本以HTTP或内部消息形式送给对话系统对话系统返回回复内容FreeSwitch再把回复文本交给讯飞TTS合成播放。整个过程是串行循环的一次对话结束后可以再次进入识别环节支持多轮对话。讯飞侧ASR和TTS可以配置成同一组MRCP会话也可以分开配置取决于现场交付的网关形态。后面会专门讲。2. 环境准备FreeSwitch、UniMRCP和讯飞网关怎么组装2.1 FreeSwitch编译时容易漏掉的MRCP依赖FreeSwitch对MRCP的支持是通过mod_unimrcp模块实现的而这个模块依赖UniMRCP开源客户端库。如果直接用官方发行版包安装很多发行版并没有把mod_unimrcp编进去网上大量教程也没说清楚这一步导致很多人卡在“模块不存在”上。我当时的做法是从源码编译FreeSwitch。在configure阶段要显式打开模块./configure --enable-mod-unimrcp make mod_unimrcp-install编译之前先确认依赖库装齐了Ubuntu/Debian类系统可以执行apt-get install -y build-essential automake autoconf libtool \ libsofia-sip-ua-dev libsofia-sip-ua-glib-dev libspeex-dev \ libcurl4-openssl-dev libapr1-dev libaprutil1-dev \ libunimrcp-dev libunimrcp-doclibunimrcp-dev是关键项没有它mod_unimrcp根本编不过去。编译完之后在FreeSwitch安装目录下确认模块文件存在/usr/local/freeswitch/mod/mod_unimrcp.soWindows环境下装FreeSwitch做快速验证会简单很多官方安装包默认带了mod_unimrcp可以直接在Windows上跑通一个demo呼叫。但生产环境建议还是放到Linux上后面对并发、RTP端口、防火墙控制都更灵活。项目验收前我把环境从Windows迁到了Ubuntu服务器模块加载和呼叫行为没有任何差异。2.2 模块加载和基础状态确认FreeSwitch启动后用fs_cli确认模块状态fs_cli -x module_exists mod_unimrcp输出true说明模块已经加载。接着看模块日志级别调试阶段我习惯把unimrcp日志调到DEBUG不然MRCP交互过程完全是黑盒configuration nameunimrcp.conf descriptionUniMRCP Client settings param namelog-level valueDEBUG/ param namelog-path value/usr/local/freeswitch/log/ /settings /configuration这个log-level和FreeSwitch的系统日志不同它专门控制mod_unimrcp内部打印的UniMRCP协议栈日志联调阶段必须打开。生产环境再调回INFO否则日志量会很大。2.3 讯飞侧网关需要提前确认的交付信息对接科大讯飞的MRCP网关前期需要收集的信息比想象中多主要包括交付项说明MRCP服务器IP和端口讯飞MRCP控制通道常见端口8060或8065以交付文档为准MRCP版本当前基本都是MRCPv2对应profile里的version2支持的音频编码G.711 a/u、PCM 16bit、采样率8k/16k等ASR语法/slot列表讯飞侧预置的识别场景名称如“通用”“导航”“业务办理”TTS音色列表发音人名称如讯飞开放平台常用的小燕、小峰等不同引擎版本支持范围不同License/并发数限制同时可以建立的MRCP会话数量网络放通要求控制通道TCP端口和RTP UDP端口段需要双向放通讯飞语音引擎3.0及以上的MRCP网关版本对MRCPv2标准支持得比较完整SPEAK、RECOGNIZE、DEFINE GRAMMAR都能正常工作。早先一些版本对MRCP头部的兼容性一般如果识别请求总超时优先检查讯飞网关版本。3. 核心配置unimrcp.conf.xml的profile到底该怎么写3.1 按ASR和TTS拆分profile还是合在一个profile里FreeSwitch的mod_unimrcp通过unimrcp.conf.xml里的profile定义MRCP服务器的连接信息。每个profile可以同时启用speechsynth和speechrecognizer也可以只启用其中一个。讯飞网关通常把ASR和TTS都挂在同一个MRCP服务上但我在配置时习惯拆成两个profileconfiguration nameunimrcp.conf descriptionUniMRCP Client settings param namedefault-tts-profile valuexf-tts/ param namedefault-asr-profile valuexf-asr/ /settings profiles profile namexf-asr version2 param nameclient-engine valuesy/ param nameserver-ip value10.0.1.11/ param nameserver-port value8060/ param nametransport valuetcp/ param namertp-ip value10.0.1.12/ param namertp-port-min value4000/ param namertp-port-max value5000/ param namespeechsynth valuefalse/ param namespeechrecognizer valuetrue/ param namemax-connection-count value30/ param namemax-session-count value30/ /profile profile namexf-tts version2 param nameclient-engine valuesy/ param nameserver-ip value10.0.1.11/ param nameserver-port value8060/ param nametransport valuetcp/ param namertp-ip value10.0.1.12/ param namertp-port-min value4000/ param namertp-port-max value5000/ param namespeechsynth valuetrue/ param namespeechrecognizer valuefalse/ param namemax-connection-count value30/ param namemax-session-count value30/ /profile /profiles /configuration拆开的直接好处是dialplan里使用引擎时不会互相干扰。比如播放TTS时只走speechsynth识别时只走speechrecognizer日志里看MRCP交互也清晰。如果讯飞网关需要同一会话里同时做打断式合成和识别就不该拆而应该让一个profile同时打开两个开关。server-ip是讯飞MRCP网关的地址rtp-ip要填FreeSwitch本机对外可路由的IP千万别省略。曾经见过有人把rtp-ip漏掉FreeSwitch在SDP里协商出来的媒体地址变成了127.0.0.1讯飞那边音频根本送不进来。3.2 识别语法SRGS文件放哪里、怎么写很多人在ASR识别不到内容时第一反应是怀疑讯飞引擎实际多半是语法文件没有放对位置或者语法内容不匹配语音识别的模式。FreeSwitch的mod_unimrcp默认把unimrcp:xxx中的xxx解释成语法名称再映射到语法目录下的xxx.grammar文件。语法目录通常在FreeSwitch安装目录的grammar子目录下比如/usr/local/freeswitch/grammar。可以自己建一个简单的语法文件测试?xml version1.0 encodingutf-8? grammar xmlnshttp://www.w3.org/2001/06/grammar xml:langzh-CN version1.0 modevoice rootroot rule idroot scopepublic one-of item查话费/item item查余额/item item转人工/item /one-of /rule /grammar保存为/usr/local/freeswitch/grammar/business.grammar。dialplan里使用语法时写unimrcp:business即可。语法文件里的root规则是识别入口scopepublic才能被外部引用。更关键的是modevoice如果写成dtmf语音识别永远不会触发。讯飞侧如果预置了大范围意图识别词库FreeSwitch端甚至可以只传递一个slot名称语法正文由讯飞网关自己维护。这时语法文件就不需要严格按SRGS来写定义好映射关系即可。具体以讯飞MRCP网关交付文档为准。3.3 TTS音色、语速和Volume这些参数在哪控制用MRCP方式调用讯飞TTS时发音人名称通常在dialplan的tts_voice变量里设置FreeSwitch会把它映射到MRCP的合成请求中。比如action applicationanswer/ action applicationset datatts_engineunimrcp/ action applicationset datatts_voicexiaoyan/ action applicationspeak dataunimrcp:欢迎致电智能客服系统/如果需要对语速、音量做细粒度控制可以通过SSML标记实现。部分讯飞MRCP网关支持在合成文本里嵌入SSML的prosody rate-10% volume80节点。实测下来语速设为-10%到10%之间人耳听感最自然超过20%语音就开始发飘。4. dialplan实战从接电话到对话回复的完整呼叫流程4.1 接听前的early media问题p-early-media-support是个大坑这个项目最折腾的点之一就是早媒体对应热搜词里的p-early-media-support。当FreeSwitch作为被叫从运营商或SIP中继接收呼叫时很多时候对方在真正接通前会先发送183 Early Media里面带SDP可能是回铃音、彩铃或者运营商提示音。FreeSwitch默认行为会在某些版本里只把这条early media当成信令处理不把媒体接入后续流程导致后面TTS播放时出现“双音”——一边是我们合成的欢迎语一边是运营商侧透传过来的原始回铃非常难听。如果需要在接听前就播放提示音或者在early media阶段启动识别sofia的profile里必须显式开启profile nameexternal settings param namep-early-media-support valuetrue/ /settings /profile修改后重启或reload sofia生效。这在呼叫中心外呼场景尤其常见FreeSwitch外呼到用户手机用户无应答前运营商返回early media此时FreeSwitch想播放“您拨打的电话正在接通中”一类提示音不开启这个开关就会出问题。需要注意早媒体支持开启后接通判定逻辑要额外小心。有些运营商在early media阶段就返回了200 OKFreeSwitch会认为呼叫已接通实际用户还没拿起电话。这种情况务必用answer前先确认呼叫状态再做后续播放。4.2 answer之后TTS欢迎语加ASR识别怎么串起来呼叫接通后最常见的动作是播放欢迎语同时开启语音识别。FreeSwitch里直接用play_and_detect_speech一步完成extension namemrcp_dialog_main condition fielddestination_number expression^(4000)$ action applicationanswer/ action applicationset datatts_engineunimrcp/ action applicationset datatts_voicexiaoyan/ !-- 播放欢迎语并开始识别语法文件是 business.grammar -- action applicationplay_and_detect_speech dataunimrcp:欢迎致电智能客服系统|unimrcp:business/ !-- 识别结果保存在 detect_speech_result 变量里 -- action applicationlog dataINFO ASR result[${detect_speech_result}]/ action applicationhangup/ /condition /extensionplay_and_detect_speech的第一个参数是TTS合成文本或音频文件路径第二个参数是识别语法。这句执行时FreeSwitch会一边把“欢迎致电智能客服系统”送到讯飞TTS合成播放一边请求讯飞ASR开始识别用户语音。用户说话可以直接打断播放MRCP层面由RECOGNIZE的barge-in机制控制不需要额外的打断逻辑。识别超时参数我习惯通过通道变量设置action applicationset datarecognizer_no_input_timeout6000/ action applicationset datarecognizer_speech_timeout10000/ action applicationset datarecognizer_max_timeout15000/各单位在MRCP标准里对应No-Input-Timeout和Recognition-Timeout含义分别是“用户多久不说话算超时”和“单次识别最长语音时长”。实测中No-Input-Timeout给6秒比较合理用户思考时间太短会产生压迫感给到10秒又会让后续流程等待过久尤其在线路繁忙时段体验很难受。4.3 识别结果交给对话系统再拿回复文本给TTS拿到detect_speech_result后对接对话系统的方式取决于业务架构。最简单的验证方式是直接用FreeSwitch的curl模块把文本GET给对话服务action applicationset datadialog_urlhttp://10.0.1.20:8080/dialog?text${detect_speech_result}/ action applicationcurl data${dialog_url}/ action applicationlog dataINFO Dialog reply[${curl_response}]/ action applicationspeak dataunimrcp:${curl_response}/这里的对话系统只需要暴露出一个HTTP接口收到text参数后返回纯文本回复FreeSwitch再把回复文本交给TTS。需要注意中文和特殊符号要做URL编码FreeSwitch的curl模块不会自动处理通常用一个lua脚本或后端服务端先编码再传。生产环境里更推荐用ESL(Event Socket)方式。原因是对话系统往往不是简单的一个接口还涉及多轮会话管理、意图解析、大模型调用、超时处理、静音等复杂逻辑。把这些逻辑全部塞进dialplan会非常难维护。ESL方式下FreeSwitch保持dialplan极简外部业务服务通过Event Socket监听并控制呼叫流程。# 伪代码ESL业务服务 import ESL import requests con ESL.ESLconnection(127.0.0.1, 8021, ClueCon) con.events(plain, CHANNEL_ANSWER DETECTED_SPEECH) while True: e con.recvEvent() if not e: continue event_name e.getHeader(Event-Name) if event_name DETECTED_SPEECH: uuid e.getHeader(Channel-Call-UUID) text e.getHeader(Speech-Result) # 把识别文本交给对话系统 resp requests.post( http://10.0.1.20:8080/dialog, json{text: text, session: uuid}, timeout3 ) reply resp.json()[reply] # 让FreeSwitch把回复文本用TTS播放出去 con.api(uuid_broadcast, f{uuid} speak::unimrcp:{reply})ESL方式的好处是识别流程和会话管理都在业务服务里实现FreeSwitch只负责媒体处理和事件上报后续加新渠道、新意图都不用改话音配置。4.4 多轮对话和二次识别多轮对话时只要在TTS播放完成后再次调用play_and_detect_speech就能循环进入下一轮识别。dialplan里可以配合transfer实现轮次跳转action applicationplay_and_detect_speech dataunimrcp:${curl_response}|unimrcp:business/ action applicationset dataround${round}1/ action applicationlog dataINFO round[${round}] result[${detect_speech_result}]/ action applicationtransfer data4000 XML default/这个写法配合round变量可以实现一个不递归的轮次循环。但要注意设置最大轮数比如三轮没识别到有效内容就转人工否则用户一直不说话会造成会话挂死。5. 生产联调踩坑我整理出来的高频故障5.1 TTS有声但ASR全空多半是音频编码和采样率不一致第一次联调时遇到的典型现象TTS正常播放但用户说话后detect_speech_result始终为空讯飞侧日志没有RECOGNIZE成功的记录。排查链路最后落在编码协商上。电话通道的G.711编码通常是8kHz采样而讯飞ASR在小词汇量语法识别时对16kHz线性PCM的识别率明显更高。如果讯飞MRCP网关默认要求16k PCM而FreeSwitch侧SDP协商出来的是PCMU/PCMA 8k就需要让mod_unimrcp在MRCP请求里带上正确的媒体类型和采样率。profile里可以这样约束param namertp-codec valueL16/16000/如果现场不需要16k坚持走8k电话音质也可以但相关超时参数可能需要放宽因为8k采样对端点检测算法更不友好静音判断容易误触发。5.2 识别结果总不中不是语法写错是超时参数没调“用户明明说了‘查话费’为什么识别出来是空的”这个问题我排查过好几轮。第一步先看讯飞MRCP日志里有没有Speech-No-Input事件。如果是这个事件说明用户语音没有被ASR的端点检测捕获常见原因是No-Input-Timeout给得太短用户还没开口就超时了。另一个原因是语法里只有精确词用户口音和自由表述稍微偏离就无法命中。解决方案是在讯飞侧语法里增加模糊匹配词表或者用大词库模型。FreeSwitch端我习惯这样设置action applicationset datarecognizer_no_input_timeout7000/ action applicationset datarecognizer_speech_timeout12000/同时语法文件的modevoice必须确认modedtmf只接受按键输入语音识别直接不触发。5.3 并发一高就超时License和连接池的共同问题MRCP网关的License数量直接限制可建立的识别会话。FreeSwitch的mod_unimrcp每个呼叫占用一个MRCP会话如果同时来10个通话而License只有5个后5个呼叫的RECOGNIZE请求就会排队或直接超时。接入生产环境前先压测确认讯飞网关能支持的并发数然后在profile里把max-connection-count和max-session-count设置成License上限的80%左右param namemax-connection-count value24/ param namemax-session-count value24/不要设置超过License否则超出的连接会造成全盘延迟影响已有会话。过路的压测记录显示并发超过License阈值的50%时平均识别响应就开始指数上涨。5.4 TTS合成延迟高缓存常用提示音比扩大带宽更重要MRCP方式每次SPEAK都会让讯飞引擎实时合成。如果播放内容总是那几句固定欢迎语、转人工提示完全没必要每次实时合成。我在dialplan里对固定话术做了缓存首次合成生成wav文件后续直接播放文件。action applicationplayback data/usr/local/freeswitch/sounds/tts_cache/welcome.wav/实测项目里常用话术缓存后每通电话的TTS媒体建立时间从几百毫秒降到几十毫秒高峰期整体话务吞吐提升很明显。动态变化的回复内容仍然走实时SPEAK。5.5 early media引发的问题会伪装成TTS音质问题前面讲过p-early-media-support没开启会出现双音。还有一种伪装成音质问题的场景FreeSwitch外呼到运营商对方在early media阶段就放彩铃此时FreeSwitch也播放TTS两路媒体叠加用户听到的内容既不清楚也不完整。排查方法就是抓SIP信令看呼叫在183之后是否又收到200 OK以及在early media阶段FreeSwitch是否发起了新的媒体播放。初期我一度以为讯飞TTS音质差后来在SIP trace里看到两路RTP流同时存在才定位到早媒体处理问题。联调阶段一定要抓包别凭耳朵判断。6. 上线前后的稳定性与性能调优6.1 RTP端口规划和防火墙放通MRCP的RTP端口范围不像SIP那样固定mod_unimrcp会从rtp-port-min到rtp-port-max之间分配端口。项目上线前需要把这段UDP端口在防火墙上放通并且保证讯飞MRCP网关的回程路由能到达FreeSwitch的rtp-ip地址。通常我把范围控制在1000个端口以内param namertp-port-min value20000/ param namertp-port-max value21000/端口段太大对安全策略不友好太小在高并发下会端口耗尽。1000个端口按每个会话2个端口信令/媒体估算可以支撑几百路并发实际取决于现场资源。6.2 日志、抓包和定位MRCP问题的完整手法调试MRCP问题时日志顺序很关键。我会同时开四个信息源FreeSwitch控制台fs_cli -x console loglevel debugunimrcp日志设置log-levelDEBUG查看协议栈分别打印的MRCP请求和响应讯飞网关侧日志确认RECOGNIZE是否到达引擎返回什么事件SIP/RTP抓包确认控制通道建立、RTP流是否双向流动有一次用户反馈识别时断时续我抓包发现RTP流在FreeSwitch和讯飞之间出现了丢包。原因是FreeSwitch的RTP包被本机防火墙的某条规则影响流量走了高延迟路径。调整网络后问题消失。这类问题如果不抓包只看应用层日志永远无法定位。6.3 会话释放和超时兜底MRCP会话如果异常断开mod_unimrcp默认会重试但如果讯飞网关重启FreeSwitch侧可能会残留半开连接。我在项目里加了一个定时检测脚本定期查看MRCP连接数如果少于预期并且有呼叫失败日志就reload unimrcp配置强制重建连接池fs_cli -x reload mod_unimrcp生产环境上线以来的经验是MRCP连接本身是稳定的最不稳定的是网络和License服务。过节促销类活动开始前一定要检查License服务是否正常运行否则FreeSwitch侧再稳定讯飞网关授权不上也白搭。6.4 演进方向多引擎切换和动态路由这套架构在后续演进中还可以支持多厂商引擎切换。因为MRCP协议是标准的只需要在FreeSwitch里增加一套指向其他厂商MRCP网关的profiledialplan里根据业务路由选择不同profile即可。我在测试环境里同时配了讯飞和另一家开源引擎切换只改一行tts_engine或asr_engine变量业务层无感知。如果后续要把对话系统升级成大模型驱动ESL方案也不需要改动FreeSwitch任何配置只需要在业务服务里把请求从规则引擎切到大模型接口。这也是我坚持把对话逻辑外置的原因。最后分享一个我常用的最小验证方法很多人一上来就写完整dialplan出了问题很难定位。我习惯先用一条fs_cli命令验证MRCP通道是否通fs_cli -x originate user/1000 play_and_detect_speech(unimrcp:欢迎测试|unimrcp:business) inline这条命令会直接对分机1000发起呼叫接通后播放测试语音并启动识别。如果这条命令能正常出音、正常识别再往完整dialplan里扩展排错范围会小很多。接新项目时先把最简链路跑通再做复杂交互是这套架构落地中最省时间的做法。