背景痛点:传统客服系统“三座大山”
去年双十一,我们老客服系统直接“罢工”——高峰期 3k 并发,CPU 飙到 95%,用户平均等待 18s 才收到“人工客服请排队”。复盘发现三大硬伤:
- 单体服务里“查询-意图-回复”全挤在一个线程池,排队效应指数级放大。
- 对话状态放在 JVM 内存,重启即丢,用户重连后得把“我要退货”再说一遍。
- 关键词正则匹配意图,新活动上线一次就要发版,准确率 68%,客诉率却 20%。
痛定思痛,老板拍板:三个月内重构一套“高并发、不丢话、懂人话”的智能客服。于是有了这篇踩坑笔记。
架构设计:为什么不是“一把梭哈”单体
先画个对比表:
| 维度 | 单体 | 微服务 |
|---|---|---|
| 扩容粒度 | 整包扩容,浪费 | 按需扩“对话服务”或“NLU 服务” |
| 发布影响 | 改一句正则全站重启 | 只热更“意图服务” |
| 语言混搭 | 全 Java | Python 做模型,Java 做事务,各取所长 |
| 故障半径 | 一挂全挂 | 超时降级、快速熔断 |
技术选型:
- Spring Cloud:团队最熟,生态全,Gateway 自带熔断。
- RabbitMQ:可靠队列+延迟消息,天然支持“超时重试”。
- Redis:轻量级 KV,<1ms 延迟,对话上下文 TTL 自动过期,省掉自己写清理线程。
系统总览(Mermaid):
graph TD A[客户端/Web] -->|WS| B(Gateway) B --> C[对话服务<br/>Spring Boot] C -->|发布事件| D[(RabbitMQ)] D -->|消费| E[意图服务<br/>Python/BERT] E -->|回包| D D --> C C --> F[(Redis<br/>对话状态)] C --> G[订单/商品服务<br/>Feign]核心实现一:BERT 意图分类(Python)
需求:支持 32 个业务意图,<150ms 返回,准确率≥90%。
模型选型:BERT-base-Chinese → 蒸馏 微调 3epoch,量化 int8,推理 90ms→40ms。
代码片段(含异常兜底):
# intent_service.py import torch, json, os, logging from transformers import BertTokenizer, BertForSequenceClassification from starlette.applications import Starlette from starlette.responses import JSONResponse import uvicorn MODEL_PATH = "/model/bert-intent" ID2LABEL = {0: "退货", 1: "查物流", 2: "修改地址", 31: "人工"} try: tokenizer = BertTokenizer.from_pretrained(MODEL_PATH) model = BertForSequenceClassification.from_pretrained(MODEL_PATH) model.eval() except Exception as e: logging.error("模型加载失败", exc_info=True) raise RuntimeError("NLU 无法启动") from e async def predict(sentence: str): try: inputs = tokenizer(sentence, return_tensors="pt", truncation=True, max_length=64) with torch.no_grad(): logits = model(**inputs).logits probs = torch.nn.functional.softmax(logits, dim=-1) idx = int(torch.argmax(probs)) confidence = float(probs[0][idx]) return {"intent": ID2LABEL.get(idx, "未知"), "confidence": confidence} except Exception as e: logging.exception("predict error") # 降级返回兜底意图 return {"intent": "人工", "confidence": 0.0} app = Starlette(debug=False) @app.route("/intent", methods=["POST"]) async def intent_endpoint(request): data = await request.json() result = await predict(data.get("q", "")) return JSONResponse(result) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=7001)部署小贴士:Gunicorn + 1worker*4thread 足以抗 1k QPS,显存只占 1.2G。
核心实现二:状态机多轮对话(Java)
需求:用户说“我要退货”→校验订单→选择退货原因→提交,全程 5min 内有效,支持超时重试。
技术方案:Spring StateMachine + Redis 持久化 + RabbitMQ 延迟队列(DLX)做“闹钟”。
关键代码(精简可运行):
@Configuration @EnableStateMachine(name = "csStateMachine") public class CSStateMachineConfig extends StateMachineConfigurerAdapter<String, String> { public static final String STATE_INIT = "INIT"; public static final String STATE_AWAIT_ORDER = "AWAIT_ORDER"; public static final String STATE_AWAIT_REASON = "AWAIT_REASON"; public static final String EVENT_REASON_OK = "REASON_OK"; @Override public void configure(StateMachineStateConfigurer<String, String> states) throws Exception { states.withStates() .initial(STATE_INIT) .states(Set.of(STATE_AWAIT_ORDER, STATE_AWAIT_REASON, "CONFIRM")); } @Override public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception { transitions.withExternal().source(STATE_INIT).target(STATE_AWAIT_ORDER).event("ASK_ORDER") .and() .withExternal().source(STATE_AWAIT_ORDER).target(STATE_AWAIT_REASON).event("ORDER_OK") .and() .withExternal().source(STATE_AWAIT_REASON).target("CONFIRM").event(EEVENT_REASON_OK); } } @Service public class DialogueService { @Autowired private StateMachineFactory<String,String> factory; @Autowired private StringRedisTemplate redis; private static final String PREFIX = "dialog:"; private static final int TTL_SEC = 300; // 5min // 每次消息入口 public String handle(String userId, String text){ String key = PREFIX + userId; String stateStr = redis.opsForValue().get(key); StateMachine<String,String> sm; if(stateStr==null){ sm = factory.getStateMachine(userId); sm.start(); }else{ sm = restore(userId, stateStr); } // 省略:调意图服务拿 intent sm.sendEvent(convertIntent2Event(text)); persist(sm, key); return generateReply(sm); } private void persist(StateMachine<String,String> sm, String key){ // 序列化状态到 JSON String json = StateJsonUtil.serialize(sm); redis.opsForValue().set(key, json, TTL_SEC, TimeUnit.SECONDS); } private StateMachine<String,String> restore(String userId, String json){ StateMachine<String,String> sm = factory.getStateMachine(userId); StateJsonUtil.deserialize(sm, json); return sm; 疏漏点:状态机 restore 后,旧实例没关会内存泄漏,记得 stop()。 }超时重试:RabbitMQ 延迟队列 5min 后投递“TIMEOUT”事件,状态机捕获后自动清除 Redis key 并提示“会话已过期”。
性能优化:压测与缓存
JMeter 线程组 500,Ramp-up 30s,循环 20 次,测得:
- 老系统:平均 QPS 210,RT 2.3s,错误率 18%
- 新系统:平均 QPS 830,RT 280ms,错误率 <1%
吞吐量提升 ≈ (830-210)/210 ≈ 300%,达成目标。
Redis 对话缓存 TTL 策略:
- 正常流程:300s 固定过期
- 用户主动结束/取消:立即 del,节省内存
- 大促预热:把 TTL 调到 600s,防止集中重连打爆 DB
内存占用峰值 8G(约 80w 进行中的对话),成本可接受。
避坑指南:敏感数据 & 幂等
日志脱敏:
正则匹配手机号、身份证、订单号,统一替换为$$1****5678。使用 LogbackMaskingPatternLayout,业务代码零侵入。幂等性:
对话服务对外接口全部带Idempotency-Key,网关层做 15min 去重表。用户重试点击只返回第一次结果,避免生成重复工单。小坑:
Spring Cloud 2021 版默认关闭hystrix,开启resilience4j后一定记得配timeoutDuration,否则 Feign 默认 1min,会把整个链路拖垮。
生产建议:监控与可观测
- 业务指标:意图识别准确率、任务完成率、平均轮次,通过 Micrometer + Prometheus 15s 采集。
- 系统指标:QPS、RT、线程池队列长度,Grafana 大盘一目了然。
- 模型指标:Python 侧暴露
/metrics,统计推理耗时、GPU 利用率,低于 80% 自动扩容 Pod。 - 告警:准确率跌 5% 或 RT>P99 1s 持续 3min,立即飞书 + 电话。
开放问题:如何平衡模型精度与推理延迟的关系?
我们在 INT8 量化后掉点 1.2%,但延迟腰斩;若用知识蒸馏到 ALBERT 可再提速 30%,却掉点 2.8%。你的业务愿意牺牲多少准确率换速度?欢迎留言一起探讨。