把大模型塞进电话里:一个 IVR 编排引擎的六个设计决

先说清楚这件事难在哪。

网页上接个大模型,用户发一句话,前端转圈三秒,没人觉得有问题。电话里不行——用户说完,"嗯…"之后是三秒静音,他会直接挂掉。而且电话是半双工的:你没法像聊天窗口那样先回一句"正在输入"占个位。

所以电话机器人的技术难点从来不是"模型够不够聪明",而是你不能让用户等你思考。下面这六个决策,全部长在这个约束上。


一、流程是"图",不是"列表"

最直觉的写法是把流程存成一个节点数组,从头跑到尾。但电话流程要支持"没听清,再问一遍",要支持条件分支,要能跳回去。数组做不到。

引擎主体就这么几行:

while (currentNode != null && !Thread.currentThread().isInterrupted()) {
    NodeGranter<UserIntent.Node> granter = nodeGranterFactory.getGranter(currentNode.getType());
    result = granter.grant(nodeContext, result.result(), currentNode);

    nodeContext.flushed(currentNode.getCode(), result.result());  // 写回上下文 + 更新 lastResult
    currentNode = nodeMap.get(result.nextId());                   // 按 nextId 跳转,不是 index + 1
}

几个点:

  • nodeMapcode -> node 的 Map,启动时一次建好,跳转是 O(1) 的;
  • 下一跳由节点自己决定。条件节点返回哪个 nextId,就跳去哪个节点。分支、回跳、循环天然成立,不需要引擎额外支持;
  • 结果链式传递result.result() 作为下一节点的入参,上一个节点的输出直接是下一个节点的输入;
  • flushed 除了按 code 存结果,还会写一个隐式的 lastResult,供后面的表达式引用。

为什么没用 Flowable / Activiti 这类工作流引擎?因为模型不一样。BPMN 是为"长时间等待外部触发"设计的异步状态机,每一步要落库、要能恢复。而电话流程是"我一直在跑,只是偶尔被 IO 卡住"的同步模型——整通电话就是一个有状态的短生命周期对象。为它引入流程实例的持久化与恢复,收益远小于复杂度。

代价也清楚:流程状态在内存里,进程重启意味着通话中断。我接受这个代价,因为通话本身中断了重拨就行,而为了"可恢复"付出的成本是实打实的。


二、把 AI 的延迟,藏进用户说话的时间里

这是整套设计里我最满意的一处。

场景:用户说了一句"我叫张三,手机 138 后面是 xxxx",信息抽取节点要调大模型把它抽成结构化 JSON。这一步 1~3 秒。同步等,用户就听到 3 秒静音。

做法是让节点支持异步:把 CompletableFuture 直接塞进流程上下文,不等。

CompletableFuture<JSONObject> future = CompletableFuture.supplyAsync(() -> {
    String varInfo = chatClients.client().prompt()
            .options(JpowerChatOptions.builder().temperature(0.5).maxTokens(1000).jsonMode().build())
            .system(SYSTEM_PROMPT)
            .user(u -> u.text(PROMPT_TEMPLATE)
                        .param("text", text)
                        .param("keys", keys))
            .call().content();
    return JSONUtil.isTypeJSONObject(varInfo) ? JSON.parseObject(varInfo) : new JSONObject();
}, taskExecutor);

return NodeResult.builder()
        .nextId(node.getNextNode())
        .result(node.getAsync() ? future : future.get(1, TimeUnit.MINUTES))
        .build();

关键在于:塞进去的是 Future,但下游拿到的必须是值。解包由上下文容器完成。框架提供了两个类,FutureConcurrentHashMap 继承 ConcurrentHashMap 重写了 get/values/entrySet/forEachFutureEvaluationContext 继承 StandardEvaluationContext 重写了 lookupVariable——也就是说,脚本里写 ${name} 时,SpEL 取值路径上就会触发解包。

我把 get() 的字节码扒出来看过(JDK 自带的 javap 就够):

 7: instanceof      java/util/concurrent/Future      // 值是不是 Future?
19: lconst_1
20: getstatic       java/util/concurrent/TimeUnit.MINUTES
23: invokeinterface Future.get:(J, TimeUnit)         // 是就阻塞等,上限 1 分钟
32: invokevirtual   put:(Object, Object)             // 拿到真值后回填,下次不再等

Exception table:
  InterruptedException / ExecutionException -> remove(key)
  TimeoutException                          -> 保留,下次再试

效果是这样的:

同步(async = false)
  放音 ──▶ [等用户按键 5s] ──▶ 抽取 3s ──▶ 播报
                                ↑ 这 3 秒,用户听到的是静音

