Spring AI实战:用RAG、工具调用与兜底打造企业智能客服 这套课程把企业客服从“人工死扛”切换到“AI 扛大头”的模式核心是五件事RAG知识库检索、工具调用让 AI 能查订单、记忆多轮不跑题、流式输出打字机效果、兜底AI 挂了也不摆烂。我在实际项目中把这五个能力按优先级排过序结论是兜底和 RAG 先做工具其次记忆和流式是体验优化。如果一开始就扑在流式效果上遇到模型服务抖动整条链路直接雪崩体验再流畅也白搭。后面内容我会把这五个模块逐一拆开结合 Spring AI 的代码实现讲最后聊一聊我在生产环境踩过的坑。1. 为什么企业客服要押注 Spring AI1.1 传统客服的困境与新解题思路传统客服分两条线一条是纯人工在对话框里复制粘贴话术遇到重复问题一天能说三百遍另一条是按键菜单式 IVR用户按了半天 1 键 2 键最后发现要的问题根本不在这棵菜单树里。这两种方案本质上都没有解决“用户用自然语言提问系统给准确答案”的核心诉求。大模型出来之后第一波做客服的团队都去接 OpenAI 的接口写 Prompt 硬聊效果马马虎虎。因为大模型不懂你公司的订单系统、库存系统、售后政策你问它“我的订单到哪了”它会非常礼貌地告诉你“可以登录官网查询”。这种废话式回答在企业场景里毫无价值。Spring AI 解决这个问题的思路很直接模型负责理解意图和生成话术知识库和工具负责提供事实和行动能力。用户说“我的订单到哪了”模型先判断这是“查订单”意图然后调用工具接口把订单号传给订单系统拿到结构化数据之后再组织成自然语言回复。整个过程用户感知是“这个客服真懂我”实际上背后是 AI 企业系统的深度集成。1.2 为什么是 Spring AI 而不是 Python 全家桶很多团队一提到做 AI 应用第一反应就是 Python LangChain FastAPI。这套组合确实生态丰富但放在企业 Java 技术栈里有个很难受的问题技术断裂。你的订单系统、会员系统、库存系统全是 Java 系如果 AI 服务单独用 Python 写首先会遇到两套语言团队的协作问题其次要额外维护一条跨语言调用链。大部分企业的核心诉求不是“搞个最前沿的 AI 应用”而是“在现有系统上以最低成本接入 AI 能力”。Spring AI 的好处在于它是 Spring 生态官方出的 AI 框架项目里加一个依赖就能用。不用新起服务、不用跨语言调用、不用推翻老代码直接把 ChatClient 注入到现有的 Service 里就能干活。对于后端团队来说学习成本极低会的还是那套东西只是多了一些 AI 相关的概念。技术选型的第二层考虑是稳定性和可控性。Python 生态的 LangChain 迭代速度非常快API 说变就变但企业项目最怕的就是“升级依赖后跑不起来”。Spring AI 虽然是后起之秀版本成熟度不如 Python 生态不过它的演进相对克制核心抽象清晰遇到问题你能直接看源码定位这在生产环境里非常重要。1.3 五块积木怎么拼成一个完整客服RAG、工具、记忆、流式、兜底不是五件独立的事情而是共同组成了一套完整的对话引擎。我用一个真实场景串起来用户进来问“我上周买的那个鼠标为什么还没到”记忆模块先上线模型知道这个用户之前问过鼠标的订单号不需要用户再说一遍工具模块接着上模型调用物流查询工具传入订单号物流接口返回“运输中”但用户想了解具体到了哪个城市RAG 模块检索知识库里的物流说明解释了“为什么运输中不显示城市节点”整个过程用流式输出逐字推给用户体验上像真人打字如果这时物流系统接口超时或模型服务报错兜底模块接管回复“稍等我帮你查一下”并转人工。用户端的感知只是一个流畅的对话过程但背后是五个模块的协同配合。这五个模块任意一个缺失客服体验都会明显打折。2. RAG 检索增强让 AI 懂企业知识库2.1 企业知识库接入的三步预处理RAG 的核心思路我总结成一句话不逼模型背你的文档检索到相关内容再给模型看。模型肚子里有它自己的世界知识但你公司的售后政策、退换货规则、内部流程模型一个字都不知道。RAG 就是给它开一扇窗让它现查资料再回答。第一步是文档清洗。企业里的知识库文档格式五花八门Word、PDF、Markdown、Excel有的带目录有的没有有的还有大量页眉页脚。我踩过的坑是直接拿 PDF 的原始文本去切块结果每页的页眉“XX公司内部文档”被当成内容喂给了模型检索时大量命中废话。所以第一步一定要做清理去页眉页脚、去水印、统一编码、修正乱码把干净文本给到下游。第二步是分块策略。分成多少个块、每块多大直接影响检索效果。块太大检索出来的一坨内容里可能只有一句有用模型看半天抓不到重点块太小语义信息被切碎检索召回率反而下降。我常用的经验值是普通制度文档按章节切块每块 500-800 字产品 FAQ 类按问答对切块一份问答就是一个块技术操作手册按步骤切块每个步骤单独存。步进重叠建议设 10%-15%比如每块 600 字下一次切块时从上一个块的 70 字处开始避免关键内容正好被切断在边界上。第三步是 Embedding 与入库。把切好的文本块交给 Embedding 模型转成向量再存进向量数据库。这里要选一个适合中文场景的 Embedding 模型我测试过几个开源模型效果参差不齐。关键是做评测拿企业真实的问答对去比对检索质量不要只看模型榜单分数实际效果哪里错就换哪里。2.2 向量库选型与 Spring AI 集成方式向量数据库的选型我在生产环境里对比过三个方案pgvector、Milvus 和 Elasticsearch 的向量检索能力。pgvector适合中小规模的业务量它本质是 Postgres 的扩展插件表结构里加一列 vector 类型就完事不用额外部署服务。如果你的系统本来就用 Postgres这是最省心的方案没有之一。Spring AI 对 pgvector 的支持也很完善配置一下数据源就能用。Milvus适合千万级以上的向量规模和复杂的过滤查询它有独立的集群部署性能强但运维复杂度也高。绝大多数客服系统根本到不了这个量级没必要一上来就上这么重的组件。Elasticsearch的优势在于你本来就有 ES 做业务搜索顺手把向量能力用起来。但 ES 的向量检索性能相比 Milvus 有差距而且集群压力已经不小的情况下别再给它加活了。我在上一个项目里用的就是 pgvector数据量大概 30 万条文档块线上检索 P95 延迟 80ms 左右业务完全够用。Spring AI 集成 pgvector 的配置方式Configuration public class VectorStoreConfig { Bean public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) { PgVectorStoreConfig config PgVectorStoreConfig.builder() .withTableName(knowledge_chunks) .withDimension(1536) .build(); return new PgVectorStore(jdbcTemplate, embeddingModel, config); } }入库的核心代码public void importKnowledgeChunks(ListString chunks, String categoryId) { ListDocument documents chunks.stream() .map(chunk - new Document(chunk, Map.of(categoryId, categoryId))) .toList(); vectorStore.add(documents); }2.3 检索参数调优TopK 与阈值的平衡检索有两个参数经常被低估TopK 和相似度阈值。TopK 决定每次给模型看几块内容。设置太小该召回的内容漏了设置太大无关内容混进来反而干扰模型回答质量。我一般从 3 开始调如果你的知识库文档块质量都比较高4 到 5 也可以。具体场景要实测对比没有统一标准。相似度阈值是最容易被忽略的一环。相似度阈值设成 0.7检索结果低于这个分数的就不返回。这相当于给 RAG 加了一层过滤当用户问的东西根本不在知识库里就应该触发“不知道”话术而不是抓一把低相关度的内容硬编答案。Spring AI 里通过SearchRequest来控制public String answerFromKnowledge(String question) { SearchRequest request SearchRequest.builder() .query(question) .topK(4) .similarityThreshold(0.75) .build(); ListDocument docs vectorStore.similaritySearch(request); if (docs.isEmpty()) { return 公司知识库里没有找到相关内容; } // 把 docs 拼接成上下文作为 prompt 的一部分 return chatClient.prompt() .user(question) .advisors(a - a.param(knowledge_docs, docs)) .call() .content(); }2.4 RAG 落地最容易踩的三个坑第一个坑检索报告里看着很美的指标放在真实对话里一塌糊涂。关键原因是企业用户的提问方式是口语化的“我新买的手机怎么退回”和知识库里文档标题“退换货政策说明”之间没有直接的字面匹配。这种情况下你需要在库里额外补充一批“同义问题”来提高召回或者做一步查询改写把用户问题改写成一个更规范的查询再去检索。第二个坑文档更新后旧的向量还留在库里。客户服务知识库天天都在变活动说明、物流政策说改就改。如果只加新的不删旧的会出现新老政策数据打架今天告诉用户退货运费全免明天同一个问题系统找到旧文档说运费自理。解决方式是文档入库时带上版本号和生效时间检索时过滤掉过期版本。第三个坑上下文塞得太满导致模型抓不住重点。检索到 5 个文档块每块 800 字合起来 4000 字塞进 prompt模型生成时容易被无关信息带偏。我通常的做法是限制拼接后的上下文在 1500 字以内配合PageContent的排序把相似度最高的放在最前面让模型先看到最相关内容。3. 工具调用让客服从“只聊不干”变成“边聊边干”3.1 工具调用解决的是行动力问题RAG 解决的是“知识从哪来”工具调用解决的是“事情怎么办”。用户问“我的订单到哪了”你可以让模型回答“请登录官网查询”也可以让它直接帮你查出来告诉用户。后者靠的是 Function Calling 能力模型根据用户意图选择调用哪个函数把参数解析好传进去拿到结果后再组织语言回复。客服系统里最常见的工具无非这么几类订单查询通过订单号查状态物流查询通过运单号查物流轨迹售后进度查询通过售后单号查退款/换货进度OTA 卡券查询查兑换码、余额会员信息查询查等级、积分、优惠券。工具调用的本质是给模型开了一个白名单 API模型自动决定“什么时候用、用什么、传什么参数”。这个能力第一次跑通的时候那种感觉挺奇妙的就有点像你教一个新同事用公司的内部系统告诉它什么情况查什么表格后面它就自己知道怎么干活了。3.2 Spring AI 的 Tool 注解实例Spring AI 对工具调用的封装非常友好。定义一个工具类在方法上加上Tool注解注册到 ChatClient 里就完事。来看一个查订单的实践代码Component public class OrderTools { private final OrderService orderService; public OrderTools(OrderService orderService) { this.orderService orderService; } Tool(description 根据订单号查询订单的最新状态包含配送进度) public String queryOrderStatus(String orderNo) { OrderOrder order orderService.findByOrderNo(orderNo); if (order null) { return 未找到订单 orderNo; } return 订单号 orderNo 状态 order.getStatus() 预计送达时间 order.getEstimatedArrival(); } }注册到 ChatClientBean ChatClient chatClient(ChatClient.Builder builder, VectorStore vectorStore, OrderTools orderTools, LogisticsTools logisticsTools) { return builder .defaultSystem( 你是某公司的智能客服助手。 你可以回答关于商品、活动、订单、物流等常见问题。 涉及用户订单时必须调用工具查询严禁编造订单信息。 如果工具返回未找到如实告知用户。 ) .defaultTools(orderTools, logisticsTools) .build(); }这块代码里有几个细节值得说明。Tool注解的 description 字段非常重要。模型不会调用一个它看不懂的方法description 写得越清晰模型判断意图越准确。我见过有人写Tool(description query order)模型经常搞不清楚这个工具到底是查订单还是改订单结果该调用时不调用不该调用时乱调用。Description 尽量写成完整的一句话说明这个工具是干什么的、输入参数是什么含义。方法的入参类型尽量用简单字符串、数字。模型填充参数的能力受限于当前模型的工具调用准确率如果你设计了一个复杂的嵌套对象参数模型解析失败的概率会大幅度上升。我当时把一个查询条件设计成了一个有五个字段的对象结果线上有 30% 的请求参数缺字段改平铺参数结构之后才稳定下来。3.3 模型自动决定调用链路工具调用的执行流程可以理解为四步拆分。第一步用户提问进入 ChatClient系统把用户消息和可用工具描述一起发到模型。第二步模型判断“这个问题需要查订单”返回一个特殊的 toolCall 指令同时给出参数 JSON。第三步Spring AI 收到 toolCall根据方法名反射找到对应工具方法把参数塞进去执行。第四步把方法执行结果作为一条工具消息发回给模型模型基于结果组织最终回复返回给用户。如果你在日志里观察这个流程会看到一次完整调用实际是大模型交互了两次甚至三次。第一次是意图识别和工具选择第二次是根据工具结果生成最终话术。对于工具链更长一点的场景比如先查订单号再查物流轨迹就会变成三次模型调用。这里性能优化有个空间你可以针对高频的工具调用上缓存。比如订单状态查询在 2 分钟内相同订单号的结果几乎没有变化工具方法内部加一层本地缓存能显著降低模型交互链路的时间。3.4 工具调用的稳定性和安全问题工具调用给 AI 赋能的同时也引入了新的风险面一个不小心客服系统能查询的数据量范围会被无限放大。第一个风险是越权查询。用户说“查一下订单 12345 的状态”工具就真的去把 12345 查了。换个说法用户说“把你们销售额最高的 10 个订单查出来”。如果你的工具方法没有加权限控制这就是个灾难。所以工具方法内部一定要做鉴权用户只允许查自己的订单。函数内部根据当前登录人的信息强制过滤模型传过来的订单号如果不是属于当前用户的直接返回“无权查询”。第二个风险是参数校验。模型生成的参数不总是有效的有时候给你传空字符串、超长字符串、非法字符直接拿这些参数去查数据库轻则报错重则把数据库拖慢。工具方法入口建议做格式校验和长度限制。第三个风险是脱敏。订单信息里有用户电话、地址、支付信息这些数据返回给模型后模型是可能原样复述出来的。工具返回给模型的内容和最终展示给用户的内容一定要区分开。工具返回时电话中间四位打码、地址只显示到市级从源头掐断隐私泄露的路径。4. 记忆管理让客服记得“你是谁”而不是“今天在哪”4.1 企业客服场景里的记忆分层大模型本身是无状态的你问一句它答一句答完就忘。但客服场景要求 AI 必须记住上下文至少要记住当前会话里用户说过什么。这就引出一个问题记忆要做到什么粒度我习惯把记忆拆成两层。短期记忆是当前会话的上下文用户刚说过“我上周买的鼠标坏了”下一句问“怎么退”你得知道“它”指的是鼠标才能给出准确的退货指导。这一层一般通过把最近几轮对话内容打包进 prompt 实现Spring AI 里叫 ChatMemory。长期记忆是用户的偏好信息和历史记录比如用户的会员等级、历史订单偏好、之前从客服渠道反馈过的问题。这一层放在生产环境里通常需要从业务系统拉取再拼接到 system prompt 里。Spring AI 本身不直接管业务侧的长期记忆但它给了你介入的入口你可以在构建 prompt 时从业务服务查数据塞进去。这两层记忆缺一不可。只有长期记忆没有短期记忆用户一句话里的指代关系无法解决只有短期记忆没有长期记忆用户每次来都得重新报一遍身份信息体验非常生硬而且又笨又呆。4.2 内存版 ChatMemory 与 MessageWindowSpring AI 提供了ChatMemory接口内置实现InMemoryChatMemory可以把对话历史存在应用内存里。搭配MessageChatMemoryAdvisor使用会自动管理和注入对话历史。先看最简单的用法Configuration public class ChatMemoryConfig { Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory, OrderTools orderTools) { return builder .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .defaultTools(orderTools) .build(); } }MessageChatMemoryAdvisor 做的事情是每个请求进来时根据 conversationId 取到历史消息把最近 N 条作为上下文塞进 prompt模型回答之后再写到存储里。N 的大小由ChatMemoryAdvisor的窗口参数控制Spring AI 中可以通过MessageWindow模型来管理比如只保留最近 20 条消息。窗口大小非常关键。窗口太小长对话中间的信息被挤出去之后模型就忘了窗口太大历史消息全塞进 prompttoken 消耗倍增模型的上下文容量也有限。我的实践经验是 15-20 条比较合适既覆盖了大部分对话场景的指代需求又不会把上下文撑爆。但你一定要明白InMemoryChatMemory 只适合单机、低并发的场景。一旦你部署了两个实例用户第一次请求打到 A 实例第二次请求负载均衡到了 B 实例B 查不到任何历史对话AI 立刻失忆。生产环境的客服系统部署结构不可能只有一个节点。所以我直接把 InMemoryChatMemory 从生产候选方案里划掉了它是开发环境用来验证效果的工具不是生产方案。4.3 基于 Redis 的分布式 ChatMemory 实现生产环境要解决的核心问题是不同实例之间共享同一份会话记忆。这种场景直接用 Redis 存对话历史实现 ChatMemory 接口即可。Component public class RedisChatMemory implements ChatMemory { private static final String KEY_PREFIX ai:chat:memory:; private final StringRedisTemplate redisTemplate; public RedisChatMemory(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public ListMessage get(String conversationId, int lastN) { ListString jsonList redisTemplate.opsForList().range(KEY_PREFIX conversationId, -lastN, -1); if (jsonList null || jsonList.isEmpty()) { return List.of(); } return jsonList.stream().map(this::deserialize).toList(); } Override public void add(String conversationId, Message message) { redisTemplate.opsForList().rightPush(KEY_PREFIX conversationId, serialize(message)); // 控制列表长度防止无限膨胀 redisTemplate.opsForList().trim(KEY_PREFIX conversationId, -50, -1); } Override public void clear(String conversationId) { redisTemplate.delete(KEY_PREFIX conversationId); } }这里用 Redis 的 List 结构每条数据是一段序列化后的消息。rightPush追加新消息range取最后 N 条trim控制列表最长保留 50 条防止内存无限增长。对话的维度不需要太细。客服场景里 conversationId 用“用户ID 会话ID”拼一个字符串。用户每次新开一个会话就生成新的会话IDRedis 里自然形成一个上下文单元同一会话里多轮消息串在一起上下文自然连续。值得注意的一个细节是系统消息和工具调用结果也要存进记忆。如果你只存用户和助手的消息下一轮模型缺少工具调用的中间状态有时候会搞不清楚上一轮已经查过什么。所以在add方法里用户消息、助手消息、工具执行结果都要统一写入。这个过程有个更简单的实现方式直接在 MessageChatMemoryAdvisor 的 add 逻辑里把整轮对话都写入。4.4 long-term memory 怎么和 Spring AI 结合长期记忆不是存对话文本而是存用户画像、业务特征摘要。最直接的实践是每次会话开始时从数据库查出用户的基础信息拼一段“用户画像”注入 system promptpublic String buildSystemPrompt(String userId) { UserProfile profile userProfileService.getByUserId(userId); return 你是一名企业客服助手。 用户当前信息 - 会员等级%s - 常用收货地区%s - 近30天订单数%d - 最近一次客服沟通主题%s 根据这些信息提供个性化的服务。 .formatted(profile.getLevel(), profile.getRegion(), profile.getRecentOrderCount(), profile.getLastContactTopic()); }这样的好处是用户回来说“我上次问的退款有结果了吗”模型不需要翻对话历史直接从画像里看到服务偏好再调用售后工具去查进度即可。需要注意的是用户画像类的敏感信息不要一股脑全塞模型容易生成无关话题。我建议只注入对当前服务有用的画像字段其他敏感字段保存在业务库里不要递进对话上下文。5. 流式输出把企业客服的响应速度拉满5.1 为什么要做流式输出而不是一次性返回大模型生成回答是需要时间的短则几百毫秒长则十几秒。如果等模型完整生成完再一次性返回给用户用户体验是“我问完一个问题等了五秒突然刷出一大段文字”。这种体验在聊天场景里非常糟糕用户会怀疑系统是不是卡死了。流式输出的思路是把生成过程变成持续推送模型每生成一小段文字就立刻通过 SSE 把这一段推到前端。用户在屏幕上看到的是文字一个一个蹦出来像真人打字一样。心理学上的感受完全不同“等 5 秒出结论”和“第 1 秒就开始出字”后者的等待焦虑要低得多。在 Spring AI 中流式调用需要返回FluxString交给 Spring WebFlux 的 SSE 机制推给前端。5.2 后端流式接口的标准写法先看代码PostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream( RequestBody ChatRequest request, RequestHeader(X-User-Id) String userId) { String conversationId buildConversationId(userId, request.getSessionId()); FluxString contentFlux chatClient.prompt() .user(request.getQuestion()) .advisors(a - a.param(conversationId, conversationId)) .stream() .content(); return contentFlux.map(token - ServerSentEvent.builder(token).build()); }这段代码返回的不是简单字符串而是 ServerSentEvent 类型Spring 会自动按 SSE 协议把数据块推送出去。前端标准 EventSource 就能直接接住。这里有几个实践要点。第一SSE 的超时时间要显式配置。Nginx 默认的 proxy_read_timeout 是 60 秒如果模型生成时间超过 60 秒连接会被 Nginx 切断。我一般在 Nginx 配置里将流式接口的proxy_read_timeout调大到 300 秒同时/chat/stream单独 location不走普通接口的缓存和压缩。第二流式接口不建议做全局缓存。SSE 每次请求都是一条独立长连接如果网关层做了响应缓存不仅缓存不了流式数据还会让请求长时间占用连接资源。我当时为这事排查了半天后来发现是网关把 SSE 当普通接口处理挂起了连接导致批量告警。第三工具调用和流式输出的配合问题。整条链路里存在一种情况用户问“我的订单到哪了”模型先调用工具查询等工具返回之后才开始生成文本。这个调用过程可能耗时 1-2 秒如果前端什么提示都不给用户会觉得“怎么没反应”。我采取的方案是工具调用阶段不能通过 SSE 输出但可以给前端推一个占位事件比如event: tool_call的数据帧前端收到后显示“正在查询订单信息...”。然后再推内容事件。这需要流式接口返回的类型不是纯字符串而是自定义事件结构public record ChatStreamEvent(String type, String content) {} FluxServerSentEventChatStreamEvent eventFlux contentFlux.map(token - ServerSentEvent.ChatStreamEventbuilder() .event(message) .data(new ChatStreamEvent(text, token)) .build());前端根据 event 类型做不同展示流式输出和工具调用过程的用户感知就能拼接到一起。5.3 前端对接流式的三种姿势第一种是原生 EventSource。用法简单代码量少但只能接收 GET 请求无法自定义 Header遇到需要带 token 鉴权的场景会比较别扭。第二种是fetchReadableStream。手动解析流数据灵活度高POST 和 Header 都能自己控制是生产环境里我最常用的方案。核心代码const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, X-User-Id: userId }, body: JSON.stringify({ question: question }) }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { value, done } await reader.read(); if (done) break; const text decoder.decode(value, { stream: true }); // 解析 SSE 格式按行拆分处理 const lines text.split(\n); for (const line of lines) { if (line.startsWith(data:)) { const data line.slice(5).trim(); if (data data ! [DONE]) { appendMessage(data); } } } }第三种是第三方 SSE 库。如果团队不想自己处理 SSE 协议细节可以用现成的前端 SDK它把断线重连、事件分发都封装好了缺点是引入额外依赖。推荐直接选第二种让你清楚了解这个协议的底层机制。以后遇到问题就不慌因为你知道数据是从哪到哪、协议封包是什么样子。5.4 流式链路常见故障排查流式接口的故障跟普通接口完全不同。普通接口要么返回 200 要么返回 500比较清晰。流式接口是“回来了但被中断了”。排查经验总结成一张速查表故障现象排查方向解决方案首 token 延迟超过 3 秒模型服务排队检查模型侧并发限制开启请求排队队列输出一半突然中断网关断连调大 proxy_read_timeout关闭缓冲区前端逐字蹦出来卡得很明显没开压缩或数据量太大开启 WebSocket 代替 SSE减少协议开销内容正常但 EventSource onerror 频繁连接被自动回收前端实现心跳重连逻辑工具调用阶段无反馈缺少占位事件工具阶段推送 tool_call 事件前端识别后展示状态流式接口是演示效果最好、线上问题也最多的地方。建议上线之前务必做一次断连演练把网关、网络、服务三层分别模拟故障确认前端在中断后的重连逻辑是可靠的。如果断连后没有任何提示用户会对着一个不动的光标发呆。6. 兜底策略智能客服之上的最后一道保险6.1 为什么必须做兜底而不是信任 AI把话撂在这模型服务和所有 AI 系统一样一定会出故障。模型服务商限流、集群抖动、GPU 资源紧张、网络抽风、上下文超长、工具调用异常。这些都是大概率事件只是时间早晚的问题。有一些团队在做 AI 客服时有个美好的愿望AI 把用户所有问题都接住人工只需要接转来的个别复杂 case。一旦模型服务挂了整个客服入口瘫痪用户连“转人工”的按钮都找不到这时候不止是体验灾难还有业务指标崩盘。兜底策略的目的不是让 AI 更聪明而是让系统在 AI 不工作的极端情况下依然能给出合理的用户反馈。总结成一句话AI 客服可以有不会答的问题但系统不能有无人接的入口。6.2 多级兜底设计从重试到转人工我的兜底分层顺序如下第一级重试。模型服务报错先做一次重试很多抖动型错误在第二次请求时就正常了。重试注意设置最大次数我一般只重试 1 次避免多次重试把已超时的链路又拖一轮加重故障。第二级限流与熔断。当模型服务错误率达到阈值比如 30%直接开启熔断不再向模型服务发请求而是走降级逻辑。这里用的是经典的熔断思路连续失败 N 次打开熔断过一段时间放少量试探流量成功率达到阈值就关闭熔断。第三级固定话术降级。熔断期间直接返回预设的引导话术“系统正在优化中您的问题已记录请稍后再试或拨打客服热线。”话术文案要事先准备多个版本并在降级期间同时引导用户转人工。第四级转人工。这是终极兜底。降级话术要有人工客服入口比如转接按钮、客服热线字段、留言表单。有的系统会在 AI 连续两次无法命中知识库时自动转人工同时把用户当前的对话文本和检索记录推给人工客服作为上下文极大降低用户的重复描述成本。代码上做一个分级兜底处理器Component public class AiServiceFallbackHandler { private final ChatMemory chatMemory; public AiServiceFallbackHandler(ChatMemory chatMemory) { this.chatMemory chatMemory; } public String handleWithFallback(String userId, String conversationId, String question) { try { return chatClient.prompt() .user(question) .advisors(a - a.param(conversationId, conversationId)) .call() .content(); } catch (ResourceAccessException ex) { log.error(模型服务不可用userId{}, userId, ex); return 抱歉我现在有点忙请稍后再试。你也可以直接拨打客服热线或点击下方按钮转人工服务。; } catch (TokenLimitExceededException ex) { // 上下文溢出清空记忆后再重试一次 chatMemory.clear(conversationId); log.warn(上下文溢出已清空会话记忆重试一次); return handleWithFallback(userId, conversationId, question); } } }6.3 Spring AI 异常体系的处理思路Spring AI 的异常大致分几类模型服务网络问题、模型 API 返回错误状态、上下文 token 超限、工具调用异常。不同的异常类型要匹配不同的兜底语义异常类型含义兜底动作ResourceAccessException网络抖动、模型服务不可达重试一次仍失败就转降级话术ApiKeyException鉴权失败检查配置不要重试直接告警TokenLimitExceededException上下文太长超过容量清空记忆 / 压缩历史再重试ToolCallException工具方法执行异常记录日志回复用户“查询功能暂时不可用”这里有个容易踩的坑异常处理不要把 AI 服务错误直接暴露给用户。模型 API 返回的原始错误文本往往是英文的、技术化的对用户没有意义。兜底回复一定要用面向用户的话术技术细节全部留在日志里。建议再配一个告警通道。兜底逻辑触发时发送一条告警给值班人员附带错误类型、用户ID、会话耗时等上下文信息。这样你能在用户反馈“刚才客服变笨了”之前就掌握服务健康状态。6.4 降级方案上线前的演练脚本兜底不是写了代码就算完成。我建议上线前跑这样一组演练场景 A模型服务整体不可用验证限流熔断和降级话术是否按预期触发场景 B模型服务部分请求超时验证超时阈值是否合理场景 C工具链路个别接口故障验证 AI 是否会被工具异常拖死以及兜底话术是否顺滑场景 D上下文超长触发 Token 溢出验证记忆清空逻辑和重试逻辑场景 E高并发时模型服务响应变慢验证排队和限流策略。这组演练的目的不是证明系统“不会挂”而是确认挂了之后用户仍然能获得一个不至于太糟糕的体验并且值班同学能在 5 分钟内收到告警、发现故障。7. 生产环境实测下来的几点经验前面把五个模块的实战过程都过了一遍最后补几段这段时间在实际运行中沉淀下来的体会。第一点关于模型选型。客服场景对模型的工具调用准确率要求比对答能力要求更高。工具调用一旦判断错整个链路就不对了再流畅的流式输出、再精准的 RAG 都救不回来。选型时一定要拿着企业自己的工具列表去测调用准确率而不是用通用题评测。模型 A 综合对话能力强但工具调用一塌糊涂也不能用。第二点关于上下文的 token 控制。客服对话长短不一用户喜欢一条消息一句话聊了二十分钟整个会话的 token 数就上去了。如果不设上限长会话一定会触发 Token 溢出。我在生产环境做了双保险窗口最多保留 20 条消息再加上单轮回复长度上限。这两个数字你可以设得宽松一点但一定要有不能裸奔。第三点关于日志和观测。AI 客服系统的排查难度比普通 CRUD 系统高一个数量级因为同一个问题RAG 没检索到、工具参数填错了、模型意图判断偏了三种原因都会产生类似的现象。强烈建议给链路设计一套 trace 贯穿从请求进入经过 RAG 检索、工具调用、模型生成每一步都打上日志和耗时。这套东西没有的话出问题只能靠猜。第四点用户反馈数据要做闭环。智能客服做出来不是一锤子买卖上线之后每一轮用户反馈都值得回收。用户点击“不知道”按钮、输入“转人工”、给回答打低分这些数据攒下来之后每个月挑一轮去分析你会发现 RAG 的召回缺陷、工具的描述歧义、兜底话术的无效场景都清清楚楚地写在数据里。AI 客服不可能一次做好它是在持续反馈循环里慢慢变好的。第五点关于“十课收官”这个系列内容的落地建议。如果你所在的团队正准备在企业客服场景里上用 Spring AI我的建议是不要一次性把五块能力全部铺开。第一个版本做好 RAG 加兜底让客服能回答知识库内的问题不擅长的问题有降级方案。版本二接入工具调用让客服能查订单、查售后。版本三再补齐记忆和流式这时候前面的链路稳定了再上体验优化才没有后顾之忧。一口吃不成胖子迭代节奏越稳线上出幺蛾子的概率越低。