最近在帮公司做客服系统智能化升级,遇到了一个挺有意思的技术挑战:怎么让现有的FreeSWITCH电话系统和智能客服AI顺畅地“对话”。特别是,我们希望能让用户直接拨打某个分机号,就能和AI客服聊起来,而不是非得通过复杂的IVR菜单或者转接人工。折腾了一阵子,总算摸出了一套比较高效的集成方案,今天就来分享一下实战心得。

智能客服系统对接示意图

1. 背景与痛点:为什么传统方式“跑不动”了?

我们之前的客服系统,用户打电话进来,先听一段长长的语音导航(“业务咨询请按1,技术支持请按2…”),然后排队等人工。高峰期排队时间长,用户体验差,客服同事压力也大。引入AI客服的初衷很简单:让简单、重复的问题由AI先处理,解放人力。

但真动手了才发现,把AI“塞进”电话系统里,没那么简单:

  1. 呼叫路由复杂:怎么让用户拨一个特定的分机号(比如8888)就直接进入AI对话流程,而不是走原来的IVR?这需要深度定制FreeSWITCH的拨号计划(Dialplan)。
  2. 语音处理延迟:电话是实时语音流(RTP),而AI接口通常是HTTP/WebSocket请求。语音要先转成文本(ASR),AI处理完文本,再转回语音(TTS)播报。这个“转来转去”的过程,如果处理不好,用户会感觉AI反应“慢半拍”,对话很不自然。
  3. 对话状态维护困难:AI对话往往不是一问一答就结束,需要多轮。比如用户问“我的订单”,AI需要反问“请问订单号是多少?”。如何在FreeSWITCH的通话上下文中,记住当前对话到了哪一步,并且把历史信息带给AI,是个技术难点。
  4. 资源与性能:AI服务调用通常有延迟,如果同时很多路通话进来,FreeSWITCH的媒体线程会不会被阻塞?如何管理并发请求,避免系统被拖垮?

2. 技术选型:为什么是FreeSWITCH + mod_dptools + Lua?

面对这些痛点,我们评估了几种方案:

  • 方案A:直接API调用:在FreeSWITCH外部写一个服务,通过ESL(Event Socket Library)监听通话事件,然后控制FreeSWITCH。这种方式灵活,但架构复杂,延迟高(多了一次网络通信),开发调试也麻烦。
  • 方案B:使用专用中间件:有些商业的CPaaS平台或智能语音中间件提供对接方案。好处是省心,但往往黑盒、定制性差、成本高,且可能形成厂商锁定。
  • 方案C:FreeSWITCH内置模块+Lua脚本:利用FreeSWITCH自带的mod_dptoolsmod_lua,直接在拨号计划里用Lua脚本处理呼叫逻辑和AI交互。

我们最终选择了方案C,主要基于以下几点考虑:

  1. 高效与低延迟:Lua脚本在FreeSWITCH进程内执行,直接操作通话和媒体流,省去了进程间或网络间通信的开销,延迟最低。
  2. 灵活与可控:拨号计划和Lua脚本完全由我们掌控,可以精细地控制呼叫的每一个环节,定制AI对话逻辑、错误处理、超时重试等。
  3. 成熟与稳定mod_dptools是FreeSWITCH的核心模块,提供了play_and_get_digitsspeakrecord等丰富的语音处理函数,与Lua结合能轻松实现“播放语音-收号-录音-转发”的完整流程。
  4. 社区与生态:FreeSWITCH和Lua都有活跃的社区,遇到问题比较容易找到资料和解决方案。

简单说,这个方案就像在FreeSWITCH这个强大的“电话交换机”内部,安装了一个轻量、敏捷的“AI对话机器人”,沟通效率最高。

3. 核心实现:一步步搭建AI对话分机

3.1 FreeSWITCH呼叫路由配置

首先,我们需要在FreeSWITCH的拨号计划(通常是conf/dialplan/default.xml)中,为AI客服分机(比如8888)设置一个路由。

<extension name="ai_customer_service">
  <condition field="destination_number" expression="^8888$">
    <action application="lua" data="ai_customer_service.lua"/>
  </condition>
</extension>

这段配置的意思是:当有呼叫的目的地号码是8888时,就执行ai_customer_service.lua这个脚本。所有复杂的AI交互逻辑,都将在这个Lua脚本里完成。

3.2 Lua脚本实现AI接口调用

这是最核心的部分。脚本需要处理:接听电话、播放欢迎语、启动语音识别、调用AI接口、播放AI回复,并循环这个过程。

-- ai_customer_service.lua
session = require “freeswitch.Session” — 引入session对象
api = freeswitch.API() — 引入API对象

— 1. 获取当前通话会话
local ses = session:new()
if not ses:ready() then return end

— 2. 播放欢迎语
ses:answer()
ses:streamFile(“ivr/ai_welcome.wav”) — 播放预录制的欢迎语音,如“您好,我是AI客服,请说出您的问题”

