Spring AI 企业智能客服实战:RAG、工具调用、记忆、流式与兜底 1. 企业智能客服系统的整体架构设计思路1.1 为什么选择 Spring AI 作为技术底座做企业级智能客服这件事技术选型是第一道分水岭。我前后试过三种路线纯手写调用大模型 API、用 LangChain 这类 Python 生态框架、以及 Spring AI。最后落到 Spring AI 上核心原因有三个。第一是工程一致性。绝大多数企业的后端服务本身就是 Java 技术栈Spring Boot 的依赖注入、配置管理、健康检查、链路追踪这一整套基础设施已经跑得很成熟了。如果为了接一个大模型单独起一个 Python 服务运维、监控、发布流程都要重新走一遍团队维护成本直接翻倍。Spring AI 把模型调用抽象成ChatClient和RestTemplate、JdbcTemplate是一个设计哲学Java 工程师上手几乎没有学习成本。第二是可替换性。企业选模型这件事变数很大今天用这个厂商明天可能因为成本、合规或者效果换另一个。Spring AI 通过ChatModel接口把底层模型屏蔽掉了切换模型基本只改配置文件里的model和base-url业务代码一行不动。这一点在真实项目里价值极高我见过太多项目因为把模型 SDK 硬编码到业务层换模型时改得满地找牙。第三是生态整合。Spring AI 对向量库、Embedding 模型、函数调用Function Calling都有统一抽象RAG 需要的几个核心组件它都覆盖了。虽然它比 Python 生态晚一些但企业场景要的不是最新最全而是稳定可控。提示Spring AI 版本迭代较快建议锁定一个稳定版本如 1.0.x 系列不要盲目追最新快照版API 变动会让你怀疑人生。1.2 五大能力模块的职责划分标题里提到的 RAG、工具、记忆、流式、兜底这五个词其实对应了智能客服系统的五个核心能力层缺一不可。我先把它们的职责讲清楚后面再逐个拆。RAG检索增强生成解决的是知识从哪来的问题。企业客服的回答不能靠模型瞎编必须基于企业自己的产品文档、FAQ、工单记录。RAG 就是把用户问题先做向量检索找到最相关的知识片段再塞进提示词让模型基于这些片段回答。工具Function Calling解决的是模型能做什么的问题。用户问我的订单到哪了这不是知识库能回答的需要实时查订单系统。工具调用让模型能主动触发后端接口把动态数据拿回来。记忆Memory解决的是上下文怎么保持的问题。多轮对话里用户说那它多少钱这个它指什么必须靠对话历史。记忆模块负责把历史消息按策略裁剪、存储、注入。流式Streaming解决的是响应快不快的问题。大模型生成一段完整回答可能要好几秒用户盯着转圈会以为卡死了。流式输出让文字一个字一个字蹦出来首字延迟从几秒降到几百毫秒体感完全不同。兜底Fallback解决的是出错怎么办的问题。模型超时、限流、返回空、检索无结果这些异常在生产环境天天发生。兜底策略保证任何情况下用户都能收到一个合理的回复而不是一个 500 错误。这五层叠起来才是一个能上生产的企业智能客服。下面我按模块展开把每一层的实现细节和踩过的坑都讲透。1.3 整体数据流转链路在动手写代码之前先把一次完整请求的流转链路理清楚这决定了你后面代码怎么分层。用户发一条消息进来链路大致是这样走的请求进入 Controller先做参数校验和会话 ID 提取。从记忆模块加载该会话的历史消息。把用户当前问题送去向量库做相似度检索拿到 Top-K 知识片段。组装提示词系统指令 检索到的知识 历史对话 当前问题。调用模型同时把可用的工具列表一起传过去。如果模型决定调用工具执行工具拿到结果再回传给模型继续生成。模型以流式方式返回内容通过 SSE 推给前端。生成结束后把本轮问答写入记忆模块。整个过程中任何一步异常走兜底逻辑返回友好提示。这条链路里第 3 步和第 6 步是最容易出问题的也是最能体现工程水平的地方。后面会重点讲。2. RAG 检索增强的落地细节与调优2.1 文档切分策略切得好比模型强更重要RAG 效果差十有八九是切分没做好。我见过太多项目直接把整篇文档丢进去做 Embedding结果检索出来的片段又长又杂模型拿到一堆无关信息回答自然一塌糊涂。切分的核心原则是语义完整 长度可控。具体怎么做我总结了一套实操方法。首先按文档结构切。企业知识文档通常有标题层级优先按二级、三级标题切这样每个片段天然是一个语义单元。如果文档是纯文本没有结构再退而求其次按段落切。其次是长度控制。Embedding 模型对输入长度有限制而且太长的片段会稀释语义。我的经验值是每个片段 300 到 500 字这个区间检索精度和上下文完整性能取得比较好的平衡。太短了语义不全太长了噪声太多。第三是重叠切分。相邻片段之间保留 50 到 100 字的重叠防止一个完整意思被硬生生切断。比如一段话讲退款流程分三步结果切分点正好在第二步中间检索时只拿到后半段模型就答不全。// 基于 TokenTextSplitter 的切分配置示例 TokenTextSplitter splitter new TokenTextSplitter( 500, // 目标 chunk 大小token 数 100, // 最小 chunk 大小 50, // 重叠 token 数 10000, // 最大文档 token 数 true // 保留分隔符 );注意这里的单位是 token 不是字符。中文一个汉字大约 1 到 2 个 token所以 500 token 大概对应 300 到 400 个汉字。别按字符数去配会切得乱七八糟。2.2 向量库选型与 Embedding 模型搭配向量库这块企业场景我一般推荐两个方向轻量级用内存或本地文件方案规模化用专业向量数据库。如果是中小规模知识库几万条以内直接用 Spring AI 内置的SimpleVectorStore就够了数据落地成 JSON 文件重启加载。优点是零依赖、部署简单缺点是数据量大之后检索变慢而且不支持并发写入。如果知识库上万条甚至几十万条就得上专业向量库。选型时重点看三个指标检索延迟、召回率、运维复杂度。我实测下来对于企业客服这种 QPS 不算特别高的场景大部分主流向量库都能扛住关键是要支持元数据过滤——比如按产品线、按文档版本过滤这个功能在真实业务里非常有用。Embedding 模型的选择更关键。这里有个常见误区很多人以为 Embedding 模型越大越好其实不然。企业中文场景下要重点看模型对中文语义和领域术语的理解能力。通用大模型在专业术语上经常翻车比如对公账户和对私账户通用模型可能认为语义很近但在银行客服场景里这是两个完全不同的业务。我的做法是先用通用 Embedding 模型跑一版然后拿一批真实用户问题做召回测试看 Top-5 里有没有正确答案。如果召回率低于 80%就考虑换模型或者做微调。2.3 检索策略从单路召回走向混合检索单纯靠向量相似度检索有个绕不开的问题关键词精确匹配能力弱。用户问XX-2000 型号怎么重置向量检索可能召回一堆讲重置的通用文档但就是找不到那个具体型号。解决办法是混合检索向量检索 关键词检索BM25 之类两路结果做融合排序。向量检索负责语义相似关键词检索负责精确命中两者互补。融合排序我常用 RRFReciprocal Rank Fusion逻辑很简单对每个文档把它在两路检索里的排名取倒数相加得分高的排前面。这个方法不需要调权重鲁棒性好。// 混合检索的伪代码思路 ListDocument vectorResults vectorStore.similaritySearch(query, 10); ListDocument keywordResults keywordSearch(query, 10); // RRF 融合 MapString, Double scores new HashMap(); for (int i 0; i vectorResults.size(); i) { scores.merge(vectorResults.get(i).getId(), 1.0 / (60 i), Double::sum); } for (int i 0; i keywordResults.size(); i) { scores.merge(keywordResults.get(i).getId(), 1.0 / (60 i), Double::sum); } // 按 scores 排序取 Top-K那个 60 是 RRF 的经验常数不用改改了效果反而可能变差。2.4 重排序让 Top-K 更精准检索回来 10 条不代表这 10 条都相关。直接全塞给模型一是浪费 token二是噪声会干扰模型判断。这时候需要重排序Rerank。重排序模型和 Embedding 模型不一样它做的是 query 和 document 的交叉编码精度更高但速度慢。所以典型流程是向量检索粗召回 20 条重排序精排取前 5 条再给模型。如果不想引入额外的重排序模型也有个土办法让大模型自己给检索结果打分。把 query 和候选片段一起丢给模型让它输出相关性分数。这个方法成本高一些但省去了部署重排序模型的麻烦小规模场景够用。实操心得重排序不是必须的但如果你发现模型回答经常答非所问先别急着换模型加一层重排序往往立竿见影。3. 工具调用与记忆模块的工程实现3.1 工具定义让模型知道能调什么工具调用的本质是给模型一份能力清单模型根据用户问题决定调哪个。Spring AI 里定义工具很直观用Tool注解标注方法即可。Component public class OrderTools { Tool(description 根据订单号查询订单的当前物流状态返回状态描述和预计送达时间) public String queryOrderStatus( ToolParam(description 订单号通常是 12 位数字) String orderNo) { // 调用后端订单服务 return orderService.getStatus(orderNo); } Tool(description 根据用户手机号查询该用户最近一笔订单的订单号) public String queryLatestOrder( ToolParam(description 用户手机号11 位数字) String phone) { return orderService.getLatestOrderNo(phone); } }这里有个关键点description 写得好不好直接决定模型调不调、调得对不对。我踩过的坑是描述写得太笼统比如查询订单结果用户问我的快递呢模型不知道这该调订单工具就瞎编了一个回答。好的 description 要包含三要素做什么、参数是什么、返回什么。而且要用业务语言不要用技术语言。模型不理解调用订单微服务但理解查询订单物流状态。3.2 工具调用的多轮编排工具调用不是一次性的经常需要多轮。比如用户说帮我查下我最近的订单到哪了模型第一步得先调queryLatestOrder拿到订单号第二步再调queryOrderStatus查状态。这个链式调用 Spring AI 会自动处理但你要注意设置最大调用轮数防止模型陷入死循环。ChatClient chatClient ChatClient.builder(chatModel) .defaultTools(orderTools, userTools) .build();默认情况下框架会处理工具调用循环但生产环境建议显式限制轮数比如最多 5 轮。超过就返回查询超时请稍后再试避免一个请求卡死几十秒。注意工具方法一定要做超时控制和异常捕获。后端接口挂了工具方法抛异常整个对话就崩了。正确做法是工具内部 catch 异常返回一个查询失败的字符串让模型基于这个信息组织回复。3.3 记忆模块的存储与裁剪策略记忆这块核心矛盾是上下文长度有限 vs 对话历史无限增长。你不可能把用户聊了一小时的所有消息都塞进提示词token 会爆。我的策略是滑动窗口 摘要压缩结合。滑动窗口好理解就是只保留最近 N 轮对话。N 取多少看模型上下文窗口大小和业务特点。客服场景一般保留最近 10 轮足够因为用户很少会引用 10 轮之前的内容。但滑动窗口有个问题如果用户在开头说了关键信息比如我是 VIP 客户聊到第 15 轮时这个信息被滑掉了模型就忘了。这时候需要摘要压缩把滑出窗口的历史消息定期做一次摘要把摘要作为一条系统消息保留。// 记忆配置示例 MessageWindowChatMemory memory MessageWindowChatMemory.builder() .maxMessages(20) // 最多保留 20 条消息 .build();存储介质上单机场景用内存 Map 就行但生产环境必须用 Redis 之类的共享存储否则多实例部署时用户请求打到不同实例记忆就串了。key 用会话 IDvalue 存消息列表设置合理的过期时间比如 30 分钟无活动就过期。3.4 记忆与 RAG 的协同记忆和 RAG 有个容易冲突的地方用户当前问题里省略的主语可能既在历史对话里也在知识库里。举个例子用户先问你们的旗舰手机多少钱模型答了。然后用户问它支持无线充电吗。这个它指旗舰手机来自记忆。但如果 RAG 检索时只拿它支持无线充电吗去检索向量库里全是它开头的无关内容检索必然失败。解决办法是查询改写在检索之前先用记忆里的上下文把当前问题补全。把它支持无线充电吗改写成旗舰手机支持无线充电吗再去检索。这一步可以用小模型做成本低效果好。// 查询改写的提示词思路 String rewritePrompt 根据以下对话历史将用户的最新问题改写成一个完整、独立的问题 补全省略的主语和指代。只输出改写后的问题不要解释。 对话历史 %s 最新问题%s ;这个改写步骤看起来不起眼但对多轮对话的检索准确率提升非常明显实测能提升 20 个百分点以上。4. 流式输出与兜底策略的实战4.1 流式输出的实现与前端配合流式输出用 SSEServer-Sent Events实现最自然。Spring AI 的ChatClient支持返回FluxString配合 Spring WebFlux 直接推给前端。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chatStream(RequestParam String message, RequestParam String sessionId) { return chatClient.prompt() .user(message) .advisors(a - a.param(chat_memory_conversation_id, sessionId)) .stream() .content(); }前端用EventSource接收每收到一段就 append 到页面上。这里有个细节流式返回的是 token 片段不是完整句子可能一个词被切成两半。前端不要做任何拼接处理直接追加就行浏览器渲染时会自然连上。流式还有个坑错误处理。流已经开始推了中途模型报错怎么办SSE 没法改 HTTP 状态码了。我的做法是约定一个特殊的事件类型比如event: error前端收到后显示错误提示并结束流。实操心得流式输出一定要加心跳保活。有些网关或代理会在连接空闲 60 秒后断开如果模型思考时间长连接就断了。每隔 15 秒推一个空注释行: heartbeat能有效防止这个问题。4.2 兜底策略的分层设计兜底不是简单的一个 try-catch要分层设计。我把它分成四层第一层模型调用兜底。模型超时、限流、返回空这些是最高频的异常。策略是重试 降级。重试 2 次还失败就降级到一个更小更快的模型或者直接返回预设话术。第二层检索兜底。向量库检索无结果或者相似度全部低于阈值。这时候不要让模型硬答而是明确告诉模型没有找到相关知识让它引导用户换个问法或者转人工。第三层工具兜底。工具调用失败返回错误信息给模型让模型基于错误信息组织回复比如订单系统暂时繁忙请稍后再试。第四层全局兜底。任何未捕获的异常统一返回一个友好的错误提示同时记录日志告警。public String chatWithFallback(String message, String sessionId) { try { return doChat(message, sessionId); } catch (ModelTimeoutException e) { log.warn(模型超时, e); return 抱歉我这边响应有点慢请再问一次好吗; } catch (RateLimitException e) { log.warn(模型限流, e); return 当前咨询人数较多请稍等片刻再试。; } catch (Exception e) { log.error(未知异常, e); return 系统开小差了请稍后重试或联系人工客服。; } }4.3 兜底话术的设计原则兜底话术看着简单其实很讲究。我总结了三条原则不要暴露技术细节。用户不需要知道什么是限流什么是超时说系统繁忙就够了。暴露技术细节既不专业也可能引发安全担忧。给出下一步动作。光说出错了没用要告诉用户怎么办。请稍后重试、请换个问法、点击转人工让用户有路可走。保持人设一致。如果客服人设是亲切的兜底话术也要亲切如果人设是专业的兜底也要专业。别平时叫亲出错时突然变成系统错误代码 500。4.4 监控与告警兜底触发了要知道兜底策略上线后必须配套监控。我关注几个核心指标指标含义告警阈值建议兜底触发率走兜底逻辑的请求占比超过 5% 告警模型平均延迟从请求到首字返回的时间超过 3 秒告警工具调用失败率工具执行异常的比例超过 10% 告警检索空结果率检索无有效结果的比例超过 15% 告警会话平均轮数单次会话的对话轮数异常升高需排查这些指标用 Micrometer 埋点接到 Prometheus Grafana 就能看。兜底触发率是最重要的它直接反映系统健康度。如果某天突然飙升八成是模型服务或者向量库出问题了。5. 常见问题排查与避坑经验实录5.1 模型回答答非所问怎么排查这是最高频的问题。排查思路按顺序走先看检索结果。把检索到的 Top-K 片段打印出来人工判断是否相关。如果不相关问题在检索层去调切分策略、换 Embedding 模型、加混合检索。如果检索结果相关但模型还是答错看提示词。是不是知识片段和问题之间没有明确的分隔是不是系统指令不够清晰我习惯在提示词里明确写只根据以下资料回答资料中没有的信息不要编造。如果提示词也没问题看模型本身。有些小模型对长上下文的理解能力弱塞太多资料反而糊涂。这时候减少检索片段数量或者换更强的模型。5.2 多轮对话失忆的定位方法用户反馈它怎么忘了刚才说的排查步骤第一步确认记忆是否真的存进去了。打印记忆模块的存储内容看历史消息在不在。第二步确认记忆是否被正确加载。有些框架的记忆加载有 bug或者会话 ID 传递错了导致每次都是新会话。第三步确认记忆是否被正确注入提示词。有时候记忆加载了但组装提示词时忘了拼进去。第四步确认是不是被窗口滑掉了。如果对话轮数超过窗口大小早期信息确实会丢这时候要靠摘要压缩。5.3 流式输出中断的常见原因流式输出中途断掉原因通常有这几个网关超时Nginx 默认 60 秒超时长回答会被切断。改proxy_read_timeout。缓冲区问题有些代理会缓冲响应导致流式变成一次性返回。要关闭缓冲设置X-Accel-Buffering: no。心跳缺失前面提过加心跳保活。前端处理错误前端收到[DONE]标记后没正确关闭连接或者异常处理没写好。5.4 工具调用该调不调的调优模型该调工具却不调或者不该调乱调都是 description 的问题。调优方法把工具 description 写得更具体、更贴近用户表达。用户说我的快递description 里就写查询快递/物流/包裹状态把同义词都列上。如果还是不行可以在系统提示词里明确引导当用户询问订单、物流、账户等实时信息时必须调用相应工具查询不要凭记忆回答。反过来如果模型乱调工具就在 description 里加限制条件比如仅当用户明确提供了订单号时才调用。5.5 高频问题速查表现象可能原因排查方向回答与问题无关检索不准检查切分、Embedding、重排序多轮对话失忆记忆未加载或滑窗检查会话 ID、窗口大小流式中断网关超时或缓冲调超时、关心跳、关缓冲工具不调用description 不清优化描述、加系统引导响应特别慢模型或检索慢看延迟指标、加缓存兜底频繁触发下游不稳定看告警、查模型和向量库token 超限上下文太长减检索片段、压缩记忆回答重复啰嗦提示词或模型问题加简洁回答指令、换模型5.6 上线前的检查清单系统上线前我一般会过一遍这个清单模型、向量库、Redis 的连接配置是否都走配置中心没有硬编码。所有外部调用是否都有超时设置默认超时往往是无限的很危险。兜底话术是否覆盖了所有异常分支有没有漏网的异常类型。日志是否脱敏用户手机号、订单号这些敏感信息不能明文打日志。限流是否配置防止恶意用户刷爆模型额度。监控告警是否接通兜底触发率、延迟这些指标能不能看到。压测是否做过单实例能扛多少 QPS扩容策略是什么。这套东西跑下来一个能上生产的企业智能客服基本就成型了。RAG 保证答得准工具保证答得全记忆保证聊得下去流式保证体验好兜底保证不出事。五个模块各司其职缺了哪个都会在真实场景里暴露问题。我个人在实际落地中的体会是这套系统最难的不是把功能跑通而是把每个环节的边界情况处理好。模型偶尔抽风、检索偶尔失准、工具偶尔超时这些都是常态。真正决定用户体验的是这些异常发生时系统怎么表现。把兜底和监控做扎实比把主流程做得花哨重要得多。