这是一篇工程实现记录。前半部分讲传统 IVR 为什么难用、语音链路怎么跑通,中间讲流程引擎的数据结构与前端可视化编排实现,后半部分是几个我觉得比较有意思的踩坑记录。项目已开源(Apache 2.0),地址在文末。
一、传统 IVR 的问题不在"技术旧",在"改不动"
做过呼叫中心的同学大概都体验过这种流程:
普通话服务请按 1,英语服务请按 2……查询订单请按 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 队列 / 坐席
一通电话的生命周期大致是这样:
- 呼叫进入 Asterisk,拨号计划把通道交给 AGI;
- AGI 侧建立一条长连接,流程引擎根据被叫号码(或线路配置)找到对应的 IVR 流程定义,实例化一个流程上下文;
- 引擎从
start节点开始,按连线依次执行节点。每个节点执行时会向 Asterisk 下发控制指令(放音、收号、录音、转接),并等待通道事件回调; Answer这类节点会把通道音频通过 WebSocket 实时推给 ASR,拿到文字后写入流程上下文变量;Condition/Intention节点基于上下文变量决定走哪条边;- 走到
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走OmniRealtimeConversationAPI,fun-asr-realtime/paraformer-realtime-8k-v2走RecognitionAPI; - 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 引擎适配这一块,多一家引擎对所有人都有好处。
十、项目地址
- 开源仓库:https://gitee.com/gdzWork/SmartCall (Apache 2.0 协议,允许商用,保留作者与 Copyright 信息即可)
技术栈速览: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 是继续维护最直接的动力。