背景痛点:传统智能客服的困境与AI带来的曙光

在接触智能客服项目之前,我对传统IVR(交互式语音应答)系统的印象还停留在“按1转人工,按2查询余额”的机械式交互上。这类系统最大的痛点在于对话逻辑完全基于预设的树状结构,一旦用户的问题稍微偏离预定路径,系统就会陷入“抱歉,我没听懂”的循环,最终只能依赖人工坐席。

更具体的问题包括:

  1. 意图识别僵化:传统系统依赖关键词匹配或简单的NLU(自然语言理解)模型,只能处理有限、固定的句式。当用户说“我想查一下上个月的话费”和“帮我看看七月花了多少钱”时,系统可能无法识别这是同一个意图。
  2. 多轮对话状态维护困难:实现一个需要多次信息确认的业务(如修改套餐),需要开发者手动设计复杂的对话状态机(DSM),代码臃肿且难以维护。
  3. 上下文感知缺失:用户中途切换话题或进行指代(如“这个服务”指代前文提到的某项业务),传统系统无法关联历史对话,体验割裂。
  4. 开发与运维成本高:语音识别(ASR)、语音合成(TTS)、对话逻辑需要分别对接不同服务,集成复杂,问题排查链路长。

而大语言模型(LLM)的出现,让我们看到了解决这些问题的全新路径。它强大的上下文理解和生成能力,恰好可以弥补传统规则系统的不足。我的目标就是构建一个以FreeSWITCH为通信核心,以LLM为“大脑”的新一代智能客服系统。

为什么选择FreeSWITCH?架构选型深度对比

在开源通信领域,Asterisk和Kamailio是另外两个常见选择。经过详细的技术调研,我们最终选择了FreeSWITCH,主要基于以下几点核心优势:

  1. 原生的高性能WebRTC支持(mod_verto):这是决定性因素。我们的系统需要支持网页一键呼叫,免插件接入。FreeSWITCH的mod_verto模块提供了高效的WebRTC信令和媒体处理能力,与SIP协议栈深度集成,性能损耗远低于通过网关转接的方案。
  2. 统一、清晰的内部架构:FreeSWITCH内部使用sofia作为SIP协议栈,媒体处理通过核心的switch_core统一调度,模块化设计清晰。相比之下,Asterisk的模块间耦合度更高,在处理复杂媒体流时调试更困难。
  3. 强大的脚本与控制接口:通过Event Socket(ESL)或mod_lua,我们可以用Python、Lua等语言从外部精细控制每一通呼叫的流程,这为我们集成AI逻辑提供了极大的灵活性。
  4. 活跃的社区与商业支持:FreeSWITCH拥有非常活跃的社区和成熟的商业公司支持,遇到生产环境下的棘手问题时,更容易找到解决方案或支持。

下图展示了我们基于FreeSWITCH构建的系统核心通信链路: https://i-operation.csdnimg.cn/images/506657cbf1a449dba4bd12ff99f00c22.jpeg

核心模块设计与实现

1. WebRTC网关与SIP协议栈的深度集成

我们的客户端通过WebRTC直接连接FreeSWITCH的mod_verto。关键在于配置verto.conf.xmlsofia的profile,确保媒体流能正确路由。

关键配置点

  • verto.conf.xml中,需要正确配置bind-local的地址和端口,并开启secure-mediasecure-signaling以支持WSS和SRTP。
  • 对应的SIP profile(例如internal)需要配置相同的RTP端口范围,并开启rtp-autoflushrtp-timeout-sec以优化媒体流释放。

外部控制逻辑(Python ESL示例): 我们启动一个Python守护进程,通过ESL监听CHANNEL_ANSWERCHANNEL_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_avmod_media_bug将双向音频流实时转发给我们的处理服务。

实现步骤

  1. 挂载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)
    
  2. 音频分块与缓冲:服务端接收原始的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))
    
  3. 异常处理:网络抖动可能导致音频包乱序或丢失。我们在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

工作流程

  1. 收到ASR转换后的用户文本。
  2. 根据call_id找到对应的DialogueState对象,调用add_interaction(‘user‘, text)
  3. 调用state.get_prompt_for_llm(text)生成提示词,发送给主LLM(如GPT-4, Claude, 或微调后的开源模型)。
  4. 收到LLM回复后,调用add_interaction(‘ai‘, response)
  5. 将回复文本通过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-12040-200,在延迟和抗抖动之间取得平衡。
  • 通过fs_cli运行show calls命令,观察jitterdelay值,作为调优依据。

2. 成本控制:基于Token计费的模型调用策略

直接使用GPT-4等商用API,成本是必须考虑的问题。我们设计了分级调用策略:

  1. 意图路由层:首先用一个轻量级、低成本的文本分类模型(或基于Embedding的相似度匹配)判断用户意图。如果是“查询天气”、“讲个笑话”等通用意图,路由到成本较低的通用对话模型(如较小的开源模型)。
  2. 业务处理层:如果识别为“办理业务”、“投诉建议”等关键意图,则路由到高能力的付费模型(如GPT-4)。
  3. 缓存与复用:对于常见问题(如“营业时间”),将LLM的回复结果缓存起来,下次直接命中缓存,避免重复调用。
  4. Token预算监控:为每个会话设置Token预算,当消耗接近阈值时,主动引导用户转向人工服务或简化回复。

避坑指南:那些我们踩过的“坑”

1. DTMF信号与语音流的冲突

在用户输入银行卡号、密码时,DTMF(电话按键音)是重要输入方式。但FreeSWITCH中,DTMF可以通过RFC2833(带内)或SIP INFO(带外)多种方式传递,容易与语音流混淆,导致ASR误识别为数字读音。

解决方案

  • 在Dialplan中,明确设置DTMF模式为inforfc2833,并统一选择一种。
  • 在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内响应的本地模型作为“快速响应通道”。

但这还不够。未来,我们正在探索“预测性生成”和“延迟补偿”等更前沿的方向。例如,能否在用户说话接近尾声时,模型就开始预测可能的回复开头?或者在网络延迟不可避免时,通过调整播放语速来微妙地“追赶”时间?这些都是非常有趣且具有挑战性的工程问题。

技术的道路没有尽头,每一次对延迟的优化,都是对更好用户体验的追求。希望我们在这条路上的实践和思考,能给你带来一些启发。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