我用 Asterisk + 大模型做了一套 AI 语音客服,聊聊 IVR 流程引擎的设计和踩过的坑

简介: 这是一篇 SmartCall 开源项目的工程实践记录:聚焦传统 IVR 难以迭代、不理解自然语言的痛点,基于 Asterisk 构建可编排语音流程引擎,支持大模型意图识别、ASR/TTS 多引擎接入与前端可视化拖拽编排,并详述踩坑经验。Apache 2.0 协议,已开源。

这是一篇工程实现记录。前半部分讲传统 IVR 为什么难用、语音链路怎么跑通,中间讲流程引擎的数据结构与前端可视化编排实现,后半部分是几个我觉得比较有意思的踩坑记录。项目已开源(Apache 2.0),地址在文末。

一、传统 IVR 的问题不在"技术旧",在"改不动"

做过呼叫中心的同学大概都体验过这种流程:

普通话服务请按 1,英语服务请按 2……查询订单请按 1,退换货请按 2,投诉建议请按 3……

这棵按键树在设计之初通常是合理的,但它有三个几乎无法回避的问题:

  1. 话术固化在录音里。运营想把"退换货"改成"退款与换货",需要重新录音、重新上传、重新配置节点,一个文案变更走完流程可能要一两天。
  2. 树的深度和用户体验成反比。业务越多,层级越深,用户在第四层就已经挂电话了。而按键树的分支数一多,运维配置本身就是灾难。
  3. 它不理解自然语言。用户说"我要退上个月买的那个东西",按键树只能要求他先听完 30 秒菜单。

流式 ASR、大模型和低延迟 TTS 成熟之后,"让用户直接说话、系统理解意图并往下走"这件事第一次具备了工程可行性。但真正做下来会发现:难点不在模型,在于怎么把'模型能力'塞进一个必须稳定运行、必须能被非技术人员编排、还必须能私有化部署的电话系统里。

SmartCall 就是围绕这个问题做的一次完整实践。它的定位很聚焦:把 Asterisk 的通信能力和大模型的语义能力用一套可编排的流程引擎粘起来。

二、一通电话进来,整条链路发生了什么

先看整体链路。这里的关键设计取舍是:Asterisk 只负责信令和媒体,所有业务逻辑都在流程引擎里。

SIP 话机 / SIP 中继
        │  (SIP 信令 + RTP 媒体)
        ▼
  Asterisk PBX (PJSIP)
        │  (AGI 长连接 / AMI 控制)
        ▼
   smart-aster 流程引擎  ←── 加载 IVR 流程图(节点 + 连线)
        │
        ├── Say / Answer 节点 ──(WebSocket)──► TTS / ASR 服务
        │                                       (阿里云 NLS / DashScope / 电信)
        ├── Intention 节点 ──(Spring AI)──► 大模型(通义千问 / DeepSeek)
        ├── Agent 节点 ──(HTTP)──► smart-maxkb ──► MaxKB 知识库
        ├── Service 节点 ──(HTTP)──► 业务方任意 API
        └── Transfer 节点 ──(AMI)──► Asterisk 队列 / 坐席

一通电话的生命周期大致是这样:

  1. 呼叫进入 Asterisk,拨号计划把通道交给 AGI;
  2. AGI 侧建立一条长连接,流程引擎根据被叫号码(或线路配置)找到对应的 IVR 流程定义,实例化一个流程上下文;
  3. 引擎从 start 节点开始,按连线依次执行节点。每个节点执行时会向 Asterisk 下发控制指令(放音、收号、录音、转接),并等待通道事件回调;
  4. Answer 这类节点会把通道音频通过 WebSocket 实时推给 ASR,拿到文字后写入流程上下文变量;
  5. Condition / Intention 节点基于上下文变量决定走哪条边;
  6. 走到 hangup 或 transfer 节点,流程结束。

