SmartCall 自定义扩展实战:手把手实现专属拦截器与工具处理器

2 阅读8分钟

前言

SmartCall 的技能编排引擎提供了配置化的通用能力:通过页面配置即可实现关键词转人工、定时提醒、HTTP 接口调用等常见逻辑,覆盖了 80% 的标准业务场景。 但在实际落地中,很多场景需要更深的定制:比如对话情绪识别、自研业务系统对接、专属质检规则、复杂状态机控制等,仅靠配置化技能无法满足。

得益于插件化的扩展架构,SmartCall 预留了标准扩展接口,开发者只需实现接口并注册为 Spring Bean,即可将自定义逻辑无缝织入通话生命周期,全程零侵入核心通话代码,也不影响原有配置化能力。

源码核心包路径:smart-aster/src/main/java/com/qidiangk/smart/aster/realtime

  • 拦截器扩展:intercept.RealtimeTurnInterceptor
  • 工具扩展:function.FunctionCallHandler

一、扩展体系基础

1.1 两大标准扩展接口

整个扩展体系围绕两个顶层接口展开,分别对应「平台主动触发」和「模型自主调用」两类场景:

接口定位运行时机
RealtimeTurnInterceptor拦截器扩展点通话生命周期各挂载点主动触发
FunctionCallHandler工具扩展点大模型通过 Function Calling 自主调用

1.2 自动注册与装配机制

SkillRegistry 在服务启动时,会自动扫描 Spring 容器中所有上述接口的实现类,纳入全局注册表。 每次通话建立时,再通过会话级装配将全局扩展与智能体私有配置技能合并,形成本次通话的执行链。

  • 全局扩展(编码实现的 Bean):对所有智能体、所有通话生效;
  • 配置化技能(页面配置):仅对当前智能体生效;
  • 两者合并排序,互不污染,扩展开发完全不影响原有配置功能。

1.3 内置通用实现(简览)

页面配置的技能,底层也基于同一套接口实现,是通用化的配置驱动实现:

  • DynamicTurnInterceptor:数据库配置驱动的通用拦截器,对应页面「拦截器型技能」;
  • DynamicFunctionHandler:配置驱动的通用工具处理器,对应页面「工具型技能」。

本文重点讲解编码级自定义扩展,通用实现不做展开。

二、实战一:自定义拦截器扩展

2.1 接口说明

RealtimeTurnInterceptor 是拦截器的顶层接口,提供 5 个生命周期钩子和 1 个排序方法:

public interface RealtimeTurnInterceptor {
    // 执行顺序,数值越小越优先
    default int order() { return 0; }
    // 会话启动
    default void onSessionStart(TurnContext context) {}
    // 用户说完话时触发,返回true表示认领本轮
    default boolean interceptUserText(TurnContext context) { return false; }
    // 模型回复前
    default void beforeTurn(TurnContext context) {}
    // 模型回复后
    default void afterTurn(TurnContext context) {}
    // 会话结束
    default void onSessionEnd(TurnContext context) {}
}

2.2 扩展步骤

  1. 新建类实现 RealtimeTurnInterceptor 接口;
  2. 类上标注 @Component,由 Spring 容器管理;
  3. 重写对应生命周期钩子方法,写入业务逻辑;
  4. 服务启动自动注册,所有智能体通话生效。

2.3 完整实战案例:敏感词质检拦截器

需求:每轮对话结束后,检测用户与 AI 回复是否包含敏感词;命中则记录质检日志,命中次数达到阈值自动转人工坐席。

完整可运行代码:

package com.qidiangk.smart.aster.realtime.intercept.impl;

import com.qidiangk.smart.aster.realtime.intercept.RealtimeTurnInterceptor;
import com.qidiangk.smart.aster.realtime.intercept.TurnContext;
import com.qidiangk.smart.aster.realtime.skill.engine.SkillActionExecutor;
import com.qidiangk.smart.aster.realtime.skill.dto.ActionDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Arrays;
import java.util.List;

/**
 * 自定义敏感词质检拦截器
 * 每轮对话后检测敏感词,命中阈值自动转人工
 */
@Slf4j
@Component
public class SensitiveWordInterceptor implements RealtimeTurnInterceptor {

    // 执行顺序,放在通用配置拦截器之后
    @Override
    public int order() {
        return 200;
    }

    // 模拟敏感词库,实际项目可接入外部词库
    private static final List<String> SENSITIVE_WORDS = Arrays.asList(
            "投诉到监管", "曝光", "媒体", "起诉"
    );

