1. 企业智能客服系统的整体架构设计思路
1.1 为什么选择 RAG + 工具调用 + 记忆 + 流式 + 兜底这套组合
做企业级智能客服,最怕的不是模型不够聪明,而是答非所问、胡编乱造、上下文断裂、响应卡顿、异常无兜底。这五个问题任何一个没处理好,上线就是灾难。我前后参与过三个客服系统的搭建,踩过的坑基本都集中在这几个点上。这套架构的核心逻辑其实很朴素:用 RAG 解决知识准确性问题,用工具调用解决动态数据获取问题,用记忆解决多轮对话连贯性问题,用流式输出解决用户体验问题,用兜底策略解决异常和安全问题。
先说 RAG。企业客服的知识库通常包含产品文档、FAQ、售后政策、操作手册等,这些内容更新频率不低,如果每次更新都去微调模型,成本高且周期长。RAG 的思路是在用户提问时,先从向量库中检索出最相关的知识片段,拼接到提示词中让模型基于这些片段回答。这样做的好处是知识更新只需要更新向量库,模型本身不用动。但 RAG 也有它的局限,比如检索不到的时候怎么办、检索到多条冲突信息怎么排序、相似度阈值怎么定,这些都是实操中必须解决的问题。
再说工具调用。有些问题不是知识库能回答的,比如“我的订单到哪了”“帮我查一下上个月的账单”“这个工单现在是什么状态”。这些需要实时查询业务系统,RAG 无能为力。工具调用就是让模型在需要的时候主动发起一个函数调用,去查数据库或调接口,拿到结果后再组织语言回复用户。Spring AI 对工具调用的支持比较成熟,通过注解就能把 Java 方法暴露给模型。
记忆这块,很多人容易忽略。单轮问答看起来没问题,但用户一旦追问“那这个呢”“刚才说的那个再详细讲讲”,没有记忆的模型就懵了。记忆分短期和长期,短期记忆是当前会话的上下文,长期记忆是用户的历史偏好和关键信息。Spring AI 提供了对话记忆的抽象,但实际用的时候要注意内存管理和过期策略,不然会话一多内存就爆了。
流式输出不用多说,用户等 5 秒看到一整段回复,和逐字逐句蹦出来,体验差距是数量级的。Spring AI 对 SSE 和 Flux 的支持让流式实现变得简单,但要注意流式场景下的异常处理和兜底逻辑跟同步场景不太一样。
兜底策略是最容易被低估的部分。模型超时了怎么办、检索为空怎么办、工具调用失败了怎么办、用户问了敏感问题怎么办、并发量突增导致响应变慢怎么办。这些没有兜底,系统就是脆的。我的经验是,兜底要分层做:第一层是模型层面的兜底提示词,第二层是业务层面的降级策略,第三层是系统层面的熔断和限流。
1.2 这套架构适合什么样的团队和业务场景
这套方案不是万能药,它有比较明确的适用边界。如果你的客服场景是纯闲聊或者纯情感陪伴,那 RAG 和工具调用基本用不上,重点应该放在对话风格和记忆上。如果你的知识库非常小且几乎不变,那直接微调一个小模型可能更划算。但如果你的场景符合以下几个特征,这套架构就很合适:
- 知识库有一定规模且需要频繁更新,比如产品迭代快、政策经常调整
- 有相当比例的问题需要查询实时数据,比如订单、物流、账户状态
- 多轮对话占比高,用户习惯追问和补充
- 对响应速度和用户体验有要求
- 需要处理异常情况和敏感内容
团队方面,需要至少有一个熟悉 Java 和 Spring 生态的后端开发,对向量数据库和嵌入模型有基本了解。如果团队里没人接触过 RAG,建议先花两天时间把检索流程跑通再往上叠其他能力。
1.3 十课收官的内容脉络
这个系列我规划了十课,从环境搭建到最终部署,每一课解决一个核心问题。第一课到第三课打基础,讲 Spring AI 的接入、向量库选型和 RAG 检索链路搭建。第四课到第六课讲进阶,包括工具调用的实现、记忆机制的设计、流式输出的处理。第七课到第九课讲保障,覆盖兜底策略、异常处理、性能优化。第十课做整体串联和部署上线。每一课都有可运行的代码和配置,不是纯理论。
下面这张表是十课的核心内容概览,方便你快速定位自己需要的部分:
| 课次 | 核心主题 | 关键产出 |
|---|---|---|
| 第一课 | 环境搭建与 Spring AI 接入 | 可运行的基础对话接口 |
| 第二课 | 向量库选型与知识入库 | 知识检索链路 |
| 第三课 | RAG 检索策略与提示词工程 | 基于知识库的问答 |
| 第四课 | 工具调用实现 | 动态数据查询能力 |
| 第五课 | 对话记忆设计 | 多轮连贯对话 |
| 第六课 | 流式输出处理 | SSE 流式响应 |
| 第七课 | 兜底策略分层设计 | 异常降级方案 |
| 第八课 | 敏感内容与安全过滤 | 内容安全机制 |
| 第九课 | 性能优化与并发处理 | 高并发稳定运行 |
| 第十课 | 整体串联与部署上线 | 完整可部署系统 |
2. RAG 检索链路的核心细节与实操要点
2.1 向量库选型:为什么我最终选了 PGVector 而不是专用向量库
向量库的选型是 RAG 的第一步,也是很多团队纠结的地方。市面上有 Milvus、Qdrant、Weaviate、Chroma、PGVector 等一堆选择。我前后用过 Milvus 和 PGVector,最终在企业客服场景下更倾向于 PGVector,原因有几个。
第一是运维成本。专用向量库需要单独部署和维护,多一个组件就多一份运维负担。PGVector 是 PostgreSQL 的扩展,如果你的业务系统本来就用 PG,那基本是零额外运维成本。第二是事务一致性。客服场景下知识入库和业务数据经常需要保持一致,PGVector 能跟业务表在同一个事务里操作,这点专用向量库做不到。第三是数据量。企业客服的知识库通常不会特别大,几万到几十万条文档片段是常见量级,PGVector 在这个量级下性能完全够用。如果你的知识库上千万条,那确实需要考虑专用向量库。
当然 PGVector 也有缺点,比如索引构建比专用库慢,高并发检索性能不如 Milvus。但客服场景的检索 QPS 通常不会特别高,这个缺点可以接受。
安装 PGVector 很简单,如果你用的是 Docker,直接拉带 pgvector 的镜像就行:
docker run -d \ --name pgvector \ -e POSTGRES_PASSWORD=yourpassword \ -e POSTGRES_DB=customer_service \ -p 5432:5432 \ pgvector/pgvector:pg16进去之后启用扩展:
CREATE EXTENSION IF NOT EXISTS vector;然后建表,我一般会建两张表,一张存原始文档,一张存向量片段:
CREATE TABLE knowledge_doc ( id BIGSERIAL PRIMARY KEY, title VARCHAR(500), content TEXT, category VARCHAR(100), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE knowledge_chunk ( id BIGSERIAL PRIMARY KEY, doc_id BIGINT REFERENCES knowledge_doc(id), chunk_text TEXT, embedding vector(1536), chunk_index INT, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX ON knowledge_chunk USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);注意:ivfflat 索引的 lists 参数跟数据量有关,一般建议 lists = 行数 / 1000,行数少于 100 万时 lists 取 100 左右比较合适。建索引之前最好先插入一部分数据,空表建索引没意义。
2.2 文档切分策略:固定长度切分是偷懒,语义切分才是正道
文档切分是 RAG 里最容易被忽视但影响很大的环节。很多人直接用固定长度切分,比如每 500 个字符切一刀。这样做的问题是经常把一句话或一个段落从中间切断,检索出来的片段语义不完整,模型理解起来也费劲。
我的做法是分层切分。第一层按文档结构切,比如按标题、章节、段落切。第二层对过长的段落再按句子切,保证每个片段语义完整。第三层控制片段长度,一般 300 到 800 个 token 比较合适,太短信息量不够,太长检索精度下降。
Spring AI 提供了TokenTextSplitter,可以按 token 数切分,比按字符数切分准确。但它的默认行为是纯按长度切,不保留语义边界。我一般会自己写一个切分器,结合段落和句子边界:
public List<String> splitDocument(String content, int maxTokens) { List<String> chunks = new ArrayList<>(); String[] paragraphs = content.split("\n\n"); StringBuilder current = new StringBuilder(); int currentTokens = 0; for (String para : paragraphs) { int paraTokens = estimateTokens(para); if (currentTokens + paraTokens > maxTokens && current.length() > 0) { chunks.add(current.toString().trim()); current = new StringBuilder(); currentTokens = 0; } if (paraTokens > maxTokens) { // 段落本身超长,按句子切 List<String> sentences = splitBySentence(para); for (String sentence : sentences) { int sentTokens = estimateTokens(sentence); if (currentTokens + sentTokens > maxTokens && current.length() > 0) { chunks.add(current.toString().trim()); current = new StringBuilder(); currentTokens = 0; } current.append(sentence); currentTokens += sentTokens; } } else { current.append(para).append("\n\n"); currentTokens += paraTokens; } } if (current.length() > 0) { chunks.add(current.toString().trim()); } return chunks; }切分的时候还要注意重叠。相邻片段之间保留 10% 到 20% 的重叠,能避免关键信息刚好落在切分边界上导致检索不到。这个重叠量不用太大,50 到 100 个 token 就够了。
2.3 嵌入模型选择与批量入库
嵌入模型的选择直接影响检索质量。OpenAI 的 text-embedding-3-small 性价比很高,1536 维,价格便宜,效果在客服场景下够用。如果对成本敏感,可以用开源的 BGE 系列,但需要自己部署推理服务。Spring AI 对 OpenAI 的嵌入模型支持最好,直接配置就行:
spring: ai: openai: api-key: ${OPENAI_API_KEY} embedding: options: model: text-embedding-3-small批量入库的时候要注意两点。第一是批大小,一次请求太多嵌入会超时,一般 50 到 100 条一批比较稳。第二是失败重试,网络抖动导致某批失败很常见,要有重试机制。我一般用 Spring Retry 做三次重试,间隔递增:
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) public List<float[]> embedBatch(List<String> texts) { return embeddingModel.embed(texts); }入库的完整流程是:读文档 -> 切分 -> 批量嵌入 -> 写入向量表。这个过程建议做成异步任务,不要阻塞主流程。可以用 Spring 的@Async或者消息队列来解耦。
2.4 检索策略:相似度阈值、TopK 和重排序
检索的时候有几个参数需要调。TopK 是返回最相似的 K 个片段,一般设 3 到 5 比较合适。设太小可能漏掉关键信息,设太大引入噪声。相似度阈值是过滤掉不相关的片段,一般设 0.7 左右,低于这个值的片段不参与回答。
但光靠向量相似度有时候不够。比如用户问“退款政策是什么”,向量检索可能返回一堆跟退款相关的片段,但其中有些是过时的政策。这时候就需要重排序。重排序可以用一个交叉编码器模型,对 TopK 结果重新打分,把最相关的排前面。Spring AI 本身不直接提供重排序,但可以自己接一个重排序服务,或者用简单的规则重排,比如按文档更新时间加权。
我一般会在检索后加一层过滤逻辑:
public List<Document> retrieveWithFilter(String query, int topK, double threshold) { List<Document> docs = vectorStore.similaritySearch( SearchRequest.query(query).withTopK(topK * 2) ); return docs.stream() .filter(doc -> doc.getScore() >= threshold) .sorted((a, b) -> { // 先按相似度,相似度接近时按更新时间 double scoreDiff = b.getScore() - a.getScore(); if (Math.abs(scoreDiff) > 0.05) { return scoreDiff > 0 ? 1 : -1; } return compareByUpdateTime(b, a); }) .limit(topK) .collect(Collectors.toList()); }实操心得:相似度阈值不要设死,不同嵌入模型的分数分布不一样。建议先用一批测试问题跑一遍,看看正确片段的分数分布,再定阈值。我见过有人直接抄别人的 0.8,结果召回率低得可怜。
3. 工具调用与记忆机制的设计与实现
3.1 工具调用的适用场景与实现方式
工具调用解决的是“模型不知道但业务系统知道”的问题。客服场景下常见的工具包括:订单查询、物流跟踪、账户余额、工单状态、退换货申请等。这些信息不在知识库里,必须实时查。
Spring AI 的工具调用实现很简洁,用@Tool注解标记方法就行:
@Component public class CustomerServiceTools { @Tool(description = "根据订单号查询订单状态,返回订单的当前状态和预计送达时间") public OrderStatus queryOrderStatus( @ToolParam(description = "订单号,通常是10位数字") String orderId) { // 实际查询业务系统 return orderService.getStatus(orderId); } @Tool(description = "查询用户最近的工单列表") public List<Ticket> queryUserTickets( @ToolParam(description = "用户ID") String userId, @ToolParam(description = "查询条数,默认5条") int limit) { return ticketService.getRecentTickets(userId, limit); } }然后在 ChatClient 里注册这些工具:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(new CustomerServiceTools()) .build();模型会根据用户问题自动决定是否调用工具。比如用户问“我的订单 1234567890 到哪了”,模型会识别出需要调用queryOrderStatus,传入订单号,拿到结果后组织语言回复。
但这里有几个坑要注意。第一是工具描述要写清楚,模型靠描述来判断什么时候调用。描述太模糊模型不知道什么时候用,描述太详细又浪费 token。第二是参数校验,模型可能传错参数格式,工具方法里要做好校验和异常处理。第三是工具调用失败的处理,如果业务系统挂了,不能让整个对话卡死,要有降级回复。
3.2 工具调用的安全边界与权限控制
工具调用是把双刃剑,用好了很方便,用不好就是安全隐患。最典型的问题是模型可能被诱导调用不该调用的工具。比如用户说“帮我查一下别人的订单”,如果工具没有权限校验,模型可能真的去查了。
我的做法是在工具方法内部做权限校验,不依赖模型判断:
@Tool(description = "查询订单状态") public OrderStatus queryOrderStatus( @ToolParam(description = "订单号") String orderId, ToolContext context) { String currentUserId = context.get("userId"); Order order = orderService.getById(orderId); if (!order.getUserId().equals(currentUserId)) { throw new AccessDeniedException("无权查询该订单"); } return order.getStatus(); }Spring AI 的ToolContext可以传递上下文信息,比如当前登录用户 ID。这样即使模型被诱导,工具层面也会拦住。
另外,涉及写操作的工具要特别谨慎。比如“提交退款申请”这种工具,最好加二次确认,或者只允许在特定意图下调用。我一般会把写操作工具单独分组,只在明确意图识别后才注册。
3.3 对话记忆的分层设计
记忆这块,我把它分成三层:会话级短期记忆、用户级长期记忆、全局知识记忆。
会话级短期记忆就是当前对话的上下文,用MessageWindowChatMemory就行,保留最近 N 条消息。N 一般设 10 到 20,太多会占用大量 token,太少又记不住上下文。
ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build();用户级长期记忆是跨会话的,比如用户上次咨询的问题、用户的偏好、用户的基本信息。这些信息可以存在 Redis 或数据库里,每次会话开始时加载。实现方式可以是在系统提示词里注入用户画像,或者在检索时把用户历史作为额外上下文。
全局知识记忆就是 RAG 的知识库,这个前面讲过了。
三层记忆的优先级是:会话级 > 用户级 > 全局知识。当信息冲突时,优先用会话级的信息。比如用户这次说“我要退的是上个月买的那件”,那就以这次说的为准,不要被历史记忆干扰。
3.4 记忆的过期与清理策略
记忆不清理,内存迟早爆。会话级记忆一般设 30 分钟过期,用户级记忆设 30 天过期。过期策略可以用 Redis 的 TTL,也可以用定时任务清理。
我一般用 Redis 存会话记忆,key 是 sessionId,value 是序列化的消息列表,TTL 设 1800 秒。每次对话时刷新 TTL,这样活跃会话不会过期,不活跃的自动清理。
public void saveMemory(String sessionId, List<Message> messages) { String key = "chat:memory:" + sessionId; redisTemplate.opsForValue().set(key, serialize(messages), 30, TimeUnit.MINUTES); }注意:序列化消息的时候要注意版本兼容,模型返回的消息结构可能随版本变化。建议用 JSON 序列化,并且加版本号字段,方便后续升级。
4. 流式输出与兜底策略的工程实现
4.1 流式输出的实现与异常处理
流式输出用 Spring AI 的stream()方法配合 SSE 就能实现。Controller 层返回Flux<String>,前端用 EventSource 接收:
@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(new MessageChatMemoryAdvisor(chatMemory, sessionId)) .stream() .content() .onErrorResume(e -> { log.error("流式输出异常", e); return Flux.just("抱歉,系统暂时繁忙,请稍后再试。"); }); }流式场景下的异常处理跟同步不一样。同步请求抛异常直接返回错误码就行,流式请求可能已经输出了一部分内容才出错,这时候不能直接返回错误,要在流里插入一个错误提示。onErrorResume就是干这个的。
还有一个坑是超时。流式请求如果模型响应慢,连接可能被网关断开。我一般会设一个总超时时间,比如 30 秒,超过就主动结束流并返回兜底话术。
4.2 兜底策略的分层设计
兜底我分三层来做。
第一层是提示词兜底。在系统提示词里明确告诉模型:如果检索到的知识不足以回答问题,不要编造,直接说“这个问题我暂时无法准确回答,建议您联系人工客服”。这能过滤掉大部分幻觉。
第二层是业务兜底。当检索为空、工具调用失败、模型超时等情况发生时,走预设的降级回复。比如检索为空时,可以回复“我暂时没有找到相关信息,您可以换个说法再问,或者转人工客服”。
第三层是系统兜底。当整个服务不可用时,返回一个静态的兜底页面或消息。这层一般由网关或前端处理。
兜底策略的配置我建议放在配置中心,方便动态调整:
fallback: empty-retrieval: "抱歉,我暂时没有找到相关信息,您可以换个说法再问,或者输入'转人工'联系客服。" tool-failure: "查询服务暂时不可用,请稍后再试。" model-timeout: "系统响应超时,请稍后再试。" sensitive-content: "您的问题涉及敏感内容,我无法回答,请咨询其他问题。"4.3 敏感内容过滤与安全机制
客服系统面向公众,敏感内容过滤是必须的。过滤分输入和输出两端。
输入端过滤是在用户消息进入模型之前做检查,如果命中敏感词或敏感意图,直接返回兜底话术,不调用模型。输出端过滤是在模型回复返回之前做检查,防止模型生成不当内容。
实现方式可以用敏感词库加正则匹配,也可以用专门的分类模型。我一般用敏感词库做第一层快速过滤,再用一个轻量分类模型做第二层判断。敏感词库要定期更新,分类模型可以用开源的。
public boolean isSensitive(String text) { // 第一层:敏感词匹配 if (sensitiveWordFilter.contains(text)) { return true; } // 第二层:分类模型 return sensitiveClassifier.isSensitive(text); }实操心得:敏感词过滤要注意误伤。比如“退款”本身不是敏感词,但“退款投诉”可能触发。建议敏感词分级,高敏感的直接拦截,低敏感的走人工审核或提示。
4.4 性能优化与并发处理
客服系统可能面临突发流量,性能优化不能等到上线才做。我一般从几个方面入手。
第一是连接池。数据库连接池、HTTP 连接池都要配好,避免请求堆积。HikariCP 的maximumPoolSize一般设 CPU 核数 * 2 + 磁盘数,但具体要看压测结果。
第二是缓存。嵌入结果、检索结果、常见问题的回答都可以缓存。嵌入结果缓存能避免重复计算,检索结果缓存能减少向量库压力。我用 Caffeine 做本地缓存,Redis 做分布式缓存。
第三是异步。工具调用、知识入库这些耗时操作尽量异步化,不要阻塞主流程。Spring 的@Async配合线程池就能搞定。
第四是限流。用 Sentinel 或 Resilience4j 做限流和熔断,防止单个用户或单个接口拖垮整个系统。
@Bean public RateLimiter rateLimiter() { return RateLimiter.of("chat", RateLimiterConfig.custom() .limitForPeriod(100) .limitRefreshPeriod(Duration.ofSeconds(1)) .timeoutDuration(Duration.ofMillis(500)) .build()); }压测的时候重点关注 P99 延迟和错误率。P99 超过 3 秒就要优化,错误率超过 1% 就要排查。我一般用 JMeter 或 Gatling 做压测,模拟 100 并发、500 并发、1000 并发三档。
5. 常见问题与排查技巧实录
5.1 检索不准的排查思路
检索不准是最常见的问题,表现是模型回答跟知识库内容对不上。排查思路是逐层检查。
先看切分是否合理。把检索到的片段打印出来,看看是不是语义完整。如果片段被切得七零八落,那就是切分策略有问题。
再看嵌入是否正常。拿一个已知问题,手动算一下它跟知识库片段的相似度,看看分数分布是否合理。如果所有分数都很低,可能是嵌入模型选错了或者维度不匹配。
然后看检索参数。TopK 和阈值是否合适,可以调大 TopK 看看能不能召回正确片段。如果调大能召回但排序靠后,那就是重排序的问题。
最后看提示词。检索到了正确片段但模型没用,可能是提示词没引导好。在提示词里明确要求“基于以下知识回答”,并且给出引用格式。
5.2 工具调用失败的常见原因
工具调用失败一般有几个原因。一是工具描述不清晰,模型不知道什么时候调用。二是参数格式不对,模型传的参数跟方法签名不匹配。三是业务系统异常,工具方法内部抛异常。
排查的时候先看日志,Spring AI 会打印工具调用的请求和响应。如果模型根本没发起调用,那就是描述问题。如果发起了但参数不对,那就是参数描述问题。如果调用抛异常,那就是业务逻辑问题。
我一般会在工具方法里加详细的日志,记录入参和出参,方便排查:
@Tool(description = "查询订单状态") public OrderStatus queryOrderStatus(@ToolParam(description = "订单号") String orderId) { log.info("工具调用:queryOrderStatus, orderId={}", orderId); try { OrderStatus status = orderService.getStatus(orderId); log.info("工具返回:{}", status); return status; } catch (Exception e) { log.error("工具调用失败", e); throw new ToolExecutionException("查询订单失败:" + e.getMessage()); } }5.3 流式输出中断的排查
流式输出中断的表现是用户看到一半内容就没了。原因可能是网络问题、网关超时、模型异常、代码异常。
排查的时候先看后端日志,有没有异常堆栈。再看网关配置,SSE 的超时时间是否够长。然后看前端,EventSource 的错误处理是否完善。
我遇到过一次是 Nginx 的proxy_read_timeout默认 60 秒,但模型响应慢的时候超过了这个时间,连接被断开。改成 300 秒就好了。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 回答与知识库不符 | 检索不准 | 检查切分、嵌入、TopK | 调整切分策略和检索参数 |
| 模型不调用工具 | 工具描述不清 | 查看工具调用日志 | 优化工具描述和参数说明 |
| 流式输出中断 | 网关超时 | 检查 Nginx 和网关配置 | 调大超时时间 |
| 多轮对话失忆 | 记忆未生效 | 检查 sessionId 和记忆配置 | 确认记忆 Advisor 已注册 |
| 响应慢 | 检索或模型耗时 | 分段计时 | 加缓存、异步化、限流 |
| 敏感内容漏出 | 过滤不严 | 检查过滤规则 | 加强敏感词库和分类模型 |
| 并发高时错误率上升 | 资源不足 | 压测定位瓶颈 | 扩容、连接池调优、限流 |
5.5 独家避坑技巧
第一个坑是嵌入模型的维度要跟向量表定义一致。我见过有人用 1536 维的模型,但表定义的是 768 维,插入的时候直接报错。建表之前一定要确认模型维度。
第二个坑是 PGVector 的索引要在数据插入之后建。空表建索引,后续插入的数据可能不走索引,检索性能很差。正确做法是先插入数据,再建索引,或者定期重建索引。
第三个坑是流式输出的时候不要用@Transactional。流式请求生命周期长,事务会一直持有连接,并发一高连接池就爆了。流式接口的事务要单独处理,或者干脆不用事务。
第四个坑是工具调用的超时要设短。业务系统如果挂了,工具调用可能等很久,把整个对话卡死。工具方法内部要设超时,比如 3 秒,超时就走降级。
第五个坑是记忆的序列化要注意版本。模型返回的消息结构可能随版本变化,序列化的时候加版本号,反序列化的时候做兼容处理,不然升级的时候历史记忆全丢。
6. 整体串联与部署上线的关键动作
6.1 从十课到一套可运行系统的串联
十课分开讲的时候每课都是独立的,但真正上线需要把它们串起来。串联的核心是定义好各层之间的接口和数据流。
用户请求进来,先过敏感内容过滤,然后加载会话记忆,接着走 RAG 检索,检索结果和记忆一起拼成提示词,调用模型。模型如果决定调用工具,就走工具调用链路,拿到结果后继续生成。生成过程用流式输出,同时做输出端敏感过滤。任何环节异常,走对应的兜底策略。
这个流程用 Spring AI 的 Advisor 机制能比较优雅地实现。Advisor 可以在请求前后插入逻辑,比如记忆加载、检索增强、敏感过滤都可以做成 Advisor。
ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors( new MessageChatMemoryAdvisor(chatMemory), new QuestionAnswerAdvisor(vectorStore), new SensitiveContentAdvisor() ) .defaultTools(new CustomerServiceTools()) .build();6.2 部署上线的检查清单
上线之前有几件事必须确认。配置方面,API key、数据库连接、Redis 连接、向量库连接都要检查。性能方面,压测报告要达标,P99 延迟和错误率在可接受范围。安全方面,敏感过滤要生效,权限校验要到位。监控方面,关键指标要有埋点,日志要能追溯。
我一般会做一个上线检查清单,逐项确认:
- 所有外部依赖的连接测试通过
- 压测报告 P99 < 3s,错误率 < 1%
- 敏感内容过滤测试用例全部通过
- 工具调用权限校验测试通过
- 兜底策略触发测试通过
- 监控大盘配置完成
- 日志采集和告警配置完成
- 回滚方案准备就绪
6.3 上线后的持续优化方向
上线不是终点,是起点。上线后要持续关注几个指标:用户满意度、问题解决率、转人工率、平均响应时间、错误率。这些指标能反映系统健康度。
优化方向主要有几个。一是知识库持续更新,把用户问得多但回答不好的问题补充进去。二是检索策略调优,根据实际数据调整 TopK 和阈值。三是提示词迭代,根据 bad case 优化提示词。四是工具扩展,把更多业务能力接入。
我一般会每周看一次 bad case,挑出回答不好的对话,分析原因,针对性优化。这个习惯坚持下来,系统效果会越来越好。
6.4 一个完整的配置示例
最后给一个完整的配置示例,把前面讲的东西串起来:
spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.3 embedding: options: model: text-embedding-3-small datasource: url: jdbc:postgresql://localhost:5432/customer_service username: postgres password: ${DB_PASSWORD} hikari: maximum-pool-size: 20 minimum-idle: 5 data: redis: host: localhost port: 6379 timeout: 3000 chat: memory: max-messages: 20 ttl-minutes: 30 retrieval: top-k: 5 similarity-threshold: 0.7 fallback: empty-retrieval: "抱歉,我暂时没有找到相关信息,您可以换个说法再问。" tool-failure: "查询服务暂时不可用,请稍后再试。" model-timeout: "系统响应超时,请稍后再试。"这套配置我在几个项目里用过,基本能覆盖中小规模客服场景。如果并发量特别大,需要把 Redis 和 PGVector 做集群,模型调用也要做负载均衡。
最后分享一个小技巧:上线初期建议开一个“影子模式”,把模型的回答和人工客服的回答都记录下来,对比分析。这样能快速发现模型的短板,针对性优化。等模型回答质量稳定了,再逐步放量。