这里有几个工程上的注意点:

  • AGI 是长连接、有状态的,一次通话对应一个流程实例,实例状态不能放本地内存(多实例部署会串),要放 Redis。项目里用 Redisson 做分布式锁和状态存储。
  • 通道事件是异步的。放音完成、用户按键、ASR 出结果,都是回调。节点执行的本质是一个"下发指令 → 等待事件 → 写上下文 → 决定下一条边"的状态机。
  • 超时和兜底必须显式设计。用户不说话、ASR 没返回、大模型超时,都要有明确的降级路径(重播一次 / 转人工 / 挂断),否则通道会一直挂着占线路。

三、流程引擎:把话术变成一张有向图

3.1 节点体系

流程被抽象成一张有向图,目前实现了 16 种节点:

分类 节点 作用
交互 say TTS 播报,内容支持 SpEL 表达式动态拼接
answer ASR 收听用户语音,支持实时打断
received DTMF 按键收号
sms 发送短信
决策 condition SpEL 表达式条件分支
intention 大模型意图识别,多意图多分支
sentiment 情感分析,正面/负面/中性三路分支
AI maxKB 知识库智能体多轮对话
extract 从对话中提取结构化字段
集成 service 调用外部 HTTP API
script Groovy / JavaScript 脚本节点
流程 start / hangup / transfer / childNodes / variable-assign 起止、转人工、子流程、变量赋值

childNodes(子流程)这个节点在实践中很重要。客服话术里"身份核验""满意度回访"这类片段几乎每个流程都要用,做成子流程之后改一处全局生效,流程图也不会膨胀到没法看。

3.2 存储结构:扁平数组,而不是 nodes + edges

后端的流程定义存储用的是扁平节点数组,连线信息内嵌在节点属性里,而不是 LogicFlow 那种 { nodes: [], edges: [] } 的结构。

以条件节点为例,存储形态接近这样:

{
   
  "code": "cond_01",
  "type": "condition",
  "x": 320,
  "y": 180,
  "conditions": [
    {
    "expr": "#ctx['level'] == 'vip'", "edge": {
    "sourceNodeID": "cond_01", "targetNodeID": "say_02" } },
    {
    "expr": "true",                   "edge": {
    "sourceNodeID": "cond_01", "targetNodeID": "say_03" } }
  ]
}

为什么这么设计?因为流程引擎执行时关心的是"这个节点的每个出口分别指向谁",而不是"图上有哪些边"。执行到条件节点,算出命中第几个条件,直接拿那个条件里的 edge.targetNodeID 就是下一个节点,一次查表,不需要在边集合里搜索。

代价是前端要做转换。这里用了 LogicFlow 的 adapterIn / adapterOut 钩子,在渲染前把内嵌 edge 拆出来重组成图结构,保存前再塞回去:

lf.value.adapterIn = function (data: unknown): LF.GraphData {
   
  const edges: LF.EdgeData[] = [];

  const nodes = (data as BaseNode[]).map((node): LF.NodeData => {
   
    const {
    code, type, x, y, ...properties } = node;

    switch (type) {
   
      case WorkflowType.Condition: {
   
        // 把每个条件里的 edge 抽到顶层 edges 数组
        properties.conditions = properties.conditions?.map((cond) => {
   
          const {
    edge, ...rest } = cond;
          if (edge) edges.push(edge);
          return rest;
        });
        return {
    id: code, type, x, y, properties } as LF.NodeData;
      }
      // intention / sentiment / transfer 同理,多分支字段各不相同
      default:
        return {
    id: code, type, x, y, properties } as LF.NodeData;
    }
  });

  return {
    nodes, edges };
};

情感分析节点的三个分支(front / negative / neuter)、转人工节点的两个分支(through / unThrough)都是同样的处理方式。存储结构服务于执行效率,展示结构服务于编辑体验,中间用 adapter 层隔离——这个模式在后面接其他可视化引擎时也能复用。

3.3 用 Vue 组件渲染 LogicFlow 节点

LogicFlow 原生节点是 SVG,画一个带表单、带图标、带下拉框的复杂节点会非常痛苦。项目的做法是用 HtmlNode(SVG foreignObject)承载 Vue 组件,再通过 Teleport 把组件挂进去。