    private static final String ATTR_HIT_COUNT = "sensitive_hit_count";
    private static final int THRESHOLD = 2;

    @Resource
    private SkillActionExecutor actionExecutor;

    @Override
    public void afterTurn(TurnContext context) {
        // 耗时检测异步执行,不阻塞音频线程
        ThreadUtil.execAsync(() -> checkSensitive(context));
    }

    private void checkSensitive(TurnContext context) {
        String userText = context.getUserText();
        String botText = context.getBotText();

        int hitCount = getHitCount(context);

        // 检测用户侧
        for (String word : SENSITIVE_WORDS) {
            if (userText != null && userText.contains(word)) {
                hitCount++;
                log.warn("[敏感词质检] 用户侧命中:{},通话ID:{}", word, context.getChannelId());
            }
            if (botText != null && botText.contains(word)) {
                hitCount++;
                log.warn("[敏感词质检] AI侧命中:{},通话ID:{}", word, context.getChannelId());
            }
        }

        // 更新命中次数到会话变量
        context.setAttr(ATTR_HIT_COUNT, hitCount);

        // 达到阈值触发转人工
        if (hitCount >= THRESHOLD) {
            log.info("[敏感词质检] 命中阈值,自动转人工,通话ID:{}", context.getChannelId());
            
            // 复用标准动作执行器执行转人工
            ActionDTO.Transfer transfer = new ActionDTO.Transfer();
            transfer.setSeatName("complaint_group");
            transfer.setAnnounceContent("检测到对话风险,正在为您转接专员,请稍候。");
            
            ActionDTO action = new ActionDTO();
            action.setType(SkillActionTypeEnum.TRANSFER);
            action.setTransfer(transfer);
            
            actionExecutor.execute(action, context, SkillHookPointEnum.AFTER_TURN);
        }
    }

    private int getHitCount(TurnContext context) {
        Object count = context.getAttr(ATTR_HIT_COUNT);
        return count == null ? 0 : (int) count;
    }
}

验证方式:

  1. 启动服务,查看启动日志,确认 [Enrollment] 列表中出现该拦截器;
  2. 拨打测试电话,对话中触发敏感词,观察是否自动转人工;
  3. 查看服务日志,确认质检日志输出正常。

三、实战二:自定义工具处理器扩展

3.1 接口说明

FunctionCallHandler 是工具型扩展的顶层接口,大模型发起 Function Calling 时按工具名路由到对应处理器。

public interface FunctionCallHandler {
    // 返回工具定义(下发给模型的描述与参数结构),返回null表示不暴露
    default Map<String, Object> toolDefinition() { return null; }
    // 是否处理该工具调用
    default boolean supports(FunctionCallRequest request) { return true; }
    // 执行工具调用,callback 必须且仅能调用一次
    void handle(FunctionCallRequest request, TurnContext context, Consumer<FunctionCallResult> callback);
}

3.2 扩展步骤

  1. 新建类实现 FunctionCallHandler 接口;
  2. 标注 @Component,Spring 自动扫描注册;
  3. 实现 toolDefinition() 返回工具元数据;
  4. 实现 supports() 按工具名路由;
  5. 实现 handle() 编写业务逻辑,通过 callback 返回结果。

3.3 完整实战案例:自定义内部订单查询工具

需求:对接企业内部订单系统,让大模型可以根据订单号查询订单状态、物流信息,并用自然语言回复用户。

完整可运行代码:

package com.qidiangk.smart.aster.realtime.function.impl;

import com.qidiangk.smart.aster.realtime.function.FunctionCallHandler;
import com.qidiangk.smart.aster.realtime.function.FunctionCallRequest;
import com.qidiangk.smart.aster.realtime.function.FunctionCallResult;
import com.qidiangk.smart.aster.realtime.intercept.TurnContext;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * 自定义内部订单查询工具
 * 对接企业内部订单系统,支持按订单号查询详情
 */
@Slf4j
@Component
public class InnerOrderQueryHandler implements FunctionCallHandler {

    private static final String TOOL_NAME = "inner_order_query";

    @Override
    public Map<String, Object> toolDefinition() {
        Map<String, Object> tool = new LinkedHashMap<>();
        tool.put("type", "function");
        tool.put("name", TOOL_NAME);
        tool.put("description", "查询企业内部订单的状态、物流、金额等详情,需要用户提供订单号");

        // 参数定义
        Map<String, Object> properties = new LinkedHashMap<>();
        Map<String, Object> orderNo = new LinkedHashMap<>();
        orderNo.put("type", "string");
        orderNo.put("description", "要查询的订单编号,如ORD20261001001");
        properties.put("orderNo", orderNo);

        Map<String, Object> parameters = new LinkedHashMap<>();
        parameters.put("type", "object");
        parameters.put("properties", properties);
        parameters.put("required", List.of("orderNo"));
        tool.put("parameters", parameters);

        return tool;
    }

