ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot构建生产级AI应用平台:多租户隔离与Agent编排实战

Spring Boot构建生产级AI应用平台:多租户隔离与Agent编排实战 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 ThreadLocalString 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) { ListMessage 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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-alibaba-spring-boot-starter/artifactId version1.0.0-M6/version /dependency版本号一定要查官方文档确认不同版本 API 差异不小。建好之后先跑一个最简单的对话接口确认模型能通再往上叠功能。4.2 数据库设计核心表我列一下tenant租户信息、tenant_model_config模型配置、conversation会话、message消息记录、agent_taskAgent 任务、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 支持返回FluxString配合 SSE 推给前端。这里要注意租户上下文在响应式流里的传递问题我一般用contextWrite把租户 ID 写进 Reactor 的 Context。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString 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 迭代效率提升了很多也避免了很多“改一行字就要走一次发布流程”的尴尬。
返回列表