异步(async = true)
  放音 ──▶ [等用户按键 5s] ──▶ 播报
            └─ 抽取 3s 并行 ──┘
              ↑ 真要用到时早就算完了,get() 立即返回

AI 的耗时被用户的交互时间吃掉了。判断标准很简单:看结果什么时候被消费。马上要播报的(比如知识库问答)就同步等;可能后面才用、甚至不一定用到的(信息抽取)就异步。

顺带一个坑。注意字节码里 astore_2(把解包结果写回局部变量)只在成功路径上,两个 catch 块都是直接跳到 aload_2; areturn。也就是说超时或失败时,get() 返回的是 Future 对象本身,不是 null。下游写 expression.getValue(ctx, String.class) 拿到的就是个 CompletableFuture,接着要么报类型转换异常,要么念出一句莫名其妙的话。日志里只有一行 获取值[xxx]超时,很难联想到是这里。

改法很直接:future.getNow(默认值)exceptionally,或者 catch 里显式返回 null,别让 Future 漏出去。


三、挂断是一种"取消",不是"异常"

AGI 脚本跑在 Asterisk 拉起的连接线程里,不是 Tomcat 线程。用户挂断时,asterisk-java 会在下一条 AGI 命令上抛 AgiHangupException

三处代码配合处理这件事:

// 1) 挂断回调:中断正在跑流程的线程
public void hangup(AgiSupport support) {
    support.thread().interrupt();
    AgiContext.clear(support.channel());
}

// 2) 引擎循环:下一轮就退出
while (currentNode != null && !Thread.currentThread().isInterrupted()) { ... }

// 3) 节点内部:遇到挂断异常自己收尾
try {
    if (node.isWaitMusic()) {
        nodeContext.getSupport().playMusicOnHold(MUSIC);
    }
} catch (AgiHangupException e) {
    ThreadUtil.interrupt(Thread.currentThread(), false);
    return NodeResult.builder().nextId("end").build();
}

为什么用 interrupt 而不是一个 volatile 标志位?两个都要。标志位负责"逻辑上别再往下走",但当流程阻塞在放音、收号这类 AGI IO 上时,标志位要等 IO 返回才会被检查;interrupt 能把一部分阻塞调用直接打断。只用其中一个都会漏。

那个 catch 包的是放等待音乐这一步,不是 AI 调用——这点很容易写错。调大模型前会先放一段等待音,用户很可能就在这时候挂断,异常从 playMusicOnHold 抛出来。如果只捕获 AI 调用那一句,一个普通的用户挂断会让整条流程炸在一个看起来毫无关系的地方。


四、把表达式交给运营,同时划清边界

条件节点的"条件"和"返回值"都是 SpEL:

for (Condition condition : list) {                       // 按 index 排序
    if (!"else".equalsIgnoreCase(condition.getCondition())) {
        boolean is = parser.parseExpression(condition.getCondition())
                           .getValue(nodeContext.getContext(), Boolean.TYPE);
        if (is) { resultCondition = condition; break; }
    }
}
Object result = parser.parseExpression(resultCondition.getResult())
                      .getValue(nodeContext.getContext());

最后一条默认是兜底分支——等于约定了 else 必须配在最后。

不止条件。收号节点要播的提示音、抽取节点要抽的文本,全都是 SpEL,运营可以在配置页写 ${'您的余额是' + balance} 这种动态话术。上下文里还预注册了 StrUtilNumberUtilPhoneUtilIdcardUtil 这些工具类,所以表达式里能直接做手机号、身份证校验。

代价是安全边界。上面那个 FutureEvaluationContext 继承的是 StandardEvaluationContext,不是 SimpleEvaluationContext。前者允许 T(java.lang.Runtime).getRuntime().exec(...) 这类类型引用和任意方法调用。

表达式是从配置页写进去的,那这个页面的权限就不是"运营权限",而是等价于"代码执行权限"。想收紧的话,换成 SimpleEvaluationContext 加自定义 PropertyAccessor 白名单,或者在上层做表达式静态检查(比如禁止出现 T()。框架层我没有动,影响面太大,但配置页的权限是按代码权限收的。


五、多租户上下文,得靠"反解"补回来

HTTP 请求带租户头,拦截器帮你切数据源。但 AMI 事件和 AGI 连接不是 HTTP,Asterisk 推过来的事件里压根没有租户概念。

做法是拿 linkedid 当关联 ID。一通电话不管经过多少次桥接、转接,所有通道共享同一个 linkediduniqueid 只标识单个通道,不够用):

