基于FreeSWITCH与大模型的智能客服系统:架构设计与AI辅助开发实战
背景痛点:传统智能客服的困境与AI带来的曙光
在接触智能客服项目之前,我对传统IVR(交互式语音应答)系统的印象还停留在“按1转人工,按2查询余额”的机械式交互上。这类系统最大的痛点在于对话逻辑完全基于预设的树状结构,一旦用户的问题稍微偏离预定路径,系统就会陷入“抱歉,我没听懂”的循环,最终只能依赖人工坐席。
更具体的问题包括:
- 意图识别僵化:传统系统依赖关键词匹配或简单的NLU(自然语言理解)模型,只能处理有限、固定的句式。当用户说“我想查一下上个月的话费”和“帮我看看七月花了多少钱”时,系统可能无法识别这是同一个意图。
- 多轮对话状态维护困难:实现一个需要多次信息确认的业务(如修改套餐),需要开发者手动设计复杂的对话状态机(DSM),代码臃肿且难以维护。
- 上下文感知缺失:用户中途切换话题或进行指代(如“这个服务”指代前文提到的某项业务),传统系统无法关联历史对话,体验割裂。
- 开发与运维成本高:语音识别(ASR)、语音合成(TTS)、对话逻辑需要分别对接不同服务,集成复杂,问题排查链路长。
而大语言模型(LLM)的出现,让我们看到了解决这些问题的全新路径。它强大的上下文理解和生成能力,恰好可以弥补传统规则系统的不足。我的目标就是构建一个以FreeSWITCH为通信核心,以LLM为“大脑”的新一代智能客服系统。
为什么选择FreeSWITCH?架构选型深度对比
在开源通信领域,Asterisk和Kamailio是另外两个常见选择。经过详细的技术调研,我们最终选择了FreeSWITCH,主要基于以下几点核心优势:
- 原生的高性能WebRTC支持(mod_verto):这是决定性因素。我们的系统需要支持网页一键呼叫,免插件接入。FreeSWITCH的
mod_verto模块提供了高效的WebRTC信令和媒体处理能力,与SIP协议栈深度集成,性能损耗远低于通过网关转接的方案。 - 统一、清晰的内部架构:FreeSWITCH内部使用
sofia作为SIP协议栈,媒体处理通过核心的switch_core统一调度,模块化设计清晰。相比之下,Asterisk的模块间耦合度更高,在处理复杂媒体流时调试更困难。 - 强大的脚本与控制接口:通过Event Socket(ESL)或mod_lua,我们可以用Python、Lua等语言从外部精细控制每一通呼叫的流程,这为我们集成AI逻辑提供了极大的灵活性。
- 活跃的社区与商业支持:FreeSWITCH拥有非常活跃的社区和成熟的商业公司支持,遇到生产环境下的棘手问题时,更容易找到解决方案或支持。
下图展示了我们基于FreeSWITCH构建的系统核心通信链路: 
核心模块设计与实现
1. WebRTC网关与SIP协议栈的深度集成
我们的客户端通过WebRTC直接连接FreeSWITCH的mod_verto。关键在于配置verto.conf.xml和sofia的profile,确保媒体流能正确路由。
关键配置点:
- 在
verto.conf.xml中,需要正确配置bind-local的地址和端口,并开启secure-media和secure-signaling以支持WSS和SRTP。 - 对应的SIP profile(例如
internal)需要配置相同的RTP端口范围,并开启rtp-autoflush和rtp-timeout-sec以优化媒体流释放。
外部控制逻辑(Python ESL示例): 我们启动一个Python守护进程,通过ESL监听CHANNEL_ANSWER、CHANNEL_HANGUP等事件,并在通话建立后,启动AI处理协程。
import ESL
def esl_event_handler(event):
event_name = event.getHeader('Event-Name')
uuid = event.getHeader('Unique-ID')
if event_name == 'CHANNEL_ANSWER':
# 通话应答,启动AI处理协程
asyncio.create_task(handle_ai_call(uuid))
elif event_name == 'CHANNEL_HANGUP':
# 通话挂断,清理资源
cleanup_call_resources(uuid)
# 连接FreeSWITCH ESL
con = ESL.ESLconnection('localhost', '8021', 'ClueCon')
if con.connected():
con.events('plain', 'ALL')
while True:
e = con.recvEvent()
if e:
esl_event_handler(e)
else:
print("无法连接到FreeSWITCH ESL")
# 在实际生产中,这里应加入重试和告警逻辑
2. 语音流实时分块与ASR接口设计
为了降低LLM处理的延迟,我们采用“流式ASR + 增量处理”的策略。FreeSWITCH可以通过mod_av或mod_media_bug将双向音频流实时转发给我们的处理服务。
实现步骤:
-
挂载Media Bug:在通话应答后,通过ESL命令
uuid_audio给频道挂载一个media bug,将音频流发送到指定的TCP/WebSocket服务端。# 通过ESL发送命令 cmd = f'uuid_audio {channel_uuid} start start tcp://127.0.0.1:8084' con.api(cmd) -
音频分块与缓冲:服务端接收原始的PCM或G.711音频流。我们设置一个约200ms的缓冲区(具体时长需根据ASR模型调整),当缓冲区满或检测到静音(VAD)时,将音频块发送给流式ASR服务(如阿里云、腾讯云或开源的Vosk)。
import asyncio import websockets from collections import deque class AudioBuffer: def __init__(self, chunk_duration_ms=200, sample_rate=8000): self.buffer = deque() self.chunk_size = int(sample_rate * chunk_duration_ms / 1000) * 2 # 16bit PCM self.sample_rate = sample_rate def add_packet(self, audio_data: bytes): self.buffer.append(audio_data) # 简单累加判断,实际生产环境应用更精确的计时 if sum(len(d) for d in self.buffer) >= self.chunk_size: chunk = b''.join(self.buffer) self.buffer.clear() return chunk return None async def handle_audio_stream(websocket, path): buffer = AudioBuffer() async for message in websocket: # message 为从FreeSWITCH接收的音频包 chunk = buffer.add_packet(message) if chunk: # 将chunk发送给ASR服务,获取实时文本 asr_text = await call_asr_service(chunk) if asr_text and asr_text.strip(): # 将文本放入LLM处理队列 await llm_input_queue.put((channel_uuid, asr_text)) -
异常处理:网络抖动可能导致音频包乱序或丢失。我们在ASR客户端实现了简单的重试和超时机制,并在连续多次失败后,向用户播放“网络不稳定”的提示音,并尝试重建媒体流。
3. 大模型对话状态机实现
这是系统的“大脑”。我们不希望每次都将完整的对话历史发送给LLM(成本高且可能超长),而是实现了一个带压缩功能的对话状态机。
核心数据结构:
class DialogueState:
def __init__(self, call_id: str):
self.call_id = call_id
self.history = [] # 存储原始对话轮次 [(‘user‘, ‘text‘), (‘ai‘, ‘text‘)]
self.compressed_summary = ““ # 压缩后的历史摘要
self.context = { # 业务上下文,如订单号、用户身份等
‘intent‘: None,
‘slots‘: {},
‘step‘: ‘greeting‘
}
def add_interaction(self, speaker: str, text: str):
"""添加一次交互,并触发压缩判断"""
self.history.append((speaker, text))
if len(self.history) > 6: # 历史超过3轮(一问一答为一轮)则触发压缩
self._compress_history()
def _compress_history(self):
"""调用LLM对早期历史进行摘要压缩"""
# 构建压缩提示词
prompt = f“““请将以下对话压缩成一段简洁的摘要,保留关键事实和用户意图:
{‘\n‘.join([f‘{s}: {t}‘ for s, t in self.history[:-4]])}
摘要:”“”
# 调用LLM的摘要接口(使用较小、较快的模型)
summary = call_llm_summarize(prompt)
self.compressed_summary = summary
# 保留最近两轮原始对话
self.history = self.history[-4:]
def get_prompt_for_llm(self, current_user_input: str) -> str:
"""生成发送给主LLM的完整提示词"""
full_context = f“““
你是智能客服助理。以下是本次对话的摘要背景:{self.compressed_summary}
最近的对话历史:
{‘\n‘.join([f‘{s}: {t}‘ for s, t in self.history])}
当前用户说:{current_user_input}
请根据以上信息,进行友好、专业的回复。如果需要询问更多信息来澄清,请直接提问。
回复时不要提及‘摘要背景‘等词。
”“”
return full_context
工作流程:
- 收到ASR转换后的用户文本。
- 根据
call_id找到对应的DialogueState对象,调用add_interaction(‘user‘, text)。 - 调用
state.get_prompt_for_llm(text)生成提示词,发送给主LLM(如GPT-4, Claude, 或微调后的开源模型)。 - 收到LLM回复后,调用
add_interaction(‘ai‘, response)。 - 将回复文本通过TTS转换后,经由FreeSWITCH播放给用户。
性能优化实战
1. 网络抖动与语音延迟:JitterBuffer的魔法
在IP语音通信中,网络抖动(Jitter)是影响音质的主要元凶。FreeSWITCH的JitterBuffer可以缓存一定量的语音包,平滑播放,但配置不当会增加延迟。
关键配置(在SIP profile或全局vars.xml中):
<!-- 设置自适应抖动缓冲区,最小延迟20ms,最大120ms -->
<param name="jitterbuffer-len" value="20-120"/>
<!-- 启用丢包隐藏(PLC),在网络丢包时尝试修复音质 -->
<param name="jitterbuffer-plc" value="true"/>
我们的调优经验:
- 内网环境:可以将最大值设小(如60ms),降低延迟。
- 公网环境:建议使用
20-120或40-200,在延迟和抗抖动之间取得平衡。 - 通过
fs_cli运行show calls命令,观察jitter和delay值,作为调优依据。
2. 成本控制:基于Token计费的模型调用策略
直接使用GPT-4等商用API,成本是必须考虑的问题。我们设计了分级调用策略:
- 意图路由层:首先用一个轻量级、低成本的文本分类模型(或基于Embedding的相似度匹配)判断用户意图。如果是“查询天气”、“讲个笑话”等通用意图,路由到成本较低的通用对话模型(如较小的开源模型)。
- 业务处理层:如果识别为“办理业务”、“投诉建议”等关键意图,则路由到高能力的付费模型(如GPT-4)。
- 缓存与复用:对于常见问题(如“营业时间”),将LLM的回复结果缓存起来,下次直接命中缓存,避免重复调用。
- Token预算监控:为每个会话设置Token预算,当消耗接近阈值时,主动引导用户转向人工服务或简化回复。
避坑指南:那些我们踩过的“坑”
1. DTMF信号与语音流的冲突
在用户输入银行卡号、密码时,DTMF(电话按键音)是重要输入方式。但FreeSWITCH中,DTMF可以通过RFC2833(带内)或SIP INFO(带外)多种方式传递,容易与语音流混淆,导致ASR误识别为数字读音。
解决方案:
- 在Dialplan中,明确设置DTMF模式为
info或rfc2833,并统一选择一种。 - 在ASR处理前,增加一个DTMF检测过滤器。如果检测到有效的DTMF事件,则将该时间段内的音频流丢弃,不发送给ASR,并直接记录按键值到对话上下文中。
- 在播放“请输入密码”等提示音后,临时调高DTMF检测的灵敏度,并短暂关闭ASR。
2. 模型服务冷启动预热
我们的LLM服务部署在Kubernetes上,支持自动扩缩容。当突发流量到来,新Pod启动时,加载大型模型(如7B参数模型)可能需要30秒以上,导致呼叫排队超时。
预热方案:
- 水平预热:部署一个独立的“预热服务”。该服务监控HPA(水平Pod自动扩缩器)的决策,在新Pod创建前,即通过Service Account请求一个虚拟预测,触发模型加载。
- 垂直预热:在Pod的
readinessProbe中,不仅检查端口是否就绪,还添加一个检查端点,该端点执行一次极小的模型推理(如对“你好”生成回复),成功后才标记Pod就绪,接入流量。 - 缓存预热:将通用的系统提示词(System Prompt)在服务启动时即完成Token化并缓存,减少第一次请求的处理时间。
安全考量:通信与数据的双重防护
1. SRTP加密实现
为防止语音流被窃听,我们强制启用SRTP(安全实时传输协议)。
FreeSWITCH配置:
<!-- 在SIP profile中 -->
<param name="tls" value="true"/>
<param name="tls-bind-params" value="transport=tls"/>
<param name="tls-cert-dir" value="/path/to/certs"/>
<param name="srtp-crypto-suite" value="AES_CM_128_HMAC_SHA1_80"/> <!-- 加密套件 -->
<param name="srtp-global-crypto-suite" value="AES_CM_128_HMAC_SHA1_80"/>
<param name="srtp-secure-media" value="true"/> <!-- 强制SRTP -->
WebRTC(Verto)客户端:在连接时,必须使用wss://协议,并在SDP协商中支持SRTP。
2. PCI DSS合规要点
如果客服系统涉及支付,需要关注PCI DSS(支付卡行业数据安全标准)。我们的做法是:
- 隔离:将处理支付信息的DTMF输入流程独立到一个专用的、逻辑隔离的FreeSWITCH context中,该context的录音、日志单独存储。
- 不存储:绝对不在日志、录音或数据库中完整记录卡号、CVV等敏感信息。DTMF按键音在内存中组合成完整信息后,立即通过加密通道提交给支付网关,随后在内存中清除。
- 审计:所有对支付相关Dialplan和ESL接口的访问,都有完整的、不可篡改的审计日志。
总结与开放性问题
经过几个月的开发和迭代,这套基于FreeSWITCH与大模型的智能客服系统已经稳定服务了我们的线上业务。它显著提升了应对复杂问题的能力,用户满意度调查中“问题一次性解决率”有了大幅提高。
然而,一个核心的矛盾始终存在:如何平衡大模型推理延迟与对话流畅性?
LLM生成文本需要时间,尤其是长回复。虽然流式TTS可以边生成边播放,但用户说完到AI开始回复之间的“思考时间”(First Word Latency)如果超过1.5秒,就会产生明显的对话卡顿感。
我们目前的策略是:
- 快速确认:在LLM开始生成时,先播放一个简短的“嗯”、“好的”等语气词,让用户知道系统已接收。
- 分句流式TTS:与LLM的流式输出结合,不是等整段话生成完再TTS,而是每生成一个完整句子就立即送入TTS引擎,缩短首句延迟。
- 本地化小模型:对于非常常见的问答对,训练一个极小的、能在100ms内响应的本地模型作为“快速响应通道”。
但这还不够。未来,我们正在探索“预测性生成”和“延迟补偿”等更前沿的方向。例如,能否在用户说话接近尾声时,模型就开始预测可能的回复开头?或者在网络延迟不可避免时,通过调整播放语速来微妙地“追赶”时间?这些都是非常有趣且具有挑战性的工程问题。
技术的道路没有尽头,每一次对延迟的优化,都是对更好用户体验的追求。希望我们在这条路上的实践和思考,能给你带来一些启发。
更多推荐
所有评论(0)