核心是要解决一个问题:LogicFlow 的节点模型是普通对象,不是响应式的,属性变了 Vue 不会重渲染。所以做了一层响应式代理:

export function createReactiveNodeProxy(node: BaseNodeModel, graph: GraphModel) {
   
  if (nodeProxies.has(node)) return nodeProxies.get(node);

  const reactiveNode = reactive(node);

  const updateHandler = (data: CallbackArgs) => {
   
    const payload = data.data;
    if (payload.id === node.id) {
   
      Object.assign(reactiveNode, payload); // 把变更同步进响应式对象
    }
  };

  [EventType.NODE_PROPERTIES_CHANGE, EventType.HISTORY_CHANGE, EventType.NODE_CLICK]
    .forEach((event) => graph.eventCenter.on(event, updateHandler));

  // ...保存 proxy 与 cleanup,节点销毁时解绑
}

配合 WeakMap 缓存代理实例、节点销毁时清理监听,避免重复注册和内存泄漏。

节点注册用了 import.meta.glob 自动扫描,新增一种节点只要在 nodes/ 下建目录,不用改注册表:

const nodes = import.meta.glob('./nodes/**/index.ts', {
    eager: true });

lf.value.batchRegister([
  ...Object.keys(nodes).map((key) => nodes[key].default),
  AppEdge,
]);
lf.value.setDefaultEdgeType('app-edge');

另外接了 @antv/layout 的 Dagre 做自动布局插件。流程图手工拖到二十几个节点之后,"一键整理"是刚需。

四、几个值得单独说的节点

4.1 Answer 节点:打断(barge-in)

answer 节点做 ASR 收听,其中"支持用户实时打断"是体验分水岭。

不打断的实现很简单:放音 → 放完 → 开始录音 → 等结果。但真实对话里用户经常在系统说话说到一半时就开口了。如果不支持打断,用户会重复说三四遍,然后骂一句挂电话。

支持打断要做的事:TTS 边合成边播放的同时,上行音频通道保持开启并持续送 ASR;一旦 ASR 返回有效的语音起始(VAD 触发或首字返回),立刻通过 AMI 停掉当前放音、丢弃 TTS 剩余缓冲区,切到收听状态。这里最容易出的问题是系统自己的声音被识别成用户输入(回声),需要依赖 Asterisk 侧的回声消除和合理的 VAD 阈值。

4.2 Intention 节点:正则 + 模型双引擎

意图识别节点支持两种判定方式,可以叠加使用:

  • 正则快速匹配:命中即返回,不消耗 token,延迟在毫秒级;
  • 大模型识别:把候选意图列表和用户话术一起交给模型,返回意图编码。

实践下来的合理用法是:高频、表述固定的意图用正则兜住,长尾和模糊表述交给模型。 比如"转人工""人工客服"这类,正则完全够用,没必要每次都走一遍模型;而"我那个单子怎么还没到啊"这种,就交给模型。这样既压住了成本,也保住了响应速度——电话场景对延迟极其敏感,多 500ms 用户就会"喂?喂?"。

4.3 Agent 节点:可插拔的智能体平台

maxKB 节点对接知识库智能体做多轮问答。这里有两个设计点:

一是会话上下文的维护维度选了"主叫号码"。 同一个号码在多轮交互中共享上下文,用户说"我刚才问的那个"能被正确理解。会话状态放 Redis,带 TTL。

二是智能体平台做成了可插拔的。 节点执行通过 NodeGranter 插件机制分发,MaxKB 只是内置的一个实现。要接 Dify、Coze、FastGPT,实现同一套接口即可,流程图和节点配置完全不用改。这个抽象在选型还没定死的时候特别有价值。

4.4 Script / Service 节点:给流程留逃生口

再完善的节点体系也覆盖不了所有业务逻辑。service 节点让流程能直接调外部 HTTP API(内置了 HTTP 调试器,配好即测),script 节点支持在线写 Groovy / JavaScript(用 CodeMirror 做了语法高亮和 Lint 校验)。

这两个节点本质上是流程引擎的"逃生口"。有了它们,遇到没预料到的需求不用等版本迭代,配置人员自己就能绕过去。

