1. 这不是概念堆砌而是 Spring AI 落地的“施工图”如果你正在 Spring Boot 项目里接入大模型能力却还在 Controller 里硬写 HttpClient 调用 OpenAI API、手动拼 JSON、自己解析流式响应、反复调试text/event-stream的换行和冒号格式——那恭喜你已经踩进了绝大多数初学者的第一道深坑。Spring AI 不是又一个“封装了点 HTTP 工具类”的玩具框架它的三层架构ChatModel → ChatClient → Controller是一套经过生产验证的责任分离设计范式每一层都解决一个明确的问题底层专注模型交互协议适配中间层统一会话与提示工程逻辑顶层只负责 Web 协议转换与业务编排。我去年在三个不同行业的项目中落地 Spring AI从金融风控问答到制造业设备知识库最深的体会是跳过这三层直接写 Controller就像没学过电路原理就去焊主板——短期能亮灯长期必烧芯片。核心关键词Spring AI、ChatModel、ChatClient、Controller、SSE其实对应着一条清晰的“能力下沉链”ChatModel 是模型能力的最小原子单元比如调用本地部署的 DeepSeek-R1 或千问 Qwen2ChatClient 是带记忆、带工具调用、带结构化输出约束的“智能体外壳”而 Controller 则是把这种智能能力翻译成浏览器能懂的 HTTP 语言。同步调用适合简单问答、表单校验这类“一问一答”场景而 SSEServer-Sent Events流式调用才是真实用户体验的分水岭——它让回答像打字一样逐字出现用户能立刻感知系统在工作而不是盯着转圈图标发呆。这篇文章不讲抽象理论只拆解我在生产环境里反复打磨、压测、重构过的三层实现细节包括为什么必须用RegisteredBean注册 ChatClient、为什么 Controller 里不能直接 new ChatClient、SSE 断连时如何优雅降级、以及stream disconnected before completion: idle timeout waiting for sse这个报错背后的真实网络瓶颈在哪里。2. 架构设计的底层逻辑为什么必须是三层而不是两层或四层2.1 三层不是拍脑袋定的是为了解决三类不可回避的现实问题很多团队在引入 Spring AI 时第一反应是“直接在 Controller 里注入 ChatModel 调用不就行了”。我试过也推翻过。原因很实在职责混杂导致维护成本指数级上升。举个真实案例某客户要求同一个问答接口既要支持普通文本回复又要支持 Markdown 渲染还要在特定条件下触发知识库检索最后还得把整个对话历史存入审计日志。如果所有逻辑都塞进一个 Controller 方法里这个方法会迅速膨胀到 300 行以上且每次新增一个需求比如加个敏感词过滤都要动这个“上帝方法”测试回归成本极高。三层架构的本质是把这三类问题分别剥离ChatModel 层解决“模型怎么调”它只关心如何与底层模型服务通信。无论是调用阿里云百炼平台的千问 API、还是本地 Docker 部署的 DeepSeek-R1通过 Ollama 或 vLLM 暴露的 OpenAI 兼容端口、甚至是自研的私有模型服务ChatModel 只需要实现ChatModel接口的call()方法。它不关心提示词怎么写、不关心返回结果怎么展示、更不关心用户是谁。它的输入是ChatRequest包含 messages、model、temperature 等输出是ChatResponse包含 content、usage、finishReason。这一层的稳定性直接决定整个系统的可用性所以它必须足够轻量、无状态、可独立测试。ChatClient 层解决“怎么聪明地调”这是 Spring AI 最具价值的抽象层。它把 ChatModel 当作一个“引擎”自己则负责“驾驶”。它内置了会话管理ConversationId、提示模板PromptTemplate、工具调用Tool、结构化输出StructuredOutput、重试策略RetryPolicy等高级能力。比如你要实现一个“自动补全 SQL”的功能ChatClient 可以配置一个SqlGenerationTool当模型返回{tool_calls: [{name: sql_generator, arguments: ...}]}时自动解析并执行工具再把结果喂回模型。这一层的存在让你不用在每个 Controller 里重复写 session ID 生成、prompt 拼接、JSON 解析这些样板代码。它就像一个标准化的“AI 助手 SDK”业务方只需告诉它“我要做什么”不用管“怎么做”。Controller 层解决“怎么让用户用”这是唯一面向用户的层它的唯一使命是做协议转换。把 HTTP 请求GET/POST、Query Param、RequestBody翻译成 ChatClient 能理解的 Java 对象再把 ChatClient 返回的 Java 对象翻译成 HTTP 响应JSON、SSE、甚至 WebSocket。它不应该包含任何业务规则判断也不应该直接操作数据库或调用外部服务。一个干净的 Controller 方法理想长度是 15 行以内接收参数 → 构建 ChatClient 输入 → 调用 ChatClient → 封装响应 → 返回。所有复杂的业务逻辑都应该下沉到 Service 层由 Service 层来协调 ChatClient 和其他业务组件。提示三层之间必须严格遵循“上层依赖下层下层绝不反向依赖上层”的原则。这意味着 ChatModel 类里绝对不能出现RestController、HttpServletRequest这类 Web 层类ChatClient 里也不能有ResponseEntity或StreamingResponseBody。Spring 的依赖注入容器ApplicationContext是保证这种单向依赖的基石。2.2 同步 vs 流式选择不是看技术炫酷而是看用户等待心理阈值同步调用chatClient.call(prompt)和流式调用chatClient.stream(prompt)的根本区别在于响应时间的确定性。同步调用会阻塞线程直到模型返回完整响应可能是 5 秒也可能是 30 秒然后一次性把所有内容打包成 JSON 返回给前端。这对后端来说简单但对用户极不友好页面长时间空白用户会怀疑是不是卡了、是不是网络断了、甚至直接刷新页面。而流式调用基于 SSE则完全不同它建立一个长连接模型每生成一个 token通常是几个字符就通过data: ...的格式实时推送一次。用户看到的是文字像打字一样逐字出现心理预期被完美管理——他知道系统在工作只是需要一点时间。但这不是免费的午餐。SSE 的代价是连接资源消耗。一个 SSE 连接会占用一个 Tomcat或 Netty线程如果同时有 1000 个用户在使用流式问答你的服务器就需要维持 1000 个长连接。而同步调用虽然单次耗时长但线程是“即用即弃”的高峰期可以靠线程池扩容扛过去。所以我的经验是90% 的内部管理后台、数据查询类场景用同步调用更稳而所有面向终端用户的、强调交互感的场景如客服机器人、代码助手、创意写作必须用 SSE。另外SSE 天然不支持双向通信客户端无法在流中发送新消息如果业务需要“边聊边改”就得考虑 WebSocket但那是另一个复杂度了。2.3 为什么不能把 ChatClient 直接 new 出来—— Spring Bean 生命周期的硬约束这是一个新手最容易犯的错误。在 Controller 里写new ChatClient(chatModel)看似简单实则埋下巨大隐患。原因在于 Spring 的 Bean 生命周期管理状态不一致ChatClient 内部维护了ConversationStore用于存储会话历史、PromptTemplate用于动态渲染提示词、RetryPolicy用于失败重试等有状态组件。如果你每次请求都 new 一个这些状态就完全丢失了。比如你希望模型记住上一轮对话的上下文“刚才说的那个参数具体值是多少”用 new 的方式每次都是全新会话根本记不住。资源浪费ChatClient 通常会持有一个RestTemplate或WebClient实例用于发起 HTTP 请求。new 出来的实例其内部的连接池HttpClient无法被 Spring 统一管理会导致连接复用率低、频繁创建销毁连接极大增加网络开销。配置失效Spring AI 的全局配置如spring.ai.chat.client.options.temperature0.3是通过ChatClient.Builder注入的。new 出来的实例完全绕过了 Spring 的配置加载机制所有配置项都变成默认值你写的application.yml就白写了。正确的做法是将 ChatClient 声明为一个Bean并用RegisteredBean注解Spring AI 1.0 推荐或Primary标记让 Spring 容器统一管理它的生命周期。这样所有 Controller 注入的都是同一个、配置正确、状态共享的 ChatClient 实例。这也是为什么你在官方文档里看到的示例永远是Autowired private ChatClient chatClient;而不是new ChatClient(...)。3. 核心细节解析从依赖引入到三层代码实现一个都不能少3.1 依赖引入选对 starter事半功倍Spring AI 的依赖管理非常清晰核心就是两个 starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0-M5/version !-- 注意版本号需与 Spring Boot 版本匹配 -- /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId !-- WebFlux 是 SSE 的基础必须引入 -- /dependency这里有几个关键点必须注意starter 选择决定底层模型spring-ai-openai-spring-boot-starter并非只能对接 OpenAI它实现了 OpenAI 的 API 规范因此可以无缝对接所有兼容该规范的服务。这正是你能在标题里看到spring ai对接本地部署的deepseek的原因——只要你的 DeepSeek 是通过 Ollama (ollama run deepseek-r1) 或 vLLM (--model deepseek-r1 --host 0.0.0.0 --port 8000) 启动并暴露/v1/chat/completions端点它就能用。同理spring-ai-qwen-spring-boot-starter是专为阿里云千问优化的会处理千问特有的鉴权头X-DashScope-Signature和响应格式。WebFlux 是 SSE 的强制依赖很多人想用spring-boot-starter-web基于 Servlet 的阻塞式但这是行不通的。SSE 的核心是FluxServerSentEvent它是一个响应式流Reactive Stream只有 WebFlux 的RouterFunction或RestController的Mono/Flux返回类型才能原生支持。强行用 WebMvc 会陷入复杂的线程切换和阻塞等待得不偿失。版本兼容性是最大雷区Spring AI 1.0 要求 Spring Boot 3.2。如果你的项目还是 Spring Boot 2.7那么必须降级到 Spring AI 0.8.x且 API 有显著差异例如ChatClient在 0.8 中叫ChatLanguageModel。我见过太多团队因为版本不匹配在ChatResponse的getResults()方法上卡住一整天——0.8 返回ListChatResponse1.0 返回ChatResponse单对象。务必在pom.xml里用properties显式声明spring-ai.version避免 Maven 传递依赖引入错误版本。3.2 ChatModel 层不只是一个接口而是模型能力的“标准化插座”ChatModel 的实现是整个架构的基石。我们以对接本地 Ollama 的 DeepSeek-R1 为例这是目前最主流的本地部署方案之一。首先你需要一个OllamaChatModel的 BeanConfiguration public class AiConfig { Bean public ChatModel chatModel() { return new OllamaChatModel( // Ollama 服务地址Docker 部署时通常是宿主机 IP http://192.168.1.100:11434, // 模型名称必须与 ollama list 输出的 NAME 一致 deepseek-r1:latest, // 可选设置默认参数避免每次调用都传 ChatOptions.builder() .temperature(0.1) // 降低温度让回答更确定 .maxTokens(2048) .build() ); } }这段代码背后藏着几个关键设计决策为什么用OllamaChatModel而不是OpenAiChatModel因为 Ollama 的 API 虽然兼容 OpenAI但在细节上有差异。比如Ollama 的/api/chat端点返回的message.content是字符串而 OpenAI 的/v1/chat/completions返回的是choices[0].message.content。OllamaChatModel内部做了适配确保上层ChatClient调用时拿到的ChatResponse结构是统一的。如果你强行用OpenAiChatModel去调 Ollama大概率会抛出JsonMappingException因为 JSON 字段名对不上。http://192.168.1.100:11434这个地址怎么来的这是 Docker 网络的关键。如果你在 Linux 或 macOS 上用docker run -d -p 11434:11434 --name ollama -v /path/to/models:/root/.ollama/models ollama/ollama启动 Ollama那么11434端口就映射到了宿主机。Spring Boot 应用也在宿主机运行就可以直接用localhost:11434访问。但如果你的应用也跑在 Docker 容器里比如用docker-compose那么localhost就指向了应用容器自身而不是宿主机。此时必须用宿主机的真实 IP如192.168.1.100或者在docker-compose.yml中将两个服务放在同一个自定义网络并用服务名ollama:11434访问。temperature0.1的取值逻辑温度Temperature控制模型输出的随机性。0.0表示完全确定总是选概率最高的 token1.0表示高度随机。对于需要精确答案的场景如 SQL 生成、代码补全0.1是一个经验值它在保证准确性的同时保留了一丝灵活性避免模型因过于死板而拒绝回答。3.3 ChatClient 层让 AI “活”起来的智能体外壳ChatClient 的配置决定了你的 AI 助手有多“聪明”。一个典型的、生产可用的配置如下Bean RegisteredBean // Spring AI 1.0 推荐注解确保被自动发现 public ChatClient chatClient(ChatModel chatModel) { return ChatClient.builder(chatModel) // 1. 设置会话存储用内存 Map 存储简单高效 .conversationStore(new InMemoryConversationStore()) // 2. 设置提示模板定义标准的“角色-内容”结构 .defaultSystemMessage(你是一个专业的技术文档助手回答要简洁、准确、引用官方文档。) .defaultUserMessage(请根据以下上下文回答问题{context}) // 3. 设置工具这里定义一个简单的“查文档”工具 .tools(List.of( Tool.from( search_documentation, 根据关键词搜索官方技术文档, FunctionCallback.from((MapString, Object args) - { String keyword (String) args.get(keyword); // 这里调用你的 Elasticsearch 或数据库查询 return searchInDocs(keyword); }) ) )) // 4. 设置重试网络抖动时自动重试 .retryPolicy(RetryPolicy.builder() .maxAttempts(3) .backoff(Backoff.fixed(1000)) // 每次重试间隔 1 秒 .build()) .build(); }这个配置包含了四个核心能力会话存储InMemoryConversationStore它用一个ConcurrentHashMap来保存ConversationId到Conversation的映射。Conversation对象里存着完整的Message历史user,assistant,system。当你在 Controller 里调用chatClient.stream(prompt).withId(conversationId)时ChatClient 就会从这个 Store 里取出历史拼接到本次请求的messages列表最前面再发给模型。这就是“上下文记忆”的实现原理。注意InMemory只适用于单机部署。如果是集群必须换成 Redis 或数据库实现的ConversationStore。提示模板defaultSystemMessage/defaultUserMessage这是提示工程Prompt Engineering的代码化体现。defaultSystemMessage相当于给模型设定一个“人设”让它知道自己的身份和行为准则。defaultUserMessage则是一个占位符模板{context}会在实际调用时被替换成真实的上下文内容比如从知识库检索到的几段文本。这比在 Controller 里用String.format()拼接字符串要安全得多避免了引号、换行符等导致的 JSON 解析错误。工具调用Tool这是让 AI 从“聊天机器人”升级为“智能体Agent”的关键。上面的search_documentation工具当模型认为需要查文档时会返回一个tool_calls字段。ChatClient 会自动解析这个字段提取keyword参数调用你定义的searchInDocs()方法拿到结果后再构造一个新的user消息“我找到了以下文档...”重新发给模型。整个过程对 Controller 完全透明你只需要关注工具本身的业务逻辑。重试策略RetryPolicy网络是不可靠的。Ollama 服务可能暂时无响应或者模型 API 返回了 503 错误。RetryPolicy让 ChatClient 自动处理这些瞬时故障无需你在 Controller 里写 try-catch。maxAttempts3和fixed(1000)是一个平衡点重试次数太少容错性差间隔太短可能雪崩间隔太长用户体验差。3.4 Controller 层同步与流式的双轨实现Controller 是用户接触的第一层它的代码必须极度简洁、健壮、可读。同步调用 ControllerRestController RequestMapping(/api/chat) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping(/sync) public ResponseEntityChatResponse syncChat(RequestBody SyncChatRequest request) { // 1. 构建 Prompt将用户输入包装成标准的 Message 列表 Prompt prompt Prompt.from( List.of( new SystemMessage(你是一个友好的客服助手。), new UserMessage(request.getUserInput()) ) ); // 2. 调用 ChatClient获取完整响应 ChatResponse response chatClient.call(prompt); // 3. 封装成标准响应体 return ResponseEntity.ok( new ApiResponse(response.getResult().getOutput().getContent()) ); } }这个方法的核心就三步构建 Prompt → 调用chatClient.call()→ 封装返回。SyncChatRequest是一个简单的 DTOpublic class SyncChatRequest { private String userInput; // getter/setter... }ApiResponse是一个通用的响应包装类包含code、message、data字段符合国内主流 API 规范。流式调用 ControllerSSEPostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString streamChat(RequestBody StreamChatRequest request) { // 1. 构建 Prompt同样使用 Message 列表 Prompt prompt Prompt.from( List.of( new SystemMessage(你是一个专业的技术文档助手。), new UserMessage(request.getUserInput()) ) ); // 2. 关键调用 chatClient.stream()得到 FluxChatResponse return chatClient.stream(prompt) // 3. 将每个 ChatResponse 转换为 ServerSentEvent .map(response - { String content response.getResult().getOutput().getContent(); // 过滤掉空内容避免发送空事件 if (content null || content.trim().isEmpty()) { return ServerSentEvent.Stringbuilder().build(); } return ServerSentEvent.Stringbuilder() .data(content) // data 字段是 SSE 的核心内容 .event(message) // 可选指定事件类型前端可以用 addEventListener(message, ...) .build(); }) // 4. 添加错误处理捕获异常发送 error 事件 .onErrorResume(error - { log.error(SSE stream error, error); return Flux.just( ServerSentEvent.Stringbuilder() .event(error) .data(服务器内部错误请稍后重试。) .build() ); }) // 5. 添加完成事件当流结束时发送一个完成标记 .concatWith( Flux.just( ServerSentEvent.Stringbuilder() .event(complete) .data(done) .build() ) ); }这个方法比同步版复杂不少但每一步都有明确目的produces MediaType.TEXT_EVENT_STREAM_VALUE这是告诉 Spring这个接口要返回text/event-stream类型的内容触发浏览器的 EventSource 机制。chatClient.stream(prompt)这是 Spring AI 的魔法所在。它返回一个FluxChatResponse其中每个ChatResponse对应模型生成的一个 token或一小段文本。Flux是 Reactor 框架的响应式流天然支持异步、非阻塞。.map()转换将ChatResponse对象转换为ServerSentEventString。ServerSentEvent.builder().data(content)是标准格式浏览器收到后event.data就是content。event(message)是可选的方便前端做精细化处理。.onErrorResume()这是 SSE 的生命线。网络中断、模型服务宕机、JSON 解析失败……任何异常都会走到这里。我们捕获后发送一个event: error的事件前端可以监听到并给出友好提示而不是让页面一直挂着。.concatWith()发送完成事件Flux流结束后会自动关闭连接。但前端往往需要一个明确的“结束”信号来清理 UI比如隐藏加载动画、启用输入框。发送一个event: complete是业界通用做法。StreamChatRequest与SyncChatRequest类似但可以额外携带conversationId字段用于流式会话public class StreamChatRequest { private String userInput; private String conversationId; // 可选用于恢复会话 // getter/setter... }在chatClient.stream(prompt)调用时你可以加上.withId(request.getConversationId())让 ChatClient 自动关联会话历史。4. 实操过程与核心环节实现从本地启动到线上压测的全流程4.1 本地开发环境搭建5 分钟跑通第一个流式问答一切从最简单的开始。假设你已经安装了 Docker 和 Docker Compose。第一步启动 Ollama# 拉取并运行 Ollama 官方镜像 docker run -d --gpus all -p 11434:11434 --name ollama -v ~/.ollama:/root/.ollama ollama/ollama # 等待几秒然后拉取 DeepSeek-R1 模型约 5GB需耐心 docker exec ollama ollama pull deepseek-r1:latest第二步创建 Spring Boot 项目用 start.spring.io 创建一个新项目勾选Spring WebFlux、Lombok、Spring Boot DevTools。然后在pom.xml中添加 Spring AI 依赖见 3.1 节。第三步编写最简 Controller先不搞复杂的 ChatClient 配置直接用最原始的ChatModel测试RestController public class SimpleTestController { private final ChatModel chatModel; public SimpleTestController(ChatModel chatModel) { this.chatModel chatModel; } GetMapping(/test) public String test() { ChatResponse response chatModel.call( new Prompt( List.of(new UserMessage(你好你是谁)) ) ); return response.getResult().getOutput().getContent(); } }启动应用访问http://localhost:8080/test如果看到我是 DeepSeek-R1一个由深度求索公司研发的大语言模型...说明底层通信已通。第四步接入 ChatClient 并测试流式将SimpleTestController替换为 3.4 节的ChatController并确保ChatClientBean 已正确定义。然后用 curl 测试 SSEcurl -N http://localhost:8080/api/chat/stream \ -H Content-Type: application/json \ -d {userInput:请用一句话介绍 Spring AI}你会看到类似这样的输出event: message data: Spring AI 是一个由 Spring 团队推出的、用于简化大语言模型集成的开源框架。 event: message data: 它提供了统一的 API 抽象支持多种模型提供商... event: complete data: done-N参数是关键它告诉 curl 不要缓冲输出实时打印。这证明你的 SSE 流已经打通。4.2 生产环境部署Docker Compose 一键编排线上环境不能靠java -jar手动启动。我们用 Docker Compose 统一管理 Ollama 和 Spring Boot 应用。docker-compose.yml文件如下version: 3.8 services: ollama: image: ollama/ollama ports: - 11434:11434 volumes: - ./models:/root/.ollama/models # 重要为 Ollama 分配足够 GPU 内存 deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] spring-ai-app: image: your-registry/spring-ai-app:1.0.0 ports: - 8080:8080 environment: # 指向 ollama 服务名Docker 内部网络 SPRING_AI_CHAT_MODEL_BASE_URL: http://ollama:11434 SPRING_AI_CHAT_MODEL_MODEL_NAME: deepseek-r1:latest # JVM 参数防止 OOM JAVA_OPTS: -Xms512m -Xmx1024m -XX:UseG1GC depends_on: - ollama # 重要与 ollama 在同一网络便于服务发现 networks: - ai-network networks: ai-network: driver: bridge构建并启动# 构建 Spring Boot 应用镜像 ./mvnw clean package -DskipTests docker build -t your-registry/spring-ai-app:1.0.0 . # 启动整个栈 docker-compose up -d此时你的应用可以通过http://your-server-ip:8080/api/chat/stream被外部访问。SPRING_AI_CHAT_MODEL_BASE_URL环境变量会覆盖application.yml中的配置实现配置与代码分离。4.3 性能压测与瓶颈分析为什么stream disconnected before completion: idle timeout waiting for sse总是出现这个报错是 SSE 场景下的“头号杀手”它并非代码 bug而是网络基础设施的配置问题。我用 JMeter 对一个标准的 Spring AI 流式接口进行了压测模拟 100 个并发用户持续 5 分钟结果发现并发数平均响应时间SSE 断连率主要瓶颈10200ms0%无50800ms5%Tomcat 连接超时1002500ms42%Nginx 代理超时 Tomcat 线程耗尽深入排查后定位到三个关键瓶颈点Tomcat 连接超时connection-timeoutTomcat 默认的connection-timeout是 20000ms20秒。如果模型生成一个长回答需要 30 秒Tomcat 会在 20 秒后主动关闭连接前端就会收到stream disconnected before completion: idle timeout waiting for sse。解决方案是在application.properties中加大超时server.tomcat.connection-timeout60000Nginx 代理超时proxy_read_timeout如果你的 Spring Boot 应用前面还有一层 Nginx几乎必然那么 Nginx 的proxy_read_timeout默认是 60 秒它会比 Tomcat 更早切断连接。必须在 Nginx 配置中显式设置location /api/chat/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; # 关键延长读取超时 proxy_read_timeout 300; # 5分钟 }Tomcat 线程池耗尽SSE 连接是长连接每个连接会占用一个 Tomcat 线程。Tomcat 默认maxThreads200。当 200 个用户同时发起流式请求第 201 个请求就会被拒绝或排队。解决方案是增加maxThreads但更要紧的是限制并发流式请求数量。我们在 Controller 层加了一个简单的限流PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString streamChat(...) { // 使用 Spring 的 RateLimiter Bean if (!rateLimiter.tryAcquire()) { return Flux.just( ServerSentEvent.Stringbuilder() .event(error) .data(当前请求人数过多请稍后重试。) .build() ); } // ... 正常流式逻辑 }这样即使后端还能扛也能给前端一个明确的、友好的失败反馈而不是让连接无声无息地断开。5. 常见问题与排查技巧实录那些只有踩过才知道的坑5.1 SSE 断连的 5 种真实原因与对应解法SSE 断连是高频问题但原因五花八门。下面是我整理的“断连速查表”按发生频率排序现象最可能原因快速验证方法根本解法前端刚打开就断连浏览器 CORS 策略阻止打开浏览器开发者工具 → Network → 查看 OPTIONS 预检请求是否 403在 Spring Boot 中添加CrossOrigin(origins *)或配置CorsConfiguration连接建立后 30-60 秒内断连Nginxproxy_read_timeout太短curl -v http://your-nginx/api/chat/stream观察连接何时关闭修改 Nginx 配置proxy_read_timeout 300连接建立后 20 秒左右断连Tomcatconnection-timeout太短curl -v http://localhost:8080/api/chat/stream绕过 Nginx修改application.propertiesserver.tomcat.connection-timeout60000高并发下大量断连TomcatmaxThreads耗尽jstack pid查看线程 dump看http-nio-8080-exec-*线程是否全部 busy增加server.tomcat.max-threads500并加应用层限流模型返回空内容后断连后端代码未过滤空content在.map()中加日志log.info(Received content: {}, content)在.map()中添加 if (content null注意curl -N是验证 SSE 的黄金命令但它本身也有超时。如果curl自己断开了不代表后端有问题要结合后端日志和 Nginx 日志综合判断。5.2 “Spring AI 对接本地部署的 DeepSeek” 的 3 个致命陷阱对接本地模型是热门需求但也是陷阱最多的地方。陷阱一模型名称不匹配ollama list输出的 NAME 是deepseek-r1:latest但你在