用 Lua 写 Dialplan 接 LLM:从零搓一个 AI 智能客服

Posted on 二 29 9月 2026 in Tech

Abstract 用 Lua 写 Dialplan 接 LLM:从零搓一个 AI 智能客服
Authors Walter Fan
Category Tech
Version v1.0
Updated 2026-09-29
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 为什么是 Lua:XML dialplan 干不了"调 HTTP + 多轮上下文 + 按结果分支"
  • 两块拼图:Lua 怎么让 FreeSWITCH 说话(TTS)、怎么让它听懂(ASR)
  • 关键一步:LLM 场景要用自由识别(dictation),不是固定语法
  • 完整骨架:一份能读懂的 voice_agent.lua,对话循环全流程
  • LLM 网关:Lua 侧就一个 HTTP POST,复杂逻辑都在网关
  • 四个必须处理的边界:打断、超时、转人工、异常兜底
  • 怎么跑起来:dialplan 挂载 + 调试顺序 + checklist
  • 常见坑:识别结果解析、阻塞、上下文、延迟

架构图画完,分工也讲清了,剩下最实在的一个问题:代码到底怎么写?

先把结论撂这儿:这活儿别用 XML dialplan 硬写,用 Lua。

原因很简单。XML dialplan 擅长的是"匹配号码 → 顺序执行几个动作"这种线性流程。但 AI 客服要干的是:识别一句话 → 拿去调 LLM 的 HTTP 接口 → 根据返回的意图决定是继续问、还是查数据、还是转人工 → 维护整通对话的多轮上下文 → 循环往复。这里面有 HTTP 调用、有状态、有分支、有循环——XML 表达不了,Lua 正合适。

下面给一份能读懂、能照着改的 voice_agent.lua 骨架,连同怎么在 FreeSWITCH 里跑起来。

  • 语音接口主用 mod_unimrcp(接前两篇的 MRCP 主线),换别的 ASR/TTS 模块怎么改也会说
  • LLM 在 Lua 里就是一次 HTTP POST,重活都在独立的 LLM 网关里

一、先搞定两块拼图:说话和听懂

写对话循环之前,得先会两件最基本的事:让 FreeSWITCH 开口(TTS),让它听懂(ASR)。这两块在 Lua 里都靠 mod_unimrcp 桥接到 UniMRCP。

让它说话:speak

TTS 分两步——先设参数(用哪个 profile、哪个嗓音),再合成:

-- 指定 unimrcp profile 和嗓音
session:set_tts_parms("unimrcp:my_tts_profile", "voice_name")
-- 合成并播放,{} 里是传给 MRCP 服务器的参数
session:speak("{speech-language=zh-CN,prosody-rate=medium}您好,请问有什么可以帮您?")

{} 里的参数(语言、语速等)会原样进 MRCP 的 SPEAK 请求。mod_unimrcp 支持纯文本和 SSML,想要更精细的停顿、语气,就传 SSML。

让它听懂:play_and_detect_speech

ASR 用 play_and_detect_speech——放一段提示音的同时开始识别,一步到位:

session:execute("play_and_detect_speech",
  "phrase:请说出您要办理的业务 " ..
  "detect:unimrcp {no-input-timeout=5000,recognition-timeout=15000}" ..
  grammar)
-- 识别结果是一段 XML(NLSML),存在这个变量里
local xml = session:getVariable("detect_speech_result")

{} 里是识别参数:no-input-timeout(等多久没人说话就算超时)、recognition-timeout(一句话最长识别多久)。这些就是 MRCP 那篇里的头字段,现在从 Lua 传进去。

换引擎只改一个词。 上面 detect:unimrcp 里的 unimrcp 是 ASR 模块名。如果你用别的模块,把它换成对应模块名即可,对话逻辑一行都不用动——这正是 MRCP 那层解耦带来的好处。

关键:LLM 场景要用"自由识别",不是固定语法

这一步最容易踩坑。传统 IVR 的 play_and_detect_speech 后面跟的是一份固定语法(grammar)——只认预设的那几个词,这正是那个别扭 IVR 的病根。

