☰
Spring Boot构建生产级AI应用平台:多租户隔离与Agent编排实战
2026/10/6 15:09:55 网站建设 项目流程

1. 为什么“能跑通”和“能上生产”之间隔着一整个工程体系

我见过太多团队用 Spring Boot 搭 AI 应用,Demo 阶段一切顺利,一旦要接入真实业务、多个客户、多个模型供应商,系统就开始到处漏风。问题不在于 Spring Boot 本身,而在于大多数人只把它当成一个“写 Controller 的框架”,忽略了它在构建生产级平台时真正该承担的角色——统一接入层、资源隔离层和可观测性底座。

这篇文章要聊的,就是怎么用 Spring Boot 把 AI 能力从“一个接口调通”推进到“一个平台稳定运行”。核心会围绕四件事展开:Spring AI 的抽象机制、Agent 的编排与执行、多租户的隔离设计、以及生产环境下的监控与容错。适合已经写过 Spring Boot 项目、准备把 AI 功能正式推上线的后端开发,也适合正在做技术选型的架构同学。读完之后,你应该能拿到一套可以直接落地的分层方案,而不是又一篇“Hello AI”的入门教程。

先说一个基本判断:AI 应用平台和传统业务系统最大的区别,是它同时具备不确定性和高成本两个特征。模型输出不稳定,调用按 token 计费,这两点决定了你不能用传统的 CRUD 思维去设计。Spring Boot 在这里的价值,是把这些不确定性收敛到可控的工程边界内。

2. 整体架构设计与技术选型思路

2.1 分层结构:把 AI 能力当成一种基础设施

我在实际项目里习惯把整个平台拆成五层,从上到下依次是:接入层、编排层、能力层、模型适配层、基础设施层。这个分法的核心逻辑是“变化频率隔离”——越靠上的层变化越快,越靠下的层越稳定。

接入层负责 HTTP 接口、鉴权、限流、租户识别,这一层用 Spring MVC 或者 WebFlux 都行,取决于你的并发模型。编排层是 Agent 和 workflow 的所在地,负责把多个模型调用、工具调用串成一个业务流程。能力层是具体的业务能力封装,比如“文档问答”“代码审查”“数据抽取”。模型适配层对接不同的模型供应商,这一层是 Spring AI 的主战场。基础设施层则是数据库、缓存、消息队列、向量库这些。

这样分的好处是,当你要换一个模型供应商时,只需要动模型适配层;当你要加一个新的业务流程时,只需要动编排层。各层之间通过接口通信,不会出现“改一个模型配置导致整个系统崩掉”的情况。

2.2 为什么选 Spring AI 而不是自己封装 HTTP 客户端

很多人第一反应是“调模型不就是发个 HTTP 请求吗,我自己用 WebClient 封装一下不就行了”。短期看确实可以,但一旦涉及多模型切换、流式输出、工具调用、向量存储,自己封装的成本会指数级上升。

Spring AI 提供的核心价值是统一的抽象接口。ChatClient、EmbeddingClient、VectorStore这些接口把不同供应商的差异屏蔽掉了。你写业务代码时面向的是接口,切换供应商时只需要换配置。它内置的Advisor机制还能让你把日志、限流、内容过滤这些横切关注点优雅地织入调用链,不用在每个业务方法里重复写。

注意:Spring AI 的版本迭代比较快,接口在不同版本间可能有调整。上生产前一定要锁定版本,并且把模型适配层的代码集中管理,避免升级时到处改。

2.3 多租户方案选型:共享还是隔离

多租户是 AI 平台绕不开的问题,因为不同租户的模型配置、API Key、用量配额、数据权限都不一样。常见的三种方案我列个表对比一下:

方案隔离级别成本适用场景
共享数据库+租户字段逻辑隔离低中小客户,数据敏感度低
独立 Schema中等隔离中中大型客户,需要数据隔离
独立实例物理隔离高大客户,强合规要求

我的建议是默认走共享+租户字段,为大客户预留独立 Schema 的能力。实现上通过 Spring 的AbstractRoutingDataSource做动态数据源路由,配合 ThreadLocal 传递租户上下文。这样一套代码能覆盖大部分场景,不用一上来就搞最重的方案。

3. 核心模块的细节拆解与实操要点

3.1 租户上下文:一切隔离的起点

多租户的根基是租户上下文。我的做法是在接入层用一个TenantContextFilter从请求头或者 JWT 里解析出租户 ID,存到ThreadLocal里,请求结束时清理掉。

