痛点分析:传统客服的“慢”与“卡”
去年双十一,公司老客服系统直接“罢工”——高峰期 300 并发,平均响应时间飙到 8 s,CPU 打满,线程池疯狂拒绝。问题根源一句话就能总结:同步 + 阻塞 + 单体。
- 同步阻塞架构:Tomcat 默认 200 工作线程,一条请求从鉴权、查库、调 NLP 到回包全部占线程,外部 RT 一高,线程池瞬间见底。
- 扩展瓶颈:单体 War 包,水平扩容只是“多复制几个大泥球”,MySQL 主库写 QPS 上限 4 k,成为全局瓶颈。
- 会话状态单机锁:HttpSession 存在内存,负载均衡后粘滞会话(Sticky Session)导致热点节点,重启即丢会话,用户体验“从头开始”。
一句话,老架构在流量洪峰面前就是“纸糊的”。
技术选型:为什么不是 Node.js + MongoDB?
立项评审时,团队有人提议“Node.js + MongoDB”——天生异步 + 文档型数据库,看着很香。我拉了一张对比表:
| 维度 | SpringBoot 2.7 + MySQL 8 | Node.js + MongoDB |
|---|---|---|
| 事务一致性 | 本地事务 + 全局 XA 成熟 | 4.0 才支持多文档事务,性能腰斩 |
| 连接模型 | Reactor(Netty) + 小工作线程 | 单线程 Event-Loop,阻塞即雪崩 |
| 运维生态 | SkyWalking、Arthas 一键诊断 | APM 工具链碎片化 |
| 算法集成 | Python 模型通过 py4j / gRPC 本地调用 | 跨进程加一层 Node 调用,序列化损耗 |
结论:客服系统强事务、低延迟、需对接 Java 家族中间件,SpringBoot 更稳。
前端选型则毫无悬念:Vue3 的 Composition API 让 WebSocket 消息驱动变得响应式,配合<script setup>写起来比 React Hooks 直观,再加 Vite 秒级热更新,开发体验直接拉满。
核心实现:三板斧搞定“问答”闭环
1. 问题分类:Spring Cloud Stream 解耦
把“问题分类”从主链路拆出去,流量高峰时先返回“正在输入”,后台异步消费。
# application-stream.yml spring: cloud: stream: bindings: classify-in: destination: question.classify group: cs-backend classify-out: destination: question.classify kafka: binder: brokers: ${kafka.brokers} configuration: max.poll.records: 500 fetch.min.bytes: 1MB@StreamListener("classify-in") public void handle(QuestionMsg msg) { // 1. 调用 Python BERT 服务 String intent = bertClient.predict(msg.getText()); // 2. 缓存热点意图 redisTemplate.opsForValue() .set("intent:" + msg.getQid(), intent, Duration.ofMinutes(5)); // 3. 向下游“答案聚合” topic 发事件 output.send(MessageFactory.create("answer-in", new IntentMsg(msg.getQid(), intent))); }2. BERT 微调与 RESTful 封装
数据准备:把历史 20 W 条会话按意图打标签,用 TF-IDF 自动标注 + 人工复核,最终 12 W 条高质量语料。
训练脚本(PyTorch):
# train_intent.py from transformers import BertForSequenceClassification, Trainer, TrainingArguments model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=37) training_args = TrainingArguments( output_dir="./intent_model", per_device_train_batch_size=32, learning_rate=2e-5, num_train_epochs=3, logging_steps=100, load_best_model_at_end=True, metric_for_best_model="eval_loss") trainer = Trainer(model=model, args=training_args, train_dataset=train_ds, eval_dataset=eval_ds) trainer.train()训练完导出torchscript,Java 侧用DJL(Deep Java Library)加载,本地推理延迟 18 ms,比 HTTP 调用 Python 服务快 3 倍。
// BertClient.java public class BertClient { private ZooModel<String, Classifications> model; @PostConstruct public void init() throws Exception { model = ModelZoo.loadModel( Criteria.builder() .setTypes(String.class, Classifications.class) .optModelPath(Paths.get("intent_model.pt")) .build()); } public String predict(String text) { try (Predictor<String, Classifications> p = model.newPredictor()) { return p.predict(text).best().getClassName(); } } }3. Vue3 实时对话界面(虚拟滚动优化)
会话列表可能一次加载上千条,DOM 直接爆炸。用vue-virtual-scroller只渲染可视区域。
<!-- ChatWindow.vue --> <script setup lang="ts"> import { ref, nextTick } from "vue"; import { VirtualScroller } from "vue-virtual-scroller"; const messages = ref<Message[]>([]); const ws = new WebSocket(`${import.meta.env.VITE_WS_URL}/chat`); ws.onmessage = (e) => { const msg: Message = JSON.parse(e.data); messages.value.push(msg); nextTick(() => VirtualScroller.scrollToBottom()); }; </script> <template> <VirtualScroller :items="messages" :item-height="56" key-field="msgId" v-slot="{ item }"> <ChatBubble :message="item" /> </VirtualScroller> </template>WebSocket 心跳、断线重连封装在useReconnect(),10 秒无响应自动重连,用户无感。
性能优化:把 QPS 从 80 提到 500
1. JMeter 压测报告
- 单体:4C8G 单节点,80 QPS 时 CPU 95%,99% RT 1200 ms。
- 微服务:同配置拆 3 节点 + Kafka 2 节点,500 QPS 时 CPU 70%,99% RT 180 ms。
瓶颈从“线程”变成“网络”,再扩容 Kafka Partition 即可继续水平扩展。
2. Redis 热点问答缓存策略
把“TOP 1000 问题–答案”对预热到 Redis,key 格式qa:hash(text),TTL 设为7 天滑动窗口 + 随机 1~6 h 抖动,防止集中失效。
String cacheKey = "qa:" + Hashing.murmur3_128() .hashString(text, UTF_8); String ans = redisTemplate.opsForValue().get(cacheKey); if (ans == null) { ans = fetchFromModel(text); redisTemplate.opsForValue() .set(cacheKey, ans, Duration.ofDays(7) .plus(Duration.ofHours(RandomUtils.nextInt(0, 6)))); }命中率 68%,DB 读 QPS 下降 4/5。
避坑指南:分布式会话与中文 NLP 踩坑
1. 会话状态同步 3 方案
- 方案 A:Spring Session + Redis
开箱即用,但序列化体积大,每次读写 2~3 KB,网络 RT 翻倍。 - 方案 B:JWT + 前端存储
无状态,但令牌无法踢人、续期复杂。 - 方案 C:自定义 Redis Hash + 本地二级缓存
最终采用:Hash 存uid->json,本地 Caffeine 缓存 30 s,读写 < 0.3 ms,支持后台踢人、一键下线。
2. 中文 NLP 词向量对齐
同一个“退货”在训练语料里出现“我要退货”“想退掉”等多种表述,BERT 分词器会把“退”与“退货”切成不同 token,导致意图漂移。解决:
- 用Whole Word Masking预训练 1 epoch;
- 数据增强:同义词替换 + 随机插入,提升 4.2% F1;
- 推理侧加后处理校正表:映射口语化词到标准词,降低误分类。
延伸思考:LLM 时代,意图识别还能怎么卷?
BERT 只能做“封闭集”分类,新增意图要重新训练。引入LLM + Prompt做“开放意图”识别:
Prompt: 下面用户问题可能涉及哪些业务意图?请从列表 [订单, 支付, 物流, 账户, 优惠, 其他] 中选一个并给出理由。 用户:我昨天买的东西怎么还没收到? LLM:意图=物流;理由:用户关注配送进度。线上流程:
- 先用 BERT 小模型快速兜底(18 ms);
- 置信度 < 0.7 时异步调 LLM 二次确认;
- 把结果写回消息队列,人工复核后自动加入训练集,实现在线自学习。
实测新增一个意图,原本需要 3 天标注 + 2 天训练,现在 30 分钟 Prompt 热插拔,次日即可生效。
把系统从“能用”推到“好用”其实没黑科技,就是把阻塞变异步、把随机变缓存、把封闭变开放。代码写完只是起点,持续压测、持续喂数据、持续听客服小姐姐吐槽,才是真正的迭代燃料。愿这份踩坑笔记,能让你的客服系统在下个流量洪峰来临时,依旧稳如老狗。