但 AI 客服要的是客户随口说什么都能转成文字。所以这里不能用固定语法,要用自由识别 / 听写模式(dictation / free-form,具体名字看你的 ASR 引擎)。识别引擎不再做"匹配预设词",而是老老实实把整句话转成文本,再交给 LLM 去理解意图。

一句话记住:固定语法是"选择题",自由识别是"填空题"。传统 IVR 做选择题,AI 客服做填空题,理解交给 LLM。


二、对话循环长什么样

把上面两块拼起来,一通 AI 客服电话的骨架就是一个循环:

voice_agent.lua 对话循环流程

翻译成大白话:

  1. 接通,放欢迎语。
  2. 循环开始:开麦自由识别,等客户说话。
  3. 没听到(超时)→ 追问一次,重试;重试几次还不行 → 转人工。
  4. 听到了 → 把识别文本 + 会话 ID 发给 LLM 网关。
  5. LLM 网关返回 {回复文本, 动作}。动作可能是:继续对话、转人工、结束通话。
  6. 合成回复文本念给客户。
  7. 按动作决定:回到第 2 步继续,还是转人工/挂断。

注意:所有"聪明"的部分——理解意图、查数据、决定下一步——都在 LLM 网关里。Lua 侧只负责"听→问网关→说→按网关的指示走"。 这种分工让 Lua 脚本保持简单,也让你换模型、改 prompt、加工具时完全不用碰电话侧。


三、完整的 voice_agent.lua 骨架

下面是一份能读懂的完整骨架。它不是复制粘贴就能上生产的成品(每个环境的 profile 名、引擎、网关地址都不同),但逻辑是完整的,关键处都标了注释。

-- voice_agent.lua —— FreeSWITCH AI 智能客服对话循环骨架
-- 由 dialplan 的 <action application="lua" data="voice_agent.lua"/> 调起

-- ============ 配置(按你的环境改)============
local TTS_PROFILE  = "unimrcp:my_tts_profile"   -- UniMRCP TTS profile
local TTS_VOICE    = "my_voice"                  -- 嗓音
local ASR_MOD      = "unimrcp"                   -- ASR 模块名,换引擎改这里
local ASR_GRAMMAR  = "builtin:dictation"         -- 自由识别(占位,按引擎实际写法改)
local LLM_GATEWAY  = "http://127.0.0.1:8080/chat" -- 你自建的 LLM 网关
local NO_INPUT_MS  = 6000
local RECOG_MS     = 15000
local MAX_NOINPUT  = 3    -- 连续没听到几次就转人工
-- ============================================

-- ---- 工具函数 ----

-- 合成并播放一段文本(TTS)
local function say(text)
  session:set_tts_parms(TTS_PROFILE, TTS_VOICE)
  -- barge-in:播报时允许客户开口打断(靠引擎的 kill-on-barge-in / 参数)
  session:speak("{speech-language=zh-CN}" .. text)
end

-- 开麦自由识别一句话,返回识别文本(听不到返回 nil)
local function listen()
  session:execute("play_and_detect_speech",
    "phrase:  detect:" .. ASR_MOD ..
    " {no-input-timeout=" .. NO_INPUT_MS ..
    ",recognition-timeout=" .. RECOG_MS .. "}" .. ASR_GRAMMAR)
  local xml = session:getVariable("detect_speech_result")
  if not xml then return nil end
  -- NLSML 结果里抠出识别到的文本(不同引擎标签略有差异,按实际调整)
  local text = xml:match("<input[^>]*>(.-)</input>")
             or xml:match("<instance[^>]*>(.-)</instance>")
  return text
end

-- 调 LLM 网关:发 {session_id, text},收 {reply, action}
-- 复杂逻辑(拼 prompt、多轮上下文、工具调用、流式)全在网关里做
local function ask_llm(uuid, text)
  -- 用 FreeSWITCH 内建的 curl API 发 HTTP(避免自己引第三方库)
  local body = string.format('{"session_id":"%s","text":%s}',
                             uuid, string.format("%q", text))
  local api = freeswitch.API()
  local resp = api:execute("curl",
    LLM_GATEWAY .. " post content-type application/json '" .. body .. "'")
  -- 解析网关返回的 JSON(生产里建议用正经 JSON 库,这里示意)
  local reply  = resp:match('"reply"%s*:%s*"(.-)"')  or "抱歉,我没太明白。"
  local action = resp:match('"action"%s*:%s*"(.-)"') or "continue"
  return reply, action
