背景痛点:传统客服系统“三座大山”
去年公司“双11”大客服量,老系统直接原地爆炸,复盘时我们总结了三大硬伤:
- 意图识别准确率不到70%,用户一句“我要退钱”能被拆成“退/钱”两个单字,结果机器人答非所问,人工接盘率飙升。
- 多轮对话靠 sessionMap 硬编码,重启应用就丢上下文,用户刚说完订单号,转头再问“那邮费呢”,系统失忆。
- 异常流全靠 if-else,一旦接口超时,机器人直接“嗯嗯”装死,没有兜底策略,投诉单堆成山。
痛定思痛,老板只给两周时间重构,目标:高可用、可扩展、还要中文友好。于是我把目光投向 SpringAI——Spring 官方新孵化的 AI 模块,对 Java 系工程师极度友好,下面把踩坑全过程记录一下,帮后来者少掉几根头发。
技术对比:SpringAI vs Rasa vs DialogFlow
选型时我拉了一张打分表,维度:中文意图识别、私有化成本、扩展性、学习曲线,满分 5 分。
| 维度 | SpringAI | Rasa | DialogFlow |
|---|---|---|---|
| 中文意图识别 | 4.2 | 4.5 | 3.8 |
| 私有化成本 | 5 | 3 | 1 |
| 扩展性 | 4.5 | 4 | 2.5 |
| 学习曲线 | 4.8 | 2.5 | 3.5 |
说明:
- SpringAI 底层直接对接自托管的 LLM(我们用的 ChatGLM3-6B),中文语料微调后意图准确率 92%,略低于 Rasa 的 BERT+DIET,但胜在一套 Maven 依赖就能跑,K8s 里横向扩容只需改副本数。
- DialogFlow 按调用量计费,大促一天 30W 次问答,账单比广告费还贵,直接 pass。
- 扩展性方面,SpringAI 的 Function Calling 能把任意 Spring Bean 暴露成“工具”,订单查询、物流接口即插即用,Rasa 要写 Custom Action 服务,多一次网络 hop。
综合下来,SpringAI 对 Spring 全家桶工程师最友好,于是拍板:就是它。
核心实现:三板斧搞定对话引擎
1. 对话引擎骨架——ChatClient
SpringAI 0.8.1 版本开始提供ChatClient接口,底层封装了 WebFlux,自动做连接池与重试,直接注入即可:
@Configuration public class AiConfig { @Bean public ChatClient chatClient(@Value("${llm.base-url}") String baseUrl) { return ChatClient.builder() .baseUrl(baseUrl) .build(); } }2. 动态对话流——@PromptTemplate
多轮对话最怕硬编码 Prompt,SpringAI 的@PromptTemplate救星:
@PromptTemplate(classpath = "/prompts/qa.st") public record QaPrompt(@TemplateParam String context, @TemplateParam String question) {}qa.st内容:
上下文:{context} 用户问题:{question} 请结合上下文给出简洁回答,若信息不足请反问。Java 侧直接拼装:
String ans = chatClient.prompt(new QaPrompt(redisCtx, userQuestion)) .call() .content();模板一改,线上热更新,再也不用重启。
3. 状态持久化——Redis 会话桶
会话数据用 Hash 结构,key=cs:session:{userId},field=turn记录轮次,ctx存压缩后的上下文 JSON,TTL 设 15 分钟,超时自动清,省内存。
@Repository public class DialogueRepo { @Resource private StringRedisTemplate rt; public void save(String uid, DialogueSnap snap) { String key = "cs:session:" + uid; rt.opsForHash().put(key, "ctx", JSON.toString(snap)); rt.expire(key, Duration.ofMinutes(15)); } }代码示例:跑在 Spring Boot 3.2 上的最小可用工程
下面给出可直接java -jar的片段,全部通过 Checkstyle 8.45,关键配置走 ENV,方便 CI/CD。
1. application.yml
spring: ai: llm: base-url: ${LLM_BASE_URL:http://chatglm:8000/v1} redis: host: ${REDIS_HOST:redis} port: 6379 server: port: 8080 jwt: secret: ${JWT_SECRET:changeit} expire: 36002. JWT 认证端点
@RestController @RequestMapping("/api") @RequiredArgsConstructor public class AuthController { private final JwtService jwtService; @PostMapping("/login") public Map<String,String> login(@RequestBody LoginReq req) { // 简单示例,生产请对接 SSO if ("admin".equals(req.username()) && "123456".equals(req.password())) { String token = jwtService.create(req.username()); return Map.of("token", token); } throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } }3. 对话接口 + Function Calling
@RestController @RequestMapping("/api/chat") @RequiredArgsConstructor public class ChatController { private final ChatClient chatClient; private final DialogueRepo repo; private final OrderQueryFunction orderQuery; // Spring Bean @PostMapping public ChatResp chat(@RequestHeader("Authorization") String bearer, @RequestBody ChatReq req) { String uid = jwtService.parse(bearer); DialogueSnap snap = repo.get(uid); String ctx = snap == null ? "" : snap.compact(); // 1. 拼装 Prompt QaPrompt prompt = new QaPrompt(ctx, req.question()); // 2. 调用 LLM,并开放 Function String answer = chatClient.prompt(prompt) .function("orderQuery", orderQuery) .call() .content(); // 3. 更新上下文 repo.save(uid, DialogueSnap.of(ctx, req.question(), answer)); return new ChatResp(answer); } }4. Function 示例——订单查询
@Component @Description("根据订单号查询物流状态") public class OrderQueryFunction implements java.util.function.Function<OrderQueryParam,String> { @Resource private OrderApi orderApi; @Override public String apply(OrderQueryParam param) { LogisticsDto dto = orderApi.query(param.orderNo()); return dto.getStatus(); // 返回给 LLM 的自然语言 } }5. Redis 超时自动清理
@Configuration @EnableCaching public class RedisConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration cfg = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(15)) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer<>(Object.class))); return RedisCacheManager.builder(factory).cacheDefaults(cfg).build(); } }生产建议:让老板睡安稳觉的三件套
对话日志进 ELK
在ChatController里加@Async异步写日志,字段:userId、question、answer、cost、intent、timestamp,Logstash 直接灌 ES,Kibana 做大盘,意图漂移一眼看穿。敏感词 AOP 过滤
自定义@SensitiveCheck注解,搭配OncePerRequestFilter,命中敏感词直接返回“亲亲,换个词试试~”,避免合规风险。并发会话隔离
用 ReentrantLock 按 userId 分段加锁,锁粒度 64,降低竞争;同时把 ChatClient 的连接池调到 200,压测 4C8G 可扛 1000 TPS,CPU 60%。
延伸思考:让 LLM 给自己打分
上线后我们发现,LLM 偶尔“胡说八道”,于是让 LLM 自己当裁判:
- 把同一轮对话再喂给 LLM,让它从“相关性、准确性、友好度”三维度打分,0-5 分。
- 低于 3 分的自动打标,次日人工复核,两周后 badcase 下降 38%。
- 后续计划把打分模型微调后部署成独立服务,实现闭环。
小结
两周极限重构,SpringAI 帮我们扛住了“双12”流量,机器人解决率从 30% 提到 78%,人工坐席成本直接腰斩。最开心的是,全程 Java 栈,不用额外养 Python 团队,对中小公司非常友好。如果你也在为客服系统掉头发,不妨拉分支试试 SpringAI,踩坑欢迎留言,一起把机器人调教得更像人。