首页 / 资讯中心 / 文章详情

Spring Boot + Spring AI 生产级 AI 应用架构实战:模型接入、Agent 编排与并发扛压

Spring Boot + Spring AI 生产级 AI 应用架构实战:模型接入、Agent 编排与并发扛压 ★ FEATURED ARTICLE
1. 为什么“能跑通”和“能上生产”之间隔着一整个工程体系我见过太多团队用 Spring Boot 搭 AI 应用的路径是这样的本地起一个 Demo注入 API Key调通一次对话接口然后拍着胸脯说“AI 功能已经完成了”。结果一上预发环境问题像开闸一样涌出来——模型响应超时把 Tomcat 线程池打满、多轮对话的上下文在重启后全部丢失、流式输出在网关层被缓冲成一次性返回、Token 用量失控导致月底账单爆炸。这些问题的根源不在于模型本身而在于大多数人把 AI 应用当成了一个普通的 REST 接口来写。但 AI 应用和传统 CRUD 服务有本质区别它的响应时间从毫秒级变成秒级甚至十秒级它的输出是不确定的自然语言而非结构化数据它的调用成本随对话轮次线性增长它还需要维护跨请求的会话状态。这些特性决定了用写业务接口的思路去写 AI 应用迟早要翻车。这篇内容面向的是已经会用 Spring Boot 写接口、但还没系统性地把 AI 能力落地到生产环境的开发者。我会围绕 Spring Boot 与 Spring AI 这套技术栈把从项目分层、模型接入、Agent 编排、流式输出、并发控制到可观测性的完整链路拆开讲。关键词里的 Spring AI、Agent、模型接入、并发扛压这些点都会落到具体的代码结构和配置参数上而不是停留在概念层面。需要提前说明的是下面涉及的具体版本号和 API 写法是基于 Spring AI 当前稳定版本的常见实践整理的不同小版本之间可能有细微差异实际落地时以你项目里引入的版本为准。我会在关键位置标注哪些地方容易因为版本变化而踩坑。2. 生产级 AI 平台的项目分层该怎么切2.1 为什么不能把模型调用直接写在 Controller 里新手最容易犯的错是在 Controller 里直接注入 ChatClient 然后调.call()。Demo 阶段这样写确实快但一旦要加缓存、要换模型、要做降级、要统计 Token 消耗你会发现所有逻辑都缠在一起改一处动全身。生产级的分层我一般这么切接入层Controller只负责参数校验和响应封装编排层Service负责业务逻辑和 Agent 调度模型层Model Gateway统一封装所有对外的模型调用基础设施层负责会话存储、限流、监控。这个分法的核心逻辑是——模型是会换的。今天用这个模型明天可能因为成本或效果换成另一个如果模型调用散落在各个 Service 里换一次模型就是一场灾难。// 模型网关层所有模型调用的唯一出口 public interface ModelGateway { String chat(String prompt, ModelOptions options); FluxString stream(String prompt, ModelOptions options); } Service public class SpringAiModelGateway implements ModelGateway { private final ChatClient chatClient; private final ModelRouter router; public SpringAiModelGateway(ChatClient.Builder builder, ModelRouter router) { this.chatClient builder.build(); this.router router; } Override public String chat(String prompt, ModelOptions options) { // 根据业务场景路由到不同模型 ChatModel target router.route(options.getScene()); return chatClient.prompt() .user(prompt) .options(target.getDefaultOptions()) .call() .content(); } }这样切的好处是模型路由、降级、缓存这些横切逻辑全部收敛在网关层。业务代码只关心“我要一段回答”不关心这段回答是哪个模型给的。2.2 会话状态到底该放哪多轮对话是 AI 应用的基本需求但会话状态放哪是个需要认真想的问题。放内存ConcurrentHashMap在单机 Demo 里没问题但一旦多实例部署用户第二次请求打到另一台机器上上下文就断了。我的建议是会话元数据会话 ID、用户 ID、创建时间放数据库对话消息历史放 Redis。原因是消息历史读写频繁、单条数据不大、且有过期需求Redis 的 List 或 Stream 结构天然适合。而会话元数据需要持久化和查询放关系型数据库更合适。Service public class ConversationService { private final ChatMemoryRepository memoryRepository; private final RedisTemplateString, Object redisTemplate; private static final String MEMORY_KEY chat:memory:; private static final int MAX_TURNS 20; public void appendMessage(String conversationId, Message message) { String key MEMORY_KEY conversationId; redisTemplate.opsForList().rightPush(key, message); // 只保留最近 N 轮防止上下文无限增长 redisTemplate.opsForList().trim(key, -MAX_TURNS * 2, -1); redisTemplate.expire(key, Duration.ofHours(2)); } public ListMessage loadHistory(String conversationId) { ListObject raw redisTemplate.opsForList() .range(MEMORY_KEY conversationId, 0, -1); return raw.stream().map(o - (Message) o).toList(); } }这里有个细节值得展开为什么要限制 MAX_TURNS。模型的上下文窗口是有限的而且 Token 是按量计费的。如果你把几十轮历史全部塞进去不仅成本飙升还可能超出模型窗口导致报错。保留最近 20 轮约 40 条消息是实践中比较平衡的值既保证连贯性又控制成本。如果业务确实需要长期记忆那应该走 RAG 检索而不是全量塞上下文。2.3 配置管理别把 API Key 写死在代码里这一点看似基础但我真的见过把 Key 硬编码在 Java 文件里提交到仓库的。生产环境必须走配置中心或环境变量并且不同环境用不同的 Key。# application.yml spring: ai: openai: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL:https://api.openai.com} chat: options: model: ${AI_MODEL:gpt-4o-mini} temperature: 0.7 max-tokens: 2048用${}占位符配合环境变量注入本地开发用.env或 IDE 的运行配置线上用配置中心。这样 Key 永远不会进代码仓库。另外temperature和max-tokens这类参数一定要可配置因为不同业务场景对创造性和长度的要求完全不同——客服问答要低 temperature 保证稳定创意写作要高 temperature。3. Spring AI 接入模型时那些文档不会告诉你的细节3.1 ChatClient 和 ChatModel 到底该用哪个Spring AI 提供了两个层级的抽象ChatModel是底层接口ChatClient是高层封装。很多人纠结用哪个我的经验是业务代码用 ChatClient需要精细控制时用 ChatModel。ChatClient 提供了 Fluent API写起来像自然语言适合绝大多数场景。但当你需要自定义ChatOptions、需要拿到完整的ChatResponse元数据比如 Token 用量、finish reason时就得下沉到 ChatModel。我通常的做法是在网关层用 ChatModel 拿到完整响应然后向上层暴露简化后的结果。ChatResponse response chatModel.call( new Prompt(prompt, ChatOptions.builder() .model(gpt-4o-mini) .temperature(0.3) .maxTokens(1024) .build()) ); // 拿到 Token 用量用于成本统计 Usage usage response.getMetadata().getUsage(); log.info(prompt tokens: {}, completion tokens: {}, usage.getPromptTokens(), usage.getCompletionTokens());这个 Token 统计非常关键。生产环境如果不监控 Token 消耗很容易出现某个用户疯狂刷接口导致成本失控的情况。我一般会在网关层把每次调用的 Token 数打到监控指标里按用户和场景维度聚合。3.2 结构化输出让模型返回 JSON 的正确姿势AI 应用里有一大类需求是“让模型返回结构化数据”比如从用户输入里抽取订单信息、做意图分类。直接让模型返回 JSON 经常出问题——它可能给你包一层 markdown 代码块可能字段名拼错可能多返回几个字段。Spring AI 提供了BeanOutputConverter来解决这个问题它的原理是把目标 Java 类的 JSON Schema 注入到 prompt 里引导模型按 schema 输出然后自动反序列化。public record OrderInfo( String productName, Integer quantity, String address ) {} BeanOutputConverterOrderInfo converter new BeanOutputConverter(OrderInfo.class); String prompt 从下面的用户输入中抽取订单信息。 {format} 用户输入%s .formatted(converter.getFormat(), userInput); OrderInfo info converter.convert(chatClient.prompt() .user(prompt) .call() .content());实测下来加了 schema 引导之后结构化输出的成功率能从六七成提升到九成以上。但要注意即使这样也不能保证 100% 成功生产代码里必须包一层 try-catch解析失败时要么重试要么降级到人工处理。我踩过的坑是某次模型返回的 JSON 里数字字段带了引号quantity: 3导致反序列化失败后来在 record 里把类型改成 String 再手动转换才解决。3.3 超时和重试AI 调用的生命线模型调用是典型的“慢且可能失败”的操作。默认的 HTTP 超时通常几十秒对 AI 调用来说要么太长要么太短。我的配置经验是连接超时 5 秒读取超时根据场景设 30 到 60 秒流式场景单独设更长的超时。Bean public RestClient.Builder restClientBuilder() { return RestClient.builder() .requestFactory(new JdkClientHttpRequestFactory( HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build())); }重试策略要特别小心。不是所有失败都该重试——如果是 401Key 无效或 400参数错误重试一万次也没用只会浪费时间和配额。只有 429限流和 5xx服务端错误以及网络超时才值得重试。而且重试要加退避不能立即重试。RetryTemplate retryTemplate RetryTemplate.builder() .maxAttempts(3) .exponentialBackoff(1000, 2, 10000) .retryOn(WebClientResponseException.TooManyRequests.class) .retryOn(WebClientResponseException.ServiceUnavailable.class) .build();指数退避的初始 1 秒、倍数 2、最大 10 秒这个组合在实测中比较稳。第一次失败等 1 秒第二次等 2 秒第三次等 4 秒避免在服务端已经过载时雪上加霜。4. Agent 编排从单次问答到多步任务执行4.1 Agent 和普通对话的本质区别普通对话是“一问一答”Agent 是“给一个目标自己决定分几步、用什么工具去完成”。这个区别决定了 Agent 的架构复杂度高一个量级。热搜词里“agent 是什么”“agent 开发”“agent 框架”这些搜索说明很多人还卡在概念阶段。用生活化的类比普通对话像问路你问“地铁站怎么走”对方直接告诉你。Agent 像请了个助理你说“帮我订一张明天去北京的票”助理得先查你的日程、再比价、再下单、再确认中间可能还要回来问你“上午还是下午的”。在 Spring Boot 里实现 Agent核心是三个东西工具定义Tool、执行循环Loop、状态管理State。Spring AI 的Tool注解让工具定义变得很简单。Component public class OrderTools { Tool(description 根据商品名称查询库存数量) public int queryStock(ToolParam(description 商品名称) String productName) { return stockService.getStock(productName); } Tool(description 为用户创建订单) public String createOrder( ToolParam(description 商品名称) String productName, ToolParam(description 数量) int quantity) { return orderService.create(productName, quantity); } }description字段极其重要它是模型判断“什么时候该调用这个工具”的唯一依据。我踩过的坑是description 写得太模糊比如只写“查询”模型经常在不该调用的时候调用或者该调用时想不起来。后来我把 description 写成“什么场景下用、输入是什么、返回什么”的完整描述准确率明显提升。4.2 Agent 执行循环的终止条件设计Agent 最容易失控的地方是循环不终止。模型可能反复调用同一个工具或者陷入“思考-调用-再思考”的死循环。生产环境必须设置硬性终止条件。public String executeAgent(String goal, int maxSteps) { ListMessage messages new ArrayList(); messages.add(new SystemMessage(你是一个任务执行助手请逐步完成用户目标。)); messages.add(new UserMessage(goal)); for (int step 0; step maxSteps; step) { ChatResponse response chatModel.call(new Prompt(messages, tools)); AssistantMessage assistantMessage response.getResult().getOutput(); messages.add(assistantMessage); // 没有工具调用说明模型给出了最终答案 if (!assistantMessage.hasToolCalls()) { return assistantMessage.getText(); } // 执行工具调用 for (ToolCall toolCall : assistantMessage.getToolCalls()) { String result toolExecutor.execute(toolCall); messages.add(new ToolResponseMessage(result)); } } // 超过最大步数强制终止 return 任务执行步数超限请简化您的需求后重试。; }maxSteps我一般设 5 到 8。设太小复杂任务完不成设太大一旦死循环成本会很高。另外每次循环都要检查总 Token 消耗超过阈值直接中断。这个“双重保险”是我在线上被死循环坑过一次之后加的——那次一个用户的问题触发了模型反复调用查询工具十分钟烧掉了几十块钱的额度。4.3 多 Agent 协作的取舍热搜里“多 ai 协作”“agent 框架”这类词很热但我要泼盆冷水大多数业务场景不需要多 Agent。多 Agent 的价值在于任务可以清晰拆分成不同专业角色比如一个负责检索、一个负责写作、一个负责审核。如果你的任务本身就是线性的硬拆成多 Agent 只会增加复杂度和成本。真要用多 AgentSpring Boot 里的实现思路是每个 Agent 封装成一个 Service通过一个编排器Orchestrator来调度。编排器负责决定“下一步交给哪个 Agent”这个决策本身也可以让模型来做但更稳的做法是用规则引擎——因为让模型决定流程走向不确定性太高调试起来很痛苦。5. 流式输出与并发扛压生产环境的真正考验5.1 SSE 流式输出的完整链路AI 应用如果不做流式输出用户体验会很差——用户盯着转圈等十秒才看到一大段文字蹦出来。流式输出让文字像打字机一样逐字出现感知延迟大幅降低。Spring Boot 里用 SSEServer-Sent Events实现配合 Spring AI 的FluxString返回类型。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString streamChat( RequestParam String message, RequestParam String conversationId) { return modelGateway.stream(message, options) .map(chunk - ServerSentEvent.builder(chunk).build()) .doOnComplete(() - conversationService.markComplete(conversationId)) .onErrorResume(e - { log.error(stream error, e); return Flux.just(ServerSentEvent.builder([生成中断]).build()); }); }这里有几个坑必须说。第一网关和 Nginx 的缓冲。默认情况下 Nginx 会缓冲响应导致流式效果失效必须在配置里加proxy_buffering off和X-Accel-Buffering: no响应头。第二连接超时。流式连接可能持续几十秒网关的默认超时要调大。第三客户端断开处理。用户中途关掉页面服务端要能感知并停止调用模型否则白白烧 Token。Spring WebFlux 的doOnCancel可以处理这个。5.2 并发场景下模型调用的限流设计热搜里“ai agent 怎么扛并发”是个非常实际的问题。模型 API 通常有 QPS 限制而且你自己的成本也有上限。生产环境必须做限流而且要分两个维度全局限流保护后端不被打爆和用户级限流防止单个用户刷爆。Component public class RateLimiter { private final MapString, RateLimiter userLimiters new ConcurrentHashMap(); private final RateLimiter globalLimiter RateLimiter.create(50); // 全局 50 QPS public boolean tryAcquire(String userId) { if (!globalLimiter.tryAcquire()) { return false; } RateLimiter userLimiter userLimiters.computeIfAbsent( userId, k - RateLimiter.create(2)); // 单用户 2 QPS return userLimiter.tryAcquire(); } }用 Guava 的 RateLimiter 做令牌桶限流全局 50 QPS、单用户 2 QPS 是我在中等规模项目里常用的起点值具体要根据你的模型配额和用户量调整。被限流的请求不要直接报错而是返回一个友好的提示或者进入排队。5.3 线程模型别让慢调用拖垮整个服务这是最容易被忽视但后果最严重的问题。如果用传统的同步阻塞模型每个 AI 调用占用一个 Tomcat 线程当并发上来时线程池很快被占满整个服务包括那些不涉及 AI 的接口全部不可用。解决方案是把 AI 调用隔离到独立的线程池或者干脆用 WebFlux 的响应式模型。我倾向于后者因为 Spring AI 本身就返回Flux天然适配响应式。Configuration public class AiThreadPoolConfig { Bean(aiExecutor) public Executor aiExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(20); executor.setMaxPoolSize(100); executor.setQueueCapacity(200); executor.setThreadNamePrefix(ai-call-); // 队列满了由调用线程执行形成背压 executor.setRejectedExecutionHandler( new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }关键点是CallerRunsPolicy——当线程池和队列都满时让提交任务的线程自己去执行这样自然形成背压请求方会感知到变慢而不是被直接拒绝。这比直接抛 RejectedExecutionException 要优雅得多。6. 可观测性上线之后你怎么知道系统在干什么6.1 必须监控的四个核心指标AI 应用上线后如果只看 CPU 和内存你根本不知道系统健康不健康。我总结下来必须盯住四个指标调用延迟P50/P95/P99、Token 消耗速率、错误率按错误类型分、并发请求数。延迟要分模型看因为不同模型的响应速度差异很大。Token 消耗要按用户和场景聚合这样才能定位到“是谁在烧钱”。错误率要区分是网络错误、限流错误还是模型返回错误处理方式完全不同。Component public class AiMetrics { private final MeterRegistry registry; public void recordCall(String model, String scene, long latencyMs, int tokens, boolean success) { registry.timer(ai.call.latency, model, model, scene, scene).record(latencyMs, TimeUnit.MILLISECONDS); registry.counter(ai.call.tokens, model, model, scene, scene).increment(tokens); registry.counter(ai.call.total, model, model, success, String.valueOf(success)).increment(); } }配合 Spring Boot Actuator 和 Prometheus这些指标可以直接暴露成/actuator/prometheus端点接 Grafana 做可视化。我一般会配一个告警规则当 P95 延迟超过 15 秒或者单用户 Token 消耗速率异常时立即通知。6.2 日志该记什么、不该记什么AI 应用的日志有个特殊矛盾记少了排查不了问题记多了既占空间又涉及隐私。我的原则是记元数据不记完整内容。每次调用记录请求 ID、用户 ID、模型名、Token 数、延迟、是否成功但用户的原始输入和模型的完整输出不落日志或者只记前若干字符用于调试。如果确实需要留存对话内容用于质量分析那要单独存到专门的表里并且做脱敏处理同时明确告知用户。这既是技术问题也是合规问题不能马虎。6.3 灰度与降级模型出问题时的预案模型服务不是 100% 可用的可能限流、可能故障、可能响应变慢。生产环境必须有降级预案。我的做法是配置主备两个模型主模型连续失败超过阈值时自动切到备用模型。Service public class ModelRouter { private final AtomicInteger failureCount new AtomicInteger(0); private volatile boolean usingBackup false; private static final int FAILURE_THRESHOLD 5; public ChatModel route(String scene) { if (usingBackup) { return backupModel; } return primaryModel; } public void recordFailure() { if (failureCount.incrementAndGet() FAILURE_THRESHOLD) { usingBackup true; log.warn(切换到备用模型); } } public void recordSuccess() { failureCount.set(0); } }备用模型可以是同厂商的另一个型号也可以是不同厂商的。切换逻辑要配合定时探活主模型恢复后自动切回来。这套机制我在线上用过一次主模型限流时自动切到备用用户几乎无感知。7. 我在实际落地中踩过的几个印象深刻的坑第一个坑是上下文长度估算错误。我一开始用字符数除以 4 来估算 Token 数结果中文场景下严重低估——中文字符的 Token 密度比英文高得多。后来改成用模型厂商提供的 Tokenizer 精确计算才避免了超窗口报错。如果你不想引入 Tokenizer 依赖那至少要按字符数除以 2 来估算中文留足余量。第二个坑是流式输出的错误处理。流式场景下错误可能发生在流的中间——前面已经吐了一部分内容突然报错。这时候不能简单地返回错误码因为 HTTP 状态码已经发出去了。我的处理是在流里发送一个特殊的错误事件前端收到后展示“生成中断请重试”同时服务端记录完整错误。第三个坑是并发下的会话串号。早期我用ThreadLocal存会话上下文结果在异步和线程池场景下出现了会话错乱——A 用户看到了 B 用户的对话历史。这个 bug 极其危险。后来改成所有上下文都通过方法参数显式传递彻底杜绝了隐式状态。这个教训让我明白在异步和并发场景下任何隐式状态都是定时炸弹。第四个坑是成本失控。有个内部测试账号没有做限流被用来批量跑数据一晚上消耗了大量额度。从那以后我给所有环境都加了限流包括测试环境。测试环境的限流可以宽松些但绝不能没有。8. 关于这套架构后续可以怎么演进如果这套基础架构跑稳了后面有几个方向可以继续深挖。一是引入 RAG把企业私有知识库接进来让模型基于你的文档回答这需要引入向量数据库和检索链路。二是做模型微调或蒸馏针对特定场景训练小模型降低成本和延迟。三是完善 Agent 的工具生态把内部系统的 API 都封装成工具让 Agent 能真正完成端到端的任务。但我要提醒一句不要为了用新技术而用新技术。我见过团队在连基础对话都没跑稳的情况下就上多 Agent 和 RAG结果系统复杂度爆炸问题定位不了。正确的顺序是先把单模型对话、会话管理、限流降级、可观测性这套地基打牢再往上叠能力。地基不稳楼越高越危险。这套架构我在几个中等规模的项目里验证过单实例扛住几十 QPS 的 AI 调用没问题配合水平扩展可以支撑更高的量。核心不在于用了多花哨的技术而在于把每个环节的边界和失败场景都想清楚了。AI 应用的生产化本质上是一场工程严谨性的考验。
阅读完成 · 觉得有帮助?
咨询建站