FreeSWITCH 入门:把电话系统接入语音客服机器人

这篇文章写给有后端经验、刚接触电话/VoIP 的开发者。读完你应该能弄明白:一通电话是怎么进到 FreeSWITCH 的,又是怎么把声音交给 AI 机器人、再把机器人的回答播回去的;你还能在自己的服务器上用分机 1000/1001/1004 把整条链路跑通。


1. 为什么语音机器人需要电话系统

我的开源项目 voice-robot(实时语音机器人)现在的工作方式是:浏览器或 App 通过 WebSocket 把麦克风音频推上来,服务端走一遍 ASR(语音识别,声音转文字)→ LLM(大模型,理解并生成回复)→ TTS(语音合成,文字转声音),再把合成的声音推回去。

但真正的"客服"场景里,用户不会打开你的网页,他们只会拨一个电话号码。要让机器人"接电话",中间少了一层:

  • 接入运营商线路:电话网说的是电信协议,不是 HTTP,也不是 WebSocket;
  • 管理通话:振铃、接听、转人工、挂断、排队、录音、话单;
  • 处理媒体:电话音频的编码格式(G.711 等)和你的 ASR/TTS 用的 PCM 不一样,需要转码、重采样。

这一层就是软交换(Softswitch)------用软件实现的电话交换机。FreeSWITCH 就是最主流的开源软交换之一。

图 1 场景示意:用户的电话经运营商线路进入 FreeSWITCH,由 voice-robot 应答,需要时转给人工坐席

FreeSWITCH 是什么、能做什么

FreeSWITCH 是一个用 C 写的开源电话平台(MPL 协议),可以把它理解成**"电话界的 Nginx"**:它不关心你的业务,只负责把呼叫可靠地接进来、路由到该去的地方,并且把能力开放给外部程序来控制。它能做:

  • 分机注册与互拨(企业内部电话)、对接运营商线路;
  • IVR(按键导航,"查话费请按 1")、呼叫中心排队、会议;
  • 录音、放音、转码、话单;
  • 通过 ESL 让你用 Python/Go/Java 远程控制每一通电话------这正是接 AI 机器人的关键。

和 Asterisk 比一句:Asterisk 历史更久、生态更老牌;FreeSWITCH 从设计上就是多线程、模块化的,高并发下更稳,对外的事件/控制接口(ESL)也更适合"程序驱动通话",所以 AI 语音机器人项目里更常见。


2. 必须先搞懂的基础概念

电话领域术语多,但核心只有十来个。下面每个都配一个后端开发熟悉的类比。

2.1 SIP 与 RTP:信令 vs 媒体

一通 VoIP 电话其实是两条完全独立的"管道":

SIP(会话初始协议) RTP(实时传输协议)
干什么 信令:谁打给谁、振铃、接听、挂断 媒体:真正的声音数据
长什么样 文本协议,像 HTTP(INVITE、200 OK、BYE) 二进制 UDP 包,每 20ms 一个
端口 通常 UDP/TCP 5060、5080 动态分配的 UDP 端口(FS 默认 16384--32768)
类比 打电话前的"握手和协商" 握手后真正流动的"数据流"

SIP 里有一段叫 SDP(会话描述协议) 的内容,双方用它协商"声音发到哪个 IP:端口、用什么编码"。记住这一点,后面讲 NAT 时你就明白为什么会"能接通但没声音"。

几个常见 SIP 消息:REGISTER(分机上线报到)、INVITE(发起呼叫)、180 Ringing(振铃)、200 OK(接听)、ACK(确认)、BYE(挂断)。

下面这张图把一通 A 拨 B 的电话拆开看:上半部分是 SIP 信令,全部经过 FreeSWITCH 的 5060 端口;中间的 RTP 媒体走的是 SDP 里协商出来的另一组 UDP 端口;最后再由 SIP 的 BYE 收尾。

图 2 SIP 信令与 RTP 媒体分离:信令和媒体走不同的端口,甚至可以走不同的路径

2.2 注册与分机(user directory)

分机 就是一个 SIP 账号,比如 1000,配一个密码。软电话(如 Linphone、MicroSIP)用这个账号向 FreeSWITCH 发 REGISTER,相当于"登录",FS 就知道"1000 现在在 192.168.1.20 这台机器上",有人拨 1000 时就能找到它。

