SpringBoot智能客服系统实战:从零搭建高可用问答引擎
背景痛点:规则引擎的“慢”与“笨”
老项目里那套 if-else 规则引擎,高峰期平均响应 1.2 s,意图识别率只有 68 %。
- 每新增一条语料就要人肉改规则,上线周期按天算;
- 同步阻塞模型,Tomcat 线程池被打满后直接 502;
- 无法平滑扩容,双 11 一压就跪。
老板一句话:给一套“能听懂人话、扛得住并发”的新方案,预算还只能买一台 4C8G 的云主机。于是有了这次 SpringBoot + 轻量级 NLP 的踩坑之旅。
技术选型:直接调第三方 OR 自建 NLP?
| 维度 | 直接调百度/阿里接口 | 自建 TF-IDF 模块 |
|---|---|---|
| 成本 | 按量计费,1 千万次 ≈ 4000 元/月 | 一次性 4 核 8 G 即可,电费可忽略 |
| 可控性 | 黑盒,意图变更需提工单 | 代码自己改,10 分钟上线 |
| 延迟 | 公网 80 ~ 200 ms 抖动 | 本地内存计算 5 ~ 15 ms |
| 数据隐私 | 明文外发 | 数据不出机房 |
结论:
- 对并发 < 20 QPS 的小厂,直接买最省事;
- 对“预算紧、需求变更快、数据敏感”的场景,自建更香。
下文全部按“自建”展开,留好扩展点,后续可一键切到第三方。
核心实现:三步搭出最小可用引擎
1. 异步骨架:SpringBoot WebFlux
@SpringBootApplication public static void main(String[] args) { SpringApplication.run(QaApplication.class, args); } @Bean public RouterFunction<ServerResponse> route(QaHandler handler) { return RouterFunctions .route(POST("/qa"), handler::answer); }Handler 里用ReactiveSecurityContext拿用户 ID,全程 Reactor 链,背压由 Netty 自动处理,Tomcat 线程 0 阻塞。
2. 意图识别:TF-IDF + 余弦相似度
语料 < 2 万条时,重型 BERT 性价比太低。
算法步骤:
- 离线把标准问题分词,计算 TF-IDF 权重,生成
Map<String, Double> vector; - 线上用户提问同样分词,实时生成 queryVector;
- 遍历标准库,取
cosineSimilarity(queryVector, stdVector)最高且 > 0.65 的 top1; - 未命中则走默认“转人工”兜底。
核心代码(带异常兜底):
public Mono<String> recognize(String query) { return Mono.fromCallable(() -> { try { Map<String, Double> qv = toTfidfVector(query); return repository.findAll() // 内存 List .parallel() .max(Comparator.comparingDouble( s -> cosine(qv, s.getVector()))) .filter(p -> cosine(qov, p.getVector()) >= 0.65) .map(StandardQa::getAnswer) .orElse("人工"); } catch (Exception e) { log.error("intent_recognize_error, query={}", query, e); return "人工"; } }).subscribeOn(Schedulers.boundedElastic()); // 计算密集型任务扔线程池 }3. 熔断保护:Feign + Sentinel
热点第三方接口(如物流查询)仍可能走外部,必须熔断:
@FeignClient(name = "logistics", fallback = LogisticsFallback.class) public interface LogisticsClient { @GetMapping("/logistics/{orderId}") Mono<LogisticsDTO> track(@PathVariable String orderId); } @Component class LogisticsFallback implements LogisticsClient { @Override public Mono<LogisticsDTO> track(String orderId) { log.warn("logistics_circuit_open, orderId={}", orderId); return Mono.just(LogisticsDTO.empty()); } }关键参数(application.yml):
feign: circuitbreaker: enabled: true failure-rate-threshold: 50 # 50 % 错误率即打开 wait-duration-in-open-state: 5s生产级考量:让老板放心睡 double 11
1. 压测数据:线程池大小对吞吐的影响
JMeter 200 并发线程,循环 5 min,不同spring.task.execution-thread结果:
| 线程池大小 | 平均 RT | 95 % RT | 吞吐/sec |
|---|---|---|---|
| 50 | 180 ms | 350 ms | 920 |
| 200 | 120 ms | 210 ms | 1650 |
| 400 | 115 ms | 200 ms | 1680 |
再往上 CPU 打满,收益递减。最终线上设 200 + 动态伸缩。
2. 敏感词过滤:AOP 一行注解搞定
@Aspect @Component @Slf4j public class SensitiveAspect { @Around("@annotation(SensitiveCheck)") public Object filter(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); for (int i = 0; i < args.length; i++) { if (args[i] instanceof String) { args[i] = SensitiveUtil.replace((String) args[i]); } } return pjp.proceed(args); } }配合 DFA 词库 0.3 ms 内完成 2 万词匹配,吞吐量几乎无损失。
避坑指南:那些官方文档没写的坑
1. SpringCache ≠ WebFlux 的好朋友
@Cacheable默认线程模型与 Reactor 调度器不一致,高并发下出现ReactiveReadTimeout。
解决:弃用 SpringCache,改用Caffeine直接Mono.fromCallable(...).cache(),或者ReactorCache封装。
2. ThreadLocal 在异步链里会丢
SecurityContextHolder传统 ThreadLocal 模式,在publishOn切换线程后直接 NPE。
解决:把用户 ID 提前transform到 Reactor Context,下游通过Mono.deferContextual读取,全程无 ThreadLocal。
互动环节:给一段“慢”代码,等你来 PR
下面这段故意把recognize写成同步 + 数据库轮询,RT 飙到 600 ms,CPU 飙到 80 %。
仓库地址(GitHub 私有,镜像到 Gitee):https://gitee.com/yourname/springboot-qa
欢迎提 PR,要求:
- 保持接口不变;
- 平均 RT < 150 ms;
- 单实例 QPS > 1500;
- 代码必须加日志与异常处理。
前 3 名合并后送《Reactor 实战》纸质书。
小结与下一步
- 先用 WebFlux 搭异步骨架,解决“慢”;
- 用 TF-IDF 轻量算法,解决“笨”;
- 用 Feign+Sentinel 兜底,解决“挂”;
- 压测、AOP、避坑三板斧,解决“上线就翻车”。
整套代码已跑在测试环境两周,目前 8 QPS 稳如老狗。下一步把标准问题库做成向量索引,再引入语义槽位解析,让机器人不仅能“答”,还能“问”。
如果你也踩过客服系统的坑,欢迎评论区交换血泪史,一起把机器人调教得更像人。