五、ASR / TTS 的多引擎抽象

电话场景的语音引擎选型比想象中复杂:不同厂商的实时性、方言支持、音色、价格差异都很大,而且经常需要混用(比如 TTS 用 A 家的音色,ASR 用 B 家的准确率)。

项目的做法是用 VoiceModelEnum 统一管理引擎,ASR / TTS 都基于接口抽象,目前内置了三家:

引擎 ASR TTS 通信方式
阿里云 NLS AliAsrClient AliTtsClient WebSocket 流式
通义千问 DashScope DashScopeAsrClient DashScopeTtsClient WebSocket 流式
电信 AI DianxinAsrClient DianxinTtsClient WebSocket 流式

其中 DashScope 的实现稍微绕一点,因为它内部还有模型差异,用了代理 + 模型路由:

  • ASR:qwen3-asr-flash-realtime 走 OmniRealtimeConversation API,fun-asr-realtime / paraformer-realtime-8k-v2 走 Recognition API;
  • TTS:Qwen3-TTS 系列和 CosyVoice 系列也是两套不同实现。

外层 DashScopeAsrClient 根据配置的模型名自动路由到具体实现,上层无感知。

配置层面有个设计我觉得挺实用:IVR 流程里每个节点都能独立配置引擎参数(模型、音色、采样率、语速),页面传入的动态配置会覆盖 application.yml 里的默认值。 于是同一个流程里,欢迎语可以用音色好听的模型,长文本播报可以用便宜的模型,识别用准确率高的模型。

ivr:
  dashscope:
    api-key: ${
   IVR_DASHSCOPE_API_KEY:}
    # 内网私有化部署时指向自己的语音服务,留空走公网官方地址
    # websocket-url: wss://your-private-dashscope-server/api-ws/v1/realtime
    asr-option:
      model: qwen3-asr-flash-realtime
      sample-rate: 8000    # 电话场景务必用 8000
      language: zh
    tts-option:
      model: qwen3-tts-flash-realtime
      sample-rate: 8000
      volume: 50           # [0, 100]
      speech-rate: 1.0     # [0.5, 2.0]

sample-rate 那行值得单独强调:电话信道是 8kHz 窄带,而多数语音模型默认 16kHz。 采样率配错的直接后果是识别率断崖式下跌,而且报错信息通常很含糊(表现为"识别不出内容"而不是"参数错误")。这是接入电话语音最容易踩的一个坑。

要接讯飞、百度、腾讯云等其他引擎,实现对应接口、在枚举里加一项就行,流程节点侧不需要任何改动。

六、部署形态:微服务和单体双模

同一套代码支持两种部署形态:

  • 单体模式:跑 smart-boot 模块,一个进程起完整系统,不依赖 Nacos / Gateway。适合开发调试和中小规模部署。
  • 微服务模式:各模块独立部署,Nacos 做注册与配置中心,Spring Cloud Gateway 统一路由鉴权。适合大规模生产。

模块划分大致是:smart-aster(核心呼叫)、smart-maxkb(智能体对接)、smart-gateway(网关)、smart-auth(认证)、smart-upms(用户权限,含系统/用户/资源三个子模块)、smart-ops(日志监控、接口文档聚合、Spring Boot Admin)、smart-common(公共模块)、smart-api(Feign 接口定义)。

权限是 RBAC(细到按钮级),认证走 OAuth2,原生支持多租户数据隔离。

内网私有化部署这块做得比较彻底,因为很多用呼叫中心的场景(金融、政务)根本不允许出公网:

  • 语音引擎:DashScope 支持自定义 websocket-url,可以指向内网部署的语音服务;
  • 大模型:走 base-url 配置,可以对接内网的 vLLM / Ollama 推理服务;
  • 智能体平台:MaxKB 的 baseUrl 直接支持内网地址;
  • 基础设施:MySQL / Redis / Nacos / Asterisk 全部通过环境变量注入;
  • 提供完整的 Docker Compose 编排,Asterisk 也有现成的 Dockerfile 和配置模板。

