很多人看到“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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 Flux<ServerSentEvent<ChatResponse>> 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,感知上的体验差距巨大。从服务端资源角度看,流式响应通过Flux<ServerSentEvent>逐块推送,避免了在服务端全量缓冲大段字符串,内存占用更均衡。
这里有一个容易被忽视的调优点:首次 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 客服平台技术上的难点,并不在于大模型本身,而在于怎么把大模型安全、稳定、高效地嵌入到复杂的业务系统中。多租户隔离如果做不好,数据泄露一次就足以断送产品的前途;性能优化如果不到位,体验上的缺陷会让客户快速流失。这些都需要架构层面的提前设计,而不是等出了问题再去补。
如果你正在计划搭建类似的系统,我的建议是:先用最小的范围跑通核心链路,把租户隔离和流式体验做好,然后再逐步叠加知识库、缓存、限流这些增强功能。每一步都留好扩展的口子,后面会轻松很多。