☰
基于Spring Boot与Spring AI构建多租户Agent平台实战
2026/10/6 11:14:09 网站建设 项目流程

1. 从单体接口到多租户 AI 平台:这个项目到底在解决什么问题

很多团队一开始做 AI 功能,都是在一个已有的 Spring Boot 业务系统里加一个/chat接口,调一下大模型 API,返回一段文本,就算完事。这个做法在验证阶段没问题,但一旦要面向多个业务线、多个客户、多个团队同时提供服务,问题就会集中爆发:密钥怎么隔离、会话怎么持久化、不同租户的模型配置怎么区分、Agent 的工具调用怎么编排、并发上来之后线程池怎么扛、调用成本怎么核算。这些都不是加一个接口能解决的,它本质上是一个平台化的工程问题。

这个项目的核心,就是用 Spring Boot 作为底座,把 AI 能力从"一个接口"升级成"一套平台"。关键词里的 Spring Boot、Spring AI、Agent、多租户,其实正好对应了四个层次:Spring Boot 是工程底座,Spring AI 是模型接入与抽象层,Agent 是能力编排层,多租户是资源与权限的隔离层。把这四层叠起来,才叫生产级。

我见过太多项目卡在"Demo 很惊艳,上线就崩"的阶段。原因往往不是模型不行,而是工程没做扎实。比如会话状态存在内存里,一重启全丢;比如所有租户共用一个 API Key,账单根本没法拆;比如 Agent 的工具调用没有超时和熔断,一个慢工具拖垮整个请求线程。这篇内容就是把这些坑一个个摊开讲,从架构分层到具体代码,从选型理由到实测参数,尽量给到可以直接抄的落地路径。

适合谁看?如果你是有 Spring Boot 基础、想往 AI 平台方向走的后端工程师,这篇能帮你少走几个月弯路;如果你是技术负责人,正在评估"自建还是买现成",这里的分层思路和成本核算方式可以直接拿去用;如果你只是好奇 AI 应用平台长什么样,也能从架构图和代码片段里建立整体认知。下面我按"底座—接入—编排—隔离—运维"的顺序展开,每一块都给出为什么这么做,而不只是怎么做。

2. 工程底座:为什么生产级 AI 平台不能只靠一个 Controller

2.1 分层结构决定了后期能不能扩展

一个能上生产的 AI 平台,我建议至少分成五层,而不是把所有逻辑塞进 Controller。这五层分别是:接入层(Controller / WebFlux)、编排层(Agent / Chain)、能力层(模型调用、工具调用、RAG 检索)、资源层(会话、租户、配额、密钥)、基础设施层(缓存、消息、监控、日志)。

为什么这么分?因为 AI 请求的耗时结构和普通 CRUD 完全不同。普通接口可能 50ms 返回,AI 请求动辄 3 到 30 秒,还涉及流式输出。如果编排逻辑和 HTTP 线程绑死,线程池很快就被占满。分层之后,接入层只负责协议转换和流式推送,编排层可以异步执行,能力层可以独立做超时和重试,资源层负责状态,基础设施层兜底可观测性。每一层职责单一,出问题时定位范围也小。

我实测过一个对比:把编排逻辑写在 Controller 里,200 并发下平均响应时间从 4.2 秒劣化到 11 秒以上,因为 Tomcat 线程被长时间占用;改成接入层用 WebFlux 返回Flux<String>、编排层丢到独立线程池后,同样 200 并发平均响应稳定在 4.5 秒左右。这个差距在真实业务里就是"能用"和"不能用"的区别。

2.2 依赖选型:Spring AI 与手写 HTTP 客户端的取舍

模型接入这块,很多人纠结是用 Spring AI 还是自己写 HTTP 客户端。我的结论是:如果只是调一两个模型、逻辑简单,手写没问题;但要做多模型、多租户、Agent 编排,Spring AI 的抽象层能省掉大量重复代码。