七、前端工程实践

前端是一个 Monorepo,基于 vben admin 二次开发:

  • 包管理:pnpm 10(workspace + catalog 统一依赖版本),Turbo 做并行构建与缓存
  • 构建:Vite 8 + TypeScript
  • UI:Element Plus + Tailwind CSS 4
  • 图形:LogicFlow(流程图)+ CodeMirror(脚本/Prompt 编辑)+ ECharts(统计图表)
  • 测试:Vitest
  • 规范:ESLint + OxLint + Stylelint + Lefthook 预提交钩子

主要功能页面包括:IVR 流程编排、机器人配置、模型与知识库管理、语音文件管理、变量管理、打断策略、通话记录、坐席与坐席组、线路管理、外呼任务、路由策略,以及软电话工作台。

八、踩过的坑(这部分可能比上面都有用)

坑 1:LogicFlow 的 foreignObject 里,inline-grid 布局会让图标消失

LogicFlow 的 HtmlNode 是通过 SVG foreignObject 渲染 HTML 的。这个环境对 CSS Grid 的支持不完整,用 inline-grid 布局时,子元素(Iconify 生成的 SVG 图标)会直接不显示,而且不报错,控制台干干净净,元素在 DOM 里也有,就是看不见。

排查了很久,最后改成 flex 布局就好了。结论:在 foreignObject 内部优先用 flex,Grid 谨慎使用。

坑 2:Vite 8 (rolldown) 预构建 lodash-es 导致 ReferenceError

升级到 Vite 8 之后,LogicFlow 页面白屏,控制台报 ReferenceError: init_castArray is not defined。

根因是 @logicflow/core 同时提供 es/(ESM)和 lib/(CJS)两套产物,而 CJS 版本里 require("lodash-es") 引用了一个 ESM-only 的包。项目代码里同时存在两种导入路径:

import {
    h } from '@logicflow/core';                       // → ESM es/index.js
import LogicFlow from '@logicflow/core/lib/LogicFlow';      // → CJS lib/

同一个包被解析出 ESM 和 CJS 两条路径,rolldown 预构建时 chunk 分割出现初始化顺序错误。

修复分两步:

// 1. 主修复:统一走 ESM 入口,类型导入用 type
import type LogicFlow from '@logicflow/core';
// 2. 安全网:vite.config.ts 里显式声明预构建
optimizeDeps: {
   
  include: ['@logicflow/core', '@logicflow/extension', 'lodash-es'],
}

另外一定要删掉 node_modules/.vite 缓存目录,否则损坏的缓存会继续被复用,改了配置也不生效,非常误导排查方向。

坑 3:new Audio() 带不了 Authorization 头,录音播放直接 401

通话录音接口是需要鉴权的,前端最初这么写:

new Audio(`${
     apiURL}/file/download/${
     id}`).play(); // 401

原因是 HTML 媒体元素(new Audio / <audio src> / <img>)发起的是浏览器原生 GET 请求,无法附加自定义请求头,Axios 拦截器里注入的 Authorization、租户编码等一律带不上。

两种解法:

// 方案 A:换取免鉴权外链
const url = await getFileUrl(id);
new Audio(url).play();

// 方案 B:走 requestClient 下载成 blob,再用 objectURL
const blob = await downloadFile(id);
const objectUrl = URL.createObjectURL(blob);
new Audio(objectUrl).play();
// 务必在组件卸载 / 弹框关闭时释放,否则内存泄漏
URL.revokeObjectURL(objectUrl);

方案 B 要注意缓存 objectUrl 并在合适的时机 revokeObjectURL,长列表页面上不释放会持续吃内存。

坑 4:内网环境下图标全挂了

前端图标用的 Iconify,默认行为是运行时按需从公网 CDN 拉取图标数据。这个在有网环境下毫无感知,一旦部署到内网,所有图标集体消失。

解决方式是提前把用到的图标集打包成本地资源,随前端一起构建产出。这里还有个细节:图标集需要扫描所有子应用的源码来收集,新增应用时容易漏掉,所以扫描逻辑做成了自动发现,而不是手写应用列表。

