news 2026/9/17 4:41:00

Spring AI三层架构实战:ChatModel、ChatClient与SSE流式调用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI三层架构实战:ChatModel、ChatClient与SSE流式调用

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 或千问 Qwen2),ChatClient 是带记忆、带工具调用、带结构化输出约束的“智能体外壳”,而 Controller 则是把这种智能能力翻译成浏览器能懂的 HTTP 语言。同步调用适合简单问答、表单校验这类“一问一答”场景;而 SSE(Server-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 类里绝对不能出现@RestControllerHttpServletRequest这类 Web 层类,ChatClient 里也不能有ResponseEntityStreamingResponseBody。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 通常会持有一个RestTemplateWebClient实例,用于发起 HTTP 请求。new 出来的实例,其内部的连接池(HttpClient)无法被 Spring 统一管理,会导致连接复用率低、频繁创建销毁连接,极大增加网络开销。

  • 配置失效:Spring AI 的全局配置(如spring.ai.chat.client.options.temperature=0.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 的依赖管理非常清晰,核心就是两个 starter:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M5</version> <!-- 注意:版本号需与 Spring Boot 版本匹配 --> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 的核心是Flux<ServerSentEvent>,它是一个响应式流(Reactive Stream),只有 WebFlux 的RouterFunction@RestControllerMono/Flux返回类型才能原生支持。强行用 WebMvc 会陷入复杂的线程切换和阻塞等待,得不偿失。

  • 版本兼容性是最大雷区:Spring AI 1.0+ 要求 Spring Boot 3.2+。如果你的项目还是 Spring Boot 2.7,那么必须降级到 Spring AI 0.8.x,且 API 有显著差异(例如ChatClient在 0.8 中叫ChatLanguageModel)。我见过太多团队因为版本不匹配,在ChatResponsegetResults()方法上卡住一整天——0.8 返回List<ChatResponse>,1.0+ 返回ChatResponse单对象。务必在pom.xml里用<properties>显式声明spring-ai.version,避免 Maven 传递依赖引入错误版本。

3.2 ChatModel 层:不只是一个接口,而是模型能力的“标准化插座”

ChatModel 的实现,是整个架构的基石。我们以对接本地 Ollama 的 DeepSeek-R1 为例,这是目前最主流的本地部署方案之一。

首先,你需要一个OllamaChatModel的 Bean:

@Configuration 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.contentOllamaChatModel内部做了适配,确保上层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访问。

  • temperature=0.1的取值逻辑:温度(Temperature)控制模型输出的随机性。0.0表示完全确定(总是选概率最高的 token),1.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((Map<String, 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来保存ConversationIdConversation的映射。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。maxAttempts=3fixed(1000)是一个平衡点:重试次数太少,容错性差;间隔太短,可能雪崩;间隔太长,用户体验差。

3.4 Controller 层:同步与流式的双轨实现

Controller 是用户接触的第一层,它的代码必须极度简洁、健壮、可读。

同步调用 Controller
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping("/sync") public ResponseEntity<ChatResponse> 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是一个简单的 DTO:

public class SyncChatRequest { private String userInput; // getter/setter... }

ApiResponse是一个通用的响应包装类,包含codemessagedata字段,符合国内主流 API 规范。

流式调用 Controller(SSE)
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> streamChat(@RequestBody StreamChatRequest request) { // 1. 构建 Prompt,同样使用 Message 列表 Prompt prompt = Prompt.from( List.of( new SystemMessage("你是一个专业的技术文档助手。"), new UserMessage(request.getUserInput()) ) ); // 2. 关键:调用 chatClient.stream(),得到 Flux<ChatResponse> return chatClient.stream(prompt) // 3. 将每个 ChatResponse 转换为 ServerSentEvent .map(response -> { String content = response.getResult().getOutput().getContent(); // 过滤掉空内容,避免发送空事件 if (content == null || content.trim().isEmpty()) { return ServerSentEvent.<String>builder().build(); } return ServerSentEvent.<String>builder() .data(content) // data 字段是 SSE 的核心内容 .event("message") // 可选:指定事件类型,前端可以用 addEventListener('message', ...) .build(); }) // 4. 添加错误处理:捕获异常,发送 error 事件 .onErrorResume(error -> { log.error("SSE stream error", error); return Flux.just( ServerSentEvent.<String>builder() .event("error") .data("服务器内部错误,请稍后重试。") .build() ); }) // 5. 添加完成事件:当流结束时,发送一个完成标记 .concatWith( Flux.just( ServerSentEvent.<String>builder() .event("complete") .data("done") .build() ) ); }

这个方法比同步版复杂不少,但每一步都有明确目的:

  • produces = MediaType.TEXT_EVENT_STREAM_VALUE:这是告诉 Spring,这个接口要返回text/event-stream类型的内容,触发浏览器的 EventSource 机制。

  • chatClient.stream(prompt):这是 Spring AI 的魔法所在。它返回一个Flux<ChatResponse>,其中每个ChatResponse对应模型生成的一个 token(或一小段文本)。Flux是 Reactor 框架的响应式流,天然支持异步、非阻塞。

  • .map()转换:将ChatResponse对象转换为ServerSentEvent<String>ServerSentEvent.builder().data(content)是标准格式,浏览器收到后,event.data就是contentevent("message")是可选的,方便前端做精细化处理。

  • .onErrorResume():这是 SSE 的生命线。网络中断、模型服务宕机、JSON 解析失败……任何异常都会走到这里。我们捕获后,发送一个event: error的事件,前端可以监听到并给出友好提示,而不是让页面一直挂着。

  • .concatWith()发送完成事件Flux流结束后,会自动关闭连接。但前端往往需要一个明确的“结束”信号来清理 UI(比如隐藏加载动画、启用输入框)。发送一个event: complete是业界通用做法。

StreamChatRequestSyncChatRequest类似,但可以额外携带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 WebFluxLombokSpring 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 测试 SSE:

curl -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-timeout:Tomcat 默认的connection-timeout是 20000ms(20秒)。如果模型生成一个长回答需要 30 秒,Tomcat 会在 20 秒后主动关闭连接,前端就会收到stream disconnected before completion: idle timeout waiting for sse。解决方案是在application.properties中加大超时:

    server.tomcat.connection-timeout=60000
  • Nginx 代理超时(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 默认maxThreads=200。当 200 个用户同时发起流式请求,第 201 个请求就会被拒绝或排队。解决方案是增加maxThreads,但更要紧的是限制并发流式请求数量。我们在 Controller 层加了一个简单的限流:

    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> streamChat(...) { // 使用 Spring 的 RateLimiter Bean if (!rateLimiter.tryAcquire()) { return Flux.just( ServerSentEvent.<String>builder() .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-timeout=60000
高并发下大量断连TomcatmaxThreads耗尽jstack <pid>查看线程 dump,看http-nio-8080-exec-*线程是否全部 busy增加server.tomcat.max-threads=500,并加应用层限流
模型返回空内容后断连后端代码未过滤空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,但你在 `
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 4:40:56

STM32F407+DS18B20温度报警系统:从单总线时序到OLED显示完整解析

简介&#xff1a;基于STM32F407与DS18B20构建的温度传感报警项目&#xff0c;配套4针0.96寸OLED屏&#xff0c;能够实时显示环境温度与日期&#xff0c;并在温度超出设定范围时给出声光报警。代码围绕STM32F4系列编写&#xff0c;模块划分清晰&#xff0c;可移植性好&#xff0…

作者头像 李华
网站建设 2026/9/17 4:40:36

并行FDTD的C语言MPI实现:从Yee网格到性能优化

简介&#xff1a;一套基于C语言的有限差分时域法&#xff08;FDTD&#xff09;并行计算实现&#xff0c;面向计算电磁学方向的开发者与研究者&#xff0c;可用于模拟电磁波传播、天线辐射等场景。压缩包内共26个文件&#xff0c;以.h头文件和.cpp源文件为主&#xff0c;另有txt…

作者头像 李华
网站建设 2026/9/17 4:39:40

RediSearch vs Elasticsearch:内存搜索为何快5倍?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 4:39:32

从ECO到签核:Conformal LEC逻辑等价性检查实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 4:39:15

AI编程助手MonkeyCode省流实战:Token与上下文管理全攻略

“省流”这个词放在MonkeyCode上&#xff0c;我一开始以为是流量不够用&#xff0c;后来才发现&#xff0c;真正该省的东西多了去了&#xff1a;Token额度、等待时间、上下文窗口、甚至你一天的耐心。这两年我用MonkeyCode的频率已经从“偶尔试试”变成了“主力写码搭档”&…

作者头像 李华