— 3. 进入主对话循环
local ai_service_url = “https://your-ai-service.com/v1/chat”
local api_key = “your-secret-api-key”
local conversation_id = ses:get_uuid() — 使用通话UUID作为本次对话的会话ID,用于保持上下文

while ses:ready() do
    — 4. 录音并识别为文本 (ASR)
    — 这里使用mod_dptools的`record`和外部ASR服务,示例使用一个假设的`asr_transcribe`函数
    local audio_file_path = “/tmp/” .. conversation_id .. “_input.wav”
    ses:execute(“record”, audio_file_path .. “ 5 200”) — 录音5秒,静音200毫秒后停止
    
    local user_speech_text = asr_transcribe(audio_file_path) — 调用ASR服务将录音文件转文本
    os.remove(audio_file_path) — 清理临时文件
    
    if not user_speech_text or user_speech_text == “” then
        ses:streamFile(“ivr/no_input.wav”)
        break — 如果没识别到内容,播放提示音并退出
    end
    
    — 5. 构造请求,调用AI接口
    local http = require “socket.http”
    local ltn12 = require “ltn12”
    local json = require “cjson”
    
    local request_body = json.encode({
        session_id = conversation_id,
        query = user_speech_text,
        — 可以附带更多上下文,比如用户基本信息(从数据库或CRM获取)
    })
    
    local response_body = {}
    local headers = {
        [“Content-Type”] = “application/json”,
        [“Authorization”] = “Bearer ” .. api_key,
        [“Content-Length”] = tostring(#request_body)
    }
    
    local res, code, response_headers = http.request{
        url = ai_service_url,
        method = “POST”,
        headers = headers,
        source = ltn12.source.string(request_body),
        sink = ltn12.sink.table(response_body),
        — 设置超时非常重要,避免通话线程被长时间阻塞
        create = function()
            local req_sock = socket.tcp()
            req_sock:settimeout(5) — 设置5秒超时
            return req_sock
        end
    }
    
    — 6. 处理AI响应
    if code == 200 then
        local response = json.decode(table.concat(response_body))
        local ai_reply_text = response.reply
        
        — 7. 文本转语音并播放 (TTS)
        local tts_audio_file = tts_synthesize(ai_reply_text, conversation_id) — 调用TTS服务
        ses:streamFile(tts_audio_file)
        os.remove(tts_audio_file) — 播放后清理
        
        — 检查AI是否建议转人工或结束对话
        if response.action == “transfer_to_human” then
            ses:execute(“transfer”, “8888 XML default”) — 转接到人工坐席分机
            break
        elseif response.action == “end_call” then
            ses:streamFile(“ivr/goodbye.wav”)
            break
        end
        — 否则,循环继续,等待用户下一句语音
    else
        — AI服务调用失败,播放错误提示并转人工或挂机
        freeswitch.consoleLog(“ERR”, “AI API call failed: ” .. tostring(code) .. “\n”)
        ses:streamFile(“ivr/error.wav”)
        ses:execute(“transfer”, “8888 XML default”)
        break
    end
end

— 8. 挂断通话
ses:hangup()

脚本关键点解析:

  • 会话管理session:new()获取当前通话对象,ses:ready()判断通话是否仍在进行。
  • ASR/TTS集成:示例中的asr_transcribetts_synthesize是伪函数,你需要替换成调用真实ASR(如阿里云、腾讯云语音识别)和TTS服务的代码。通常是通过HTTP API发送音频文件或文本,接收文本或音频文件。
  • 超时处理:在HTTP请求中设置sock:settimeout(5)至关重要,防止网络问题导致FreeSWITCH媒体线程无限期挂起。
  • 上下文保持:我们将每次通话的UUID作为session_id传递给AI服务,AI服务端可以利用这个ID来存储和检索本次对话的历史,从而实现多轮对话的连贯性。
  • 错误处理与降级:对ASR失败、AI接口调用失败等情况都有处理,例如播放提示音或转接人工,保证服务的鲁棒性。
  • 资源清理:及时删除本地临时录音文件和TTS音频文件,避免磁盘空间被占满。
3.3 对话状态管理与上下文保持

多轮对话的关键在于“记住”之前说过什么。我们的方案是:

  1. 客户端(Lua脚本)传递会话ID:如上所述,将conversation_id(即通话UUID)随每次请求发送给AI服务。
  2. 服务端(AI客服)维护对话状态:AI服务在内存或Redis等缓存中,以session_id为key,存储一个对话上下文对象。这个对象通常包含:
    • 历史对话记录(Q-A对)
    • 用户已提供的槽位信息(例如订单号、姓名、问题类型等)
    • 当前对话所处的节点或意图
  3. 上下文传递:Lua脚本除了发送用户当前语句,还可以从外部系统(如CRM)查询用户信息,一并作为上下文传给AI,使AI的回答更个性化。

4. 性能优化:让AI对话更流畅

当并发量上来后,一些优化措施必不可少:

  1. 连接池与请求批处理(针对ASR/TTS)

    • 频繁地创建和销毁HTTP连接开销很大。可以为ASR和TTS服务维护一个HTTP连接池(在Lua中可以用keepalive池)。
    • 如果AI服务支持,可以考虑将短语音打包进行批量识别,但实时对话中通常不适用。
  2. 语音流处理的延迟优化

    • 流式ASR:如果ASR服务支持流式识别(如WebSocket发送音频流),可以在用户说话的同时就上传音频并接收部分识别结果,能大幅减少“等待录音结束-上传-识别”的总时间。
    • TTS预加载与缓存:对于一些固定的、常用的回复语(如“请问您还有什么问题吗?”),可以提前合成好音频文件并缓存起来,直接播放文件,省去实时调用的时间。
  3. 高并发下的资源管理

    • 限制并发AI调用:在Lua脚本中引入简单的信号量机制,或通过外部计数器,限制同时向AI服务发起的请求数,防止AI服务被压垮。
    • 监控与告警:监控FreeSWITCH的CPU、内存、通话通道数,以及AI接口的响应时间和错误率。设置阈值告警。

5. 避坑指南

  1. 常见配置错误

    • 拨号计划不生效:检查dialplan/default.xml是否被正确加载,表达式^8888$是否正确。
    • Lua脚本权限问题:确保FreeSWITCH运行用户有权限读取和执行Lua脚本文件。
    • 音频格式问题:ASR和TTS服务对音频格式(编码、采样率、位深)有要求。确保FreeSWITCH录音(record)和播放(streamFile)的格式与之匹配,必要时使用session:execute(“playback”, …)session:execute(“record”, …)时指定格式参数。
  2. 语音质量优化

    • 调整record函数的静音检测参数,使其更符合实际环境,避免过早切断用户说话或长时间录音。
    • 在播放TTS音频前,可以适当插入极短的静音(session:sleep(50)),让语音切换更自然。
    • 选择高质量的TTS引擎,并调整语速、语调,使其更接近真人。
  3. 安全防护

    • API密钥保护:不要将AI服务的API密钥硬编码在Lua脚本中。可以通过FreeSWITCH的db(如SQLite)或外部配置服务动态获取。
    • 输入验证:虽然主要输入是语音转文本,但如果AI接口返回的内容包含可执行指令(极少见),需要做过滤。
    • 限流:在FreeSWITCH入口或AI服务前端,对来自FreeSWITCH的请求进行速率限制,防止恶意调用。

6. 总结与展望

通过FreeSWITCH + mod_dptools + Lua的方案,我们成功地将智能客服AI无缝集成到了电话系统中,实现了“分机直通AI”的高效场景。这套方案的核心优势在于低延迟、高可控、易集成,特别适合对实时性要求高、需要深度定制的企业级应用。

技术架构流程图

一个简单的呼叫流程时序图:

sequenceDiagram
    participant U as 用户
    participant F as FreeSWITCH
    participant L as Lua脚本
    participant AI as AI服务(含ASR/TTS)
    U->>F: 拨打分机 8888
    F->>L: 触发Lua脚本
    L->>F: 接听并播放欢迎语
    loop 多轮对话
        U->>F: 说话
        F->>L: 录音
        L->>AI: 发送音频进行ASR
        AI-->>L: 返回识别文本
        L->>AI: 发送文本请求对话
        AI-->>L: 返回回复文本
        L->>AI: 发送文本请求TTS
        AI-->>L: 返回音频文件
        L->>F: 播放TTS音频
        F->>U: 听到AI回复
    end
    L->>F: 挂断或转接

当然,这只是个起点。在此基础上,还可以探索更多高级功能:

  • 情感识别与应对:在ASR后加入情感分析,让AI能感知用户情绪(焦急、不满),并调整回复策略。
  • 实时语音打断(Barge-in):允许用户在AI说话时直接插话,提升交互自然度。这需要更复杂的媒体控制和事件处理。
  • 与业务系统深度集成:AI在对话中可以直接查询订单状态、生成工单,甚至完成简单的业务办理(如重置密码),真正成为“业务能手”。

最后留个思考题: 在我们当前的方案中,多轮对话的上下文完全由AI服务端维护。如果我想在FreeSWITCH侧(Lua脚本中)也能感知和控制对话流程(例如,实现一个简单的、状态明确的业务流程,如“收集订单号->验证->查询->播报”),应该如何设计Lua脚本与AI服务之间的交互协议,才能让两者更好地协同工作,既保持AI的语义理解能力,又能实现精准的流程控制呢?

希望这篇笔记能对正在探索FreeSWITCH与智能客服对接的朋友有所帮助。这条路走通了,对于提升客服效率和用户体验,效果是立竿见影的。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