SpringBoot实战:构建智能客服问答系统的架构设计与实现
开篇:传统客服的“三座大山”
去年我在一家电商公司做后端,客服系统天天被吐槽:
- 响应延迟:高峰期排队 30 秒起步,用户直接关 App。
- 意图误判:“我要退货”被识别成“我要换货”,人工介入率 38%。
- 扩展性差:上新活动就加机器,618 前一周通宵扩容,成本直线上升。
老板一句话:“能不能用 AI 扛掉 80% 重复问题?”于是有了这套 SpringBoot + TensorFlow Lite 的智能问答系统。上线三个月,机器人解决率 72%,平均响应 220 ms,服务器缩了 40%。下面把踩过的坑、调优数据、完整代码一并奉上。
技术选型:为什么不用 Rasa/DialogFlow?
| 方案 | 优点 | 缺点 | 结论 | |---|---|---|---|---| | Rasa | 开源、社区活跃 | Python 运行时,与 Java 主站混布成本高 | 放弃 | | DialogFlow | 谷歌托管、NLU 强 | 按调用量计费、数据出境合规风险 | 放弃 | | 自研 SpringBoot+TF-Lite | 全链路 Java、可离线推理、内存占用 50 MB 以内 | 要自己训练模型 | 采用 |
一句话:团队全是 Java 栈,不想为了聊天机器人再养一套 Python 集群。
核心实现
1. 基于注解的意图识别模块
把 NLU 看成“路由问题”:一句话进来,先分意图,再填槽位。自定义注解@Intent直接挂在方法上,Spring 启动时扫包建立映射,省掉一堆 if-else。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Intent { String value(); // 意图编码 double threshold() default 0.75; // 置信度阈值 } @Component public class IntentRouter { private final Map<String, Method> intentCache = new ConcurrentHashMap<>(); @PostConstruct public void init() { Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Component.class); beans.values().forEach(bean -> MethodIntrospector.selectMethods(bean.getClass(), (MethodIntrospector.MetadataLookup<Method>) method -> Optional.ofNullable(method.getAnnotation(Intent.class)) .map(Intent::value) .orElse(null)) .forEach((intent, method) -> intentCache.put(intent, method)) ); } public IntentResult route(String text) { float[] features = Features.extract(text); // 文本转向量 try (Interpreter tfLite = new Interpreter(modelBuffer)) { float[][] prob = new float[1][intentCache.size()]; tfLite.run(features, prob); int idx = argMax(prob[0]); String intent = (String) intentCache.keySet().toArray()[idx]; double score = prob[0][idx]; return new IntentResult(intent, score); } } }- 模型文件放在
resources/ml/model.tflite,通过modelBuffer = ByteBuffer.wrap(Files.readAllBytes(Paths.get(resource.getURI())))加载。 - 置信度低于阈值直接走“人工兜底”策略,不瞎猜。
2. 对话上下文用 Redis 保持
多轮对话最怕“前一句后一句”对不上。把DialogContext序列化成 Protobuf 塞进 Redis,key 设计为dialog:{userId},TTL 300 s。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, byte[]> redisTemplate(RedisConnectionFactory f) { RedisTemplate<String, byte[]> t = new RedisTemplate<>(); t.setConnectionFactory(f); t.setKeySerializer(RedisSerializer.string()); t.setValueSerializer(RedisSerializer.byteArray()); return t; } } @Service public class ContextHolder { @Autowired private RedisTemplate<String, byte[]> rt; public DialogContext get(String userId) { byte[] val = rt.opsForValue().get("dialog:" + userId); return val == null ? new DialogContext() : DialogContext.parseFrom(val); // Protobuf 反序列化 } public void set(String userId, DialogContext ctx) { rt.opsForValue().set("dialog:" + userId, ctx.toByteArray(), Duration.ofMinutes(5)); } }- Protobuf 比 JSON 省 40% 空间,压测时 8 万并发 QPS 下网络带宽降 32%。
- 设置 TTL 自动清脏数据,避免 Redis 爆炸。
3. 异步响应与 CompletableFuture
客服接口必须 200 ms 内返回,否则前端弹“人工客服”。把 TF-Lite 推理、知识图谱查询、敏感词过滤全扔线程池,主线程只拿CompletableFuture。
@GetMapping("/chat") public DeferredResult<Answer> chat(@RequestParam String userId, @RequestParam String text) { DeferredResult<Answer> dr = new DeferredResult<>(2200L); // 超时 2.2 s dialogService.reply(userId, text) .thenAccept(dr::setResult) .exceptionally(e -> { dr.setErrorResult(new Answer("人工客服已接入", true)); return null; }); return dr; }- 线程池隔离:CPU 密集推理用
ForkJoinPool,I/O 密集查库用CachedThreadPool,互相打不打。 - 超时直接降级到人工,保证用户体验。
代码片段三合一
知识图谱加载的 Spring Bean
@Configuration public class KgConfig { @Bean public Graph graph() throws IOException { Resource r = new ClassPathResource("kg/product.tsv"); Graph g = new Graph(); Files.lines(r.getFile().toPath()) .map(l -> l.split("\t")) .forEach(arr -> g.addEdge(arr[0], arr[1], arr[2])); return g; } }- TSV 格式:头实体、关系、尾实体,启动时一次性载入内存,查询走
graph.findPath(e1,e2),平均 0.8 ms。
限流保护的 @Aspect 实现
@Aspect @Component public class RateLimitAspect { private final RateLimiter rateLimiter = RateLimiter.create(2000); // 每秒 2000 @Around("@annotation(RateLimit)") public Object limit(ProceedingJoinPoint pjp) throws Throwable { if (!rateLimiter.tryAcquire()) { throw new ServiceException("系统繁忙,请稍后再试"); } return pjp.proceed(); } }- 基于令牌桶,高并发下比 Redis Lua 脚本省一次 RTT。
对话状态机核心状态转换
public enum State { IDLE, AWAIT_NAME, AWAIT_PHONE, CONFIRM; public State next(Event e) { switch (this) { case IDLE: if (e == Event.ASK_RETURN) return AWAIT_NAME; break; case AWAIT_NAME: if (e == Event.PROVID_NAME) return AWAIT_PHONE; break; case AWAIT_PHONE: if (e == Event.PROVID_PHONE) return CONFIRM; break; default: } return IDLE; } }- 状态与事件双枚举,单元测试直接
assertEquals(State.CONFIRM, state.next(Event.PROVID_PHONE)),稳。
性能调优实战
JMeter 压测报告(单机 4C8G)
| 指标 | 数值 |
|---|---|
| 并发线程 | 800 |
| 平均 RT | 220 ms |
| P99 | 480 ms |
| 错误率 | 0.3% |
| CPU 峰值 | 72% |
| 内存峰值 | 3.2 GB |
瓶颈卡在 TF-Lite 推理,把线程池提到parallelism=8后 CPU 打满,错误率降到 0.1%。
Protobuf 序列化优化
Redis 存上下文先用 JSON,平均 1.2 KB;换 Protobuf 后 0.7 KB,8 万 QPS 节省 40 MB/s 带宽,顺带降低 GC 次数 15%。
敏感词过滤 DFA 算法
public class SensitiveFilter { private final TrieNode root = new TrieNode(); public SensitiveFilter(List<String> words) { words.forEach(this::insert); } private void insert(String word) { TrieNode node = root; for (char c : word.toCharArray()) { node = node.children.computeIfAbsent(c, k -> new TrieNode()); } node.end = true; } public String replace(String text) { StringBuilder out = new StringBuilder(); for (int i = 0; i < text.length(); i++) { TrieNode node = root; int j = i; while (j < text.length() && node.children.containsKey(text.charAt(j))) { node = node.children.get(text.charAt(j++)); if (node.end) { out.append("***"); i = j - 1; break; } } if (j == i) out.append(text.charAt(i)); } return out.toString(); } }- 10 万条敏感词库加载 0.6 s,替换 1 KB 文本 < 1 ms,比正则快 20 倍。
避坑指南
对话超时处理
只设 Redis TTL 不够,前端断网重连会带新 sessionId,结果旧上下文还在。解决:每次请求带seq序号,服务端发现序号跳变立即清旧 key。多轮对话幂等性
用户狂点“提交”会生成多条工单。在State.CONFIRM阶段把幂等令牌预生成 UUID 返回前端,提交时带回来,服务端用SETNX uuid 1防重。知识图谱热加载
运营天天改“商品别名”,重启太傻。把 TSV 放配置中心,监听RefreshEvent,重新new Graph()后原子替换旧引用,读操作无锁,延迟 50 ms 内完成。
还没完:强化学习怎么玩?
目前状态机是写死的,遇到新活动就得改代码。能不能让机器人自己“试错”学策略?如果把“用户满意度”当奖励,用 Q-Learning 或 Policy Gradient 在线调状态跳转,是不是就能少写一堆 if?你有试过吗,欢迎一起交流。
把代码丢到 GitLab,CI 跑完单测、压测再合并,回滚按钮随时待命。智能客服不是“模型越大越好”,而是把每个环节都调到刚好够用,这才是工程师该干的事。祝你也能让自家客服系统安静一点。