ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AI 企业智能客服实战:RAG、工具调用、记忆、流式与兜底

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 整体数据流转链路

在动手写代码之前,先把一次完整请求的流转链路理清楚,这决定了你后面代码怎么分层。

用户发一条消息进来,链路大致是这样走的:

  1. 请求进入 Controller,先做参数校验和会话 ID 提取。
  2. 从记忆模块加载该会话的历史消息。
  3. 把用户当前问题送去向量库做相似度检索,拿到 Top-K 知识片段。
  4. 组装提示词:系统指令 + 检索到的知识 + 历史对话 + 当前问题。
  5. 调用模型,同时把可用的工具列表一起传过去。
  6. 如果模型决定调用工具,执行工具拿到结果,再回传给模型继续生成。
  7. 模型以流式方式返回内容,通过 SSE 推给前端。
  8. 生成结束后,把本轮问答写入记忆模块。
  9. 整个过程中任何一步异常,走兜底逻辑返回友好提示。

这条链路里,第 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 之类),两路结果做融合排序。向量检索负责语义相似,关键词检索负责精确命中,两者互补。

融合排序我常用 RRF(Reciprocal Rank Fusion),逻辑很简单:对每个文档,把它在两路检索里的排名取倒数相加,得分高的排前面。这个方法不需要调权重,鲁棒性好。

// 混合检索的伪代码思路 List<Document> vectorResults = vectorStore.similaritySearch(query, 10); List<Document> keywordResults = keywordSearch(query, 10); // RRF 融合 Map<String, 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 用会话 ID,value 存消息列表,设置合理的过期时间(比如 30 分钟无活动就过期)。

3.4 记忆与 RAG 的协同

记忆和 RAG 有个容易冲突的地方:用户当前问题里省略的主语,可能既在历史对话里,也在知识库里。

举个例子,用户先问"你们的旗舰手机多少钱",模型答了。然后用户问"它支持无线充电吗"。这个"它"指旗舰手机,来自记忆。但如果 RAG 检索时只拿"它支持无线充电吗"去检索,向量库里全是"它"开头的无关内容,检索必然失败。

解决办法是查询改写:在检索之前,先用记忆里的上下文把当前问题补全。把"它支持无线充电吗"改写成"旗舰手机支持无线充电吗",再去检索。这一步可以用小模型做,成本低效果好。

// 查询改写的提示词思路 String rewritePrompt = """ 根据以下对话历史,将用户的最新问题改写成一个完整、独立的问题, 补全省略的主语和指代。只输出改写后的问题,不要解释。 对话历史: %s 最新问题:%s """;

这个改写步骤看起来不起眼,但对多轮对话的检索准确率提升非常明显,实测能提升 20 个百分点以上。

4. 流式输出与兜底策略的实战

4.1 流式输出的实现与前端配合

流式输出用 SSE(Server-Sent Events)实现最自然。Spring AI 的ChatClient支持返回Flux<String>,配合 Spring WebFlux 直接推给前端。

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> 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 保证答得准,工具保证答得全,记忆保证聊得下去,流式保证体验好,兜底保证不出事。五个模块各司其职,缺了哪个都会在真实场景里暴露问题。

我个人在实际落地中的体会是,这套系统最难的不是把功能跑通,而是把每个环节的边界情况处理好。模型偶尔抽风、检索偶尔失准、工具偶尔超时,这些都是常态。真正决定用户体验的,是这些异常发生时系统怎么表现。把兜底和监控做扎实,比把主流程做得花哨重要得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进