这些账号定义在 user directory(用户目录) 里,路径通常是 conf/directory/default/*.xml。默认安装自带 1000--1019 共 20 个分机,密码统一是 1234 (由 vars.xml 里的 default_password 决定)------这是上公网前必须改的第一件事。

2.3 Sofia 模块与 internal / external profile

FreeSWITCH 的功能都在模块里,负责 SIP 协议的模块叫 mod_sofia (基于 Sofia-SIP 协议栈)。一个 Sofia profile(配置档) 就是"一个监听着某个 IP:端口的 SIP 服务",默认有两个:

profile 默认端口 给谁用 是否要求认证 来电进入的 context
internal 5060 内部分机(员工话机、软电话) 要(分机账号密码) default(认证后按分机的 user_context 走)
external 5080 运营商线路、外部 SIP 服务 一般不要(靠 IP 白名单或网关注册) public

类比:一个 Web 服务开了两个端口,一个给登录用户用的后台,一个给外部回调用的开放接口,安全策略不同。

2.4 网关 / SIP 中继:接入真实电话线路

要打通手机和固话,你需要向运营商或云通信服务商申请 SIP 中继(SIP Trunk)------一条走 IP 网络的"电话线",通常会给你一个或一批号码(DID,直拨号码),外加账号、密码、服务器地址(或者只做 IP 对接)。

在 FreeSWITCH 里,一条中继配置成一个 gateway(网关) ,放在 sip_profiles/external/ 下,填上运营商给的账号、密码、服务器地址(proxy)以及是否注册(register)。之后外呼写成 sofia/gateway/<网关名>/<号码>。本地测试不需要中继,用 user/1001 呼叫本地分机即可,两者只是"拨号字符串"不同,其余逻辑完全一样。

2.5 拨号计划 dialplan:电话的"路由表"

一通电话进来后去哪里,由 dialplan(拨号计划) 决定。它就是电话版的 Nginx location / Web 框架路由:

  • context(上下文) :一组路由规则的集合,类似"虚拟主机"。内部分机进 default,外线进 public。这样隔离是为了安全:外部来电不能随便用你的线路往外打。
  • extension(扩展) :一条路由规则,类似一个 location 块。
  • condition(条件) :匹配条件,最常用的是 field="destination_number"(被叫号码)配正则。
  • action(动作) :条件命中后依次执行的"应用(application)",如 answer(接听)、playback(放音)、bridge(接通另一方)、park(挂起等待外部程序控制)、socket(交给外部程序)。

FreeSWITCH 的 XML 拨号计划分两个阶段,理解了这一点,很多"变量为什么没生效"的疑问就解开了:

  1. 查找(hunting)阶段 :在来电对应的 context 里,按文件和书写顺序逐个检查 extension 的 condition。条件命中,就把这个 extension 里的 action 收进"待执行列表"(条件不命中时,收进的是 anti-action)。默认命中第一个 extension 就停止查找,除非这个 extension 写了 continue="true"。
  2. 执行(execute)阶段 :按顺序执行列表里的 action。所以查找阶段里 set 的变量,要等到执行阶段才真正生效;如果后面的 condition 需要用到它,要给这个 set 加上 inline="true"。

图 3 dialplan 匹配流程:context → extension → condition → action

2.6 Channel / Session / Call-ID / UUID

  • Channel(通道):FS 里每一个"通话腿"就是一个 channel。A 拨 B,FS 内部其实是两条腿:A 腿(呼入 FS)和 B 腿(FS 呼出到 B),FS 在中间把它们**桥接(bridge)**起来。
  • Session(会话):channel 在代码层面的载体,大多数时候你可以把两者当成一回事。
  • UUID :FreeSWITCH 给每个 channel 分配的唯一 ID,几乎所有控制命令都以它为参数(uuid_kill <uuid>、uuid_break <uuid>)。在机器人系统里,UUID 就是会话主键,对话历史、ASR 流、TTS 任务都挂在它下面。
  • Call-ID :SIP 协议层的呼叫标识,由发起方生成,和运营商对账、抓包排错时用。FS 里对应通道变量 sip_call_id。注意它和 UUID 是两套东西。

每个 channel 从创建到销毁会经过一组固定的状态(channel state) ,show channels 的 state 列显示的就是它。主干如下:

状态 含义 相关事件
CS_NEW → CS_INIT 收到 INVITE 或 originate,创建并初始化通道 CHANNEL_CREATE
CS_ROUTING 在拨号计划里查找路由;transfer 后会回到这里 ---
CS_EXECUTE 依次执行 action,answer、park、bridge 都在这一步执行 CHANNEL_ANSWER
CS_EXCHANGE_MEDIA 作为被桥接的一条腿收发媒体(如 bridge 的 B 腿) CHANNEL_BRIDGE
CS_HANGUP 挂断 CHANNEL_HANGUP
CS_REPORTING 汇总通道变量,用于写话单 CHANNEL_HANGUP_COMPLETE
CS_DESTROY 释放资源 CHANNEL_DESTROY

注意两点:接听不是一个 channel state ,它属于另一套"呼叫状态"(Channel-Call-State:DOWN → RINGING/EARLY → ACTIVE → HANGUP);拨号计划里 park 住的通道仍显示 CS_EXECUTE,只有 uuid_park 停住的通道才进入 CS_PARK。

2.7 编解码:PCMU / PCMA / Opus 与 8k 采样率

codec(编解码器) 决定声音怎么压缩成字节:

编码 采样率 码率 说明
PCMU(G.711 μ-law) 8 kHz 64 kbps 北美/日本常用,几乎所有设备都支持
PCMA(G.711 A-law) 8 kHz 64 kbps 中国、欧洲运营商线路最常见
Opus 8--48 kHz 6--510 kbps 现代宽带编码,WebRTC 默认,音质好

关键结论:传统电话网是 8 kHz 窄带音频 ,带宽只到约 3.4 kHz。而多数 ASR 模型按 16 kHz 训练,TTS 常输出 16k/24k。所以链路中一定有重采样这一步------要么让 FreeSWITCH 或媒体模块做,要么在 voice-robot 里做,后面会具体讲。

2.8 NAT 与 ext-rtp-ip / ext-sip-ip

云服务器通常只有内网 IP,公网 IP 是通过 NAT(网络地址转换) 映射过来的。如果 FS 在 SDP 里告诉对方"声音发到 172.16.x.x",对方当然发不过来------结果就是经典的**"能接通、没声音"或"单通"(只有一方能听见)**。

解决办法是在 Sofia profile 里设置对外宣告的地址:

  • ext-sip-ip:SIP 信令里对外宣告的 IP;
  • ext-rtp-ip:SDP 里对外宣告的媒体 IP。

默认它们引用 vars.xml 中通过 STUN 探测出来的 external_sip_ip / external_rtp_ip。云服务器上建议直接写死公网 IP,并在安全组放行 SIP 端口和 RTP 端口段。

2.9 ESL:用程序遥控电话

ESL(Event Socket Library,事件套接字) 由 mod_event_socket 提供,是一个基于 TCP 的文本协议。通过它你可以:发命令 (接听、放音、转接、挂断、外呼)和收事件(振铃了、接通了、挂断了、按键了)。它有两种模式,"in/out" 是站在 FreeSWITCH 的角度说的:

inbound 模式 outbound 模式
谁连谁 你的程序 → FS 的 8021 端口 FS → 你的程序(端口自定,如 8040)
连接数 一条长连接管全局 每通电话一条连接
类比 客户端连数据库 Nginx 反向代理到后端
适合 外呼、全局监控、话单、控制中心 逐通电话的顺序流程(IVR 脚本)

本文的示例用 inbound:一个常驻的"控制服务"订阅全局事件,管所有电话。

重要:ESL 只传命令和事件,不传声音。声音要另外走一条路。

2.10 mod_audio_fork / WebSocket 音频流

要把通话的实时音频交给 voice-robot,需要一个"媒体分叉"模块:它在通道上挂一个 media bug(媒体钩子) ,把音频复制一份,转成 L16(16 位线性 PCM) ,通过 WebSocket 推给你的服务。

社区里常用的是 mod_audio_fork (来自 drachtio-freeswitch-modules 项目,另有若干社区分支)和 mod_audio_stream(amigniter/mod_audio_stream)。需要如实说明:

  • 它们都是第三方模块,不在 FreeSWITCH 官方发行版里 ,需要自己拿源码、装好 libwebsockets 等依赖后单独编译,再在 modules.conf.xml 里加载;
  • 不同分支的命令参数和能力有差异,尤其是"机器人把音频回传给 FS 播放"这部分。上线前请对照你实际编译的那份源码,确认命令参数和事件名。

本文以 mod_audio_fork 的经典用法为例,它提供 API 命令 uuid_audio_fork。

把这一节的概念拼起来,就是下面这张图:FreeSWITCH 本身是一个核心,SIP、拨号计划、ESL、音频流、话单都是插在核心上的模块。

图 4 FreeSWITCH 模块组成:核心之上按需加载 mod_sofia、mod_dialplan_xml、mod_event_socket、mod_commands、mod_audio_fork(第三方)、mod_cdr_csv


3. 整体架构

图 5 整体架构:电话接入、ESL 控制与 WebSocket 音频流

分工一句话:FreeSWITCH 管电话,控制服务(ESL)管"这通电话下一步干什么",voice-robot 管"听懂并回答"。控制面和媒体面分开,各自可以独立扩容、独立重启。


4. 核心流程:一通 AI 电话的一生

4.1 呼入流程

  1. INVITE :用户拨 9999(或拨打运营商号码,中继把呼叫送进 external profile)。FS 回 100 Trying,为这条腿创建 channel,分配 UUID。
  2. 拨号计划匹配 :在对应 context 中找到 destination_number 为 9999 的 extension,开始执行 action。
  3. answer :接听,FS 回 200 OK,RTP 开始流动。FS 发出 CHANNEL_ANSWER 事件。之后执行 park,让通道"停着"等控制服务指挥。
  4. 把音频转给机器人 :控制服务收到 CHANNEL_ANSWER,看到通道变量 ai_bot=true,执行 uuid_audio_fork <uuid> start ws://... mono 16k {...}。FS 主动连到 voice-robot 的 WebSocket,先发一帧 JSON 元数据,之后持续发二进制音频帧。
  5. ASR → LLM → TTS :voice-robot 做 VAD 断句、流式识别,把一句话交给 LLM,回复按句切分送进 TTS。合成的音频通过 WebSocket 发回(playAudio),或者落盘后由控制服务用 uuid_broadcast 播放。
  6. 打断(barge-in) :机器人说话期间音频流不停 ,ASR 一直在听。一旦检测到用户开口(且不是回声),立刻停止放音(uuid_break <uuid> all 或发 killAudio),并取消正在生成的 LLM/TTS 任务。
  7. 转人工(bridge) :LLM 判断需要人工(或者用户说"转人工"),voice-robot 通知控制服务;控制服务先停掉音频流,再 uuid_transfer 到坐席分机 1004,由拨号计划执行 bridge user/1004,把用户和坐席接通。
  8. 挂断与话单(CDR) :任何一方挂机,FS 发出 BYE,最后抛出 CHANNEL_HANGUP_COMPLETE 事件,里面带着完整的通道变量(开始/接听/结束时间、挂断原因等)。控制服务据此写话单、清理该 UUID 的会话状态。

图 6 一通 AI 呼入电话的完整时序:从 INVITE 到写话单

4.2 呼出流程(originate)

外呼回访、催缴、通知类场景是机器人主动打出去 ,核心命令是 originate(完整写法见 5.2):

  • {} 里是只作用于这一路的通道变量 :ai_bot=true 打标记,origination_caller_id_number 是主叫号码(必须是运营商允许你使用的号码),originate_timeout 是振铃超时秒数;
  • 中间是拨号字符串 :真实线路用 sofia/gateway/carrier1/<号码>,本地测试用 user/1001;
  • 末尾的 &park() 表示对方接听后先"停住",等控制服务接管。

接下来的流程和呼入一样:对方接听 → CHANNEL_ANSWER(带 variable_ai_bot=true)→ 开始音频流 → 对话 → 挂断写话单。在 ESL 里用 bgapi originate ... 异步执行,结果通过 BACKGROUND_JOB 事件返回,失败时可以看到 -ERR NO_ANSWER、-ERR USER_BUSY 之类的原因。

外呼要额外注意:接通不等于是真人。有可能是彩铃、语音信箱、"您拨打的电话已关机"的运营商提示音,机器人开场前最好等用户先出声,或者做一个简单的应答检测。


5. 最小实操:用自部署 FreeSWITCH 跑通

环境:一台 Linux 服务器上自部署的 FreeSWITCH,默认配置。分机 1000 是测试用户(拨 9999 体验机器人),1001 是外呼测试对象,1004 是人工坐席。先给这三个分机改掉默认密码,再用软电话分别注册上去。

5.1 dialplan:拨 9999 进 AI 机器人

在 dialplan/default/ 下新建 01_ai_robot.xml,保存后在 fs_cli 里执行 reloadxml:

xml 复制代码
<extension name="ai_robot">
  <condition field="destination_number" expression="^9999$">
    <action application="set" data="ai_bot=true"/>
    <action application="answer"/>
    <action application="park"/>
  </condition>
</extension>

ai_bot=true 是给控制服务看的标记,park 让通道停住等 ESL 指挥。转人工的 1004 不用另外配置,默认的 Local_Extension 规则会对 10xx 分机执行 bridge。运营商号码打进来时,在 public context 里用 transfer 把它转到 9999 XML default 即可。

5.2 fs_cli 常用命令

fs_cli 是 FreeSWITCH 自带的命令行客户端,最常用的就这几条:

text 复制代码
sofia status profile internal reg        # 哪些分机注册上来了
show channels                            # 当前所有通道
originate {ai_bot=true}user/1001 &park() # 让机器人外呼 1001(真实线路换成 sofia/gateway/carrier1/<号码>)
uuid_break <uuid> all                    # 停止放音(打断)
uuid_transfer <uuid> 1004 XML default    # 转人工

5.3 控制服务(ESL)伪代码

控制服务用 inbound 模式连 8021,Python 可以选 greenswitch 或 python-ESL。逻辑如下:

text 复制代码
连接 ESL(127.0.0.1:8021, 密码);断线后指数退避重连,重连后重新订阅
订阅 CHANNEL_ANSWER、CHANNEL_HANGUP_COMPLETE、CUSTOM mod_audio_fork::json

on CHANNEL_ANSWER(ev):
    if ev.variable_ai_bot != "true": return
    bgapi "uuid_audio_fork {uuid} start ws://127.0.0.1:9000/fs mono 16k {meta}"

on mod_audio_fork::json(ev):            # voice-robot 发来的指令
    barge_in → api "uuid_break {uuid} all"
    transfer → api "uuid_audio_fork {uuid} stop"; api "uuid_transfer {uuid} 1004 XML default"
    hangup   → api "uuid_kill {uuid}"

on CHANNEL_HANGUP_COMPLETE(ev):
    写 CDR(字段见 6.7);清理该 uuid 的会话状态

两点提醒:mod_audio_fork 各版本的 start 参数顺序不同,有的分支在采样率后面多了 bugname 参数,务必按你编译的版本调整;机器人回传音频的方式也因版本而异,经典版收到 playAudio 后只落成临时文件、抛出 mod_audio_fork::play_audio 事件,需要控制服务再用 uuid_broadcast 播放。

5.4 voice-robot 侧伪代码

mod_audio_fork 连上 WebSocket 后,先发一帧文本(元数据 JSON),之后是二进制帧:16 位、小端、单声道 PCM,采样率就是 start 里指定的 16k。

text 复制代码
ws_server(127.0.0.1:9000, subprotocol="audiostream.drachtio.org")
on connect: meta = 第一帧 JSON;session = RobotSession(meta.uuid)

for frame in ws:                              # 每帧 L16 16k PCM
    if session.speaking and vad.user_speaking_confirmed(frame):
        session.cancel_llm_tts()              # 打断
        ws.send({"type": "killAudio"}); ws.send({"type": "json", "action": "barge_in"})
    text = asr.feed(frame)
    if vad.end_of_utterance and text:
        if intent(text) == 转人工: ws.send({"type": "json", "action": "transfer"}); break
        for sentence in llm.stream(text):     # 流式 LLM,按句送 TTS
            pcm = resample(tts(sentence), tts_rate → 8000)
            ws.send({"type": "playAudio", "data": {"audioContentType": "raw",
                     "sampleRate": 8000, "audioContent": base64(pcm)}})
on close: session.close()

重采样提示:推流直接用 16k,模块会从 8k 升采样,但这样做并不会多出信息,选支持 8k 电话场景的 ASR 往往效果更好;TTS 常输出 16k/24k,回传前降到 8k,能省掉 FS 的一次转码。


6. 生产注意事项

6.1 延迟预算

电话里沉默超过 1.5 秒,用户就会"喂?喂?"。下面是一个经验性的预算拆分(数值随厂商、网络差异很大,只是量级估算):

环节 典型耗时 优化手段
网络 + 抖动缓冲 50--150 ms FS 与 voice-robot 同机或同内网
VAD 判断"说完了" 300--700 ms 调尾部静音阈值;结合语义判断说完
ASR 出最终结果 100--300 ms 流式 ASR,边说边识别
LLM 首 token 200--800 ms 流式输出、短 system prompt、就近部署
TTS 首包 100--300 ms 流式 TTS、按句合成、首句优先
回放链路 50--100 ms 减少落盘和转码

目标是用户说完到听到第一个字控制在 1--1.5 秒内。等用户时可以先播一句很短的"好的""稍等"来填补空白,但别滥用。

6.2 回声与打断

  • 用 mono 只推用户声道,避免 ASR 听到机器人自己的声音;
  • 用户用免提时,机器人的声音会通过对方的话筒"漏"回来,被误判成插话。打断条件不要太敏感:要求持续语音几百毫秒,或者出现有意义的识别文字再打断;
  • 打断要做彻底:停止放音(uuid_break <uuid> all)、取消 LLM 生成、清空 TTS 队列,并把"机器人说到一半被打断"写进对话历史,让 LLM 知道用户没听完。

6.3 编解码与采样率

  • 运营商线路国内多是 PCMA。在 vars.xml 里设置 global_codec_prefs / outbound_codec_prefs 时,把线路支持的编码放进去,减少转码;
  • 一条链路里尽量只做必要的重采样,每多一次转换就多一点延迟和音质损失;
  • 用 uuid_dump <uuid> 查看 read_codec、write_codec,确认实际协商出来的编码。

6.4 并发与资源

  • FreeSWITCH 本身处理几百路 G.711 通话压力不大,转码(尤其 Opus)和录音才是 CPU/磁盘大户;
  • 真正的瓶颈通常在 AI 侧:每路通话占一个流式 ASR 连接、一个 LLM 会话、一个 TTS 流。要按路数做压测,并为 ASR/TTS 服务配置并发上限;
  • 在 switch.conf.xml 里调整 max-sessions、sessions-per-second,在系统层调大文件描述符上限;
  • RTP 端口段要够用(每路通话占用若干 UDP 端口),安全组要整段放行。

6.5 ESL 断线重连

  • 控制服务重启或网络抖动时,ESL 连接会断。要像示例里那样指数退避重连 ,重连成功后重新订阅事件;
  • 断线期间发生的事件会丢失。重连后用 show channels 对账:哪些机器人通话还在、哪些已经挂了,修正内存/Redis 里的会话状态,补写缺失的话单;
  • 订阅 HEARTBEAT 事件(FS 默认每 20 秒一次)做看门狗,长时间收不到就主动断开重连;
  • 关键:控制服务挂了不应导致通话立刻异常 。通道在 park 状态会一直停着,所以要有兜底:比如在拨号计划中设置超时挂断,或者重连后对"无人认领"的通话播放致歉语后转人工。

6.6 安全

FreeSWITCH 上公网后几分钟内就会被 SIP 扫描器盯上(日志里常见 friendly-scanner、sipvicious 等 User-Agent),被盗打一晚上可能就是一大笔话费。最低限度要做到:

  • 改默认密码 :vars.xml 的 default_password(默认 1234),并给每个分机设独立强密码;event_socket.conf.xml 的 ESL 密码(默认 ClueCon);
  • ESL 只监听本机 :listen-ip 设为 127.0.0.1(部分版本默认是 ::,即所有网卡),配合 apply-inbound-acl;远程调试走 SSH 隧道;
  • ACL 白名单 :在 acl.conf.xml 里定义运营商 IP 段,external profile 只接受这些来源;internal profile 也可以限制来源网段;
  • fail2ban :在 Sofia profile 里打开 log-auth-failures,让 fail2ban 监控 FS 日志里的认证失败,自动封禁暴力破解的 IP;
  • dialplan 隔离 :public context 里绝不能出现直接桥接到网关的规则,否则任何人都能借你的线路打国际长途;
  • 改掉默认的 SIP 端口能挡掉一部分扫描,但只是"降噪",不能替代以上措施;
  • voice-robot 的 WebSocket 服务也只监听本机或内网。

6.7 录音与 CDR

录音 :用 record_session 录整通电话,RECORD_STEREO=true 时双方分别在左右声道,方便质检和 ASR 回放分析。注意录音前要按当地法规告知用户("本次通话可能会被录音"),并制定保存期限。

CDR(Call Detail Record,话单) :FS 自带 mod_cdr_csv(默认写到日志目录下的 cdr-csv/)、mod_xml_cdr(可以 POST 到你的 HTTP 接口)等模块;也可以像示例那样在 CHANNEL_HANGUP_COMPLETE 事件里自己写库。建议记录的字段:

字段 取值位置(事件头 / 通道变量) 用途
UUID Unique-ID 会话主键,关联对话日志
SIP Call-ID variable_sip_call_id 与运营商对账、抓包
主叫 / 被叫 Caller-Caller-ID-Number / Caller-Destination-Number 统计与回拨
方向 Call-Direction 呼入 / 呼出
开始 / 接听 / 结束时间 variable_start_stamp / answer_stamp / end_stamp 时长计算
通话时长 / 计费时长 variable_duration / variable_billsec 计费
挂断原因 Hangup-Cause、variable_sip_term_status 分析失败原因(如 USER_BUSY、NO_ANSWER)
谁先挂 variable_sip_hangup_disposition 用户挂断还是系统挂断
编码 variable_read_codec 排查音质
录音路径 自定义变量 质检回放
业务字段 自定义变量,如意图、是否转人工、坐席 ID、对话轮数 运营分析

7. 总结与下一步

回顾一下整条链路:SIP 管"打电话",RTP 管"传声音";dialplan 决定电话去哪;ESL 让程序遥控电话;mod_audio_fork 把声音用 WebSocket 交给 voice-robot;voice-robot 用 ASR→LLM→TTS 回答,必要时打断或转人工;挂断后用 CDR 收尾。

对 voice-robot 来说,接入电话的改动其实很小:原来浏览器推上来的 WebSocket 音频,现在换成由 FreeSWITCH 推上来;多出来的只是采样率处理和一个用于转人工、挂断的控制通道。

建议的学习路线:

  1. 装好 FreeSWITCH,分机互拨 :1000 拨 1001,用 sofia status profile internal reg、show channels 观察,开 siptrace 看一遍 INVITE/200 OK/BYE;
  2. 读懂 dialplan :自己写 9999 的规则,用 playback、echo 练手;
  3. 用 telnet 手动走 ESL :auth → event plain ALL → 拨个电话看事件流,理解 UUID 和通道变量;
  4. 跑通 Python 控制服务 :originate、uuid_broadcast、uuid_break、uuid_transfer;
  5. 编译安装 mod_audio_fork,先写一个把收到的音频存成 wav 的 WebSocket 服务,确认音频正常;
  6. 接上 voice-robot:先做一问一答,再做流式、打断、转人工;
  7. 接运营商中继:配置 gateway,处理 NAT,做好安全加固,再上线真实号码;
  8. 进阶:mod_callcenter 排队转人工、多台 FS 的负载均衡(Kamailio/OpenSIPS 做前置)、通话质量监控(MOS、丢包、抖动)。

电话系统看起来古老,但把 SIP/RTP、dialplan、ESL 这三件事搞懂,你会发现它和后端开发的思路是相通的:路由、事件、长连接、状态管理。剩下的,就是让 AI 在电话那头说得更快、更像人。

相关推荐
桃西西呀2 小时前
我把网关限流配置成每秒 100 请求,大促一来照样被打挂,还把别人也拖垮了
人工智能·llm·ai编程
武子康2 小时前
Agent 偏好记忆怎样确认写入:范围、版本与撤销的一个最小例
人工智能·后端·agent
Bazingga3 小时前
Java16 record 完整拆解——语法、语义、限制、反射
java·后端
对象存储与RustFS3 小时前
网盘限速逼你开会员?用 RustFS 自己搭一个不限速的“私人网盘
服务器·后端·docker
Thneonl3 小时前
你写的 NetworkPolicy 可能一条都没生效
后端·架构
王中阳Go3 小时前
质保期第 90 天我翻了一遍日志目录:交付后真正会坏的 3 处
人工智能·后端·agent
卷无止境3 小时前
一人公司的全栈选型指南:让 AI Agent 写得快,也让生意跑得稳
后端·python
桃西西呀3 小时前
我把整本接口文档塞进 system prompt,前 3 轮答得准,第 8 轮开始胡说,账单还翻了 4 倍
人工智能·llm·ai编程
天天代码码天天3 小时前
给孩子打印一本“数字连点画”!连出小鱼、火箭,画完还能涂色
人工智能