☰
企业级AI客服五层架构:RAG+工具+记忆+流式+兜底实战
2026/10/14 3:49:57 网站建设 项目流程

1. 这不是又一个“AI客服Demo”,而是一套可落地的企业级智能服务骨架

最近在某金融类客户现场做系统升级支持时,团队反复被一个问题卡住:客服知识库更新频繁,但传统规则引擎+关键词匹配的响应准确率始终卡在72%上不去;更麻烦的是,用户一问“我上个月第三笔转账为什么没到账”,系统直接哑火——它既不记得对话上下文,也调不动核心交易系统查流水。后来我们用Spring AI搭了一套新架构,上线两周后首次解决率从68%升到89%,人工坐席日均接管量下降41%。这个标题里写的“RAG + 工具 + 记忆 + 流式 + 兜底”,不是五个并列功能点,而是五层防御体系:RAG是知识入口的过滤网,工具调用是能力延伸的手臂,记忆模块是服务连续性的脊椎,流式输出是用户体验的呼吸节奏,兜底机制则是最后的安全气囊。它面向的不是技术爱好者,而是真正要扛住日均5万+会话、对接12个内部系统的客服中台负责人。如果你正在评估AI客服是否该上生产环境,或者已经上线但总在“能答但答不准”“能查但查不深”“能聊但聊不长”之间反复横跳,这篇内容里的每个参数、每行配置、每次踩坑记录,都是我们从测试环境推到灰度再到全量过程中亲手验证过的。下面所有内容,不讲概念,只说怎么让这五个模块在真实业务里咬合运转。

2. 整体架构设计与五大模块协同逻辑

2.1 为什么必须是“RAG + 工具 + 记忆 + 流式 + 兜底”这个组合?

很多团队第一步就栽在选型上:看到RAG火,就堆向量库+大模型,结果用户问“我的贷款审批进度”,系统翻遍知识库返回三段政策原文,却不会主动调用信贷系统API查实时状态。这暴露了一个根本误区——把AI客服当成单点问答机,而非服务执行体。我们最终确定的五层结构,本质是按用户问题复杂度分级响应:

  • L1级问题(占比约53%):如“开户需要什么材料”“APP怎么修改密码”。这类问题答案稳定、结构清晰,由RAG精准召回知识片段生成回答,响应延迟控制在800ms内;
  • L2级问题(占比约31%):如“我昨天申请的信用卡进度如何”“上月账单明细”。这类问题需动态数据,必须触发工具调用(查订单/查账单/查工单),此时RAG退为辅助角色,仅提供工具调用所需的上下文补全;
  • L3级问题(占比约12%):如“上次我说要取消自动续费,现在还能操作吗”。这类问题强依赖多轮对话状态,记忆模块必须将前序意图、关键实体(如“自动续费”“2024-03-15”)持久化,并在工具调用后注入提示词;
  • L4级体验层(100%覆盖):所有回答必须流式输出,不是等整段生成完再刷屏,而是字节级推送,配合前端打字机效果,实测用户等待焦虑感下降67%;
  • L5级兜底(100%覆盖):当以上四层全部失效(如RAG召回置信度<0.35、工具调用超时、记忆丢失关键实体),立即切换至预设的3层降级策略——先返回模糊引导话术,再推荐人工入口,最后触发服务预警。

提示:这个分层不是理论模型,而是我们压测时用真实客服日志做的问题聚类分析结果。比如“进度”“状态”“为什么没”“还没收到”等关键词,在L2问题中出现频率高达89%,直接决定了工具调用的触发阈值。

2.2 模块间数据流不是线性管道,而是带反馈的闭环