同时移除了 setFetch 的自定义配置,直接依赖 Iconify 内置的"本地优先、网络回退"机制,这样有网无网都能正常工作。

坑 5:电话场景的采样率

前面提过了,但值得重复一遍:8000,不是 16000。 这个参数配错的症状是"ASR 连接正常、无报错、就是识别不出内容",非常难往参数方向想。

九、目前的边界

说清楚项目现在能做什么、不能做什么,比一味讲优点更有意义:

已经完整实现的部分:Asterisk 通信对接、IVR 流程引擎与可视化编排器、16 种流程节点、三家 ASR/TTS 引擎接入、大模型意图识别、情感分析、信息提取、MaxKB 知识库智能体对接、微服务/单体双模部署、RBAC 与多租户、内网私有化部署支持。

属于二次开发范围的部分:坐席管理、通话记录(CDR)、智能外呼、线路管理、数据大屏这些运营模块,项目在数据模型和 API 层面预留了完整的扩展基础(比如 PJSIP 队列策略、SSE 坐席状态推送、外呼任务实体与分布式调度),但界面层需要根据实际业务自行实现。这个取舍是因为不同企业的运营流程差异太大,做死了反而不好用。

还在打磨的部分:多引擎并发下的资源调度、大模型超时的降级策略细化、流程图的版本管理与灰度发布。

如果你正好在做呼叫中心相关的东西,或者对"大模型怎么和实时语音链路结合"这个方向感兴趣,欢迎去看看代码,也欢迎提 Issue 和 PR——尤其是 ASR/TTS 引擎适配这一块,多一家引擎对所有人都有好处。

十、项目地址

技术栈速览:Java 17 / Spring Boot 3.5 / Spring Cloud 2025 / Spring Cloud Alibaba / MyBatis-Flex / Asterisk + PJSIP / Spring AI / Redisson / SkyWalking | Vue 3 / Vite / TypeScript / Element Plus / Tailwind CSS / LogicFlow / CodeMirror / pnpm + Turbo Monorepo。

有问题可以在仓库提 Issue,附上复现步骤和环境信息会更容易定位。


如果这篇文章对你有点用,欢迎点个赞。项目还在持续迭代,你的 Star 是继续维护最直接的动力。

相关文章
|
4天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
|
云安全 安全 网络安全
|
18天前
|
JSON 程序员 数据格式
最新版Notepad++下载、安装、配置保姆级教程(附官网安装包)
Notepad++ 是一款免费开源、轻量快速的 Windows 文本编辑器,支持多标签、语法高亮、代码折叠与宏录制,体积小、启动快、资源占用低,兼容 x64/x32/ARM 架构,是程序员和文字工作者的高效利器。
4221 2
|
17天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
2792 49
|
10天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
2078 2
|
9天前
|
人工智能 JSON Linux
【保姆级】ComfyUI部署教程(下载+安装+配置+工作流,2026最新)
ComfyUI 是一款开源节点式AI生成工具,支持图像、视频、音频及3D内容创作。通过拖拽连线组合模型加载、提示词、采样等节点,构建可复用、易修改的工作流,兼容SD1.5、SDXL、Flux、Qwen Image等主流模型。
|
14天前
|
Web App开发 人工智能 前端开发
2026 年 AI 浏览器怎么选?Ego Lite、Tabbit、Chrome DevTools MCP 深度对比,开发者到底该用哪个?
2026年AI浏览器选型指南:Ego Lite专注多Agent协同(Mac优先),Tabbit国产首选,原生支持Windows与Codex控制;Chrome DevTools MCP非浏览器而是开发者利器,深度集成Console/Network/Performance调试能力,让AI真正“看懂并验证”前端代码。
1543 3
|
11天前
|
开发者 索引
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
Open Plugin Market 是一个开放插件索引平台,收录800+优质DSH插件,支持分类浏览、智能推荐与随机发现。无需安装市场,粘贴索引地址即可一键接入,网页版直访 dshopen.org,助力小开发者被看见。(239字)
2182 1
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用

热门文章

最新文章