ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring AI多模型协作智能客服系统架构与实战

Spring AI多模型协作智能客服系统架构与实战 上个月客服工单里有一条投诉让我印象特别深用户问“门店这个月的优惠券核销差了12笔麻烦帮我查一下”我们的机器人先一本正经地念了一段优惠券定义然后建议用户“联系运营同事复核”全程没有任何可执行动作用户当场炸了。这事不怪模型笨怪我一开始的思路就错了——我试图用一个全能大模型包揽客服所有工作结果高射炮打蚊子还打不准。后来我花了三周时间基于Spring AI把客服系统重构成了多模型协作架构意图识别走轻量模型规则性问题走规则引擎复杂业务咨询走大模型各干各的活儿再由统一调度层串起来。这套系统上线之后答非所问率明显下降高峰期响应也没那么飘了。这篇文章就围绕这套Spring AI多模型协作智能客服系统从架构设计、代码接入、路由策略到源码层面的Advisor机制和线上踩坑完整记录一遍。如果你在Java技术栈里做客服、做智能问答想把大模型接进Spring Boot项目又不想被某一家模型厂商绑死这篇值得收藏。1. 单模型客服的三个失控现场以及我为什么要做多模型协作1.1 失控现场一一本正经地编造流程客服系统刚上线那会儿我天真地认为“反正大模型什么都会直接接一个最强模型就行了”。结果第一个版本就出了大问题用户问对账问题模型能流畅地编出一整套“对账流程”从打开财务报表到逐笔比对讲得头头是道但那些步骤根本不存在于我们的系统里。用户照着做自然做不通然后投诉量直接翻倍。这个现象在业内有个说法叫“幻觉”但落到客服场景里它比技术演示里的幻觉更致命。技术Demo里你问个历史人物模型胡说一通你笑笑就过去了客服场景里用户是按操作指引去执行的一句编造的操作步骤浪费的是用户整段工作时间消耗的是平台信任。单模型对这类高精度业务场景完全没有办法因为它没有“知道自己不知道”的能力。1.2 失控现场二高频问题答得又慢又贵餐饮SaaS客服里真正的高频问题其实是“怎么改门店营业时间”“怎么设置菜品估清”“打印机不出单怎么办”这一类。这些问题答案相对固定知识库里都有甚至很多问题用规则匹配就能命中。但单模型架构把所有问题都送到大模型里理解一遍、生成一遍成本和延迟都上来了。实测下来当时接的大模型单次回答平均要2秒到4秒高峰期甚至到8秒。用户等不起更关键的是成本顶不住。每天几万次对话其中七成以上是简单重复问题全部走大模型推理一个月账单涨得我肉疼。那会儿我才意识到在客服这种流量大、场景杂、容错低的场景里“所有问题都丢给最强模型”是一种昂贵且低效的偷懒。1.3 失控现场三高峰期并发把对话全堵死单模型架构还藏着一个并发问题。同一个ApiKey所有业务共享午高峰和晚高峰流量一上来模型接口的响应时间直线恶化一批对话直接超时。更头疼的是有些用户在这个时段咨询的是“怎么退订会员”这种本可以用一句话回答的问题也被堵在队列里等着大模型慢慢算体验奇差。我当时看着监控面板上那一排红色的超时告警心里只有一个念头必须拆。把简单问题和复杂问题拆开把意图识别和内容生成拆开让不同模型承担不同职责谁也别拖累谁。1.4 破局思路按场景拆模型而不是再加一个更大的模型多模型协作的核心不是“多模型投票”或者“模型叠加”而是一句话让最合适的模型干最合适的事。意图识别用便宜快速的轻量模型知识库检索命中后用独立流程复杂业务咨询才调用大规模模型最后所有结果汇到统一出口。这个思路在工程上落地光有模型还不够还需要一个能编排这些模型的框架。我调研了一圈最后选了Spring AI。原因很简单我们是Java技术栈项目本身就是Spring Boot服务Spring AI作为Spring官方生态里的AI应用框架天然能融入现有工程体系而且它对各家模型做了统一抽象以后想换模型厂商只改配置不动业务代码。这才是长期能维护的架构。2. 服务端架构模型网关、会话管理、路由决策三层的拆分2.1 整体请求链路一句话讲清楚重构后的系统链路可以概括成一句话渠道消息进来先过会话管理层恢复上下文再过意图识别层判断用户想干什么接着路由决策层决定由哪个模型或规则来回答最后结果原路返回。所有请求都经过同一个入口这个入口我习惯叫它“模型网关”。模型网关不是一个独立的微服务在多数场景下它就是一个Spring Boot应用里的核心Service层。这样做的好处是部署简单、链路短而且网关作为唯一入口日志、限流、超时、熔断、成本统计这些横切逻辑都可以集中管理不用散落到各个渠道的controller里。2.2 整体模块划分我按职责把系统拆成下面几块渠道接入层对接小程序、App、企业微信等渠道负责消息收发。会话管理层维护每个用户的多轮对话状态记忆存Redis重启不丢。意图识别层对用户输入做分类输出意图标签和置信度。路由决策层根据意图标签、规则命中结果、模型健康状态决定走哪个模型。模型适配层基于Spring AI的统一ChatModel抽象对接智谱、通义等多家模型。知识库检索层对高频标准化问题做向量检索或关键词匹配减少大模型无谓消耗。这六块边界清晰每一块都能单独测试、单独降级。比如知识库检索层挂了系统还能退化成纯大模型问答意图识别层挂了规则匹配还能兜底。多模型协作架构最大的底气不是模型多而是每个环节都有后备方案。2.3 对外响应格式统一的必要性所有模型返回给用户的内容最后都要包一层统一的响应结构。这个设计一开始我嫌麻烦后来发现太重要了。因为不同的模型、不同的规则分支返回内容的形态差异很大有的返回纯文本有的返回JSON有的返回Markdown。如果不统一封装前端和各个渠道的对接成本会失控。我的做法是定义一个ResponseResult里面包含reply回复正文、intent命中的意图、source回答来源模型还是规则、confidence置信度等字段。这样不管是哪个模型回答的上游渠道拿到的都是同一份结构后续做用户满意度分析、回答质量评估也都方便。3. Spring AI接入智谱AI依赖版本、配置和第一个可用的ChatClient3.1 maven依赖与BOM版本管理Spring AI接入的第一步就踩了坑因为它的版本迭代太快了。早期用spring-ai-openai-spring-boot-starter后来又有一堆第三方starter冒出来groupId和版本号经常对不上。网上很多人搜“spring ai maven 智谱ai version”搜到的答案五花八门直接抄作业很容易翻车。我的建议是一定要用BOM统一管理版本不要自己在dependency里写死版本号。项目里我是这样配置的dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0-M6/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-zhipuai-spring-boot-starter/artifactId /dependency /dependencies这里特别提醒一句版本号以你项目实际拉取到的为准。Spring AI的版本号和Spring Boot的版本有对应关系M系列对应Spring Boot 3.x的某个版本正式版出来后命名规则还会变。最稳的做法是先去Spring AI官方仓库看一眼Releases页再回项目里定版本。别图省事用latest一次意外升级就能让你排查半天。3.2 配置文件与模型参数依赖加好之后配置文件比较简单api-key用环境变量注入别硬编码进application.yml。我用的智谱配置是这样spring: ai: zhipuai: api-key: ${ZHIPU_API_KEY} base-url: https://open.bigmodel.cn/api/paas/v4 chat: options: model: glm-4-flash temperature: 0.7 max-tokens: 1024智谱的glm-4-flash是个性价比很高的模型响应快、便宜非常适合意图识别和高频简单回答。复杂业务咨询我单独再配一个glm-4-plus的ChatModel通过路由层按需切换。一个应用里同时配置多个ChatModel完全没问题Spring AI的AutoConfiguration允许多个ChatModel通过Qualifier区分。3.3 定义一个带客服人设的ChatClientSpring AI里最常用的门面是ChatClient它的API风格和Spring WebClient一脉相承用起来很顺手。我建议系统启动时就构建一个带人设的ChatClientConfiguration public class AiConfig { Bean Qualifier(customerServiceClient) public ChatClient customerServiceClient(ChatModel chatModel) { return ChatClient.builder(chatModel) .defaultSystem(你是餐饮SaaS平台的智能客服助手。回答要简洁准确不要编造系统里不存在的功能 涉及操作步骤时先说明前提条件用户情绪激动时保持克制并引导转人工。) .build(); } }这个defaultSystem会在每次请求时自动拼进Prompt里不用业务代码里反复传。构建一次、全局复用正好符合客服系统的场景。调用也简单String reply chatClient.prompt() .user(userMessage) .call() .content();如果你之前用的是OpenAI官方SDK或者各家厂商自己的SDK第一次用ChatClient会觉得它薄得不像框架但这恰恰是它的价值它没有绑死在任何一家模型厂商的SDK上ChatModel换一个实现你的业务代码一行都不用改。3.4 ChatClient不是SdkClient而是你的应用会话门面这里说一个容易误解的点Spring AI的ChatClient不是简单地封装“发请求给模型”这件事它承担了更多编排职责。你可以在prompt().advisors()里挂记忆加载、日志记录、敏感词过滤等横切逻辑也可以指定在调用前把用户消息先存入记忆。它更像是一个“会话门面”所有会话相关的动作都可以挂在它上面。理解这一点之后你再看Spring AI的官方文档就不会觉得乱。它其实就是在回答一个问题如何用一套统一API把模型调用、上下文管理、外部工具调用、RAG检索这些能力组织起来。在这个体系里ChatClient是门面ChatModel是底层实现Advisor是拦截器ChatMemory是记忆仓库各司其职。4. 意图识别与模型路由小模型分类大模型干活4.1 用glm-4-flash做意图分类多模型协作的关键在路由路由的前提是意图识别。我把意图识别单独做成一个服务使用轻量模型glm-4-flash通过结构化输出直接拿到意图标签。Prompt设计很讲究不是随便问一句“用户想干嘛”就完事。我做了一个相对严格的分类Prompt你是客服意图分类器。请判断用户的诉求属于以下哪种意图只输出JSON格式 {intent: 单个人机协作意图, confidence: 0到1之间的置信度} 可选项 ORDER_QUERY订单查询 REFUND退款退货 PROMOTION优惠券/活动咨询 DEVICE_FAULT设备故障报修 MANUAL_HANDOFF要求转人工 OTHER其他 用户问题{question}之所以限定可选意图是因为我们知识库里就覆盖这几类高频问题范围太宽反而会让分类模型糊涂。实际跑起来glm-4-flash对这个六分类任务准确率能到90%以上单次调用基本在300毫秒到800毫秒之间成本可以忽略不计。这个速度是glm-4-plus这类大模型做不到的。4.2 规则引擎前置不要什么都丢给模型模型分类虽然准但不是所有场景都要先经过模型。大量请求其实是包含强关键词的比如“发票”“退款”“打印机”这些用规则匹配就够了又稳又省。我在意图识别层做了一个前置规则引擎public OptionalIntent matchByRule(String question) { if (question.contains(发票)) return Optional.of(Intent.INVOICE); if (question.contains(退款) || question.contains(退货)) return Optional.of(Intent.REFUND); if (question.contains(打印机) || question.contains(不出单)) return Optional.of(Intent.DEVICE_FAULT); if (question.contains(转人工)) return Optional.of(Intent.MANUAL_HANDOFF); return Optional.empty(); }规则引擎命中就直接走对应流程不再调用模型。没命中才送到意图识别模型。这样架构上形成“规则兜底模型模型兜底未知问题”的层层降级关系既保住了绝大多数高频问题的稳定性和速度又不会漏掉长尾问题。4.3 路由决策表与委托调用拿到意图之后路由决策层根据一张“路由决策表”选择具体的执行目标。我实现得比较简单就是一个枚举到ChatClient的映射public enum RouterTarget { INTENT_CLASSIFY(clientA), REFUND(clientB), ORDER_QUERY(clientC), COMPLEX_QA(clientD), DEFAULT(clientD); }每个意图对应的目标不一定是一个大模型它可以是另一个获取订单数据的工具函数也可以是知识库检索链路。这里我自定义了一个RouteTarget概念本质上就是“把不同的意图委托给不同的处理单元”处理单元内部再决定是调函数、查知识库、还是调大模型。委托调用的代码大概长这样public String route(intent, String conversationId, String question) { RouterTarget target routerTable.get(intent); return target.chatClient().prompt() .user(question) .call() .content(); }需要注意的是同一个用户的多轮对话不同意图之间是可能互相切换的。比如用户前面在问订单后一句突然问“那退款多久能到账”这时候系统要把意图从ORDER_QUERY切到REFUND同时保留前文订单号的上下文。所以我特意在路由时不锁定单一意图而是对每一轮输入都重新做意图识别结合历史上下文决定是否切换。4.4 超时熔断与兜底降级策略路由层再往下是一个必须提前想清楚的问题如果某个模型挂了或者响应超时系统怎么办我在网关层做了三重兜底规则兜底意图识别超时时把所有可选项里的规则匹配结果直接用上命中不了就走人工。模型降级glm-4-plus超时或报错时自动降级到glm-4-flash保证用户至少能拿到一段可用回答。人工兜底所有自动回答都不可靠时话术切换为“当前咨询量较大已为您转接人工客服”并把完整会话上下文带到工单系统。这套兜底策略用代码写起来其实不复杂核心是统一封装一个RouterExecutor内部用超时控制和异常捕获保证无论哪个环节出问题上游拿到的都是可控结果而不是一堆异常堆栈。5. 多轮记忆的重构从MessageWindow到RedisChatMemory5.1 默认MessageWindowChatMemory为什么不够用客服系统没有多轮记忆就是残废用户上一句报了自己的门店编号下一句你就忘记了用户只会觉得你在敷衍。Spring AI默认提供了MessageWindowChatMemory它的用法很简单核心是维护一个滑动窗口超过窗口大小就丢弃最早的消息。但直接用它有两个问题。第一窗口是内存态的服务重启、水平扩容时会话记忆全丢。第二它只按条数截断不区分消息优先级如果窗口设得大Token消耗就大设得小早期关键信息比如门店编号会被过早挤掉。在客服场景里用户往往会在对话过程中陆续给出多个关键实体用简单FIFO截断并不合适。5.2 自定义ChatMemory把记忆搬到Redis我的做法是自定义一个ChatMemory实现把记忆异步存到Redis里同时支持按窗口取最近N条。核心就三个方法add、get、clearComponent public class RedisChatMemory implements ChatMemory { private static final String KEY_PREFIX chat:memory:; private final StringRedisTemplate redisTemplate; public RedisChatMemory(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public void add(String conversationId, ListMessage messages) { String key KEY_PREFIX conversationId; for (Message message : messages) { redisTemplate.opsForList().rightPush(key, JsonUtils.toJson(message)); } // 控制List长度防止无限膨胀 redisTemplate.opsForList().trim(key, -50, -1); } Override public ListMessage get(String conversationId, int lastN) { String key KEY_PREFIX conversationId; ListString range redisTemplate.opsForList().rightRange(key, -lastN, -1); return range.stream().map(JsonUtils::fromJson).collect(Collectors.toList()); } Override public void clear(String conversationId) { redisTemplate.delete(KEY_PREFIX conversationId); } }然后把自定义Memory注册进ChatClient的构建过程ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(redisChatMemory)) .build();这里会涉及一个接口签名在不同版本里的差异问题不同Spring AI版本里Message和ChatMemory的接口可能略有调整但只要核心的add/get/clear语义不变自定义实现就能兼容。实在遇到编译问题翻一下当前版本的源码接口照着实现就行。5.3 长会话的上下文压缩策略Redis记忆解决了持久化但解决不了另一个问题一个特别长的会话历史消息全量拼进PromptToken成本爆炸而且模型处理长上下文时首字延迟也会明显上升。我用了“窗口摘要”的组合策略最近6轮消息直接保留原文。6轮之前的消息由模型生成一段压缩摘要只保留用户提供的关键实体门店编号、订单号、诉求和未解决的问题。对话结束时把摘要持久化到数据库下一次会话直接以摘要开场。摘要压缩我用的是glm-4-flash专门写一个SummarizePrompt只取关键信息不保留寒暄和重复追问。实测下来一个原本50轮的会话压缩后上下文Token用量能减少80%多轮追问的准确率反而有提升因为模型不再被大量无关历史记录干扰。6. Advisor机制源码级拆解Spring AI的拦截器到底怎么工作6.1 Advisor和Spring AOP的对应关系如果你熟悉Spring AOP看Spring AI的Advisor会非常亲切。它本质上就是AOP思想在AI调用链上的实现在ChatClient发起模型调用前后插入一批横切逻辑比如加载历史消息、记录成本日志、做敏感词过滤、追加RAG检索结果。Spring AI的Advisor接口设计上借鉴了Spring AOP的Advice概念核心是before、after、around三个方法其中around方法里可以拿到下一个Advisor的引用形成一个责任链。看起来就像一个微型的AOP拦截器链每一层可以决定是直接放行、修改请求还是修改响应。这个设计很聪明。因为AI应用里的横切关注点实在太多了记忆注入、上下文增强、日志埋点、成本统计、安全审查、结果校验如果这些逻辑全部散落在业务代码里系统很快就没法维护了。Advisor把这些问题集中起来按Order顺序执行业务代码反而保持着干净。6.2 内置AdvisorMessageChatMemoryAdvisor与问答增强Spring AI内置了几个常用Advisor实际项目里基本够用了。最常用的是MessageChatMemoryAdvisor它负责两件事调用前从ChatMemory里取出历史消息拼进Prompt调用后把当前轮的用户消息和模型回答写回记忆。这里有一个容易被忽略的细节MessageChatMemoryAdvisor加载历史消息的时机一定要发生在其他需要读取上下文的Advisor之前所以Order要排在最前面。另一个很常用的是QuestionAnswerAdvisor它是RAG场景下的核心组件。它会把用户问题转换成向量查询从VectorStore里检索出相关文档片段然后在调用模型前把这些片段拼到Prompt里让模型基于检索结果回答。落实在代码上就是一行.defaultAdvisors(new QuestionAnswerAdvisor(vectorStore))这个Advisor的价值在于它让“知识库检索生成”这个流程变成了声明式配置不用你自己在业务代码里手动编排检索和拼接Prompt。你只要把向量库配好挂上这个AdvisorRAG链路就跑通了。6.3 自定义一个“统一成本统计”Advisor内置Advisor之外我强烈建议自定义一个成本统计Advisor。客服系统的成本管理是必须做精细化的否则月底账单会教会你做人。我的实现思路是在around方法里记录调用前后耗时、模型名称、Token用量然后输出到日志系统public class CostLogAdvisor implements Advisor { private final MeterRegistry meterRegistry; public CostLogAdvisor(MeterRegistry meterRegistry) { this.meterRegistry meterRegistry; } Override public AdvisedResponse around(AdvisedRequest request, AdvisorChain chain) { long start System.currentTimeMillis(); AdvisedResponse response chain.next(request); long costMs System.currentTimeMillis() - start; // 从request和response中提取模型、token信息不同版本API位置可能不同 String model request.llmOptions().getModel(); int inputTokens response.tokens().getInputTokens(); int outputTokens response.tokens().getOutputTokens(); meterRegistry.counter(ai.cost, model, model).increment(inputTokens outputTokens); log.info([AI Cost] model{}, inputTokens{}, outputTokens{}, costMs{}, model, inputTokens, outputTokens, costMs); return response; } }注意不同Spring AI版本里AdvisedRequest和AdvisedResponse的字段位置确实一直在变有时候是直接get有时候要context().get()。但原理都一样你在chain.next(request)前后可以拦截到完整链路这就是Advisor最核心的用法。把这种通用逻辑做成Advisor挂到ChatClient上之后业务代码里再也不会出现一行成本统计代码。6.4 源码层面的执行顺序细节再往源码里挖一层ChatClient内部对Advisor链的执行顺序大致是构建Prompt时先按Order执行before逻辑然后调用ChatModel.call拿到响应后按相反顺序执行after逻辑最后返回给业务代码。如果你的Advisor依赖了记忆数据而记忆加载Advisor排在你后面那你读到的一定是空历史。所以排Order时有一个经验记忆类Advisor在最前安全类Advisor在最前业务包装类Advisor尽量靠后。如果将来排查某次Prompt里为什么没有带上历史上下文先不要怀疑模型先检查Advisor的Order。激素水平的错误往往就藏在一条没打日志的Advisor链上。7. 线上运行半年后的踩坑清单与参数调优7.1 连环排查一次“无法处理”的线上故障这套系统上线运行半年整体稳定但中间出过一次让我印象深刻的故障。现象是用户集中反馈“机器人突然只会说‘抱歉我暂时无法处理请稍后再试’”日志里没有任何报错。排查链路我完整走了一遍。第一步从网关日志里定位到“无法处理”这个回复来自兜底分支说明上游某个环节超时了。第二步看监控面板发现意图识别接口的P95耗时从平时的600毫秒飙到了6秒。第三步查智谱的调用配置发现默认的超时时间是3秒3秒内没返回就直接走了兜底。第四步继续追为什么意图识别变慢最后发现是有个Batch任务在相同ApiKey下批量跑报表总结把分配给意图识别的并发额度占满了。解决措施有三条意图识别模型用独立ApiKey并单独限流避免被其他业务挤占意图识别调用超时从3秒调到5秒给轻量模型多一点容忍度兜底话术从“无法处理”改成“已为您转接人工客服”绝不把用户晾在一边。这次之后我养成了一个习惯每个依赖外部模型的服务都得有独立的超时、重试、降级预案这个思想后来被我写成了团队内部的AI服务接入规范。7.2 版本与依赖的兼容性问题Spring AI目前还在快速迭代我在接入过程中至少遇到过三次版本引发的编译错误。最典型的是spring-ai-zhipuai这个starter在不同版本里groupId不一样早期版本还叫别的名字。另一个坑是Spring AI的BOM引入了之后会连带升级Spring Boot的某些基础组件如果项目里其他中间件对Spring Boot版本敏感就可能被牵连。我的建议是项目里所有AI相关依赖统一交给spring-ai-bom管理版本号只改BOM一处升级Spring AI前先在Git分支上升级、跑全量测试确认没影响再合主干生产环境用固定版本别追最新。这套系统到现在用的还是我当时锁定的版本只有确定有重大功能改进时才考虑升级。7.3 各业务场景的模型参数推荐不同业务场景对生成风格和Token量的要求差别很大我最终调的参数如下场景使用模型temperaturemax-tokens超时意图分类glm-4-flash0.1643s高频规则回答glm-4-flash0.32563s业务咨询问答glm-4-plus0.6102410s工单摘要生成glm-4-flash0.32563s复杂问题兜底glm-4-plus0.7204810s意图分类的temperature调到0.1是因为分类任务要的是稳定输出越低越好。业务问答0.6是因为客服回答不能太机械也不能太飘0.6是个平衡点。max-token方面客服回答一般不需要超过1024个token设得太高一是浪费钱二是会让模型啰嗦化。7.4 最后的两个实践建议根据我这段时间的实操经验还有两条很具体的建议想分享。第一条用户明确说“转人工”时千万别硬留。一旦意图识别命中MANUAL_HANDOFF直接走人工转接流程把完整上下文带到工单系统让人工客服看到用户已经问过什么、系统回答过什么。多模型协作再强也不要试图用一个模型去安抚一个已经炸了的用户这是客服系统里最重要的一条原则。第二条每一次调用都要留下可审计的日志。用户如果投诉“机器人回复错了”你要能快速定位到那一轮对话用了哪个模型、传了什么上下文、输出了什么内容。这要求从网关层开始就给每次对话分配一个traceId贯穿整个链路。我在实践里就是靠traceId把渠道消息、意图识别结果、路由决策、模型响应串起来的排查问题的效率完全不一样。这套基于Spring AI的多模型协作智能客服系统从最初的单模型失控到现在各司其职中间踩了不少坑。但回过头来看Spring AI提供的模型抽象和Advisor扩展机制确实让整个重构过程顺了很多。如果你也想在Spring Boot里做类似的AI应用我建议先别急着写业务先把你的路由决策想清楚把记忆方案定好再动手接模型。架构稳了接什么模型都只是配置的事。
返回列表