String linkedId = Fc.toStr(map.getOrDefault("linkedid", map.get("linkedId")));
String tenant = TENANT_CACHE.get(linkedId);

if (Fc.isBlank(tenant) && event instanceof AbstractChannelEvent e) {
    String endpointName = StrUtil.subBetween(e.getChannel(), "/", "-");  // PJSIP/1001-0000000a -> 1001
    EndpointsDO endpoint = endpointsDao.getById(endpointName);
    if (endpoint != null) {
        TENANT_CACHE.put(linkedId, endpoint.getTenantid());
    }
}
TenantBroker.runAs(TENANT_CACHE.get(linkedId), tc -> super.onManagerEvent(event));

缓存用 TimedCache,TTL 一小时。一通电话能产生几十个事件,每条都查库扛不住;TTL 兜底防止内存泄漏。

这里有个脆弱点:租户是从通道名"反解"出来的,依赖 PJSIP/分机号-xxx 这个命名约定。坐席分机通道没问题——PJSIP/1001-0000000a 解出 1001,库里主键就是 1001。但网页外呼走的是 Local 通道:

Local/10086@outbound-dialer-00000001;1
      └──────────┬──────────┘
       第一个 '-' 出现在 outbound-dialer 里
       subBetween(channel, "/", "-") 解出来是 "10086@outbound"

10086@outbound 在 endpoints 表里查不到,租户就是 null,事件会在错误的租户上下文里被处理。呼入正常、外呼异常——这种"一半对"的问题最难查,因为看上去系统一直在正常工作。

更稳的做法是外呼发起时就把租户带在通道上:OriginateAction 支持 setVariable,dialplan 里 Set(__TENANT_ID=xxx)(双下划线前缀表示跨桥继承),事件侧直接读变量,不再依赖字符串解析。或者像项目里已经在做的那样,用 Redisson 存一份 channel → tenant 的映射——反正外呼的初始参数本来就要走 Redis 传给 AGI。


六、外呼那一腿:Local 通道与"不阻塞"

action.setChannel("Local/" + phone + "@" + outContext);
action.setContext(outContext);
action.setExten(phone);
action.setAsync(true);
action.setTimeout(30000);

三个决定:

  • 用 Local 通道而不是直接打中继。Local 是 Asterisk 的虚拟通道对,一腿进 dialplan,真正的选路、中继、失败重试都写在 dialplan 里。Java 只负责"触发",不负责"怎么打出去"。改路由不用改代码,职责也清楚。
  • async = true。Originate 默认同步,会一直阻塞到呼叫有结果(最长 30 秒),HTTP 线程扛不住这个。
  • AMI 是软依赖@Autowired(required = false),Asterisk 没起时注入为 null,后端照样能启动,只是呼叫功能降级。所有动作做 null 守护并 warn,不抛异常。这个设计救过我好几次——Asterisk 在另一台机器上,它挂了不代表整个后台该挂。

一个没有架构价值的细节

流程跑完的最终返回值要交给 TTS 念出来。直接念会出问题:数字被念成英文,横杠被念成"减号"。

return StrNumber.coverChinese(
        StrUtil.removeAny(StrUtil.cleanBlank(Fc.toStr(nodeState.proceed(support)), true),
                          "*", "-", "/", "\\"));

清掉空白 → 去掉会被念出声的符号 → 数字转中文读法(123 念"一百二十三")。

顺带一提,原型 Bean 的获取用的是 @Lookup

@Service
public abstract class IvrServiceImpl implements IvrService {
    @Lookup
    protected abstract NodeState createNodeState(List<? extends UserIntent.Node> nodes,
                                                 NodeGranterFactory factory);
}

NodeState@Scope("prototype") 且有状态(持有 currentNode、上下文),每通电话必须是一个新实例。单例 Bean 依赖原型 Bean,除了 @Lookup,还可以用 ObjectProvider 或者作用域代理。我选 @Lookup 是因为它不侵入容器 API,代价是这个类得是 abstract 的。

这段没有任何架构价值,但它是"真的跑过电话"和"跑通了 demo"之间的区别。


最后

回头看,这套东西的本质不是"把大模型接到电话上",而是在一个不允许等待的介质上,编排一堆思考时间不可控的服务

图跳转解决的是"往哪走",惰性 Future 解决的是"等多久",中断取消解决的是"什么时候停",关联 ID 与租户反解解决的是"这通电话属于谁"。所有决策都绕着时间和上下文这两个变量转。

模型能力决定机器人聪不聪明,但决定这通电话会不会被挂掉的,是这些看起来一点也不酷的工程细节。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