从零设计一个通用智能电话平台:FreeSWITCH + ESL + AI 实战架构
很多团队做“AI 接电话”,第一版是把机器人直接塞进 FreeSWITCH 拨号计划。Demo 能跑,但很快就会遇到:换家 ASR 要改呼叫代码,加分机要改 XML,转人工后通话莫名挂断,话单对不上。
这篇文章换个思路:先设计通用的电话平台,再让 AI 以插件方式接进来。 下面挑出最关键、最容易踩坑的部分。
一、场景与目标
平台覆盖三类场景:客服接听(机器人先接,复杂问题转人工)、外呼回访/通知(回访、提醒、预约确认)、人机协同(机器人带摘要转人工,坐席处理完再转回机器人做满意度调查)。
目标是五个“能”:能打通(呼入呼出、可打断)、可插拔(换机器人或供应商不改呼叫代码)、能运营(配置在管理台改,不登服务器)、能验证(浏览器就能测全链路)、能扩展(单机平滑扩到集群)。
二、分层架构:让 FreeSWITCH 只做执行者
整体分五层:
| 层 | 做什么 |
|---|---|
| 接入层 | 运营商中继、SIP 话机、浏览器软电话;集群期加 Kamailio 做注册和分发 |
| 媒体层 | FreeSWITCH:信令、媒体、转码、放音录音、桥接 |
| 控制层 | 配置服务(xml_curl)、呼叫控制引擎(ESL)、排队与外呼调度 |
| AI 接入层 | AI 接入网关 + 外部机器人或 ASR/TTS/LLM 适配器 |
| 业务层 | 管理台、坐席工具条、开放 API、质检报表 |
核心原则只有一句:FreeSWITCH 只负责信令、媒体和执行命令;配置放数据库,决策放在 ESL 程序里,AI 通过统一协议接入。
配套原则:只有控制引擎持有 ESL,AI 要打断或转人工就发事件给它,不直接碰通道;异常时要么挂机要么回到等待,不留僵尸通道;全链路一个 call_id,从通道变量带到日志、AI 请求和话单。
三、配置数据库化:mod_xml_curl + 一键发布
改 XML 再 reloadxml 的做法,分机和节点一多就难管。mod_xml_curl 的思路是:FreeSWITCH 需要配置时,发 HTTP 请求向你的服务要。
<param name="gateway-url" value="http://10.0.0.11:8080/fs/xml"
bindings="directory|dialplan|configuration"/>
配置服务按请求里的 section 分发:directory 返回分机,dialplan 返回拨号计划,configuration 再按 key_value 区分网关、ACL 等。几个要点:
- 查不到就返回
<result status="not found"/>,不要返回空内容; - 分机密码下发
a1-hash,不下发明文。
静态与动态的边界:全局变量、模块列表、profile 骨架、ESL 配置放静态 XML;分机、网关、路由、ACL 放数据库。静态 XML 里还要留一个兜底拨号计划:配置服务挂了时播放“系统繁忙”,而不是让客户听到一片寂静。
入库后分两类:
- 路由和分机:保存即生效。 来电或注册时实时拉取,下一通电话、下一次注册就用新配置。如果开了
cacheable,保存后由程序自动执行xml_flush_cache。 - 网关和 ACL:要点「发布」。 网关是 FreeSWITCH 主动去运营商注册的,分机则是话机来注册,改了不会自动生效。
点发布时先校验,再通过 ESL 执行命令:新增网关用 sofia profile external rescan,修改或删除先 killgw 再 rescan;ACL 用 reloadacl。再检查网关是否 REGED,成功标记“已发布”,失败提示报错。多节点规模大了,再考虑灰度。
四、呼叫控制:统一 park,ESL 接管
所有电话先 park
拨号计划不再写复杂的路由,只返回一个统一入口:
<action application="set" data="vr_call_id=${uuid}"/>
<action application="set" data="park_timeout=20"/>
<action application="park"/>
控制引擎通过 ESL(inbound 模式)收到 CHANNEL_PARK 后,按号码、日程、坐席状态决定去机器人、IVR 还是人工。呼入、外呼、转接走同一套状态机;park_timeout 防止引擎故障时通道一直挂着。
事件按通话分配到固定处理单元
一通电话的 ANSWER、PLAYBACK_STOP、HANGUP 如果并发处理,很容易乱序。可靠做法是:固定 N 个 worker,按通话 ID 哈希分配,同一通电话永远在同一个 worker 里串行处理,不同电话并行。
key = ev.get("variable_vr_call_id") or ev["Unique-ID"]
await queues[hash(key) % N].put(ev) # 有界队列,满了就背压或丢弃非关键事件
用整通电话的 call_id 做键(没有时退回 UUID),A、B 两条腿的事件也不会互相抢。
几个必知的命令组合
- 外呼:应用预先生成
origination_uuid,先写缓存再发bgapi originate ... &park(),接通后由程序决定下一步; - 转人工:桥接前设置
park_after_bridge=true,一方挂断后另一方回到 park,不会被连带挂掉; - 拆桥转回机器人:
uuid_transfer <uuid> -both 'set:park_after_bridge=true,park:' inline;
Redis 状态别忘了 TTL
通话状态存 Redis,必须设置过期时间。ESL 断线期间漏掉挂机事件,没有 TTL 就会留下永久脏数据。ESL 重连后立刻用 show channels 和 Redis 对账:Redis 有、FreeSWITCH 没有的,补一张“异常结束”话单再清理。断线重连用指数退避(1s、2s……最多 30s),别用每分钟轮询。
五、可插拔 AI 接入:一套协议接万家
媒体怎么出来
FreeSWITCH 到 AI 这一段用 mod_audio_stream:uuid_audio_stream <uuid> start <ws地址> mono 8k <元数据>,把通话音频以 PCM 帧推到 WebSocket。需要注意:社区版的下行是“服务端回 base64 音频,模块落成文件再播放”,真正的双向流式是商业版功能。所以 MVP 建议按句返回、逐句播放,第一句尽量短。
统一协议 + 两种接入模式
平台提供一个 AI 接入网关,把 FreeSWITCH 的媒体转换成统一的 WebSocket 协议:
| 方向 | 消息 | 作用 |
|---|---|---|
| 平台 → 机器人 | session.start / session.end | 会话开始/结束,带场景、音频格式 |
| 平台 → 机器人 | 二进制 PCM、dtmf、playback.finished | 用户声音、按键、播放进度 |
| 机器人 → 平台 | response.audio、transcript.final | 播报音频、识别文本 |
| 机器人 → 平台 | response.cancel | 打断:平台立即 uuid_break |
| 机器人 → 平台 | action.transfer / action.hangup | 转人工(带摘要)/ 挂机 |
接入方有两种选择:
- 整机接入:自带 VAD、ASR、LLM、TTS 的机器人,实现这套协议就能上电话线路;
- 组件接入:不想自建机器人,就由平台内置编排调用 ASR、TTS、LLM 适配器,换供应商只换适配器。
我开源的 voice-robot 就是“整机接入”的示例。它原本是浏览器语音机器人(腾讯 ASR、火山方舟、火山流式 TTS),已具备流式输出和 cancel + generation_id 打断。接电话时只需加一层协议转换:服务端补上原本在浏览器做的 VAD,自动提交每一轮,把 generation_id 映射为 response_id。
打断与延迟
打断的关键是:用户开口持续超过阈值(如 200~400ms,需调参)或识别出非空文字时,控制引擎执行 uuid_break <uuid> all,同时取消当前这轮的 LLM 和 TTS。“嗯、好”这类附和语可以配置为不打断,合规声明可以标记为不可打断。
延迟方面,句尾检测、ASR、LLM 首字、TTS 首包加起来粗略估算 1~3 秒,需实测;靠全链路流式、首句短、固定话术预合成来压缩。电话是 8k 窄带,优先选 8k 电话 ASR 模型。
六、浏览器 WebSocket 软电话:系统验证的入口
打开网页就能登录分机、拨打和接听,是验证全系统最省事的方式。
选型:SIP over WSS + WebRTC
FreeSWITCH 有两条路:Sofia 的 ws/wss 端口(标准 SIP,客户端用 SIP.js 或 JsSIP),或 mod_verto(私有 JSON-RPC)。推荐前者:标准协议,客户端成熟,集群期可直接放到 Kamailio 后面。
FreeSWITCH 配置要点
- 证书:
wss-binding默认:7443,证书放wss.pem。没有证书时 FS 会自动生成自签证书,但浏览器会拒绝连接。推荐用域名申请正式证书;只有 IP 时,Let's Encrypt 已能签发 IP 证书,但有效期只有约 6 天,必须自动续期。 - 公网地址:云主机上
ext-sip-ip、ext-rtp-ip要设成公网 IP,否则 SDP 里是内网地址,表现为单通或无声。domain也别用默认的内网 IP,否则注册认证会失败。 - 加密与穿透:WebRTC 强制 DTLS-SRTP;企业网络封 UDP 时需要 TURN(如 coturn),TURN 凭证由后端按登录动态生成。
- 编解码:浏览器用 OPUS,运营商中继多为 PCMA/PCMU,由 FreeSWITCH 转码,记得压测 CPU。
- 端口:开放 7443 和 RTP 端口段;8021(ESL)绝不对公网开放。
身份与两条 WebSocket
浏览器不保存固定 SIP 密码:坐席登录平台后拿到临时 SIP 密码(存 Redis,有效期与登录会话一致),注册时 FreeSWITCH 通过 xml_curl 查询,平台根据登录态返回 a1-hash 或拒绝。登出时执行 flush_inbound_reg,让注册立即失效。
坐席页面上有两条 WebSocket:信令 WebSocket 连 FreeSWITCH,负责注册和通话;业务 WebSocket 连平台,负责坐席状态、来电弹屏、置忙置闲。规则是“SIP 已注册 + 业务连接在线 + 状态空闲”三者同时满足才分配来电,并且先弹屏再振铃。
怎么验证
环境搭好后,先用两个浏览器分别登录 1000 和 1001 互拨,确认双向有声;再从外线打进来,看浏览器坐席能否先弹屏后振铃;最后走一遍“机器人接听 → 转人工”。这三步都通了,说明注册、媒体、路由和 AI 接入整条链路是通的。
七、IVR 与话单
树形 IVR:解释执行,而不是生成配置文件
常见的 IVR 配置是一棵树:每个节点有名称、按键、话术、处理方式(进入下级菜单、转坐席、转分机、收号,比如身份证后四位),* 重听、# 返回上级。节点表核心字段:
ivr_node(id, flow_id, parent_id, node_name, dtmf_key, prompt_text,
action_type, action_target, collect_cfg, timeout_sec, max_retries)
点击“应用 IVR”时:校验按键是否冲突、目标是否存在,预合成话术音频,生成一个不可变版本。执行方式推荐由 ESL 程序解释执行:控制引擎逐节点下发 play_and_get_digits,根据按键走下一节点。相比生成 ivr.conf,它能直接转排队、转机器人、记录按键轨迹,发布不用 reload;ivr.conf 可留作兜底菜单。
注意一个小冲突:收号节点里 # 是结束键,可以约定“不输入数字直接按 #”才算返回上级。
billsec 和 duration 的区别
话单列表里最容易被问的就是“通话时长”和“持续时长”:
- duration(持续时长):从开始呼叫到结束,包含振铃,未接通也有值;
- billsec(通话时长):从接通到结束,是计费时长,未接通为 0。
还有一个坑:呼入时 IVR 或机器人一接听,客户这条腿就算“接通”了,所以客户腿的 billsec 包含了 IVR、机器人对话和排队时间。坐席的通话时长应该取坐席腿的 billsec。另外一通电话至少两条腿,统计前要先判断主腿,否则数量翻倍;话单经消息队列异步写库,并与原生话单对账。
八、安全坑点:默认密码 1234
FreeSWITCH 默认配置自带 1000~1019 共 20 个演示分机,密码都是 vars.xml 里的 default_password,官方默认值是 1234。服务器有公网 IP 时,扫描器很快就会找上门,注册成功就能用你的线路往外打,账单可能一夜暴涨。
必做清单:
- 上线前修改默认密码;改用 xml_curl 下发分机后,删除静态目录里的演示用户。否则 xml_curl 返回“查不到”时,FreeSWITCH 会回退到静态配置,旧密码依然有效;
- ESL 只监听内网并配置 ACL,xml_curl 接口要鉴权,日志里不要打印凭据;
- 默认禁止国际、高费率号段,单分机、单租户限制并发和每日呼出量,异常呼出立即告警并自动停用;
- 外呼要有用户同意凭证、黑名单和时段控制。按工信部规定,呼叫中心呼出需留存不少于 30 日的录音和同意凭证,不允许商业营销类呼出,具体以法务意见为准。
写在最后
这套设计的关键选择就三个:配置进数据库,决策进 ESL 程序,AI 走统一协议。做到了,换供应商、加节点、改流程都不用动 FreeSWITCH。
想动手的话,可以拿 voice-robot 加一台 FreeSWITCH,从“浏览器登录分机、拨通机器人、说话打断它”开始。欢迎评论区交流踩过的坑。