1. 为什么“能跑通”和“能上生产”之间隔着一整个架构我见过太多团队用 Spring Boot 搭 AI 应用的路径是这样的写一个RestController注入ChatClient调一下.call()返回字符串本地跑通欢呼上线然后被现实按在地上摩擦。问题从来不在“能不能调通模型”而在于当你的 AI 应用真正面对生产流量时那些在 demo 阶段完全不存在的东西会同时爆发出来——超时、重试、并发、成本、上下文管理、多模型切换、Agent 编排、可观测性一个都不会少。Spring Boot 在这个场景里的价值不是帮你调模型 API而是它作为一套成熟的生产级应用框架天然提供了依赖注入、自动配置、健康检查、指标暴露、配置管理等基础设施。你要做的是把这些能力“翻译”到 AI 应用的语境里。Spring AI 的出现正是干这件事的——它把模型调用、Prompt 模板、向量存储、工具调用这些 AI 领域的概念映射成了 Spring 生态里你熟悉的Bean、Advisor、ChatMemory这些抽象。这篇内容适合两类人一类是已经用 Spring Boot 写过业务系统、现在要把 AI 能力嵌进去的后端工程师另一类是刚开始接触 Spring AI、想知道“从 demo 到生产到底要补哪些课”的开发者。我会围绕模型接入、Agent 编排、并发与成本控制、可观测性这几条主线把生产级 AI 平台真正需要的东西拆开讲中间穿插我自己踩过的坑和实测有效的方案。2. 模型接入层别把 ChatClient 当成一个 HTTP 客户端来用2.1 多模型共存的配置结构设计生产环境几乎不可能只用一个模型。常见组合是主力对话用一个大模型轻量任务分类、摘要、意图识别用一个小模型Embedding 单独走一个模型可能还有本地部署的模型作为兜底。如果你在代码里到处new客户端后面换模型就是灾难。正确的做法是把每个模型提供方配置成一个独立的ChatModelBean通过Qualifier区分再用一个门面类统一对外。Spring AI 的自动配置默认会读取spring.ai.openai.*这类前缀但多模型场景下你需要手动构建spring: ai: openai: api-key: ${MAIN_MODEL_KEY} chat: options: model: gpt-4o temperature: 0.7 deepseek: api-key: ${LIGHT_MODEL_KEY} chat: options: model: deepseek-chat然后在配置类里显式声明Configuration public class ModelConfig { Bean(mainChatClient) public ChatClient mainChatClient(OpenAiChatModel model) { return ChatClient.builder(model) .defaultSystem(你是一个严谨的技术助手) .defaultAdvisors(new SimpleLoggerAdvisor()) .build(); } Bean(lightChatClient) public ChatClient lightChatClient(DeepSeekChatModel model) { return ChatClient.builder(model).build(); } }这里有个容易忽略的点不同模型的temperature、maxTokens默认值不一样如果你在门面层统一设置可能覆盖掉某个模型特有的最优参数。我的做法是在每个ChatClient构建时就绑定好该模型推荐的默认参数门面层只做业务级的覆盖。2.2 超时、重试与降级的真实参数模型调用是典型的“慢且不稳定”依赖。默认的 HTTP 超时很多客户端是 10 秒或 30 秒在大模型场景下完全不够一次长文本生成超过 60 秒是常态。但你把超时设成 120 秒又会把 Tomcat 线程池拖垮。我的实测配置是这样的连接超时 10 秒读取超时 90 秒重试 2 次重试间隔指数退避1s、3s。关键在于重试必须区分异常类型——网络超时和限流429可以重试参数错误400和鉴权失败401重试毫无意义只会浪费配额。RetryTemplate retryTemplate RetryTemplate.builder() .maxAttempts(3) .exponentialBackoff(1000, 2, 10000) .retryOn(ResourceAccessException.class) .retryOn(WebClientResponseException.TooManyRequests.class) .build();降级策略要提前想清楚主力模型连续失败后是切到备用模型还是返回缓存结果还是直接告诉用户“稍后再试”我倾向于分级降级——先切同级别的备用模型再切小模型做简化回答最后才返回兜底话术。这个链路要写成配置而不是硬编码在业务代码里。2.3 流式响应的线程模型陷阱流式输出SSE是 AI 应用的标配体验但它对线程模型的要求和普通接口完全不同。如果你用 Spring MVC 的SseEmitter每个流式请求会占用一个容器线程直到流结束高并发下线程池瞬间打满。两个方案一是用 Spring WebFlux 的FluxString返回底层是事件循环不占线程二是继续用 MVC但把模型调用放到独立的线程池SseEmitter只负责转发。我实测下来如果团队对 WebFlux 不熟第二种方案更稳因为 WebFlux 里一个阻塞调用就会拖垮整个事件循环反而更危险。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestParam String message) { SseEmitter emitter new SseEmitter(120_000L); aiExecutor.execute(() - { try { chatClient.prompt().user(message).stream().content() .subscribe( chunk - { try { emitter.send(chunk); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete ); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }注意SseEmitter的超时时间必须大于模型的最长响应时间否则流还没结束就被容器断开了前端会收到莫名其妙的截断。3. Agent 编排工具调用不是“加个注解”那么简单3.1 Spring AI 的工具调用机制拆解Spring AI 的工具调用Tool Calling看起来很简单——给方法加个Tool注解模型就能调用它。但生产环境里工具调用的复杂度在于模型什么时候决定调用、调用参数对不对、调用失败了怎么办。底层流程是这样的你把工具的定义名称、描述、参数 schema随请求一起发给模型模型返回一个“我要调用某个工具”的指令Spring AI 解析后反射调用你的方法把结果再发回模型模型基于结果生成最终回答。这个循环可能重复多次多步工具调用。关键点在于工具描述的质量直接决定模型调用的准确率。我见过太多人把工具描述写成“查询用户信息”结果模型在用户问“我的订单到哪了”时也去调这个工具。描述要写得像给一个新员工看的操作手册Tool(description 根据用户ID查询用户的基本信息包括昵称、注册时间、会员等级。 仅在用户明确询问自己的账户信息时使用不要用于订单、物流相关问题。) public UserInfo getUserInfo(ToolParam(description 用户ID纯数字字符串) String userId) { return userService.findById(userId); }3.2 多步 Agent 的状态管理与循环控制真正的 Agent 不是一次工具调用就结束的。比如用户说“帮我查一下上个月的销售数据然后生成一份总结报告”这需要查数据 → 分析 → 生成报告可能还涉及多个工具的组合。Spring AI 本身不提供完整的 Agent 编排框架你需要自己控制循环。核心是最大迭代次数和终止条件。我一般设置最大 5 轮工具调用超过就强制模型基于已有信息回答。没有这个限制模型可能陷入“调用工具→结果不满意→再调用”的死循环烧钱又慢。int maxIterations 5; ChatResponse response chatClient.prompt().user(task).tools(tools).call().chatResponse(); while (response.hasToolCalls() iterations maxIterations) { // 执行工具把结果追加到对话 response chatClient.prompt().messages(history).tools(tools).call().chatResponse(); iterations; }这里有个经验把每一轮的中间结果都存进 ChatMemory这样即使循环被强制终止模型也能基于已有的部分结果给出回答而不是完全失败。3.3 到底选 Spring AI 还是 LangGraph4j这是最近被问得最多的问题。我的判断标准很简单如果你的 Agent 逻辑是线性的调用→判断→再调用Spring AI 够用如果是有向图、有分支、有并行、有状态回滚的复杂编排LangGraph4j 更合适。Spring AI 的优势在于和 Spring 生态无缝集成依赖注入、配置管理、事务这些你都不用重新学。LangGraph4j 的优势在于它把 Agent 建模成状态图节点和边清晰复杂流程的可维护性高一个量级。实际项目中我经常混用用 Spring AI 做模型接入和工具定义用 LangGraph4j 做流程编排。两者不冲突LangGraph4j 的节点内部就是调 Spring AI 的 ChatClient。维度Spring AILangGraph4j学习成本低Spring 开发者零门槛中需要理解状态图概念简单对话非常适合过度设计多步 Agent需要手写循环原生支持状态管理靠 ChatMemory内置状态快照生态集成Spring 全家桶相对独立4. 并发、成本与上下文生产环境的三个隐形杀手4.1 AI 接口的并发模型和线程池隔离AI 接口的响应时间通常是普通接口的 10 到 100 倍。如果你让 AI 调用和普通业务共用同一个 Tomcat 线程池一个慢的模型响应就会阻塞整个应用。必须做线程池隔离。我的做法是给 AI 调用单独配一个ThreadPoolTaskExecutor核心线程数按“预期 QPS × 平均响应时间”估算。比如预期 10 QPS、平均响应 5 秒那至少需要 50 个线程。但线程不是越多越好模型提供方通常有并发限制超过就限流。所以线程池要配合**信号量Semaphore**做并发上限控制Bean(aiExecutor) public ThreadPoolTaskExecutor aiExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(20); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix(ai-call-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }CallerRunsPolicy是关键——队列满了之后让调用方线程自己执行形成天然的背压而不是直接抛异常丢请求。4.2 Token 成本控制的几个实操手段成本控制不是“省着用”而是“该省的省该花的花”。几个实测有效的手段第一Prompt 缓存。系统提示词System Prompt如果很长且固定很多模型提供方支持缓存命中缓存的部分计费大幅降低。把不变的内容放前面变化的内容放后面能显著提高缓存命中率。第二上下文窗口裁剪。多轮对话不能无限追加历史。我的策略是保留最近 N 轮完整对话更早的做摘要压缩。摘要本身也用便宜的小模型来做成本可以忽略。第三按任务选模型。意图识别、文本分类、格式转换这类任务小模型完全够用成本可能只有大模型的十分之一。在门面层做一个简单的路由public ChatClient route(TaskType type) { return switch (type) { case COMPLEX_REASONING, CODE_GENERATION - mainChatClient; case CLASSIFICATION, SUMMARIZATION, EXTRACTION - lightChatClient; }; }第四设置 maxTokens 上限。不设上限模型可能生成超长回复费用不可控。根据业务场景设一个合理值比如对话 2000、摘要 500。4.3 ChatMemory 的持久化与滑动窗口Spring AI 提供了ChatMemory抽象默认是内存实现。生产环境必须持久化否则应用重启对话就丢了。我一般用 Redis 存最近对话用数据库存完整历史。滑动窗口的大小需要权衡窗口太小模型记不住上下文窗口太大成本和延迟都上去了。我的经验值是保留最近 10 轮对话20 条消息超过的部分做摘要。摘要的触发时机是窗口满的时候把最老的一轮对话压缩成一句话存起来。Bean public ChatMemory chatMemory(ChatMemoryRepository repository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) .maxMessages(20) .build(); }注意不同模型的上下文窗口大小差异很大maxMessages要结合你实际用的模型来定。20 条消息在 128K 上下文的模型上绰绰有余但在 8K 上下文的模型上可能就超了。5. 可观测性AI 应用的“黑盒”必须被打开5.1 记录什么、不记录什么AI 应用的可观测性和普通应用最大的区别是输入输出都是自然语言体积大且可能包含敏感信息。全量记录 Prompt 和响应存储成本高还有合规风险。我的做法是分层记录元数据全量记录模型名、耗时、Token 数、是否命中缓存、工具调用次数内容采样记录按比例采样或者只记录长度和哈希。出问题时通过元数据定位到具体请求再根据请求 ID 去查采样内容。log.info(ai_call model{} latency{}ms promptTokens{} completionTokens{} toolCalls{}, modelName, latency, usage.getPromptTokens(), usage.getCompletionTokens(), toolCallCount);5.2 用 Micrometer 暴露关键指标Spring Boot 的 Actuator Micrometer 是现成的把 AI 调用的关键指标注册进去就能接入 Prometheus 和 Grafana。我必看的几个指标ai.call.duration按模型、按接口维度统计 P50/P95/P99ai.call.errors按错误类型分类超时、限流、鉴权、参数ai.tokens.total按模型统计 Token 消耗直接对应成本ai.tool.calls工具调用次数和成功率Timer.Sample sample Timer.start(meterRegistry); try { // 模型调用 sample.stop(Timer.builder(ai.call.duration) .tag(model, modelName) .tag(status, success) .register(meterRegistry)); } catch (Exception e) { sample.stop(Timer.builder(ai.call.duration) .tag(model, modelName) .tag(status, error) .register(meterRegistry)); throw e; }5.3 排查线上问题的完整链路线上 AI 应用出问题排查顺序和普通接口不一样。我的固定链路是第一步看错误率突增的时间点对比模型提供方的状态页确认是不是对方的问题。第二步看 P99 延迟如果延迟飙升但错误率没变通常是模型负载高或你的 Prompt 变长了。第三步看 Token 消耗如果某个接口的 Token 突然翻倍多半是上下文窗口没控制住。第四步抽样看具体请求的 Prompt 和响应确认是不是模型“抽风”了。有一次我们线上突然大量超时排查发现是某个新上线的功能把系统提示词从 200 字加到了 2000 字导致每次请求的输入 Token 暴增模型处理时间线性上升。这种问题不看 Token 指标根本发现不了。6. 从单体到平台架构演进的几个关键决策6.1 什么时候该拆出独立的 AI 服务一开始把 AI 能力放在业务应用里没问题简单直接。但当你遇到这些信号时就该考虑拆出独立的 AI 平台服务了多个业务线都要用 AI 能力、模型配置需要独立变更、AI 调用的资源消耗影响到主业务、需要统一的成本核算和配额管理。拆分的边界我建议按“模型接入 工具注册 对话管理”这三块来切业务侧只保留 Prompt 模板和业务逻辑。这样模型切换、工具增减都不需要业务方改代码。6.2 配置热更新与灰度切换模型参数temperature、maxTokens和 Prompt 模板应该支持热更新不要每次调整都重新发版。我的做法是把这些配置放在配置中心Nacos、Apollo 都行监听变更事件动态刷新ChatClient的默认参数。灰度切换模型时用请求头或用户 ID 做分流新模型先接 5% 流量观察指标没问题再逐步放大。这个能力在换模型时特别有用——你永远不知道新模型在你的业务场景下表现如何小流量验证是必须的。6.3 我踩过的三个印象最深的坑第一个坑把 ChatClient 做成了单例但没考虑线程安全。Spring AI 的ChatClient本身是线程安全的但如果你在构建时传入了可变的Advisor或ChatMemory并发场景下会出问题。我的教训是所有 Advisor 和 Memory 都必须是线程安全的实现自定义的尤其要注意。第二个坑SSE 流式响应没有处理客户端断开。用户关闭页面后后端还在傻傻地调模型、推数据浪费资源。必须监听SseEmitter的onCompletion和onTimeout及时取消模型调用。Spring AI 的Flux支持cancel要利用起来。第三个坑工具调用的参数校验缺失。模型生成的工具参数不一定符合你的预期比如该传数字的传了字符串该传枚举的传了不存在的值。每个工具方法内部都要做参数校验不能信任模型生成的参数。校验失败时返回明确的错误信息给模型让它重新生成而不是直接抛异常。7. 一些关于选型和节奏的个人判断Spring AI 目前还在快速迭代2.0 版本在 API 稳定性上比早期好很多但和 LangChain 生态相比工具链的丰富度还有差距。我的建议是如果你的团队是 Spring 技术栈Spring AI 是首选不要因为“别人用 LangGraph”就动摇。技术选型的第一原则是团队熟悉度而不是功能对比表。Agent 开发这块我的经验是先从简单的单工具 Agent 做起跑通“模型决策→工具执行→结果回传”这个闭环再逐步增加工具数量和编排复杂度。一上来就搞多 Agent 协作、状态图编排大概率会陷入调试地狱。最后说一个容易被忽视的点AI 应用的测试和传统应用完全不同。模型的输出是不确定的你不能用assertEquals去断言。我的做法是分层测试——单元测试只测工具方法和参数校验集成测试用固定的 Prompt 和 mock 模型验证流程端到端测试用真实模型但只断言“包含关键信息”而不是精确匹配。这个测试策略的调整比任何架构设计都更能决定你的 AI 应用能不能持续迭代。
阅读完成 · 觉得有帮助?