public class TenantContext { private static final ThreadLocal<String> CURRENT = new ThreadLocal<>(); public static void set(String tenantId) { CURRENT.set(tenantId); } public static String get() { return CURRENT.get(); } public static void clear() { CURRENT.remove(); } }

这里有个坑必须提醒:如果你用了异步或者线程池,ThreadLocal 是不会自动传递的。Spring AI 的流式输出、Agent 的异步执行都可能跨线程,这时候要么用TransmittableThreadLocal,要么在提交任务时手动把租户 ID 传进去。我踩过这个坑,表现为“主线程能查到租户,异步任务里租户变成 null”,排查了半天。

3.2 模型配置的租户级隔离

每个租户可能用不同的模型、不同的 API Key、不同的参数。我的做法是建一张tenant_model_config表,存租户 ID、供应商、模型名、密钥(加密存储)、温度、最大 token 等。运行时根据租户 ID 动态构建ChatClient。

public ChatClient buildChatClient(String tenantId) { TenantModelConfig config = configRepository.findByTenantId(tenantId); return ChatClient.builder(modelFactory.create(config.getProvider())) .defaultOptions(ChatOptions.builder() .model(config.getModelName()) .temperature(config.getTemperature()) .build()) .build(); }

密钥一定要加密存储,我一般用 Jasypt 或者云厂商的 KMS。千万不要把密钥明文写在配置文件里提交到代码仓库,这是最常见的安全事故来源。

3.3 Agent 的编排:把复杂任务拆成可执行步骤

Agent 的本质是“让模型决定下一步做什么”。在 Spring Boot 里实现 Agent,核心是一个循环:思考 → 选择工具 → 执行 → 观察结果 → 继续思考,直到任务完成或者达到最大步数。

我通常会把 Agent 的执行逻辑封装成一个AgentExecutor,它持有一个ChatClient和一组ToolCallback。每一轮把历史消息和可用工具的描述发给模型,模型返回要调用的工具和参数,执行完把结果追加到消息历史里,进入下一轮。

public String execute(String task, int maxSteps) { List<Message> history = new ArrayList<>(); history.add(new SystemMessage(systemPrompt)); history.add(new UserMessage(task)); for (int i = 0; i < maxSteps; i++) { ChatResponse response = chatClient.call(history); if (response.hasToolCalls()) { for (ToolCall call : response.getToolCalls()) { String result = toolRegistry.invoke(call); history.add(new ToolResponseMessage(result)); } } else { return response.getContent(); } } throw new AgentMaxStepsException("超过最大执行步数"); }

提示:maxSteps一定要设上限,否则模型可能陷入死循环,疯狂消耗 token。我一般设 10 到 15 步,具体看任务复杂度。

3.4 工具注册与权限控制

Agent 能调用哪些工具,必须和租户权限绑定。比如租户 A 只能查自己的订单,那“查询订单”这个工具在执行时就必须带上租户上下文。我的做法是在工具执行前做一次权限校验,把租户 ID 注入到工具参数里。

工具注册用 Spring 的@Component加自定义注解,启动时扫描并注册到ToolRegistry。这样加新工具只需要写一个类,不用改注册代码。

4. 完整实操流程与关键环节实现

4.1 项目骨架搭建

先用 Spring Initializr 建项目,依赖选 Web、Spring AI、MyBatis、Redis、Actuator。Spring AI 的 starter 根据你用的模型供应商选,比如对接阿里云百炼就用对应的 starter。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-alibaba-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency>

版本号一定要查官方文档确认,不同版本 API 差异不小。建好之后先跑一个最简单的对话接口,确认模型能通,再往上叠功能。

4.2 数据库设计

核心表我列一下:tenant(租户信息)、tenant_model_config(模型配置)、conversation(会话)、message(消息记录)、agent_task(Agent 任务)、usage_record(用量记录)。用量记录这张表特别重要,它是计费和配额控制的基础。

CREATE TABLE usage_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id VARCHAR(64) NOT NULL, model_name VARCHAR(128), prompt_tokens INT, completion_tokens INT, cost DECIMAL(10,4), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_tenant_time (tenant_id, created_at) );

索引建在tenant_id + created_at上,因为用量查询基本都是按租户和时间范围来的。

4.3 流式输出的实现