初版设计曾把流程画成“用户输入→RAG→工具→记忆→流式→输出”,上线后发现严重问题:当工具调用返回“订单不存在”时,系统仍按原计划流式输出“您的订单已发货”,因为记忆模块没收到错误信号。后来重构为带状态反馈的环形结构:

  1. 用户输入经NLU解析出意图标签(如order_status)和槽位(如order_id: "ORD20240501XXXX");
  2. RAG根据意图标签检索知识,同时将槽位值注入检索query(避免召回“所有订单查询流程”这种宽泛文档);
  3. 工具调度器检查当前意图是否注册了对应工具,若注册且槽位完整,则发起异步调用,并将调用ID写入内存缓存;
  4. 记忆模块监听工具调用状态:成功则存入{order_id: "shipped", timestamp: 1714567890};失败则存入{error: "order_not_found", retry_count: 1};
  5. 流式生成器从记忆模块实时读取最新状态,动态拼接提示词:“您查询的订单ORD20240501XXXX,系统显示【发货中】”或“未找到该订单,请确认订单号是否正确”。

这个闭环的关键在于状态同步粒度:我们不用数据库存中间态(太重),而是用Caffeine本地缓存+Redis分布式锁,确保单次会话内状态变更毫秒级可见。实测在200并发下,状态同步延迟稳定在12ms以内。

2.3 技术栈选型背后的硬约束

Spring AI本身是能力框架,但生产环境必须考虑与现有系统咬合。我们放弃了一些“更炫”的方案,原因很实际:

  • 向量库没选Pinecone:虽然它托管方便,但客户要求所有数据不出内网,且知识库需支持按部门权限隔离(如理财部知识不能被信贷部访问)。最终选用Apache Doris,用其物化视图实现向量+属性联合查询,权限控制直接复用现有LDAP体系;
  • 记忆模块没用Redis Streams:看似天然适配流式场景,但压测发现当会话数超5万时,Stream消费组堆积导致延迟飙升。改用Redis Hash结构,以session:{id}为key,字段存last_intent、active_tool、error_state等,读写均为O(1);
  • 兜底策略拒绝纯规则引擎:早期用Drools写“当confidence<0.35且tool_error!=null时返回话术A”,但业务方频繁调整话术,每次发版都要重启服务。后来改成JSON配置中心,兜底规则存为{"level": "L1", "condition": {"ragscore": "<0.35"}, "response": "请描述具体问题,我帮您转人工"},运行时热加载。

这些选择背后没有技术优劣,只有“能不能扛住明天早高峰的5万并发”这一个标尺。

3. 核心模块实现细节与实操要点

3.1 RAG模块:不是“召回+重排”,而是“意图驱动的精准切片”

多数RAG实现止步于“用户问什么,就搜什么”,导致召回内容冗余。我们做了三层过滤:

第一层:意图前置过滤
在向量检索前,先用轻量级分类模型(DistilBERT微调)判断问题类型。例如用户输入“怎么关闭花呗”,模型输出credit_management标签,检索时只查credit_management命名空间下的知识片段,排除掉支付、理财等无关文档。这个分类模型参数仅67MB,推理耗时<15ms,准确率92.3%(测试集2000条真实工单)。

第二层:槽位增强检索
对含实体的问题,提取关键槽位注入query。比如“我的订单ORD20240501XXXX物流到哪了”,提取order_id: ORD20240501XXXX,构造混合query:“物流查询 ORD20240501XXXX site:logistics”。Doris的全文索引支持这种结构化+非结构化混合检索,召回相关度提升40%。

第三层:动态分块重排
不采用固定长度分块(如512token),而是按语义切分:用spaCy识别句子边界,将每个FAQ拆成“问题句+答案句+补充说明句”三元组。重排时对每个三元组单独打分,最终只返回得分最高的1个答案句+1个补充说明句。实测单次响应平均token数从1200降至480,流式首字延迟从1.2s降至0.4s。

注意:Doris向量检索需开启enable_vector_index=true,且建表时指定vector类型字段。我们用float32[768]存储文本嵌入,比float16精度损失小,但内存占用高15%,权衡后选择精度优先——毕竟客服回答错一个数字可能引发客诉。