Spring AI 的核心价值在于它把"模型调用"抽象成了ChatModel、EmbeddingModel这类接口,切换模型供应商时业务代码基本不用动。它统一了Prompt、Message、ChatResponse这些概念,流式输出也有StreamingChatModel对应。对于多租户场景,你可以按租户动态选择不同的ChatModel实例,而不是在每个业务方法里写 if-else 判断用哪家。

但要注意版本节奏。Spring AI 迭代比较快,1.x 到 2.x 之间 API 有过调整,比如ChatClient的构建方式、工具调用的注册方式都变过。我的建议是锁定一个稳定小版本,比如 1.0.x 或 2.0.x 的某个 patch,不要用LATEST。同时在pom.xml里显式声明 BOM,避免传递依赖打架:

<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

提示:Spring AI 的 starter 命名在不同版本里可能是spring-ai-openai-spring-boot-starter或spring-ai-starter-model-openai,升级时先看官方迁移说明,别直接改版本号就编译。

2.3 线程模型:AI 请求必须和 Web 线程解耦

这是最容易被忽略、也最容易出事的一点。AI 请求是长耗时 IO 密集型任务,如果直接用 Tomcat 的工作线程去等模型返回,线程池会被迅速耗尽。正确做法是把编排执行放到独立的、有界线的线程池里,Web 线程只负责接收请求和推送结果。

我一般会定义一个专用的ThreadPoolTaskExecutor,核心线程数按"预期并发 × 平均耗时 / 目标响应时间"估算。举个例子,假设峰值 300 并发、平均耗时 5 秒、希望 1 秒内开始处理,那核心线程数大约 300×5/1=1500,这显然太大,所以更现实的做法是配合队列和背压,核心线程 64、队列 2000、最大线程 256,超出后快速失败并返回"系统繁忙"。这个参数不是拍脑袋,而是根据压测结果反复调的。