end

-- 转人工:把呼叫桥接到坐席队列(具体目标按你的路由改)
local function transfer_to_agent()
  say("好的,正在为您转接人工客服,请稍候。")
  session:execute("transfer", "agent_queue XML default")
end

-- ---- 主流程 ----

session:answer()
session:sleep(300)  -- 稍等媒体建立稳

local uuid = session:get_uuid()
say("您好,这里是智能客服,请问有什么可以帮您?")

local no_input = 0

-- 对话循环:直到客户挂断、转人工或明确结束
while session:ready() do
  local text = listen()

  if not text or text == "" then
    -- 没听到:计数、追问、超限转人工
    no_input = no_input + 1
    if no_input >= MAX_NOINPUT then
      transfer_to_agent()
      break
    end
    say("不好意思,我没有听清,您能再说一遍吗?")
  else
    no_input = 0  -- 听到了就清零
    freeswitch.consoleLog("info", "ASR: " .. text .. "\n")

    -- 问大脑
    local reply, action = ask_llm(uuid, text)
    say(reply)

    if action == "transfer" then
      transfer_to_agent()
      break
    elseif action == "hangup" then
      say("感谢您的来电,再见。")
      break
    end
    -- action == "continue":回到循环,进入下一轮
  end
end

if session:ready() then
  session:hangup()
end

这份骨架把前面说的每个工程点都落到了具体位置:

  • 对话循环:while session:ready() 是主体,session:ready() 保证客户挂断后循环立刻停。
  • 多轮上下文:Lua 侧只传 uuid,上下文历史存在 LLM 网关里(按 session_id 索引)。别让 Lua 背着对话历史,它该轻。
  • 超时兜底:no_input 计数,连续听不到就转人工,不让客户在死循环里干等。
  • 动作驱动:LLM 网关返回 action 决定流程走向,把"聪明"留在网关,Lua 只做调度。

四、LLM 网关:Lua 侧一个 HTTP,重活都在这

上面 ask_llm 在 Lua 侧就一次 HTTP POST。为什么不让 Lua 直接调模型厂商的 API?就俩字:解耦。

LLM 网关(一个普通的后端服务,Python/Go/Node 随你)干这几件事:

  1. 按 session_id 取出/更新对话历史。
  2. 拼 prompt:系统人设(你是某公司客服,只回答业务范围内的问题)+ 历史 + 本轮识别文本 + 可用工具定义。
  3. 调 LLM,开流式;若 LLM 要查数据,执行 function calling(调账务/订单接口),把结果喂回模型。
  4. 返回 {reply, action}。action 是网关根据 LLM 判断给出的调度指令(continue / transfer / hangup)。

把这些放网关而不是 Lua 的好处很实在:换模型、改 prompt、加工具、做限流和审计,全在网关动,FreeSWITCH 侧一行不改。 电话侧要保持稳定,LLM 侧要能快速迭代,这道边界必须清楚。


五、四个不处理就翻车的边界

demo 能跑不代表能用。真正决定体验的是这几个边界情况:

边界 现象(不处理) 在哪处理
打断 barge-in 机器念长句,客户想插话,它自顾自念完 Lua/FreeSWITCH 侧:speak 时允许 barge-in,收到开口信号停 TTS
超时无输入 客户没说话,系统傻等或直接挂 Lua 侧:no-input-timeout + 计数追问 + 超限转人工
转人工 LLM 搞不定还硬聊,客户抓狂 LLM 网关判断 + Lua 执行 transfer
异常兜底 网关超时/报错,通话直接崩 Lua 侧:ask_llm 要有超时和 fallback,失败就"稍等/转人工"

其中异常兜底最容易被 demo 忽略。ask_llm 那次 HTTP 一定要设超时,网关挂了或答非所问,得有兜底话术("系统繁忙,正在为您转接人工"),而不是让 Lua 抛异常、通话直接断。给客户挂断的电话,比答得慢的电话更糟。


六、怎么跑起来

从零到打通第一通能对话的电话,按这个顺序:

1. dialplan 挂载脚本