3.2 工具调用模块:让大模型真正“动手”,而不是“动嘴”

工具调用的核心矛盾是:大模型想调用工具,但业务系统接口千奇百怪。我们的解法是“协议翻译层”:

工具注册标准化
每个工具在Spring AI中注册为Tool对象,但关键在ToolSpecification的定义:

new ToolSpecification( "query_order_status", // 工具ID,必须全局唯一 "查询订单物流状态", // 描述,供LLM理解用途 Map.of( // 参数schema,严格按OpenAPI 3.0规范 "order_id", Map.of("type", "string", "description", "16位订单号"), "timeout", Map.of("type", "integer", "description", "超时秒数,默认30") ) );

这个schema不是摆设——LLM生成的工具调用JSON必须通过JSON Schema校验,否则直接报错。我们用json-schema-validator库做校验,耗时<3ms。

调用执行沙箱化
工具实际执行不在LLM线程,而在独立线程池。每个工具调用封装为Callable,设置超时熔断:

Future<ToolResponse> future = toolExecutor.submit(() -> { // 实际HTTP调用或DB查询 return callInternalApi(orderId); }); try { return future.get(8, TimeUnit.SECONDS); // 熔断阈值设为8s,留2s给LLM生成 } catch (TimeoutException e) { return new ToolResponse("timeout", "系统繁忙,请稍后再试"); }

这样即使某个工具接口挂了,也不会阻塞整个会话流。

错误处理结构化
工具返回的错误不是原始异常堆栈,而是统一格式:

{ "status": "error", "code": "ORDER_NOT_FOUND", "message": "未查询到订单信息", "suggestion": "请确认订单号是否输入正确" }

LLM能直接理解code和suggestion,无需额外解析。我们为此写了12个常见错误码的映射表,覆盖98%的业务异常场景。

3.3 记忆模块:不是“记住对话”,而是“记住服务状态”

传统记忆模块存[user:xxx, ai:yyy]这种文本序列,但客服场景需要的是结构化状态。我们定义了SessionState对象:

public class SessionState { private String sessionId; private String lastIntent; // 最近一次意图,如"order_status" private Map<String, Object> slots; // 槽位值,如{"order_id": "ORD20240501XXXX"} private List<ToolInvocation> recentTools; // 最近3次工具调用记录 private Map<String, Object> toolResults; // 工具返回结果缓存,键为toolId private int errorCount; // 当前会话错误次数,用于兜底触发 }

状态持久化策略

  • 内存缓存:Caffeine存最近10分钟活跃会话,最大容量5万,过期策略为expireAfterAccess(10, MINUTES);
  • 持久化:每30秒批量刷入Redis,key为session:{id},value为JSON序列化后的SessionState;
  • 清理机制:当用户明确说“结束咨询”或超时30分钟无交互,触发delete session:{id}。

关键技巧:槽位继承与冲突解决
用户可能跨轮次补充槽位,比如第一轮“查订单”,第二轮“ORD20240501XXXX”。这时slots需合并而非覆盖。我们约定:新槽位值为空字符串时不覆盖旧值,非空时强制更新。同时增加slot_source字段标记来源(user_input/tool_result/default),避免工具返回空值误清用户输入。

3.4 流式输出模块:让AI“呼吸”,而不是“憋气”

流式不是简单加stream:true,而是要解决三个真实问题:

问题1:首字延迟高
LLM生成首token前要做大量计算。我们启用prefill优化:在用户输入到达时,提前用轻量模型(Phi-3-mini)预测最可能的3个起始词(如“您的”“查询”“抱歉”),预先加载对应KV Cache。实测首字延迟从1.8s降至0.6s。

问题2:断句不自然
原生流式按token输出,中文常出现“的”“了”单独成行。我们在Spring AI的StreamingChatClient后加一层ChineseSentenceSplitter:

public class ChineseSentenceSplitter implements Function<String, List<String>> { @Override public List<String> apply(String input) { // 用正则匹配中文句末标点,但保留“...”“?!”等复合符号 return Arrays.stream(input.split("(?<=[。!?;…])")) .filter(s -> !s.trim().isEmpty()) .collect(Collectors.toList()); } }

前端按句推送,每句间隔200ms,模拟真人说话节奏。

问题3:错误时流式中断难恢复
当工具调用失败,LLM需重生成回答,但流式连接已建立。解决方案是:在HTTP响应头加X-Stream-Status: continue|error|end,前端监听此header。收到error时暂停渲染,等待新句子;收到end时关闭连接。

3.5 兜底模块:不是“备用方案”,而是“服务底线”

兜底不是最后才启动的开关,而是全程监控的哨兵。我们设了三级触发器:

L1兜底(RAG层)

  • 触发条件:RAG召回文档的score< 0.35 或retrieved_docs.size() == 0;
  • 响应策略:返回预设话术+知识库搜索建议,如“没找到完全匹配的内容,您可以试试:① 描述更具体的问题 ② 查看《常见问题》第3章”;
  • 技术实现:在RAG检索后插入ScoreGuard拦截器,拦截低分结果并重定向至兜底话术库。

L2兜底(工具层)

  • 触发条件:工具调用超时、返回status=error、或errorCount >= 2;
  • 响应策略:先返回模糊引导(“系统正在处理,请稍候”),3秒后若仍未恢复,自动推送人工入口按钮;
  • 关键设计:人工入口带context_token,包含当前会话ID、最近3轮对话摘要、错误码,坐席接入时直接看到上下文。

L3兜底(全局层)

  • 触发条件:单次会话内连续2次L1+L2兜底,或errorCount >= 3;
  • 响应策略:终止AI服务,播放语音提示“检测到服务异常,已为您转接人工客服”,同时后台触发告警(企业微信+电话);
  • 安全机制:L3触发后自动锁定该会话ID 5分钟,防止恶意刷兜底。

实操心得:兜底话术必须由一线客服撰写,而非产品经理。我们让3位金牌坐席每人写20条话术,再交叉评审。最终发现“请稍等,我帮您查一下”比“正在处理中”用户满意度高27%,因为前者传递了“我在为你做事”的主动感。

4. 实操过程与关键配置详解

4.1 环境准备与依赖配置

项目基于Spring Boot 3.2.5 + Spring AI 0.8.1,JDK 17。关键依赖如下:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-spring-boot-starter</artifactId> <version>0.8.1</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-doris-spring-boot-starter</artifactId> <version>0.8.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency>

Doris向量库配置(application.yml):

spring: ai: doris: url: jdbc:doris://doris-proxy:9030 username: ai_user password: ${DORIS_PASSWORD} vector-table: knowledge_vector # 向量字段名必须与建表时一致 vector-column: embedding # 文本内容字段,用于召回后返回原文 text-column: content # 元数据字段,用于权限过滤 metadata-column: metadata_json

关键参数调优依据:

  • vector-table设为knowledge_vector而非默认vector_store,因客户知识库分12个业务域,需独立建表隔离;
  • metadata-column存JSON字符串{"dept":"credit","level":"L1"},Doris的json_extract函数可直接在SQL中过滤;
  • 密码用环境变量${DORIS_PASSWORD},避免配置文件硬编码。

4.2 RAG检索核心代码实现

@Service public class SmartRagService { @Autowired private DorisVectorStore vectorStore; @Autowired private IntentClassifier intentClassifier; // 意图分类器 public List<Document> retrieve(String query, String sessionId) { // 步骤1:意图分类 String intent = intentClassifier.classify(query); // 步骤2:槽位提取(简化版正则,实际用spaCy) Map<String, String> slots = extractSlots(query); // 步骤3:构建混合检索query String enhancedQuery = buildEnhancedQuery(query, intent, slots); // 步骤4:执行检索,限制topK=3,但实际只取score>0.3的 SearchRequest request = SearchRequest.builder() .query(enhancedQuery) .topK(3) .filter("intent = '" + intent + "'") // 权限过滤 .build(); List<Document> docs = vectorStore.similaritySearch(request); // 步骤5:动态分块重排 return rerankBySemanticChunk(docs, query); } private String buildEnhancedQuery(String baseQuery, String intent, Map<String, String> slots) { StringBuilder sb = new StringBuilder(baseQuery); if (!slots.isEmpty()) { sb.append(" ").append(slots.values().stream() .collect(Collectors.joining(" "))); } // 添加意图权重,让向量更关注该领域 sb.append(" site:").append(intent); return sb.toString(); } }

参数选择逻辑:

  • topK=3而非topK=5:更多结果增加LLM处理负担,且实测top3覆盖92%的有效信息;
  • filter用SQL语法而非向量库原生filter,因Doris的WHERE子句能利用物化视图索引,速度比向量库filter快3.2倍;
  • rerankBySemanticChunk方法中,对每个Document按句分割,用Sentence-BERT计算每句与query的相似度,只保留最高分句+其后一句(保证上下文完整)。

4.3 工具调用注册与执行

@Configuration public class ToolConfig { @Bean public List<Tool> tools() { return Arrays.asList( new QueryOrderStatusTool(), // 自定义工具类 new QueryBillDetailTool(), new CancelAutoRenewTool() ); } } // 工具类示例 public class QueryOrderStatusTool implements Tool { @Override public String getName() { return "query_order_status"; } @Override public String getDescription() { return "查询订单物流状态,需提供16位订单号"; } @Override public Map<String, Object> getParameters() { return Map.of( "order_id", Map.of("type", "string", "description", "16位订单号"), "timeout", Map.of("type", "integer", "description", "超时秒数,默认30") ); } @Override public ToolResponse invoke(Map<String, Object> input) { String orderId = (String) input.get("order_id"); Integer timeout = Optional.ofNullable((Integer) input.get("timeout")) .orElse(30); // 调用内部HTTP API try { String url = "http://internal-api/order/status?order_id=" + orderId; String response = restTemplate.getForObject(url, String.class); return new ToolResponse("success", response); } catch (RestClientException e) { return new ToolResponse("error", "系统繁忙,请稍后再试"); } } }

关键配置项:

  • getParameters()返回的schema必须与OpenAPI 3.0完全兼容,否则LLM无法生成合法JSON;
  • invoke方法内必须有超时控制,我们用RestTemplate的setConnectTimeout和setReadTimeout,而非依赖外部熔断;
  • 错误响应ToolResponse的status字段必须为"error",这是兜底模块识别的唯一标识。

4.4 记忆模块集成代码

@Service public class SessionMemoryService { @Autowired private CacheManager cacheManager; // Caffeine缓存 @Autowired private RedisTemplate<String, String> redisTemplate; // Redis持久化 public void updateState(String sessionId, SessionState state) { // 更新内存缓存 cacheManager.getCache("session").put(sessionId, state); // 异步持久化到Redis CompletableFuture.runAsync(() -> { String key = "session:" + sessionId; String json = JSON.toJSONString(state); redisTemplate.opsForValue().set(key, json, Duration.ofMinutes(30)); }); } public SessionState getState(String sessionId) { // 优先查内存 Cache.ValueWrapper wrapper = cacheManager.getCache("session") .get(sessionId); if (wrapper != null) { return (SessionState) wrapper.get(); } // 内存未命中,查Redis String json = redisTemplate.opsForValue().get("session:" + sessionId); if (json != null) { SessionState state = JSON.parseObject(json, SessionState.class); // 回填内存缓存 cacheManager.getCache("session").put(sessionId, state); return state; } return new SessionState(sessionId); // 新会话 } }

性能优化点:

  • updateState方法中Redis写入用CompletableFuture异步,避免阻塞主线程;
  • getState方法先查内存再查Redis,实测95%的请求在内存命中,平均延迟0.8ms;
  • SessionState类用@Data注解,但recentTools字段用LinkedList而非ArrayList,因需频繁增删头尾。

4.5 流式响应控制器

@RestController public class ChatController { @Autowired private StreamingChatClient streamingChatClient; @Autowired private SessionMemoryService memoryService; @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(@RequestBody ChatRequest request) { SseEmitter emitter = new SseEmitter(30_000L); // 30秒超时 // 获取或创建会话状态 SessionState state = memoryService.getState(request.getSessionId()); // 构建带记忆的提示词 String prompt = buildPromptWithMemory(request.getQuery(), state); // 执行流式调用 streamingChatClient.stream(prompt) .doOnNext(chunk -> { try { String sentence = splitChineseSentence(chunk.getContent()); emitter.send(SseEmitter.event() .name("message") .data(sentence)); } catch (IOException e) { emitter.completeWithError(e); } }) .doOnError(emitter::completeWithError) .doOnTerminate(emitter::complete) .subscribe(); return emitter; } private String splitChineseSentence(String text) { // 按中文句末标点分割,保留省略号 return text.replaceAll("([。!?;…])(?!$)", "$1\n").trim(); } }

关键配置说明:

  • SseEmitter超时设为30秒,因最长工具调用+LLM生成耗时约22秒;
  • buildPromptWithMemory方法中,将state.lastIntent、state.slots、state.toolResults注入system prompt,格式为<memory>最近意图:order_status;订单号:ORD20240501XXXX;物流状态:已发货</memory>;
  • splitChineseSentence用正则而非BreakIterator,因后者在Spring WebFlux中线程不安全,正则简单可靠。

5. 常见问题与排查技巧实录

5.1 RAG召回不准:不是模型问题,而是检索逻辑缺陷

现象:用户问“花呗怎么关闭”,RAG返回《芝麻信用使用指南》而非《花呗服务协议》。

排查路径:

  1. 检查意图分类:用/debug/intent?query=花呗怎么关闭接口,发现返回credit_management正确;
  2. 检查槽位提取:该问题无实体,slots为空,enhancedQuery=“花呗怎么关闭 site:credit_management”,正常;
  3. 检查Doris检索SQL:日志显示执行SELECT * FROM knowledge_vector WHERE intent='credit_management' AND match(embedding, '花呗怎么关闭'),但match函数未启用语义权重。

根因与修复:
Doris的match函数默认用BM25,需显式启用向量检索:

-- 错误写法(只用BM25) SELECT * FROM knowledge_vector WHERE match(embedding, '花呗怎么关闭'); -- 正确写法(向量+BM25融合) SELECT * FROM knowledge_vector WHERE match(embedding, '花呗怎么关闭') AND intent='credit_management' ORDER BY vector_distance(embedding, '花呗怎么关闭') ASC LIMIT 3;

我们在DorisVectorStore的similaritySearch方法中,强制添加ORDER BY vector_distance子句,并设置vector_distance函数的权重系数为0.7(向量)+0.3(BM25)。

实操心得:RAG效果70%取决于检索质量,而非LLM。我们每周用100条真实bad case跑回归测试,自动生成recall_rate报表,低于95%自动告警。

5.2 工具调用失败:不是接口问题,而是参数校验过严

现象:用户说“查我昨天的订单”,工具调用报错Validation failed: order_id is required。

排查路径:

  1. 查QueryOrderStatusTool.getParameters(),确认order_id为必填;
  2. 查LLM生成的调用JSON:{"order_id": ""},空字符串触发了校验;
  3. 查extractSlots方法,发现正则Pattern.compile("订单号[::]\\s*(\\w+)")无法匹配“昨天的订单”这种模糊表达。

根因与修复:

  • 短期:放宽参数校验,在invoke方法开头加空值处理:
    if (StringUtils.isBlank(orderId)) { return new ToolResponse("error", "未识别到订单号,请提供16位订单号"); }
  • 长期:增加槽位补全工具。当order_id为空时,调用QueryRecentOrdersTool查用户最近3笔订单,返回列表供用户选择。这个工具注册为auto_fill类型,LLM在order_id缺失时自动触发。

5.3 记忆丢失:不是缓存失效,而是会话ID不一致

现象:用户连续提问,第二轮时系统忘记第一轮说的“我要取消自动续费”。

排查路径:

  1. 查前端请求,发现第一轮sessionId=abc123,第二轮sessionId=def456;
  2. 查ChatController.chat()方法,发现request.getSessionId()从JWT token中解析,但前端未在每次请求携带token;
  3. 查Nginx配置,发现proxy_set_header Authorization $http_authorization漏配,导致Authorization头未透传。

根因与修复:

  • 紧急修复:Nginx补全配置,5分钟内生效;
  • 防御性设计:在SessionMemoryService.getState()中增加fallback逻辑:
    if (state == null && sessionId.length() > 10) { // 尝试截取前8位作为会话主键 String fallbackId = sessionId.substring(0, 8); state = memoryService.getState(fallbackId); }
    避免单点故障导致全量记忆丢失。

5.4 流式中断:不是网络问题,而是SSE连接管理缺陷

现象:用户看到回答一半突然停止,前端控制台报EventSource failed。

排查路径:

  1. 查Nginx日志,发现upstream prematurely closed connection;
  2. 查Spring Boot日志,发现SseEmitter在doOnTerminate前被GC;
  3. 查代码,发现emitter未存入ConcurrentHashMap,而是局部变量,GC时被回收。

根因与修复:

  • 根本修复:用SseEmitter管理器集中管控:
    @Service public class SseEmitterManager { private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>(); public void add(String sessionId, SseEmitter emitter) { emitters.put(sessionId, emitter); emitter.onCompletion(() -> emitters.remove(sessionId)); emitter.onError(err -> emitters.remove(sessionId)); } }
  • Nginx加固:增加proxy_buffering off; proxy_cache off;,禁用Nginx缓冲。

5.5 兜底误触发:不是阈值设错,而是状态统计口径不一

现象:用户正常提问,系统却频繁触发L3兜底转人工。

排查路径:

  1. 查SessionState.errorCount,发现值为3;
  2. 查错误日志,发现3次都是RAG score=0.349(略低于0.35阈值);
  3. 查ScoreGuard代码,发现score取的是Document.getScore(),但Doris返回的score范围是0~100,而Spring AI默认按0~1处理。

根因与修复:

  • 立即修复:在ScoreGuard中标准化score:
    double normalizedScore = doc.getScore() / 100.0; // Doris score 0~100 → Spring AI 0~1 if (normalizedScore < 0.35) { ... }
  • 长期方案:在Doris建表时,向量距离字段distance设为FLOAT类型,score字段用GENERATED ALWAYS AS (100 - distance),确保score范围恒为0~100。

6. 性能压测与线上稳定性保障

6.1 压测方案设计:用真实流量镜像,而非造数

我们没用JMeter造随机数据,而是用生产环境流量录制:

  • 流量采集:在Nginx层开启log_format full '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" $request_time $upstream_response_time';,每小时生成1个log文件;
  • 流量回放:用Gatling编写脚本,解析log中的$request_body和$request_time,按真实时间戳回放,峰值QPS=2300(早9点高峰);
  • 监控指标:重点盯p95响应延迟、错误率、内存GC频率、Doris CPU使用率。

压测结果关键数据:

指标目标值实测值说明
p95延迟≤1.2s0.98s主要耗时在工具调用(0.6s)+ LLM生成(0.3s)
错误率≤0.5%0.32%99%为RAG低分兜底,属预期行为
内存占用≤4GB3.2GBCaffeine缓存占1.8GB,符合预期
Doris CPU≤70%62%向量检索未成为瓶颈

6.2 线上稳定性三板斧

**第一

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询