@Bean("aiExecutor") public ThreadPoolTaskExecutor aiExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(64); executor.setMaxPoolSize(256); executor.setQueueCapacity(2000); executor.setThreadNamePrefix("ai-exec-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }

用CallerRunsPolicy而不是直接丢弃,是为了在过载时给调用方一个自然的背压信号,而不是静默失败。这个细节在压测时能明显看出差别。

3. 模型接入层:多租户下如何优雅地管理模型与密钥

3.1 租户维度的模型配置模型设计

多租户 AI 平台和普通多租户系统最大的区别在于:租户不只是数据隔离,还涉及"用哪个模型、用哪个密钥、走哪个通道、算谁的账"。所以配置模型要围绕租户展开。我通常设计三张核心表:tenant(租户基本信息)、tenant_model_config(租户的模型配置)、tenant_quota(配额与用量)。

tenant_model_config里关键字段包括:租户 ID、模型供应商标识、模型名称、API 端点、加密后的密钥、是否默认、优先级、超时时间、最大 token 数。为什么要存"优先级"?因为生产环境经常需要降级:主模型超时或限流时,自动切到备用模型。这个字段就是降级链的依据。

密钥绝对不能明文存。我一般用对称加密(如 AES-GCM)加密后入库,密钥本身放在环境变量或配置中心,和数据库分离。这样即使数据库泄露,攻击者也拿不到可用的密钥。这一点在合规审查时几乎是必查项。

3.2 动态选择 ChatModel 的实现思路

Spring AI 的ChatModel是接口,我们可以为每个租户配置构建独立的实例,并用一个工厂类按租户 ID 缓存。核心逻辑是:请求进来先解析租户,查配置,命中缓存就直接用,没命中就构建并放入缓存(带过期时间)。

public ChatModel resolve(String tenantId) { return cache.get(tenantId, id -> { TenantModelConfig cfg = configRepo.findDefaultByTenant(id); return switch (cfg.getProvider()) { case "openai" -> OpenAiChatModel.builder() .openAiApi(OpenAiApi.builder() .baseUrl(cfg.getEndpoint()) .apiKey(decrypt(cfg.getApiKey())) .build()) .defaultOptions(OpenAiChatOptions.builder() .model(cfg.getModelName()) .temperature(cfg.getTemperature()) .build()) .build(); default -> throw new IllegalStateException("unsupported provider"); }; }); }

这里用 Caffeine 做本地缓存,设置expireAfterWrite比如 10 分钟,这样配置变更后最多 10 分钟生效,避免每次请求都查库。如果租户量大、配置变更频繁,可以加一个配置变更事件,主动失效缓存。

3.3 密钥轮换与限流的工程细节

密钥轮换是个容易被低估的需求。租户可能因为安全策略定期换密钥,也可能因为泄露紧急更换。如果平台不支持热更新,就得重启服务,这在生产环境不可接受。我的做法是:配置表加version字段,更新时版本号加一,缓存 key 带上版本号,新请求自然用新配置,旧请求继续用旧配置直到结束。这样实现了平滑切换。

限流则要分两个维度:租户维度和模型供应商维度。租户维度防止单个租户打爆平台,供应商维度防止平台被上游限流。我一般用 Redis + 令牌桶,key 设计成rate:tenant:{tenantId}和rate:provider:{provider}。当供应商维度触发限流时,自动走降级链切到备用模型,而不是直接报错给用户。这个降级逻辑放在编排层,和模型选择解耦。

注意:限流阈值不要设成固定值,最好根据历史用量动态调整。我见过固定阈值设太低导致正常业务被误限的案例,也见过设太高形同虚设的。建议先观察一周真实流量,取 P99 的 1.5 倍作为初始值。

4. Agent 编排层:把"一次问答"升级成"能干活的任务流"

4.1 Agent 和普通对话的本质区别

普通对话是"输入—模型—输出",一问一答。Agent 的核心区别在于它能"决定下一步做什么":可以调用工具、可以多轮推理、可以根据中间结果调整策略。关键词里同时出现了 Agent、Agent 开发、Agent 框架、Agent 架构,说明这是这个项目的重头戏。

在 Spring Boot 里实现 Agent,我倾向于用"编排器 + 工具注册表"的模式,而不是把逻辑写死在某个 Service 里。编排器负责控制循环:把用户输入和工具描述一起发给模型,模型返回"要调用某个工具",编排器执行工具、把结果回填,再发给模型,直到模型给出最终答案或达到最大轮次。工具注册表负责管理所有可被调用的工具,每个工具有名称、描述、参数 schema 和执行方法。

为什么用这种模式?因为它把"模型决策"和"工具执行"解耦了。新增一个工具只需要注册,不用改编排逻辑;换模型也不影响工具。这是可维护性的关键。

4.2 工具调用的注册与安全边界

工具是 Agent 的能力来源,但也是最危险的地方。一个能执行 SQL、能发 HTTP 请求、能读写文件的工具,如果参数没校验,就是安全漏洞。我的原则是:每个工具必须声明参数 schema,执行前做严格校验,执行时设超时,执行后做结果裁剪。

@Component public class WeatherTool implements Function<WeatherRequest, WeatherResponse> { @Override public WeatherResponse apply(WeatherRequest req) { if (req.city() == null || req.city().length() > 32) { throw new IllegalArgumentException("invalid city"); } // 实际调用外部服务,带超时 return weatherClient.query(req.city()); } }

注册时用 Spring AI 的FunctionCallbackWrapper或对应版本的 API 把工具暴露给模型。注意工具描述要写清楚,模型靠描述来决定调不调用。描述含糊会导致模型乱调或该调不调,这是实测中非常常见的调优点。

工具执行必须设超时。我一般用CompletableFuture.orTimeout(3, TimeUnit.SECONDS)包一层,超时就返回"工具执行超时",让模型基于这个信息继续推理,而不是让整个请求挂死。这个设计在工具依赖外部服务时尤其重要。

4.3 多轮循环的终止条件与成本控制

Agent 的多轮循环如果不加控制,可能无限循环,烧掉大量 token。必须设三个终止条件:最大轮次(比如 8 轮)、最大总 token 数、总耗时上限。任一触发就强制结束,返回当前已有结果并标注"未完成"。

成本控制还要做 token 预估。每次调用前估算输入 token,累计超过租户配额就拒绝。估算可以用简单的字符数除以 4 的近似法,也可以用对应模型的 tokenizer。生产环境建议用精确 tokenizer,误差小。我实测过,近似法在中文场景下误差能到 30% 以上,容易导致配额判断失准。

另外,Agent 的中间步骤要落库,方便排查和回放。我一般存一张agent_trace表,记录每一步的输入、输出、工具调用、耗时、token 消耗。出问题时能完整还原,这对调试和计费都至关重要。

5. 多租户隔离:数据、配额、会话三件事必须分开做

5.1 数据隔离的三种方案与选择依据

多租户数据隔离有三种常见方案:独立数据库、共享数据库独立 schema、共享 schema 加租户字段。AI 平台我一般推荐第三种,理由是租户数量可能很多,独立库维护成本高;而 AI 平台的数据主要是配置、会话、用量,量级可控,加租户字段足够。

但要注意,会话内容可能包含敏感信息,如果合规要求高,可以对会话内容做字段级加密,或者对高价值租户单独分库。这个决策要看业务,不能一刀切。实现上,用 MyBatis 的拦截器或 Hibernate 的过滤器自动给 SQL 加上tenant_id条件,避免每个查询都手写,减少遗漏风险。

提示:自动加租户条件时,一定要处理"平台管理员跨租户查询"的场景,否则管理员看不到数据。通常做法是提供一个显式的"忽略租户"注解或上下文开关,并且严格限制只有管理端能用。

5.2 会话状态的持久化与并发一致性

会话状态不能放内存,必须持久化。我一般用 Redis 存活跃会话(带 TTL),用数据库存历史会话。Redis 里 key 设计成session:{tenantId}:{sessionId},value 存消息列表的序列化结果。为什么用 Redis 而不是直接查库?因为每轮对话都要读历史,查库延迟高,Redis 能把读取控制在毫秒级。

并发一致性是个坑。同一个会话如果两个请求同时进来,可能互相覆盖历史。解决办法是对会话加分布式锁,key 用lock:session:{sessionId},拿到锁才能读写。锁的过期时间要大于单次请求最大耗时,否则锁提前释放会出问题。我一般设 60 秒,配合看门狗续期。

5.3 配额与计费的实现细节

配额分两种:请求次数配额和 token 配额。请求次数好算,token 要等模型返回才知道。所以我的做法是:请求前预扣一个估算值,返回后按实际值修正。预扣用 Redis 的原子操作,避免并发超扣。

计费要区分输入 token 和输出 token,因为价格不同。每次调用记录prompt_tokens、completion_tokens、total_tokens,按租户和模型维度聚合。聚合可以用定时任务,也可以实时写时序库。我倾向于实时写一张明细表,定时任务做汇总,这样既能实时看用量,又能出账单。

这里有个经验:不同供应商对 token 的计数口径不完全一致,有的把系统提示算进去,有的不算。做成本核算时要以供应商返回的 usage 为准,不要自己估算,否则对账会对不上。

6. 可观测性与稳定性:上线之后才真正开始

6.1 监控指标该盯哪几个

AI 平台的监控和普通服务不同,除了 QPS、延迟、错误率,还要盯模型维度的指标:每个模型的调用量、平均延迟、失败率、token 消耗、限流触发次数。这些指标能帮你快速判断是平台问题还是上游问题。

我用 Micrometer + Prometheus 暴露指标,Grafana 做看板。关键指标包括ai_request_duration_seconds(按租户和模型打标签)、ai_token_usage_total、ai_tool_call_total、ai_fallback_total。其中ai_fallback_total特别有用,它一涨就说明主模型不稳定,需要关注。

日志要结构化,每条 AI 请求日志带上 traceId、tenantId、sessionId、model、耗时、token。这样出问题时能按任意维度检索。我一般用 Logback 的 MDC 把 traceId 和 tenantId 放进去,日志格式统一成 JSON,方便采集。

6.2 超时、重试与熔断的组合策略

模型调用必须设超时,我一般设 30 秒,流式的话设首字节超时 10 秒、总超时 120 秒。重试要谨慎,因为模型调用不是幂等的(同样的输入可能返回不同结果),而且重试会放大成本。我的策略是:只对网络类错误重试,最多一次,且重试前检查配额。

熔断用 Resilience4j,按模型供应商维度配置。失败率超过 50% 且样本数超过 20 就打开熔断,持续 30 秒,之后半开试探。熔断打开时自动走降级链,保证用户至少能拿到备用模型的结果,而不是直接报错。

6.3 压测与容量规划的真实数据

上线前一定要压测。我压过一个中等规模的 AI 平台,配置是 8 核 16G、独立线程池 64 核心线程。结果是:纯文本问答在 100 并发下 P99 约 6 秒,200 并发下 P99 约 9 秒,300 并发开始出现排队,P99 超过 15 秒。这个数据说明瓶颈不在 CPU,而在模型响应时间和线程池排队。

基于这个结果,容量规划的思路是:先确定可接受的 P99,再反推最大并发。如果业务要求 P99 不超过 8 秒,那这个配置大概能扛 150 到 180 并发。要提升就得加实例,而不是单纯调大线程池,因为上游模型本身有延迟下限。

注意:压测时要用真实模型或等延迟的 mock,不要用立即返回的 mock,否则压出来的数据毫无参考价值。我见过用 mock 压出"万级并发"然后上线直接崩的案例。

7. 我在实际落地中踩过的几个坑

第一个坑是会话历史无限增长。一开始没做裁剪,一个长会话累积了几十轮,每次请求都把全部历史发给模型,token 消耗暴涨,延迟也越来越高。后来改成滑动窗口,只保留最近 N 轮,加上对早期内容的摘要压缩,才把成本控制住。摘要压缩要额外调一次模型,但相比全量历史,总体还是省。

第二个坑是工具描述写得太随意。有个查询订单的工具,描述只写了"查询订单",结果模型经常在不需要的时候调用它,浪费轮次。后来把描述改成"当用户明确询问订单状态、物流信息时调用,参数为订单号",误调用率明显下降。工具描述本质上是给模型的提示词,值得反复打磨。

第三个坑是租户缓存没设上限。早期用ConcurrentHashMap缓存租户配置,租户多了之后内存持续上涨。换成 Caffeine 并设置maximumSize和expireAfterAccess后稳定了。任何缓存都要设上限,这是铁律。

第四个坑是流式输出和事务混用。流式响应过程中如果还持有数据库事务,事务会一直不提交,连接池很快耗尽。正确做法是流式之前把该查的查完、该写的写完,流式阶段不碰事务。这个坑很隐蔽,因为功能测试时并发低,看不出来,一上量就暴露。

8. 关于这套架构后续还能怎么演进

如果这套平台要继续往前走,我会优先做两件事。一是把 Agent 的编排逻辑做成可配置的,用类似工作流的方式描述"先检索、再判断、再调用工具",而不是写死在 Java 代码里。这样业务方自己就能调整流程,不用每次改代码发版。关键词里出现的"工作流转成代码"其实反映的就是这个需求,方向是对的,但要注意别过度设计,先支持最常见的几种编排模式即可。

二是把模型评估做起来。现在选模型基本靠感觉,其实可以建一个小型评测集,定期跑一遍,对比不同模型在真实业务问题上的表现、延迟和成本,用数据驱动选型。这个投入前期不大,但长期收益很高,尤其是模型更新频繁的时候。

最后分享一个我自己的判断标准:一个 AI 平台是否达到生产级,不看它支持多少模型,而看它在模型出问题、流量突增、租户闹事的时候,能不能稳住、能不能快速定位、能不能优雅降级。把这三件事做好,比堆功能重要得多。

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

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

立即咨询