背景痛点:传统客服的“三座大山”
去年公司双11大促,客服系统直接“罢工”——高峰期平均响应时间飙到8s,用户排队上千人,老板在群里连发十几个“”。事后复盘,问题集中在三点:
- 响应慢:同步阻塞+单线程模型,一条消息卡住,后面全排队。
- 意图识别不准:关键词+正则的“Rule-Based”方案,用户换个说法就“鸡同鸭讲”。
- 扩展性差:新业务上线要硬编码规则,发版一次回滚一次,开发天天“996”。
痛定思痛,我们决定用Java重写一套“能听懂人话”的智能客服,目标很朴素:2000 TPS、P99 延迟<500ms、上线不踩坑。
技术选型:为什么放弃“if-else”拥抱NLP
先给两种方案做个对比:
| 维度 | Rule-Based | NLP+Spring Boot |
|---|---|---|
| 意图识别 | 关键词+正则,召回率60% | BERT微调,召回率92% |
| 新意图扩展 | 硬编码+发版,2天 | 标注数据+热更新,2小时 |
| 并发能力 | 单机500 TPS | 单机2000 TPS(线程池+异步) |
| 运维成本 | 低 | 需GPU/TF Serving,Docker一键搞定 |
结论:Rule-Based适合“小作坊”,要抗大流量还得NLP。最终技术栈:
- 框架:Spring Boot 2.7 + Netty WebSocket
- 算法:BERT-base-chinese + TensorFlow Serving 2.8
- 部署:Docker Compose(生产切K8s)
- 缓存:Redis 6.2(对话上下文+意图结果)
- 压测:JMeter 5.5
核心实现:三步让系统“听懂+记住+回复”
1. WebSocket双工通信:一条连接全双工
Spring官方给的STOMP太重,我们直接用Netty手写一个轻量级处理器,代码不到150行,关键片段:
/** * 基于Netty的WebSocket入口 */ @Component public class WsServer { private static final int PORT = 8888; public void start() { EventSpace boss = new NioEventLoopGroup(2); EventLoopGroup worker = new NioEventLoopGroup( Math.max(4, Runtime.getRuntime().availableProcessors() * 2)); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(boss, worker) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast( new HttpServerCodec(), new HttpObjectAggregator(65536), new WebSocketServerProtocolHandler("/chat"), new ChatHandler() // 业务逻辑 ); } }); bootstrap.bind(PORT).sync(); } finally { // 优雅关闭 } } }Channel生命周期绑定用户会话,内存里用一个ConcurrentHashMap<String, Session>维护,key为userId。
2. BERT意图识别:从“字符串”到“向量”只需50ms
模型训练不展开,直接说Java端怎么调TF Serving。核心就两步:预处理→RPC推理。
/** * 意图识别客户端 */ @Service public class IntentService { private final ManagedChannel channel = ManagedChannelBuilder.forTarget("tf-serving:8500").usePlaintext().build(); private final PredictionServiceGrpc.PredictionServiceBlockingStub stub = PredictionServiceGrpc.newBlockingStub(channel); /** * 返回最高概率意图 */ public String predict(String text) { // 1. 分字+转ID List<Integer> inputIds = tokenizer.encode(text); // 2. 组装TensorProto TensorProto tensor = TensorProto.newBuilder() .addAllIntVal(inputIds) .setTensorShape(TensorShapeProto.newBuilder() .addDim(TensorShapeProto.Dim.newBuilder().setSize(1)) .addDim(TensorShapeProto.Dim.newBuilder().setSize(inputIds.size()))) .setDtype(DataType.DT_INT32).build(); // 3. 推理 PredictRequest request = PredictRequest.newBuilder() .setModelSpec(ModelSpec.newBuilder().setName("bert_intent")) .putInputs("input_ids", tensor).build(); PredictResponse response = stub.predict(request); // 4. 解析结果 List<Float> probList = response.getOutputsOrThrow("intent_prob") .getFloatValList(); int idx = IntStream.range(0, probList.size()) .reduce((i, j) -> probList.get(i) > probList.get(j) ? i : j) .orElse(0); return IntentEnum.of(idx).getLabel(); } }注意点:
- 分词器要和训练时保持一致,直接用HuggingFace的
BertTokenizer。 - input_ids最大长度设128,不足补0,超过截断。
- 结果做一层本地缓存,Redis key=
intent:userId,TTL=300s,避免重复调用。
3. 对话状态机:让机器人“记得住说到哪”
如果每次请求都当新会话,用户会崩溃。这里用Spring StateMachine太笨重,自己写个轻量级“状态+上下文”模式:
/** * 对话状态机 */ public class DialogContext { private String userId; private String currentIntent; private int slotIndex; // 当前待填充槽位 private Map<String, String> slots = new HashMap<>(); // 根据意图路由到不同SlotFiller public Optional<String> nextQuestion() { switch (currentIntent) { case "query_order": return OrderSlotFiller.nextQuestion(slotIndex, slots); case "return_goods": return ReturnSlotFiller.nextQuestion(slotIndex, slots); default: return Optional.empty(); } } }所有状态快照序列化后扔Redis,key=dialog:userId,TTL=1800s;用户再次发消息先restore,再驱动状态机,实现“断点续聊”。
性能优化:把2000 TPS榨到极致
1. 线程池公式:拒绝“拍脑袋”
Netty IO线程只负责读写,业务逻辑丢给业务线程池。参数按业界公式:
N_threads = N_cpu * U_cpu * (1 + W/C)- N_cpu=8
- U_cpu目标0.8
- W/C=IO时间/计算时间≈20(调BERT网络IO重)
算出8*0.8*21≈134,取整128。队列用LinkedBlockingQueue,长度5000,拒绝策略CallerRuns,防止突刺把内存打爆。
2. Redis缓存:省下的都是钱
- 意图结果缓存命中率68%,日均少调TF Serving 250万次,GPU机器省下一半。
- 对话上下文用
Hash存储,只存diff,网络IO从12KB降到1.3KB。 - key加随机TTL(300~600s),避免集中过期“雪崩”。
3. 压测数据:用数字说话
JMeter 5.5,200并发,每个线程循环1000次:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| TPS | 890 | 2150 |
| 平均RT | 610ms | 220ms |
| 99RT | 1800ms | 480ms |
| CPU | 70% | 55% |
| OOM次数 | 3 | 0 |
瓶颈最后落在BERT GPU显存,单卡T4上限2500 TPS,再上就得加卡或模型蒸馏。
避坑指南:掉过的坑,希望你别再掉
1. 异步日志别乱用,OOM就在不远处
起初为了“性能”把Logback换成异步AsyncAppender,结果大促日志量暴涨,队列积压到2G+,老年代直接爆。解决:
- 日志队列设上限,
queueSize=2048,满队列丢弃NeverBlock。 - 业务日志&访问日志分离,异步只留访问日志,核心链路同步写,宁可慢点也不丢日志。
2. 第三方API熔断:别让外部拖死自己
短信、物流查询都是外部接口,超时没兜底会雪崩。用Resilience4j一行代码搞定:
CircuitBreaker breaker = CircuitBreaker.ofDefaults("sms"); Supplier<String> decorated = CircuitBreaker .decorateSupplier(breaker, () -> smsClient.send(msg)); Try<String> result = Try.ofSupplier(decorated) .recover(throwable -> "fallback");参数:失败率50%、滑动窗口10s、最小请求数20,触发后先开3s半开,再逐步恢复。
3. 敏感词过滤:正则别“贪婪”
最早.*敏感词.*全匹配,CPU直接100%。优化:
- 用DFA构建敏感词树,复杂度O(n)。
- 预编译
Pattern,用re2j库替换JDK正则,性能提升4倍。 - 敏感词库放Redis,异步刷新,无需重启。
延伸思考:K8s自动扩缩容,让流量“无感”
目前Docker Compose靠人肉起容器,大促前提前扩容30%浪费资源。下一步搬上K8s:
- HPA:根据TF Serving GPU利用率(自定义指标)+ CPU双指标,阈值60%,最小副本2,最大20。
- VPA:自动调Request/Limit,避免“大马拉小车”。
- 蓝绿发布:新模型先在影子环境跑10%流量,对比意图准确率,无误再全量。
再配合Istio做金丝雀,基本可以做到“用户无感,开发睡个好觉”。
整套系统上线三个月,已撑过两次大促,最高峰值2300 TPS,平均响应稳定在250ms。作为老Javaer,最深的体会是:别让“if-else”限制想象力,把计算交给模型,把稳定性交给代码。下一步准备把BERT蒸馏成TinyBERT,再砍一半GPU成本,到时候再来分享。祝你也能早日让客服系统“听懂人话”,少加班,多喝茶。