最近在做一个智能客服项目客户对并发量和响应速度要求很高传统的基于规则或简单NLP的客服机器人根本顶不住。经过一番折腾我们基于 SpringAI 框架结合向量数据库和缓存搭建了一套还算能打的系统。今天就把整个架构设计和踩过的坑梳理一下希望能给有类似需求的同学一些参考。1. 为什么传统方案在高并发下容易“翻车”在项目初期我们调研了市面上几种常见的方案发现它们在面对真实场景时各有各的“软肋”基于规则引擎的客服这是最老派的做法。优点是响应极快规则明确。但缺点太致命了——意图识别全靠关键词匹配稍微换个说法就“听不懂”了。比如用户问“怎么付钱”和“支付方式有哪些”在规则引擎里可能就是两条完全不同的规则维护成本爆炸。更别提多轮对话了几乎无法实现。基于传统NLP模型如BERT微调的客服意图识别准确率比规则引擎高不少能理解语义相似度。但问题在于它通常是一个“一问一答”的模型很难记住上下文。用户如果问“上一款手机怎么样”接着问“那它的续航呢”传统NLP模型很可能不知道“它”指代什么。而且模型推理需要GPU资源并发一高响应延迟P99就会变得很难看成本也居高不下。突发流量下的性能瓶颈无论是规则引擎的频繁正则匹配还是NLP模型的同步推理在“双十一”或产品发布这类瞬间涌入大量咨询的场景下CPU或GPU资源很容易被打满导致服务雪崩用户体验就是“机器人已掉线”。正是这些痛点让我们把目光投向了以 ChatGPT 为代表的大语言模型LLM和 SpringAI 这样的集成框架。LLM 强大的上下文理解和生成能力正好能解决意图模糊和多轮对话的问题。2. 技术栈选型为什么是 SpringAI Redis Milvus确定了用 LLM 作为核心大脑后接下来就是为它搭配“四肢”和“记忆系统”。SpringAI作为 Spring 官方项目它最大的优势是“开箱即用”和“统一抽象”。它封装了 OpenAI、Azure OpenAI、Ollama 等多种 LLM 供应商的 API我们用一套ChatClient的代码就能切换底层模型避免了供应商锁定的风险。这对于需要快速试错、调整策略的项目初期来说效率提升不是一点半点。RedisLLM 本身是无状态的但对话必须有状态。我们需要一个地方来存储和管理对话的上下文历史、当前对话状态例如正在询问订单号、正在处理退货。Redis 的高性能和丰富的数据结构如 String 存历史Hash 存状态Sorted Set 做会话超时管理完美契合。我们为每个会话设置了 TTL生存时间比如30分钟无活动自动清理既节省内存又符合用户习惯。Milvus向量数据库这是提升响应速度和准确率的“秘密武器”。虽然 LLM 很强大但让它直接从海量、实时更新的产品知识库FAQ中寻找答案不仅 Token 消耗大贵而且速度慢。我们的做法是将所有标准问答对FAQ通过嵌入模型Embedding Model转换成向量存入 Milvus。当用户提问时先将问题转换成向量然后在 Milvus 中进行高速相似度检索找到最相关的几个标准答案。如果相似度超过一个很高的阈值比如0.95我们直接返回这个预设答案又快又准又省钱。只有检索不到满意答案时才把问题连同检索到的参考信息一起交给 LLM 去组织生成最终回复。这套组合拳的核心思想是用向量检索处理大量已知、标准的“快问快答”用 LLM 处理复杂、未知的“深度咨询”用 Redis 来串联整个对话流程。3. 核心实现三步搭建对话引擎理论说完了来看看代码怎么组织。整个对话服务的核心流程可以抽象为三步接收问题 - 检索/管理状态 - 调用LLM生成。第一步用 SpringAI 的 ChatClient 构建对话管道SpringAI 让调用 LLM 变得像调用本地方法一样简单。首先在配置文件中定义你的模型连接。spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.2 # 初期建议调低让输出更稳定然后在服务中注入ChatClient即可使用。import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Service; Service public class ChatService { private final ChatClient chatClient; // 通过构造器注入 public ChatService(ChatClient.Builder chatClientBuilder) { this.chatClient chatClientBuilder.build(); } /** * 调用LLM生成回复的核心方法。 * * param userMessage 用户输入的问题 * param context 从Redis获取的对话历史上下文 * return LLM生成的回复内容 */ public String generateWithLLM(String userMessage, String context) { String prompt String.format( 你是一个专业的客服助手。请根据以下对话历史和用户最新问题给出专业、友好的回答。 如果问题涉及你不知道的信息请如实告知不要编造。 对话历史 %s 用户最新问题%s , context, userMessage); return chatClient.prompt() .user(prompt) .call() .content(); } }第二步基于 Redis 实现对话状态机每个用户会话通常由一个唯一sessionId标识都需要维护自己的状态。我们用 Redis 的 Hash 结构来存储。import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; Component public class DialogStateManager { private final RedisTemplateString, Object redisTemplate; private static final String KEY_PREFIX dialog:state:; private static final long TTL_MINUTES 30; public DialogStateManager(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } /** * 初始化或更新一个对话的状态。 * * param sessionId 会话唯一标识 * param state 当前状态如WAITING_ORDER_NUMBER * param history 追加的对话历史 */ public void updateState(String sessionId, String state, String history) { String key KEY_PREFIX sessionId; HashOperationsString, String, String ops redisTemplate.opsForHash(); ops.put(key, currentState, state); ops.put(key, history, history); // 每次更新都刷新TTL redisTemplate.expire(key, TTL_MINUTES, TimeUnit.MINUTES); } /** * 获取指定会话的当前状态。 * * param sessionId 会话唯一标识 * return 当前对话状态如果不存在则返回null */ public String getCurrentState(String sessionId) { String key KEY_PREFIX sessionId; return (String) redisTemplate.opsForHash().get(key, currentState); } }第三步Milvus 向量检索优化 FAQ 匹配这部分涉及混合编程。我们使用 Python 脚本离线生成 FAQ 向量并导入 MilvusJava 服务端则通过 HTTP 或 gRPC 调用 Milvus 进行检索。Python 端数据处理与导入# embed_faq.py from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType from sentence_transformers import SentenceTransformer import json # 1. 连接Milvus connections.connect(hostlocalhost, port19530) # 2. 定义集合类似表结构 fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue, auto_idTrue), FieldSchema(namequestion, dtypeDataType.VARCHAR, max_length500), FieldSchema(nameanswer, dtypeDataType.VARCHAR, max_length2000), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim768) # 假设嵌入维度是768 ] schema CollectionSchema(fields, descriptionFAQ collection) collection Collection(faq_collection, schema) # 3. 加载嵌入模型 model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) # 4. 读取FAQ JSON文件并生成向量 with open(faq_data.json, r, encodingutf-8) as f: faq_list json.load(f) questions [item[q] for item in faq_list] answers [item[a] for item in faq_list] embeddings model.encode(questions).tolist() # 生成向量 # 5. 插入数据 data [questions, answers, embeddings] collection.insert(data) collection.flush() print(FAQ数据导入完成。)Java 端服务调用检索// MilvusService.java import io.milvus.client.MilvusServiceClient; import io.milvus.grpc.SearchResults; import io.milvus.param.R; import io.milvus.param.dml.SearchParam; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.stereotype.Service; import java.util.Arrays; import java.util.List; Service public class MilvusService { private final MilvusServiceClient milvusClient; private final EmbeddingModel embeddingModel; // SpringAI 的嵌入模型 public MilvusService(MilvusServiceClient milvusClient, EmbeddingModel embeddingModel) { this.milvusClient milvusClient; this.embeddingModel embeddingModel; } /** * 在FAQ库中搜索与用户问题最匹配的标准答案。 * * param userQuestion 用户问题 * param topK 返回最相似的结果数量 * param scoreThreshold 相似度分数阈值高于此值才认为匹配 * return 匹配到的标准答案若无则返回空字符串 */ public String searchFAQ(String userQuestion, int topK, float scoreThreshold) { // 1. 将用户问题转换为向量 ListDouble queryVector embeddingModel.embed(userQuestion); // 2. 构建搜索参数 ListString outputFields Arrays.asList(answer, question); SearchParam searchParam SearchParam.newBuilder() .withCollectionName(faq_collection) .withVector(queryVector) .withTopK(topK) .withOutFields(outputFields) .build(); // 3. 执行搜索 RSearchResults resp milvusClient.search(searchParam); if (resp.getStatus() ! R.Status.Success.getCode()) { return ; } // 4. 处理结果取分数最高的 SearchResults results resp.getData(); // 简化处理实际需遍历results.getScores()和results.getFieldsData() if (!results.getResults().isEmpty() results.getScores().get(0) scoreThreshold) { // 这里获取第一个匹配结果的答案字段 return results.getFieldsData().get(0).getFieldData(answer).get(0).toString(); } return ; } }4. 性能优化应对高并发的两大法宝架构搭好了但要扛住5000 TPS还得在性能上下功夫。法宝一异步非阻塞响应WebFlux不能让慢速的 LLM 调用阻塞整个网络线程。使用 Spring WebFlux 实现异步响应让线程池去处理耗时任务。import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Mono; RestController RequestMapping(/api/chat) public class ChatController { private final ChatOrchestratorService chatService; // 一个编排了上述所有逻辑的总服务 public ChatController(ChatOrchestratorService chatService) { this.chatService chatService; } PostMapping public MonoChatResponse chat(RequestBody ChatRequest request) { // Mono.fromCallable 将同步调用包装为异步由弹性线程池执行 return Mono.fromCallable(() - chatService.processMessage(request)) .subscribeOn(Schedulers.boundedElastic()); // 指定在弹性线程池中执行 } } // ChatRequest 和 ChatResponse 是简单的DTO对象 record ChatRequest(String sessionId, String message) {} record ChatResponse(String sessionId, String reply) {}法宝二分级降级策略LLM 服务可能不稳定或响应慢必须要有 Plan B。我们设计了一个分级降级策略。用户提问 | v [1. 向量检索 FAQ] -- 匹配成功 -- 是 -- 直接返回预设答案 (最快路径) | | 否 (结束) v [2. 调用主 LLM (如 GPT-4)] -- 响应正常 -- 是 -- 返回LLM答案 | | 超时或失败 (结束) v [3. 降级至备用 LLM (如 GPT-3.5-Turbo 或 本地 Ollama)] -- 响应正常 -- 是 -- 返回降级答案 | | 失败 (结束) v [4. 最终回退至规则引擎/静态应答] -- 返回兜底答案 (如“当前咨询量大请稍后再试”)这个流程可以通过 Spring Cloud Circuit Breaker如 Resilience4j或简单的超时重试、后备方法Fallback来实现确保核心对话链路在任何情况下都有响应。5. 避坑指南那些我们踩过的“坑”对话ID的幂等性设计网络可能超时重试同一请求可能被发送多次。一定要在入口处Controller或Filter根据sessionId 客户端消息ID做幂等校验避免因重试导致重复生成回答和扣费。可以用 Redis 的SETNX命令实现一个简单的分布式锁或幂等键。大语言模型的 temperature 参数调优这个参数控制输出的随机性0-2之间。在客服场景下我们追求准确和稳定而不是创意。一开始用了默认值0.7左右结果回答时而严谨时而“放飞自我”。后来把temperature调到 0.2甚至 0.1输出的答案就稳定、可靠多了。这是一个需要根据实际效果反复调整的关键参数。敏感词过滤的异步检测方案LLM 可能生成不合规的内容。同步进行敏感词过滤会增加响应延迟。我们的做法是在异步返回给用户响应后再异步触发一个敏感词检测任务。如果检测到问题一方面记录日志告警另一方面可以通过 WebSocket 或消息推送尝试撤回或更正刚才发送给用户的消息。这样既保证了实时性又满足了合规要求。6. 延伸思考如何定位线上慢查询系统上线后我们遇到了新的挑战偶尔有用户反馈回复慢。在分布式环境下一次对话请求可能流经网关、对话服务、Redis、Milvus、LLM API 等多个环节如何快速定位延迟瓶颈我们正在探索引入分布式追踪系统如 SkyWalking 或 Zipkin。理想情况下可以为每个用户对话分配一个唯一的traceId贯穿整个调用链。这样在监控面板上就能清晰地看到时间主要消耗在向量检索阶段还是 LLM 生成阶段调用外部 LLM API 的网络延迟是否异常Redis 查询是否因为大 Value 而变慢通过追踪数据我们可以有的放矢地进行优化比如为 Milvus 增加索引、优化 Redis 中存储的对话历史结构例如分段存储、或者为慢速的 LLM 调用设置更合理的超时与降级策略。写在最后从零开始构建一个高并发的 SpringAI 智能客服系统就像搭积木选对组件SpringAI, Redis, Milvus并设计好它们之间的连接方式异步、降级、状态管理是关键。这套架构让我们在可控的成本下实现了较高的意图识别准确率、流畅的多轮对话和稳定的高并发性能。当然没有完美的架构只有适合的场景。随着业务发展我们可能还需要考虑知识库的实时更新、多模态图片、视频问答、以及更复杂的情感分析和客户意图预测等功能。但以目前这套基于 SpringAI 的框架为基础进行扩展和迭代会相对顺畅很多。希望这篇实践笔记能为你带来一些启发。