背景痛点:规则客服的“三板斧”失灵了
去年双十一,公司老客服系统直接“罢工”。
用户问“我订单拆成两包了,运费险怎么算?”——规则引擎里只有“运费险 退运费”关键词,答非所问,满意度掉到 62%。
更惨的是,为了扛 2 k 并发,运维同学横向加了 8 台节点,结果会话状态放在本地 Map 里,用户被轮流踢到不同机器,秒变“复读机”。
痛定思痛,老板拍板:必须上 AI,还要能横向扩,最好 Java 一把梭。于是有了这套 SpringBootAI 智能客服。
技术选型:为什么留在 JVM 舒适区
- 纯 Python 方案
- 训练+推理一条龙,开源模型多
- 但线上没有 Python 基建,网关、限流、链路追踪全在 Java 侧,跨语言调用 RT 多 20 ms,排障还要两边抓包
- SpringBoot + TensorFlow Lite
- tf-lite-java 包只有 3.4 MB,BERT 模型量化后 48 MB,直接放 classpath,推理走 JNI,一次 GC 停顿 < 10 ms
- 限流、熔断、监控全部用现有 Spring Cloud 组件,0 额外学习成本
- 最重要的:Redis、Kafka 客户端对 JVM 最友好,背压机制用 Reactor 就能玩,Python 侧还要自己撸 asyncio
结论:团队 90% 是 Java 栈,留在 JVM 等于“省一支运维团队”。
核心实现:让对话“长”在 Redis 上
- 长连接:WebSocket 统一入口
- 端口 8080,Nginx 四层转发,SSL 终止在网关,减少证书热更新麻烦
- 路径
/chat/{userId},建立后先塞一条 ack,防 502
- 分布式会话:Redis Hash 存上下文
- key =
chat:${userId},field =turn:${turnId},value = JSON(问、答、意图、置信度、时间戳) - 设置 30 min 过期,后台定时任务做“半过期”续期,避免高峰大量穿透
- key =
- 异步流水线:Kafka + 自定义线程池
- WebSocket 收到消息 → 限流 → 写 Kafka(topic: chat.in)→ 消费组 8 分区 → 业务线程池(核心 16,最大 32,队列 2 k)
- 推理完 → 写 Kafka(topic: chat.out)→ WebSocket 回推用户
- 背压用 Kafka lag 做 HPA 自动扩容,QPS 从 1 k 飙到 5 k 只加了 2 个 Pod
代码示例:三板斧直接落地
下面这段 Controller 把“限流、缓存、异常”一口气打包,Alibaba 规范扫描 0 警告。
@RestController @RequestMapping("/chat") @RequiredArgsConstructor public class ChatController { private final KafkaTemplate<String, ChatRequest> kafka; private final RedisTemplate<String, ChatTurn> redisTemplate; private static final String CHAT_KEY = "chat:%s"; /** * 基于令牌桶的限流,每秒 20 个请求,突发 50 */ @GetMapping("/limit") @RateLimiter(name = "chat-limit", fallbackMethod = "limitFallback") public String limitDemo() { return "pass"; } /** * WebSocket 入口,保存上下文 */ @MessageMapping("/chat.send") public void handle(ChatRequest req, SimpMessageHeaderAccessor header) { String userId = header.getUser().getName(); // 1. 写 Redis:先占坑,防重 String key = String.format(CHAT_KEY, userId); ChatTurn turn = new ChatTurn(req.getQuestion(), null, System.currentTimeMillis()); redisTemplate.opsForHash().put(key, "turn:" + req.getTurnId(), turn); redisTemplate.expire(key, Duration.ofMinutes(30)); // 2. 发 Kafka 异步推理 kafka.send("chat.in", userId, req); } /** * 全局异常捕获,脱敏后写日志 */ @ControllerAdvice public static class ChatAdvice { @ExceptionHandler(value = Exception.class) public void handle(Exception ex, SimpMessageHeaderAccessor header) { log.error("user:{} msg:{}", header.getUser().getName(), ex.getMessage().replaceAll("\\d{4,}", "****")); } } }要点解释
RateLimiter用 resilience4j,注解方式零侵入- RedisTemplate 自己写了一个
HashMapper<ChatTurn, String, String>,省内存 30% - 异常日志把连续 4 位以上数字脱敏,防手机号泄露
生产考量:压测、敏感词、脱敏一个都不能少
- 压力测试
JMeter 脚本核心:- 200 线程,每秒新建 40 条 WebSocket,共用 10 万条真实聊天记录做 body
- 断言 < 600 ms 且错误率 < 1%,跑 30 min
- 观察 Kafka lag、CPU load、GC 停顿,发现 G1 最大停顿 280 ms,换 ZGC 后降到 12 ms
- 敏感词过滤
DFA 构造 6 k 词库,占内存 1.2 MB,敏感词匹配 0.08 ms/条。更新词库时双数组切换,无锁。 - 日志脱敏
对话落盘前先跑正则\\b\\d{4}\\b,把 4 位连续数字替换成****,再存 ES。ES 模板关闭_source的norms,省 20% 磁盘。
避坑指南:踩过的坑,帮你先埋好
- WebSocket 心跳
服务端setHeartbeatSec(25),Nginx 默认 proxy_read_timeout 60 s,看似够用。
实际 K8s Ingress 层 30 s 就断,用户看到 1006。解决:Ingress 注解nginx.ingress.kubernetes.io/proxy-read-timeout: "120",前后对齐。 - 线程池参数
经验公式:core = Ncpu * 2,max = core * 2,queue = core * 100
但推理任务 IO 占比 70%,换公式:core = Ncpu * (1 - 0.7) * 2,max = core * 3,queue = 0(SynchronousQueue),防任务堆积导致 Full GC - 模型热更新
tf-lite 模型文件通过 ConfigMap 挂进 Pod,滚动发布时旧模型被卸载,但 JNI 层忘了close(),metaspace 只增不减。
解决:用@PreDestroy显式Interpreter.close(),再配-XX:MaxMetaspaceSize=256m,压测 20 轮无泄漏。
效果数据:跑出来的才是真的
上线两周,核心指标:
- 意图识别准确率 92.3%(老规则 52%)
- 平均响应 320 ms(老系统 1.2 s)
- 双 11 峰值 5 k QPS,CPU 65%,内存 4 G,零宕机
- 运维人数从 3 人降到 0.5 人(半个人偶尔看看 Grafana)
还没完:精度与延迟的跷跷板怎么踩?
模型越大,意图越准,可 GPU 推理 1 s 往上,用户早跑了;模型蒸馏后 30 ms,却偶尔把“开发票”当成“开发票据”。
开放问题留给你:
- 动态路由方案——置信度高走大模型,低就走小模型?
- 还是客户端本地先跑轻量模型,云端兜底?
- 又或者把缓存做到意图层,用 Redis 把“常见问题”直接当 KV 查?
欢迎在评论区聊聊你的解法,一起把智能客服做成“既聪明又不拖沓”的样子。