1. 为什么我要把踩过的坑一条条记下来Spring AI 这个框架从 2024 年初开始进入国内开发者的视野到 2025 年已经迭代了好几个大版本Spring AI Alibaba 也跟着把通义千问、百炼这些国内模型接得很顺。我所在团队从去年下半年开始把一个原本跑在 Python 侧的大模型对话能力往 Java 主站迁移选型上没怎么纠结就定了 Spring AI——理由很朴素主站是 Spring Boot 体系团队里没人愿意为了一个对话功能再维护一套 Python 服务而且 Spring AI 的ChatClient抽象确实把多模型切换这件事做得足够干净。但真正上手之后才发现能跑通 Demo 和能上生产之间隔着的不是文档而是一堆文档里不会写、搜索引擎也搜不到准确答案的坑。这篇文章我整理了 14 个实际踩过的坑覆盖依赖版本、SSE 流式输出、通义千问接入、单元测试、Agent 编排、超时与断连这些环节。其中最想先讲的是第 9 个坑——它让我的单元测试全绿但产品在真实环境里是坏的这个坑我花了整整两天才定位到也是促使我写下这篇记录的直接原因。这篇文章适合两类人看一类是正准备用 Spring AI 做第一个生产级项目的 Java 后端另一类是已经跑通了 Demo、正在被各种诡异问题折磨的同行。我会尽量把每个坑的现象、原因、解决方式讲清楚涉及参数的地方会把计算过程也写出来方便你直接对照自己的项目排查。2. 依赖与版本前三个坑都在这里2.1 坑一Spring AI 的版本号和 Spring Boot 版本是强绑定的我一开始用的是 Spring Boot 3.2.x随手在pom.xml里写了spring-ai-openai-spring-boot-starter的 0.8.1 版本结果启动直接报NoSuchMethodError堆栈指向org.springframework.ai.autoconfigure下面的某个类。当时第一反应是依赖冲突用mvn dependency:tree排查了半天也没看出问题。真正的原因是 Spring AI 在 1.0.0 之前每个小版本都对 Spring Boot 的最低版本有要求而且自动配置类的包路径在 0.8 到 1.0 之间改过一次。0.8.x 用的是org.springframework.ai.autoconfigure.xxx1.0.0-M 之后改成了org.springframework.ai.model.xxx.autoconfigure。你如果混用了 starter 和核心包的版本编译能过启动就炸。我的建议是直接用 BOM 统一管理dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement引入 BOM 之后所有spring-ai-*的 starter 都不用写版本号Spring Boot 的版本也建议对齐到 3.3.x 或 3.4.x。我实测下来 1.0.0 Spring Boot 3.4.1 是最稳的组合Spring AI Alibaba 那边对应的是spring-ai-alibaba-starter1.0.0.2 版本。注意Spring AI 1.0.0 正式版把 artifactId 里的-spring-boot-starter后缀去掉了变成了spring-ai-openai、spring-ai-alibaba-starter这种形式。如果你照着半年前的博客抄依赖大概率会抄到一个已经不存在的坐标。2.2 坑二通义千问的 starter 和 OpenAI 的 starter 会打架我们项目里同时接了通义千问和 OpenAI 兼容接口用于灰度对比结果发现两个 starter 同时引入时ChatModel这个 Bean 会冲突Spring 启动时报NoUniqueBeanDefinitionException。原因是两个 starter 都会往容器里注册一个ChatModel类型的 Bean而ChatClient.Builder在自动装配时是按类型注入的。解决办法有两个。第一个是用Qualifier显式指定但这样ChatClient的自动配置就用不了了得手动构建。第二个更干净把其中一个模型配置成Primary或者在application.yml里通过spring.ai.model.chatnone关掉不需要自动装配的那个然后手动用ChatModel的构造器创建。我最后选的是手动配置因为灰度对比本来就需要在运行时动态切换自动装配反而碍事Configuration public class ChatModelConfig { Bean Primary public ChatModel primaryChatModel(DashScopeChatModel dashScopeChatModel) { return dashScopeChatModel; } Bean public ChatModel fallbackChatModel(OpenAiChatModel openAiChatModel) { return openAiChatModel; } }2.3 坑三spring.ai.dashscope.api-key读不到环境变量这个坑比较低级但很典型。我在application.yml里写的是spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY}本地 IDE 里跑没问题因为 IDE 会自动加载环境变量。但打包成 jar 用java -jar启动时如果启动脚本里没有 export 这个变量Spring 会直接把${DASHSCOPE_API_KEY}这个字面量当成 key 传下去然后在第一次调用时返回 401。更坑的是这个错误不会在启动时暴露只有真正发起对话才报错。我的做法是在配置类里加一个启动校验PostConstruct public void validate() { if (apiKey null || apiKey.startsWith(${)) { throw new IllegalStateException(DASHSCOPE_API_KEY 未正确注入); } }这样至少能让问题在启动阶段就暴露而不是等到线上第一次对话才炸。3. SSE 流式输出四个坑一个比一个隐蔽3.1 坑四produces写错导致前端收不到流Spring MVC 里做 SSE最标准的写法是返回SseEmitter或者FluxServerSentEventString。我一开始用的是FluxController 上写了GetMapping(value /chat, produces MediaType.TEXT_EVENT_STREAM_VALUE)本地用 curl 测试能收到流但前端 Vue 那边一直卡在 pending 状态。排查后发现是 Nginx 的问题。Nginx 默认会对响应做缓冲proxy_buffering on的情况下SSE 的流会被攒在缓冲区里直到攒够一定大小或者连接关闭才一次性吐出去。前端看到的就是一直没反应最后突然全出来。解决方式是在 Nginx 的 location 里加location /api/chat { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; }proxy_http_version 1.1和Connection 这两行是必须的否则 Nginx 会用 HTTP/1.0 转发SSE 直接失效。3.2 坑五stream disconnected before completion: idle timeout waiting for sse这个报错我在热搜词里看到过好几次说明踩的人不少。现象是流式对话进行到一半前端突然收到一个错误后端日志里是idle timeout。原因有两层。第一层是 Spring MVC 的异步请求超时默认是 30 秒spring.mvc.async.request-timeout这个配置项控制。第二层是模型侧的首字节超时通义千问在 prompt 很长的时候首 token 可能要等 10 秒以上如果中间又遇到网络抖动很容易触发超时。我的配置是这样的spring: mvc: async: request-timeout: 300000同时在前端侧也要做处理EventSource本身没有超时配置但浏览器对单个 SSE 连接有默认的空闲超时。我的做法是在后端每隔 15 秒发一个心跳事件FluxServerSentEventString heartbeat Flux.interval(Duration.ofSeconds(15)) .map(i - ServerSentEvent.Stringbuilder().comment(heartbeat).build());心跳用comment类型前端不会触发onmessage但能保持连接活跃。3.3 坑六封装 SSE 调用逻辑时把背压搞丢了我们后来把 SSE 的调用逻辑封装成了一个SseStreamTemplate想统一处理重连、心跳、错误转换。封装的时候我犯了一个错在Flux中间加了一个buffer()操作想把小片段攒成大片段再发减少前端渲染次数。结果就是背压彻底失效。buffer()会把上游的元素攒起来如果下游消费慢上游不会收到背压信号内存里堆积的 buffer 越来越多最后 OOM。而且buffer()之后流的实时性也没了用户看到的是一段一段蹦出来。正确的做法是用windowTimeout或者干脆不做缓冲让前端自己去处理渲染节流。SSE 的每个 event 本来就不大网络开销可以接受。3.4 坑七SseEmitter的complete()和completeWithError()用混了SseEmitter有两个结束方法complete()表示正常结束completeWithError()表示异常结束。我一开始在 catch 块里统一用了complete()结果前端收到的是正常的onclose以为对话正常结束了实际上后端是报错中断的。这个坑的隐蔽之处在于前端如果只监听onmessage和onerrorcomplete()触发的是onclose而onclose在正常结束和异常结束时都会触发前端根本区分不出来。正确做法是异常时用completeWithError()并且在前端onerror里做重试或者提示。4. 单元测试那个让测试全绿但产品坏掉的坑4.1 坑八Mock 掉了ChatModel等于什么都没测写单元测试的时候最省事的做法是把ChatModelmock 掉让call()返回一个固定的ChatResponse。这样测试跑得飞快覆盖率也好看。但问题是你测的其实是我的代码能不能正确处理一个固定的响应而不是我的代码能不能正确处理真实的模型响应。我一开始就是这么干的ChatClient的测试全绿但上线后发现模型返回的ChatResponse里getResult().getOutput().getContent()在某些情况下是 null而我的代码直接对这个值做了trim()NPE 了。Mock 的时候我返回的是一个完整的对象根本没覆盖到 null 的情况。4.2 坑九测试全绿但产品是坏的——MockBean掩盖了 Bean 装配问题这个就是标题里说的那个坑我详细讲一下。我们的ChatService依赖一个ChatClient而ChatClient是通过ChatClient.Builder构建的。在单元测试里我用MockBean把ChatClient替换掉了测试跑得很顺。但生产环境启动时ChatClient.Builder的自动装配依赖ChatModel而我们的ChatModel是通过一个ConditionalOnProperty控制的配置类注册的。测试环境里这个 property 没配MockBean又把ChatClient顶掉了所以测试根本不会去检查ChatModel是否存在。结果就是测试全绿但生产环境启动时ChatModel没注册ChatClient.Builder注入失败应用起不来。这个坑的本质是MockBean会替换掉整个 Bean 定义包括它的依赖链。你 mock 了ChatClientSpring 就不会去解析ChatClient的依赖也就不会发现ChatModel缺失。我的解决方式是加一个SpringBootTest的冒烟测试不 mock 任何东西只验证上下文能正常启动SpringBootTest class ApplicationContextSmokeTest { Test void contextLoads() { // 只要上下文能起来就说明所有 Bean 的依赖链是完整的 } }这个测试跑得慢但它是唯一能发现Bean 装配问题的测试。我的建议是把它放在 CI 的必跑列表里哪怕它要花 20 秒。注意MockBean在 Spring Boot 3.4 之后被标记为 deprecated推荐用MockitoBean。但不管用哪个这个坑的本质不变——mock 会切断依赖链的验证。4.3 坑十测试里用了真实的 API KeyCI 上跑一次烧掉几十块这个坑比较贵。我一开始写集成测试的时候直接用了真实的通义千问 API Key想着反正测试用例不多。结果 CI 每次 push 都跑一遍一天下来跑了上百次账单出来的时候我人都傻了。后来改成了两层策略单元测试全部 mock集成测试用Tag(integration)标记只在手动触发或者 nightly build 时跑并且用单独的、限额更低的 API Key。Test Tag(integration) DisabledIfEnvironmentVariable(named CI, matches true) void realChatTest() { // 只在本地手动跑 }5. Agent 编排与工具调用两个容易翻车的地方5.1 坑十一工具方法的参数类型和模型返回的对不上Spring AI 的Tool注解可以把一个 Java 方法暴露给模型调用。我定义了一个查询订单的工具Tool(description 根据订单号查询订单状态) public OrderStatus queryOrder(ToolParam(description 订单号) String orderId) { // ... }模型返回的orderId有时候是12345有时候是12345数字有时候甚至是订单号12345。Spring AI 在反序列化参数时如果类型对不上会直接抛异常整个对话就中断了。我的处理方式是在工具方法内部做一次清洗public OrderStatus queryOrder(String orderId) { String cleaned orderId.replaceAll([^0-9], ); // ... }同时在description里把格式要求写清楚比如订单号是纯数字字符串不要带任何前缀。实测下来description 写得越具体模型返回的格式越稳定。5.2 坑十二Agent 循环调用工具导致死循环Spring AI 的 Agent 模式ChatClient配合ToolCallback在模型决定调用工具后会把工具结果再喂回模型让模型决定下一步。如果模型的判断逻辑有问题可能会反复调用同一个工具形成死循环。我遇到过一次模型调用查询工具拿到结果后觉得信息不够又调用了一次同样的工具如此反复。Spring AI 默认没有循环次数限制最后是 token 耗尽才停下来的。解决方式是在ChatClient的调用链上加一个maxIterations控制。Spring AI 1.0.0 之后提供了ToolCallingManager的配置项可以设置最大工具调用轮数。我设的是 5超过就强制返回当前结果。ChatClient.builder(chatModel) .defaultToolCallingManager(ToolCallingManager.builder() .maxIterations(5) .build()) .build();6. 超时、重试与降级生产环境的必修课6.1 坑十三没有配置重试一次网络抖动就返回 500大模型 API 的网络抖动比普通 HTTP 接口频繁得多尤其是流式接口连接建立后中途断开的概率不低。我一开始没做重试用户看到的就是对话到一半突然报错。Spring AI 本身没有内置重试需要自己用 Resilience4j 或者 Spring Retry 包一层。我的做法是在ChatModel外面套一个装饰器public class RetryableChatModel implements ChatModel { private final ChatModel delegate; private final RetryTemplate retryTemplate; Override public ChatResponse call(Prompt prompt) { return retryTemplate.execute(ctx - delegate.call(prompt)); } }重试策略上我设的是最多 3 次退避 1 秒、2 秒、4 秒。注意流式接口的重试要特殊处理因为流已经开始输出了重试会导致重复内容。我的做法是只在首字节之前失败时重试一旦开始输出就不再重试而是把错误传给前端。6.2 坑十四降级时直接返回固定文案用户体验很差模型不可用时的降级策略我一开始是直接返回服务繁忙请稍后再试。但用户已经输入了问题看到这句话会觉得产品很敷衍。后来改成了两级降级第一级是切换到备用模型比如从通义千问切到另一个兼容接口第二级才是返回固定文案。备用模型的响应质量可能差一些但至少能给出一个回答。public ChatResponse callWithFallback(Prompt prompt) { try { return primaryChatModel.call(prompt); } catch (Exception e) { log.warn(主模型失败切换备用模型, e); return fallbackChatModel.call(prompt); } }7. 常见问题速查表把上面 14 个坑整理成一张表方便你对照排查编号现象根因解决方式1启动报 NoSuchMethodErrorSpring AI 与 Spring Boot 版本不匹配用 BOM 统一管理版本2NoUniqueBeanDefinitionException多个 ChatModel Bean 冲突指定 Primary 或手动配置3首次调用返回 401环境变量未注入key 是字面量启动时校验 key 格式4前端收不到流Nginx 缓冲了 SSE 响应关闭 proxy_buffering5idle timeout异步超时或首字节超时调大 timeout 心跳6流式输出一段一段蹦buffer() 破坏了背压去掉缓冲前端做节流7前端分不清正常/异常结束complete() 和 completeWithError() 混用异常时用 completeWithError()8上线后 NPEMock 返回了完整对象没覆盖 null补充边界值测试9测试全绿但产品起不来MockBean 切断了依赖链验证加 SpringBootTest 冒烟测试10CI 账单暴涨集成测试用了真实 API Key用 Tag 隔离CI 跳过11工具调用抛异常模型返回的参数类型对不上工具方法内做清洗12Agent 死循环没有限制工具调用轮数设置 maxIterations13网络抖动返回 500没有重试机制用 RetryTemplate 包装14降级文案体验差直接返回固定文案两级降级先切备用模型8. 几个我反复验证过的实操心得关于 SSE 的封装我的建议是不要过度设计。我见过有团队把 SSE 封装成了一个支持断线重连、消息去重、顺序保证的框架代码量上千行最后维护成本比业务代码还高。SSE 本身就是一个简单的单向流前端用EventSource或者fetch的ReadableStream都能处理后端只要保证不缓冲、有心跳、异常时正确关闭这三件事就够了。关于单元测试我的原则是涉及外部依赖的代码单元测试只测逻辑分支不测集成。ChatService里对模型响应的解析逻辑用 mock 测没问题但模型能不能正常调用这件事必须用集成测试覆盖。两者不能互相替代也不能因为单元测试全绿就认为集成没问题。关于版本升级Spring AI 在 1.0.0 之前的 API 变动非常频繁ChatClient的call()方法签名改过至少三次。如果你的项目还在用 0.x 版本升级到 1.0.0 时一定要留出至少两天的适配时间重点检查ChatResponse的取值路径和ToolCallback的注册方式。最后分享一个小技巧在开发阶段把spring.ai.dashscope.chat.options.temperature设成 0这样模型的输出是确定性的方便你复现问题。上线前再调回正常值。这个设置帮我省了很多这次能复现下次不能的调试时间。