用 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 客服电话的骨架就是一个循环:
翻译成大白话:
- 接通,放欢迎语。
- 循环开始:开麦自由识别,等客户说话。
- 没听到(超时)→ 追问一次,重试;重试几次还不行 → 转人工。
- 听到了 → 把识别文本 + 会话 ID 发给 LLM 网关。
- LLM 网关返回
{回复文本, 动作}。动作可能是:继续对话、转人工、结束通话。 - 合成回复文本念给客户。
- 按动作决定:回到第 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 随你)干这几件事:
- 按
session_id取出/更新对话历史。 - 拼 prompt:系统人设(你是某公司客服,只回答业务范围内的问题)+ 历史 + 本轮识别文本 + 可用工具定义。
- 调 LLM,开流式;若 LLM 要查数据,执行 function calling(调账务/订单接口),把结果喂回模型。
- 返回
{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 / 超时 / 转人工 / 异常 四个边界逐个验证
- [ ] 延迟:客户说完到听见回话,压进可接受范围(靠全链路流式)
先各自单测,再串联——这是调这种多组件系统唯一不抓狂的办法。中间任何一层出问题,你都能快速定位是识别、是网关、还是合成。
七、几个真实会踩的坑
-
识别结果解析别写死。
detect_speech_result是 NLSML 的 XML,不同引擎的标签结构有差异。骨架里用xml:match是示意,生产里先把真实的 XML 打出来看清楚,再写解析,最好用正经 XML/JSON 库而不是正则。 -
别在 Lua 里阻塞。
ask_llm的 HTTP、listen的识别都是阻塞的。单通电话没事,但要清楚每一步都在占着这个会话线程。慢接口务必设超时。 -
上下文别塞进 Lua。对话历史留在 LLM 网关按 session_id 管。Lua 只传 uuid,保持无状态、好重启。
-
延迟是体感命门。串行等"识别完→LLM 想完→合成完"轻松破 2 秒。真要好用,ASR/LLM/TTS 三段都得流式——这块工程量在网关和引擎侧,Lua 骨架先跑通,再上流式优化。
-
先跑通再优化。别一开始就追求流式、情感、打断全都完美。先让"拨 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

参考资料
- FreeSWITCH mod_unimrcp 文档
- FreeSWITCH Lua API 参考
- RFC 6787 - MRCPv2
- 姊妹篇:MRCP 协议入门、FreeSWITCH + UniMRCP + LLM 语音智能体(本站)
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。