ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SaaS多租户AI客服平台实战:Spring AI下的隔离、路由与性能优化

SaaS多租户AI客服平台实战:Spring AI下的隔离、路由与性能优化 很多人看到“SaaS 模式多租户 AI 客服平台”这个标题第一反应是“这不就是在 Spring Boot 里调一下大模型接口吗”。如果只是做一个 Demo确实是这样但要把它推向生产环境变成一套能同时服务几十家客户、每个客户的数据互不可见、每个客户都能自定义 Prompt 和知识库的 SaaS 系统难度会直线上升。这篇文章是我在搭建这套系统过程中的实战记录重点讲清楚多租户隔离、模型路由、上下文管理、SSE 流式推送和性能优化这几个绕不开的坎。内容以 Spring AI 为核心但很多思路和踩坑经验同样适用于其他语言和框架。如果你正在做或准备做类似的 AI 应用这篇文章值得你花几分钟看完尤其是后半部分的性能优化能帮你少走不少弯路。1. 整体架构设计与技术选型1.1 为什么选择 Spring AI 而不是直接调用 HTTP 接口在项目立项的时候团队内部做过一次技术选型讨论。当时有两个方向一个是直接用 OkHttp 或 WebClient 调大模型的 HTTP 接口另一个是使用 Spring AI 这类封装框架。最终我们选择了 Spring AI核心原因有三个。第一Spring AI 提供了统一的 ChatClient 编程模型。不管底层接的是哪家大模型只要替换依赖和配置上层业务代码基本不用动。这意味着我们不需要在代码里写一堆 if-else 来区分不同厂商的 API 格式省掉了大量的胶水代码。第二Spring AI 原生支持流式调用。AI 客服的核心体验是“打字机”式的回复效果用户输入一个问题答案一个字一个字地蹦出来。如果自己用 WebClient 写流式解析需要处理背压、断线重连、超时控制等一系列问题工程量不小。Spring AI 把这块封装好了直接返回 Flux 流接入 WebFlux 就能用。第三社区生态在快速成长。Spring AI 的更新频率很高从最初的 ChatClient 到后面的 Advisor、Tool Calling功能越来越完善。虽然它还不够成熟但在可维护性和扩展性上比我们自己维护一套封装要强得多。1.2 多租户技术方案的选型对比多租户是 SaaS 系统的地基选型错误后面会非常痛苦。业界主流方案有三种独立数据库、共享数据库独立 Schema、共享数据库共享表。我在设计时对比过这三种方案的优劣这里直接说结论。独立数据库的隔离性最好数据安全级别最高但成本也最高。每个租户一套数据库意味着连接数、备份任务、运维复杂度都成倍增长。适合金融、医疗等强合规场景一般 SaaS 产品没必要这么奢侈。共享库共享表是成本最低的方案所有租户的数据混在一张表里通过 tenant_id 字段区分。但风险很大一旦某个 SQL 漏写了租户条件就是跨租户数据泄露这是 SaaS 行业的重大事故。我最终选择的是共享数据库独立 Schema 方案。每个租户创建一套独立的 Schema表和索引结构完全一样但数据物理隔离。这样既避免了共享表的泄露风险又不会像独立数据库那样带来巨大的运维成本。在 MySQL 里一个实例可以轻松承载几十个 Schema配合读写分离撑起几百个租户问题不大。1.3 整体架构分层系统的整体架构分为四层。接入层负责 HTTP 请求接入和鉴权统一走 Spring Cloud Gateway根据请求头中的租户标识路由到对应的服务实例。业务层是核心逻辑包含会话管理、知识库检索、Prompt 组装、模型调用编排。模型层是 Spring AI 整合的各类大模型通过 ChatClient 统一调用。存储层使用 MySQL 存储结构化数据、Redis 做缓存和分布式锁、向量数据库存放知识库 embedding 数据。这里有一个容易踩坑的地方在 AI 客服系统里向量数据库的选型会直接影响检索效果。如果知识库规模在百万级文档以内直接用 PostgreSQL 的 pgvector 插件就足够了不用单独搭建 Milvus 或 向量数据库集群能省掉不少运维负担。2. 核心难点拆解与解决方案2.1 租户隔离与上下文隔离策略多租户 AI 客服最难的不是数据表隔离而是“对话上下文隔离”。用户问“我的订单什么时候到”AI 要能准确关联到这个租户下的订单系统而不是另一个租户的订单数据。我在设计上下文隔离时采用了两层策略。第一层是模型层的 System Prompt 隔离。每个租户在开通服务时都会配置自己的系统指令比如某电商租户的 Prompt 是“你是 XX 商城智能客服回答要简洁专业涉及订单问题引导用户提供订单号”某教育租户的 Prompt 是“你是 XX 教育机构的课程顾问重点介绍课程优势和优惠活动”。这些 Prompt 在请求模型前通过 ChatClient 的 system 方法动态注入。第二层是检索范围的隔离。知识库检索时必须带上 tenant_id 过滤条件只从当前租户的文档集合中召回相关内容。这里要特别注意如果向量检索没有强制租户过滤召回结果会混入大量无关的跨租户内容看起来每条都相似但回答的准确性会断崖式下降。2.2 动态 Prompt 管理与模板化每个租户的 Prompt 都不一样而且运营人员会频繁调整不可能每次改 Prompt 都发版。所以我设计了一套动态 Prompt 管理机制。在数据库中有一张 prompt_template 表存储每个租户的各类场景模板。包含欢迎语模板、问答系统模板、转人工提示模板以及投诉场景下的情绪安抚模板等。模板中使用占位符比如 {user_name}、{order_info}、{knowledge_base_content}业务层在调用模型前将这些占位符替换为实时数据。这里要特别提醒一点Prompt 也是代码的一部分一定要做版本管理。我在 prompt_template 表里加了 version 字段每次修改都会生成新版本线上可以随时回滚。有一次运营同学把某个租户的 Prompt 改出了一段有误导性的内容上线后用户投诉率明显上升就是因为没有版本控制改完就覆盖了想回滚都不知道之前长什么样。加了版本管理之后这类事故基本杜绝了。2.3 多租户下的模型路由与 Key 管理SaaS 平台的模型调用成本和 Key 管理是个大问题。每个租户对模型的需求不同有的追求效果用大杯型号有的看重成本用小杯型号还有的租户要求数据不出境必须走指定的模型通道。我在模型路由层做了一个抽象。每个租户可以绑定自己的模型策略包括模型厂商、模型名称、temperature、max_tokens 等参数。请求进来后根据租户的配置动态创建 ChatClient而不是用一个全局的 ChatClient。这里涉及到一个性能细节ChatClient 的创建是有开销的如果每个请求都 new 一个QPS 一高就会频繁 GC。我的做法是使用本地缓存以 “租户ID 模型配置版本号” 作为缓存 key配置变更时主动失效缓存确保既不频繁重建又能及时感知配置更新。另外不同租户可能使用不同的 API Key有的租户甚至使用自己申请的大模型 API Key。所以模型调用层必须支持 Key 的动态切换不能用配置文件里写死的 Key。在 Spring AI 的 OpenAI ChatModel 中可以通过自定义 OpenAiApi 的实现来做到这一点。3. 实操过程与核心环节实现3.1 项目初始化与依赖配置先看项目的基础依赖。我使用的是 Spring Boot 3.2 和 Spring AI 1.0 版本。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0-M6/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency这里有一个版本兼容的坑Spring AI 1.0 的里程碑版本对 Spring Boot 的版本有严格对应关系。如果 Spring Boot 版本低于 3.2或者高于 3.4都可能出现依赖冲突或 Bean 注入失败。建议直接使用 Spring Boot 3.2.x 版本这个组合实测最稳定。3.2 多租户数据源的动态切换共享库独立 Schema 的核心实现是动态数据源切换。这个环节有两个实现层面。第一层是路由规则。我使用一个基于 AOP 的租户上下文工具类在请求进入 Controller 之前从 JWT Token 或请求头中解析出 tenant_id存入 ThreadLocal。然后通过 Spring 的 AbstractRoutingDataSource 实现动态路由根据当前线程中的租户 ID 切换到对应的 Schema。这里有两个细节需要注意。一个是 ThreadLocal 使用结束后必须清理否则线程池复用会导致租户数据串掉。我是用 Filter 的 finally 块统一清理的。另一个是数据源连接池的配置每个租户对应一个独立 Schema如果为每个 Schema 都创建一个独立的 HikariCP 连接池几十个租户就会创建几十个连接池内存占用和连接数都会被拖垮。我的最终方案是只维护一个物理数据源连接时指定默认 Schema在 SQL 执行前通过USE schema_name切换。这样连接池数量保持为 1切换代价只是多一条 USE 语句。对于大多数查询场景性能损耗可以忽略不计。3.3 流式对话的完整实现AI 客服的核心交互是流式回复。我使用 Spring AI 的 StreamableChatClient 配合 WebFlux 的 SSE 实现。PostMapping(/chat) public FluxServerSentEventChatResponse chat(RequestBody ChatRequest request) { String tenantId request.getTenantId(); TenantContext.set(tenantId); ChatClient chatClient getChatClient(tenantId); return chatClient.stream() .system(loadSystemPrompt(tenantId)) .user(loadUserMessage(request)) .stream() .map(response - ServerSentEvent.builder(response) .event(message) .build()) .doFinally(signalType - TenantContext.clear()); }这段代码是核心中的核心。有几个容易被忽略的细节。第一个是超时控制。大模型接口的响应时间不可控高峰期一个简单问题可能几十秒才有响应。如果不做超时控制用户的 HTTP 连接会一直挂着浪费连接资源。我在网关层配置了 60 秒的流式超时前端每 15 秒发送一次心跳检测确保连接状态是健康的。第二个是错误恢复。流式回复过程中如果模型端突然断开前端会收到不完整的回复。我的处理方式是全量生成一份兜底回复在流式中断时拼接在已输出内容的尾部并附带一条提示语“网络波动回复已中断”。这个体验细节是测试中发现的早期没加这个逻辑时用户会看到一句话说到一半就停了体验非常差。第三个是响应内容的校验。模型生成内容包含了大量的 Markdown 格式或特殊字符直接推送给前端会出乱码。我在推送前增加了一个轻量级的格式化过滤器把三连反引号、加粗标记等特殊的格式符号转换成前端可识别的消息卡片。3.4 会话管理多轮对话的上下文处理AI 客服必须支持多轮对话。用户先问“你们有什么手机”再问“这个多少钱”AI 要能理解“这个”指的是上一轮提到的手机。这就需要在请求模型时携带历史对话记录。在 Spring AI 中可以通过 ChatMemory 和 Advisor 机制实现上下文记忆。我先用量化后的类测试了内存记忆模式MessageWindowChatMemory可以设置保留最近多少条消息。这种模式实现简单但存在两个问题一是占用 Token对话轮数多了以后历史消息会占用大量 Token 额度成本很高二是无法跨会话持久化服务重启后记忆就丢了。所以我在生产环境中自定义了一个数据持久化的 ChatMemory 实现。每次用户消息和 AI 回复都会异步写入 Redis 的 ZSet 结构中过期时间设置为 24 小时。每次请求模型时按时间顺序拉取最近的 8 条消息4 轮对话拼接成上下文。这个数量是我多次测试后确定的少于 4 轮会丢失关键上下文多于 8 条会明显拖慢首字响应时间。由于 Redis 的存储结构是纯文本读取和写入速度都很快而且过期机制自动清理不会造成存储无限增长。唯一要注意的是并发场景下的一致性问题同一用户连续发送两条消息时有可能读到的上下文不是最新的。解决方案是对同一会话的请求加分布式锁串行化上下文更新操作。4. 性能优化实战从秒级到百毫秒级4.1 知识库检索的性能优化客服平台一般会有知识库模块先用 Embedding 模型把文档向量化存入向量库用户提问时先做相似度检索再把命中内容拼进 Prompt。这个环节最容易出现的问题是“相似度检索结果不准确”。向量检索不是精确匹配语义相近但并非答案的内容很容易被召回。我做了两个优化。第一是混合检索。纯向量检索在专业术语多的场景如保险条款、法律条文中效果不稳定。我的方案是结合 BM25 关键词检索两者结果用 RRF 算法融合排序。BM25 走 MySQL 的全文索引或 Elasticsearch向量库走 pgvector 的 ivfflat 索引。融合之后召回的准确率有明显提升实测在保险场景中回答准确率从 71% 提升到了 89%。第二是检索范围限制。知识库检索结果不能无脑全部塞进 Prompt一般只取 top 5 到 top 10 条。而且每条内容在拼入 Prompt 前要截断到固定长度避免超长文本拉高模型的处理时间。我在检索层设置单条内容最多 512 个 Token超出部分截断效果是模型响应速度提升约 30%。4.2 缓存策略消除重复计算AI 客服同一时间可能收到大量类似的咨询。比如新版 App 发布后大量用户都在问“怎么修改支付密码”这类问题的答案其实是固定的。如果每个请求都重新调用模型生成成本高且速度慢。我的做法是增加一层语义缓存。当用户提问进来时先用 Embedding 模型算出问题的向量然后在 Redis 中检索如果找到相似度超过 0.92 的历史问题且该问题的答案是 30 分钟内生成的就直接返回缓存答案。这个 0.92 的阈值是多次测试调出来的。太低会导致缓存误命中比如“怎么退款”和“怎么退货”虽然语义相近但答案不同缓存会把两个问题混淆太高则命中率太低缓存形同虚设。0.92 在实际业务中命中率大约 25%虽然不是一个夸张的数字但省掉的模型调用成本已经很可观了。还要注意一点有敏感信息的对话不能缓存。我在写入缓存时增加业务规则判断包含手机号、订单号、身份证等敏感信息的对话一律不入缓存。4.3 模型调用层的并发控制与限流模型调用是高成本操作必须做限流。SaaS 平台要防止两种情况一是某个租户疯狂调用导致模型配额被耗尽二是整体流量高峰导致模型服务被限流甚至封 Key。我实现了一套两层限流机制。第一层是租户级限流基于 Redis 的滑动窗口每个租户每分钟最多调用 30 次第二层是全局限流整个平台每分钟最多调用 2000 次。超出限制的请求直接返回提示语“当前咨询量较大请稍后再试”而不是让请求挂在模型接口上排队。这套限流机制在压测中发现了它的价值。有一次做促销活动某租户的流量瞬间飙升如果没有租户级限流大量请求会同时打进模型接口结果是模型服务超时率飙升连累其他租户的用户体验。加了租户级限流后虽然单个租户部分请求被拒绝但整体系统保持了稳定。4.4 流式输出带来的性能红利前面提到了流式响应这不仅是用户体验的提升也是性能优化的重要手段。如果使用非流式接口大模型生成 500 个 Token 可能总共要 15 秒用户只能傻等。而流式接口能让用户在 2 秒内就看到第一个 Token感知上的体验差距巨大。从服务端资源角度看流式响应通过FluxServerSentEvent逐块推送避免了在服务端全量缓冲大段字符串内存占用更均衡。这里有一个容易被忽视的调优点首次 Token 延迟。大模型的预热时间和 Prompt 长度直接影响首个 Token 的出现速度。我的做法是在模型部署侧开启 KV Cache并将常见场景的 Prompt 前缀进行固定减少每次请求的重复计算量。实测首 Token 延迟从 2.8 秒降到了 1.2 秒左右。5. 常见问题与排查技巧实录5.1 流式请求偶发卡死排查过程上线初期我们发现一个诡异的问题用户发了消息页面上的“正在输入”状态一直转但就是没有内容输出。排查了很久最终定位到是网关层的响应超时时间小于模型流式输出的总时长。模型还在慢慢吐字但网关已经断了连接。这个问题好解决把网关超时时间调长到 90 秒即可。但真正困难的是定位的过程。我给后来者的建议是在排查这类“偶发”问题时先看链路追踪里的耗时数据别猜。我们就是因为一开始怀疑模型侧的问题花了不少时间查模型日志结果问题根本不在那。5.2 Token 统计不准成本核算失真Token 统计问题。从不同模型的返回结果中解析 Token 使用量时发现 OpenAI 的 usage 字段和国产模型的消耗统计方式不一致。有的模型只统计输入输出 Token有的会把系统 Prompt 和聊天记录都算进去这导致成本核算失真。解决方案是自己算不依赖模型返回的 usage。我封装了一个 Tokenizer 服务在请求入参时估算输入 Token在流式返回时累加输出 Token。虽然不能做到 100% 精确但误差在 5% 以内足够支撑成本分析决策了。5.3 常见问题速查表问题现象可能原因解决思路上下文隔三差五串掉ThreadLocal 未清理确认 Filter finally 清理压测并发场景同租户并发响应慢动态 ChatClient 频繁创建加入本地缓存并设置版本失效机制知识库回答乱答向量检索未加租户过滤在检索前强制拼接 tenant_id 过滤条件模型返回格式错乱未做输出格式化对流式返回内容增加轻量级过滤处理大量请求打到模型接口缺少租户级限流用 Redis 滑动窗口实现两层限流对话上下文不连续上下文存储未持久化改用 Redis ZSet 持久化并设置过期时间5.4 踩坑记录与规避经验最后补充几个容易踩的坑。第一Spring AI 的版本更新非常快API 变动也频繁。不同版本之间 ChatClient 的 builder 方法可能会有差异升级后要回归测试所有调用链路。我自己遇到过从 M5 升到 M6 后原先的system(String)方法签名变了导致编译错误虽然好解决但确实会打乱开发节奏。第二别在大模型 Prompt 里塞太多东西。Prompt 越长模型处理速度越慢成本也越高。知识库内容要精不要多与其塞 20 条相似内容让模型自己挑不如只塞 5 条高质量结果响应速度和准确率都会更好。第三AI 客服系统上线后一定要有监控告警。重点监控三个指标模型调用成功率、首 Token 延迟、平均响应耗时。我们使用的是 Prometheus 和 Grafana 做看板任何指标异常都能第一时间感知。回头再看这套系统的搭建过程最大的体会是AI 客服平台技术上的难点并不在于大模型本身而在于怎么把大模型安全、稳定、高效地嵌入到复杂的业务系统中。多租户隔离如果做不好数据泄露一次就足以断送产品的前途性能优化如果不到位体验上的缺陷会让客户快速流失。这些都需要架构层面的提前设计而不是等出了问题再去补。如果你正在计划搭建类似的系统我的建议是先用最小的范围跑通核心链路把租户隔离和流式体验做好然后再逐步叠加知识库、缓存、限流这些增强功能。每一步都留好扩展的口子后面会轻松很多。
RELATED READING

延伸阅读

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