AI 应用的用户体验很大程度取决于流式输出。Spring AI 支持返回Flux<String>,配合 SSE 推给前端。这里要注意租户上下文在响应式流里的传递问题,我一般用contextWrite把租户 ID 写进 Reactor 的 Context。

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamChat(@RequestParam String message) { String tenantId = TenantContext.get(); return chatService.stream(message) .contextWrite(ctx -> ctx.put("tenantId", tenantId)); }

4.4 配额与限流

每个租户要有 token 配额,超了就拒绝。我用 Redis 做计数器,key 是quota:{tenantId}:{yyyyMM},每次调用后累加 token 数。限流用 Resilience4j 的RateLimiter,按租户维度配置。

public void checkQuota(String tenantId, int estimatedTokens) { String key = "quota:" + tenantId + ":" + YearMonth.now(); Long used = redis.opsForValue().get(key); if (used != null && used + estimatedTokens > getQuotaLimit(tenantId)) { throw new QuotaExceededException("本月配额已用完"); } }

注意:token 数是调用后才准确的,所以这里只能做预估拦截,实际扣减在调用完成后进行。预估可以用字符数除以 2 粗略估算。

4.5 监控埋点

生产环境必须能看到每个租户的调用量、延迟、错误率、token 消耗。我用 Micrometer 打点,暴露给 Prometheus,再用 Grafana 做面板。关键指标包括:ai.request.count、ai.request.latency、ai.token.usage、ai.error.count,都带上tenant_id和model_name标签。

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

5.1 问题速查表

现象可能原因排查方向
异步任务租户为 nullThreadLocal 未传递检查线程池和响应式上下文
流式输出中断超时或连接被重置检查网关超时和 SSE 配置
token 消耗异常高历史消息未裁剪检查上下文窗口管理
模型调用偶发失败供应商限流加重试和降级策略
多租户数据串了数据源路由失效检查 AOP 切面和事务边界

5.2 上下文窗口管理

这是最容易被忽视的问题。对话轮次多了之后,历史消息会撑爆上下文窗口,导致调用失败或者成本飙升。我的做法是保留最近 N 轮完整对话,更早的做摘要压缩。摘要本身也用模型生成,但要控制频率,不能每轮都摘要。

5.3 重试与降级

模型调用失败是常态,尤其是高峰期。我用 Spring Retry 做重试,但要注意只对幂等的调用重试,而且重试次数不能太多,否则会放大供应商的压力。降级策略是切换到备用模型,或者返回缓存的相似答案。

@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) public String callWithRetry(String prompt) { return chatClient.call(prompt); }

5.4 踩过的坑

第一个坑是事务和模型调用混在一起。模型调用可能耗时几秒甚至几十秒,如果包在数据库事务里,会长时间占用连接,高并发下直接把连接池打满。我的原则是模型调用绝不放事务里,先调用拿到结果,再开事务写库。

第二个坑是日志打印了完整的 prompt 和响应。这在调试时方便,但生产环境会泄露用户数据,而且日志量巨大。我后来改成只打印长度和摘要,敏感内容做脱敏。

第三个坑是没有做模型输出的格式校验。模型返回的 JSON 经常带 markdown 代码块标记,直接解析会失败。我加了一层清洗逻辑,先剥离代码块标记再解析,解析失败时触发一次修复重试。

6. 上线前的检查清单与个人经验

上线前我一般会过一遍这个清单:租户隔离是否覆盖了数据、缓存、文件、日志四个维度;配额和限流是否生效;监控面板是否能看到关键指标;模型调用的超时和重试是否配置;敏感内容过滤是否开启;密钥是否加密存储;历史消息是否有裁剪策略。

关于 Agent 的并发问题,我的经验是不要在单个 Agent 实例上做并发,而是每个请求创建独立的执行上下文。Agent 的状态都在消息历史里,共享状态会导致串话。如果并发量高,用线程池隔离不同租户的执行,避免一个租户的慢任务拖垮其他租户。

最后分享一个实用技巧:把模型调用的 prompt 模板外置到数据库或者配置中心,不要硬编码在代码里。这样调整 prompt 不用重新发版,运营同学也能参与优化。我现在的做法是每个业务场景对应一个 prompt 模板,带版本号,可以灰度切换,出问题能快速回滚到上一个版本。这套机制上线后,prompt 迭代效率提升了很多,也避免了很多“改一行字就要走一次发布流程”的尴尬。

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

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

立即咨询