背景痛点:传统客服系统到底卡在哪
过去三年,我先后接手过两套“祖传”客服系统:一套基于关键字匹配,一套在 Dialogflow 上做了二次封装。上线后问题高度雷同:
- 意图识别准确率低于 75%,用户换种问法就“答非所问”。
- 多轮对话靠 session 里硬编码字段维护,一旦分布式部署,状态说丢就丢。
- 高峰期并发突增,系统直接 502;扩容后 CPU 打满,QPS 仍卡在 120 左右。
核心矛盾是“黑盒”NLU 与“白盒”业务耦合难,改一句话术就要重新训练模型,迭代周期按周计算。于是我们把目光投向了 Spring AI——一个能把提示词、检索、微调都当成普通 Bean 管理的框架。
技术对比:为什么最终选了 Spring AI
| 维度 | Dialogflow ES | Rasa 3.x | Spring AI |
|---|---|---|---|
| 托管方式 | 全托管 | 自部署 | 自部署 |
| 中文微调 | 不支持直接微调 | 支持,但需写 pipeline 脚本 | 直接调用本地 LLM,可微调 |
| 上下文保持 | 依赖 Context 生命周期,跨节点失效 | Tracker Store 需自己配 Redis | 内置 ChatMemory,可插 Redis |
| 与 Java 集成 | gRPC/SDK,模型黑盒 | HTTP,序列化麻烦 | 原生 Starter,零样板代码 |
| 成本 | 按次计费,量大后价格翻倍 | 免费,但 GPU 推理机自己扛 | 免费,GPU 机可弹性伸缩 |
一句话总结:Spring AI 把“提示词即代码”带进 Java 世界,让我们用熟悉的事务、缓存、线程池就能治理 LLM,而不再被“黑盒”卡脖子。
核心实现:三步搭出对话引擎
1. 引入依赖与自动配置
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-SNAPSHOT</version> </dependency>application.yml里把spring.ai.openai.api-key换成自己网关转发的 key,即可注入ChatClient。
2. 构建带 RAG 的 ChatClient
@Configuration public class AiConfig { @Bean public ChatClient ragClient(ChatClient.Builder builder, EmbeddingModel embModel, VectorStore vectorStore) { // 1. 把产品手册灌进向量库 vectorStore.add( new Document("产品A", "7 天无理由退货", Map.of("sku", "A")) ); // 2. 返回带检索增强的 ChatClient return builder .defaultAdvisors( new RetrievalAugmentationAdvisor(vectorStore, embModel)) .build(); } }3. 多轮上下文与重试
@Component public class ChatService { private final ChatMemoryRepository memoryRepo; // Redis 实现 @Retryable(value = { RemoteException.class maxAttempts = 3, backoff = @Backoff(500)) public String talk(String userId, String prompt) { ChatMemory memory = memoryRepo.get(userId); String answer = ragClient.prompt() .user(prompt) .advisors(a -> a.param("memory", memory)) .call() .content(); memory.add(UserMessage.of(prompt), AssistantMessage.of(answer)); memoryRepo.save(userId, memory); return answer; } }@Retryable直接加在业务方法,比自己去写try/catch简洁得多;远程超时、429 场景都能覆盖。
代码示例:Controller 层完整片段
@RestController @RequestMapping("/api/v1/bot") @RequiredArgsConstructor public class BotController { private final ChatService chatService; private final JwtValidator jwtValidator; @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> chat(@RequestHeader("Authorization") String bearer, @RequestBody ChatReq req) { // 1. 鉴权 String userId = jwtValidator.parse(bearer); // 2. 流式返回,前端打字机效果 return Flux.fromStream( () -> new BufferedReader(new StringReader( chatService.talk(userId, req.getPrompt()))) .lines()) .delayElements(Duration.ofMillis(30)); } @ExceptionHandler(RemoteException.class) public ResponseEntity<ErrorBody> handleRemote() { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) .body(new ErrorBody("AI 服务繁忙,请稍后")); } }Redis 配置片段( Lettuce 连接池):
spring: data: redis: host: redis-cluster port: 6379 lettuce: pool: max-active: 200 max-idle: 100 min-idle: 20性能优化:线程池与 QPS 压测
把默认的SimpleAsyncTaskExecutor换成自定义线程池:
@Bean public TaskExecutor aiExecutor() { ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); exec.setCorePoolSize(32); exec.setMaxPoolSize(64); exec.setQueueCapacity(200); exec.setThreadNamePrefix("ai-"); exec.initialize(); return exec; }压测数据对比(8C16G,单实例,OpenAI 代理延迟 250 ms):
| 线程池策略 | 平均 RT | 99 线 | QPS | CPU 占用 |
|---|---|---|---|---|
| 默认无池化 | 1.2 s | 2.5 s | 180 | 90% |
| 自定义池 | 0.35 s | 0.6 s | 520 | 65% |
结论:池化后 RT 下降 70%,QPS 提升近 3 倍,CPU 反而更闲。
避坑指南:上线前必须踩的坑
1. 对话状态丢失
- 把
ChatMemory序列化成 JSON 存 Redis,并加@RedisHashTTL(hours = 24)。 - 发布消息时监听
CacheExpireEvent,把过期 key 同步到 DB,可做离线质检。
2. 敏感词过滤
用 AOP 拦截talk()方法,O(1) 匹配 DFA 词表:
@Around("@annotation(PublicApi)") public Object filter(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); String prompt = (String) args[1]; if (SensitiveDFA.match(prompt)) { return "抱歉,无法回答该问题"; } return pjp.proceed(); }3. 冷启动性能
- 预加载
EmbeddingModel到内存,关闭spring.ai.openai.embedding.lazy-init=true。 - 向量索引用 Faiss IVF-Flat,训练数据 10 w 条,nlist=4096,查询 nprobe=32,召回 95%+,耗时 12 ms。
延伸思考:让 LLM 直接做意图识别
目前 NLU 仍用微调的 BERT,召回 92%。如果把用户问题直接丢给 LLM,让其在 prompt 里输出 JSON 意图,再交给下游流程,是否可行?
- 优点:无需单独训练,话术变更只需改提示词。
- 风险:LLM 输出不稳定,格式错误率 3% 左右。
- 折中:用“LLM 意图 + 规则兜底”双通道,线上 A/B 显示 LLM 通道准确率 96%,RT 增加 80 ms,可接受。
下一步,我们准备把意图识别、槽位抽取、答案生成三段全部用 Spring AI 的PromptTemplate串联,实现“一条链路透传”,把迭代周期从周缩短到小时。
踩坑三个月,最大感受是:别把 LLM 当黑盒,也别把 Spring AI 当玩具。只要按 Java 习惯把它拆成 Bean、线程池、缓存、重试这些老伙伴,高并发、高可用的智能客服其实没那么玄乎。希望这份笔记能帮你少熬几个通宵,早日让 AI 把键盘声从客服大厅里“消音”。祝编码顺利,出错时记得先打日志,再问 GPT。