在 conf/dialplan/default.xml 加一个 extension,把呼叫引到 Lua:

<extension name="ai_agent">
  <condition field="destination_number" expression="^(9000)$">
    <action application="answer"/>
    <action application="lua" data="voice_agent.lua"/>
  </condition>
</extension>

拨 9000 就进 AI 客服。脚本放在 FreeSWITCH 的 scripts/ 目录下。

2. 分层验证,别一上来就全链路

按依赖顺序逐层通:

  • [ ] UniMRCP 单独能识别、能合成(先不接 FreeSWITCH)
  • [ ] mod_unimrcp 连上 UniMRCP,Lua 里 say() 能出声
  • [ ] listen() 能拿到识别文本(先打日志看 detect_speech_result 长啥样)
  • [ ] LLM 网关单独能吃文本吐 {reply, action}
  • [ ] Lua 把三者串成循环,能多轮对话
  • [ ] barge-in / 超时 / 转人工 / 异常 四个边界逐个验证
  • [ ] 延迟:客户说完到听见回话,压进可接受范围(靠全链路流式)

先各自单测,再串联——这是调这种多组件系统唯一不抓狂的办法。中间任何一层出问题,你都能快速定位是识别、是网关、还是合成。


七、几个真实会踩的坑

  1. 识别结果解析别写死。detect_speech_result 是 NLSML 的 XML,不同引擎的标签结构有差异。骨架里用 xml:match 是示意,生产里先把真实的 XML 打出来看清楚,再写解析,最好用正经 XML/JSON 库而不是正则。

  2. 别在 Lua 里阻塞。ask_llm 的 HTTP、listen 的识别都是阻塞的。单通电话没事,但要清楚每一步都在占着这个会话线程。慢接口务必设超时。

  3. 上下文别塞进 Lua。对话历史留在 LLM 网关按 session_id 管。Lua 只传 uuid,保持无状态、好重启。

  4. 延迟是体感命门。串行等"识别完→LLM 想完→合成完"轻松破 2 秒。真要好用,ASR/LLM/TTS 三段都得流式——这块工程量在网关和引擎侧,Lua 骨架先跑通,再上流式优化。

  5. 先跑通再优化。别一开始就追求流式、情感、打断全都完美。先让"拨 9000 → 说一句 → 它听懂了回一句"这条链路通,再逐个边界补齐。


总结:Lua 是黏合剂,聪明留给 LLM

回到开头那个问题——代码到底怎么写?一句话:Lua 只当黏合剂,别让它变聪明。

三句话记住:

  • 分工:Lua 负责"听(play_and_detect_speech)→ 问网关(HTTP)→ 说(speak)→ 按 action 调度",聪明全在 LLM 网关。这条边界让电话侧稳、LLM 侧能快速迭代。
  • 关键:LLM 场景要用自由识别而非固定语法——从"选择题"换成"填空题",理解交给大模型。
  • 落地:分层单测再串联,四个边界(打断/超时/转人工/异常兜底)一个都不能省,先跑通再上流式。

下一步很直接:把这份骨架拷进你的 scripts/,改掉 profile 名和网关地址,拨那个号,说第一句话。从"能拨通"到"能对话",中间隔的就是这一个 Lua 文件。

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* Lua + LLM AI 客服
** 为什么用 Lua
*** XML dialplan 干不了 HTTP+状态+分支
*** Lua 做对话循环刚好
** 两块拼图
*** 说话 set_tts_parms + speak
*** 听懂 play_and_detect_speech
*** 换引擎只改模块名
** 关键
*** 自由识别 不是固定语法
*** 选择题 vs 填空题
*** 理解交给 LLM
** 对话循环
*** 放欢迎语
*** 循环:听→问网关→说→调度
*** action: continue/transfer/hangup
** LLM 网关
*** Lua 侧就一个 HTTP POST
*** 上下文/prompt/工具调用都在网关
*** 换模型不碰电话侧
** 四个边界
*** 打断 barge-in
*** 超时无输入
*** 转人工
*** 异常兜底
** 落地
*** dialplan 挂 lua
*** 分层单测再串联
*** 先跑通再上流式
@endmindmap

Lua + LLM AI 客服 - 思维导图

参考资料


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。