    @Override
    public boolean supports(FunctionCallRequest request) {
        // 按工具名精确路由
        return TOOL_NAME.equals(request.getName());
    }

    @Override
    public void handle(FunctionCallRequest request, TurnContext context, Consumer<FunctionCallResult> callback) {
        // 必须切异步线程,不阻塞模型事件线程
        ThreadUtil.execAsync(() -> {
            try {
                // 解析入参
                JSONObject args = parseArgs(request.getArguments());
                String orderNo = args.getString("orderNo");

                log.info("[内部订单查询] 开始查询,订单号:{},通话ID:{}", orderNo, context.getChannelId());

                // 调用内部业务接口(示例)
                // 实际项目替换为真实内部服务调用:RPC、HTTP、数据库查询等
                JSONObject orderInfo = queryInnerOrder(orderNo);

                // 返回结果给大模型
                callback.accept(FunctionCallResult.success(
                        request.getCallId(),
                        orderInfo.toJSONString()
                ));

            } catch (Exception e) {
                log.error("[内部订单查询] 执行失败,通话ID:{}", context.getChannelId(), e);
                // 失败也必须回调,否则模型会一直等待
                callback.accept(FunctionCallResult.fail(
                        request.getCallId(),
                        "订单查询失败,请稍后重试"
                ));
            }
        });
    }

    private JSONObject parseArgs(String arguments) {
        if (arguments == null || arguments.isBlank()) {
            return new JSONObject();
        }
        return JSONObject.parseObject(arguments);
    }

    /**
     * 模拟内部订单查询接口
     * 实际项目替换为真实业务调用
     */
    private JSONObject queryInnerOrder(String orderNo) {
        JSONObject result = new JSONObject();
        result.put("orderNo", orderNo);
        result.put("status", "运输中");
        result.put("logistics", "顺丰速运,预计明天送达");
        result.put("amount", "1299.00");
        result.put("createTime", "2026-10-01 14:30:00");
        return result;
    }
}

验证方式:

  1. 启动服务,确认启动日志中该处理器已注册;
  2. 在智能体配置中,无需额外配置,工具自动对所有智能体可用;
  3. 拨打测试电话,让 AI 查询订单号,确认是否正确调用工具并返回结果。

四、扩展最佳实践

1. 线程约束

拦截器的钩子方法、工具的 handle 方法,默认运行在模型事件线程。 严禁在此线程执行耗时操作(数据库、HTTP、复杂计算),必须切换到业务线程池异步执行,否则会阻塞音频流,导致通话卡顿。

2. 回调幂等

FunctionCallHandler 的 callback 必须且仅能调用一次。 成功、失败都要回调,否则大模型会一直等待工具返回,导致对话卡死。

3. 异常隔离

自定义扩展内部的异常尽量捕获处理,不要抛到通话主线程,避免因扩展逻辑异常导致通话中断。 日志建议加上统一前缀,便于排查问题。

4. 状态隔离

不要在实现类中持有跨会话的可变状态。每次通话、每次工具调用都是独立的,状态数据存入 TurnContext 的属性中,随会话生命周期销毁。

5. 顺序控制

拦截器通过 order() 控制执行顺序:

  • 数值越小越先执行;
  • 通用前置校验建议设小值,后置业务处理建议设大值;
  • 配置化技能与编码扩展混排,统一按 order 排序。

五、总结

SmartCall 的插件化扩展体系,真正做到了「配置解决通用问题,编码解决定制问题」:

  • 普通业务:运营通过页面配置技能,分钟级上线;
  • 复杂业务:开发通过标准接口扩展,零侵入核心代码;
  • 两者共用同一套执行器、同一套上下文、同一套生命周期,体验一致、维护成本低。

对于需要深度对接内部系统、有专属业务规则、追求差异化能力的团队,这套扩展机制既能保留开源项目的自主可控,又能快速支撑业务定制,是企业级落地的重要技术保障。

资源地址

📘 官方技术文档:SmartCall — 大模型 AI 客服呼叫中心
⭐ Gitee 开源仓库:gitee.com/gdzWork/Sma…
🌐 官方网站:qidiangk.com