news 2026/9/28 23:23:36

SpringAI流式输出实战:SSE与JDK21虚拟线程性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringAI流式输出实战:SSE与JDK21虚拟线程性能优化

1. 从一次线上事故说起:为什么我要死磕SSE

去年底我接手了一个基于SpringAI的对话机器人项目,前端用React,后端Spring Boot 3.2,大模型走的是远端API。上线第三天,客服群里炸了锅——用户反馈“回答要等十几秒才蹦出来,像死机了一样”。我打开浏览器开发者工具一看,请求确实发出去了,但响应体是空的,直到大模型完整生成完毕,才一次性把整段文字吐回来。用户体验极差,尤其是长回答场景,等待时间直接劝退。

这就是典型的非流式调用问题。大模型生成文本是逐token进行的,如果后端等全部生成完再返回,前端就只能干等。解决思路很明确:用SSE(Server-Sent Events)把每个token实时推给前端,实现“打字机”效果。但真正落地时,我发现事情没那么简单——从显式调用到隐式封装,再到JDK21虚拟线程的性能优化,每一步都有坑。

这篇文章就是我这几个月踩坑、调优、重构的完整记录。涉及Java、SSE、SpringAI、虚拟线程、JDK21这几个核心关键词,适合正在做AI对话应用、或者对Java高并发流式输出感兴趣的开发者。不管你是刚接触SSE的新手,还是已经在用SpringAI的老手,应该都能从里面找到能直接抄作业的东西。

2. SSE到底是个啥:用生活化类比讲清楚

2.1 从“打电话”和“发短信”说起

很多人分不清SSE、WebSocket和轮询。我用一个生活场景类比:

  • 轮询:你每隔5秒给朋友发一条短信问“写好了吗?”,朋友回复“还没”,直到某次回复“写好了”。浪费短信费,还有延迟。
  • WebSocket:你和朋友建立一条电话专线,双方随时可以说话,双向通信。功能强,但建立和维护成本高。
  • SSE:你给朋友发一条短信说“你写好了就一段一段发给我”,然后朋友每写一段就发一条短信过来,你只管接收。单向、长连接、基于HTTP。

SSE的本质是服务器向客户端推送文本流,协议非常简单:响应头Content-Type: text/event-stream,然后服务端持续写入data: xxx\n\n格式的数据。浏览器端的EventSourceAPI 会自动接收并触发onmessage回调。

2.2 为什么大模型场景首选SSE而不是WebSocket

我试过用WebSocket做流式输出,能用,但有几个问题:

对比维度SSEWebSocket
协议纯HTTP独立协议,需升级握手
方向服务端→客户端单向双向
自动重连浏览器原生支持需手动实现
代理兼容走HTTP,兼容性好部分代理会拦截
实现复杂度低,Spring MVC直接支持高,需配置
适用场景大模型流式输出、通知推送聊天室、协同编辑

大模型对话本质是“用户发一次请求,服务端持续返回”,单向足够。SSE的自动重连和HTTP兼容性让它成为最优解。SpringAI的StreamingChatClient底层就是SSE。

注意:SSE默认有超时限制,Nginx默认60秒会断开。大模型生成长文本可能超过这个时间,必须在代理层和代码层都做超时配置。

3. 显式调用SSE:手写Controller的完整过程

3.1 最原始的写法:SseEmitter

Spring MVC提供了SseEmitter类,这是最显式的SSE实现方式。我最初就是这么写的:

@GetMapping("/chat/stream") public SseEmitter streamChat(@RequestParam String message) { SseEmitter emitter = new SseEmitter(180_000L); // 3分钟超时 executor.execute(() -> { try { // 调用大模型,逐token返回 chatClient.prompt(message) .stream() .content() .subscribe(token -> { try { emitter.send(SseEmitter.event() .data(token) .id(UUID.randomUUID().toString())); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }

这段代码能跑,但问题不少。首先,executor是我自己定义的线程池,每个请求占用一个线程直到流结束。大模型生成30秒,线程就阻塞30秒。并发100个用户,就需要100个线程。Tomcat默认200个线程,看似够用,但加上其他业务,很快就打满了。

其次,异常处理很粗糙。如果客户端提前断开连接,emitter.send()会抛异常,但线程池里的任务不会自动取消,造成资源浪费。

3.2 显式调用的三个致命问题

问题一:线程阻塞。每个SSE连接占用一个Tomcat线程,这是最核心的瓶颈。我压测过,200并发时响应时间从2秒飙升到15秒,CPU没满,线程全在等待。

问题二:背压缺失。如果大模型生成速度快于网络传输速度,emitter.send()会阻塞,但阻塞的是业务线程,没有背压机制通知上游减速。

问题三:连接管理混乱。客户端断开后,服务端不一定能立即感知。我遇到过用户关闭页面后,后台还在傻傻地调用大模型API,白白烧钱。

实操心得:用SseEmitter时一定要注册onCompletion和onTimeout回调,在里面取消上游订阅。否则就是资源泄漏。

3.3 改进版:手动管理生命周期

后来我改成这样:

@GetMapping("/chat/stream") public SseEmitter streamChat(@RequestParam String message) { SseEmitter emitter = new SseEmitter(180_000L); Disposable disposable = chatClient.prompt(message) .stream() .content() .subscribe( token -> { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete ); emitter.onCompletion(disposable::dispose); emitter.onTimeout(() -> { disposable.dispose(); emitter.complete(); }); return emitter; }

这样客户端断开时,onCompletion会触发,取消对大模型的订阅。但线程阻塞问题依然存在,因为subscribe是同步阻塞的(取决于底层实现)。要彻底解决,必须换思路。

4. 隐式封装:SpringAI如何把SSE藏起来

4.1 SpringAI的StreamingChatClient设计哲学

SpringAI的设计目标就是“让开发者不用关心SSE细节”。它提供了StreamingChatClient接口,你只需要:

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamChat(@RequestParam String message) { return streamingChatClient.prompt(message) .stream() .content(); }

返回Flux<String>,Spring WebFlux会自动把它转成SSE流。没有SseEmitter,没有手动send,没有线程管理。这就是隐式封装——框架帮你处理了所有底层细节。

4.2 隐式封装背后的技术栈

SpringAI的流式输出基于Reactor(响应式编程)。Flux是一个异步序列,支持背压。当客户端消费慢时,Reactor会自动调节上游生产速度。这解决了显式调用的背压问题。

但这里有个关键点:Spring MVC和WebFlux是两套东西。如果你用的是Spring MVC(Servlet栈),返回Flux需要适配;如果用WebFlux(Netty栈),原生支持。我一开始在MVC项目里返回Flux,发现它被当成普通对象序列化了,根本不是SSE流。

注意:SpringAI的流式接口在MVC和WebFlux下行为不同。MVC下需要produces = TEXT_EVENT_STREAM_VALUE,且底层会用ResponseBodyEmitter适配。WebFlux下才是真正的响应式流。

4.3 隐式封装的代价:调试难度上升

封装越好,出问题时越难查。我遇到过一次“流中断”问题,日志只显示stream disconnected before completion: idle timeout waiting for sse。排查了半天,发现是Nginx的proxy_read_timeout默认60秒,而大模型生成超过了60秒。

隐式封装把SSE细节藏起来了,但网络层、代理层、容器层的超时配置依然要手动处理。我的经验是:不管用哪种方式,都要在以下几个地方检查超时:

层级配置项建议值
Nginxproxy_read_timeout300s
TomcatconnectionTimeout300000ms
Spring MVCasync request timeout300000ms
SseEmitter构造函数超时300000L
大模型客户端readTimeout300s

4.4 从显式到隐式的迁移实录

我把项目从SseEmitter迁移到Flux时,改了这些地方:

  1. 引入spring-boot-starter-webflux依赖(即使主栈是MVC,Reactor也要用)。
  2. Controller返回类型从SseEmitter改为Flux<String>。
  3. 添加produces = MediaType.TEXT_EVENT_STREAM_VALUE。
  4. 移除所有手动线程池和SseEmitter管理代码。
  5. 前端EventSource代码不变,因为SSE协议格式一样。

迁移后代码量减少了60%,但性能提升有限——因为底层还是Servlet栈,每个请求依然占用线程。真正的飞跃要等虚拟线程。

5. 虚拟线程登场:JDK21带来的性能飞跃

5.1 平台线程的困境

传统Java线程是平台线程,一对一映射到操作系统线程。创建成本高(约1MB栈内存),上下文切换贵。Tomcat的200个线程池,在SSE场景下就是200个并发上限。超过就排队。

我压测过:200并发时,P99响应时间8秒;300并发时,大量请求超时。CPU利用率只有30%,瓶颈全在等待I/O。

5.2 虚拟线程是什么:用“员工和工位”类比

JDK21正式引入虚拟线程(JEP 444)。你可以这样理解:

  • 平台线程:每个员工一个固定工位,工位有限,员工多了就没地方坐。
  • 虚拟线程:员工不固定工位,需要干活时才分配工位,干完就释放。工位数量还是那么多,但员工可以成千上万。

虚拟线程由JVM管理,挂载到少量平台线程(载体线程)上。当虚拟线程阻塞(如等待I/O)时,JVM会自动把它卸载,让载体线程去跑其他虚拟线程。阻塞不再浪费线程。

5.3 在Spring Boot 3.5中启用虚拟线程

Spring Boot 3.2开始支持虚拟线程,3.5已经非常成熟。启用方式简单到离谱:

# application.yml spring: threads: virtual: enabled: true

就这一行。Spring Boot会自动把Tomcat的线程池替换成虚拟线程执行器。每个请求一个虚拟线程,阻塞时自动卸载。

我实测:启用虚拟线程后,同样200并发,P99响应时间从8秒降到1.2秒;500并发时P99也只有2.5秒。CPU利用率提升到65%,吞吐量翻了4倍。

5.4 虚拟线程 + SSE的化学反应

SSE场景下,每个连接需要等待大模型生成,这是典型的I/O阻塞。平台线程模式下,线程被占住不能动;虚拟线程模式下,线程阻塞时自动让出载体线程,其他请求可以继续处理。

但有个坑:虚拟线程对synchronized敏感。如果代码里有synchronized块,虚拟线程会被“钉住”(pinned),无法卸载。JDK21中synchronized会导致pin,JDK24才修复。所以要用ReentrantLock替代。

// 不推荐:会导致虚拟线程pin synchronized (lock) { // 阻塞操作 } // 推荐:ReentrantLock不会pin private final ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 阻塞操作 } finally { lock.unlock(); }

实操心得:启用虚拟线程后,用-Djdk.tracePinnedThreads=full启动参数可以检测pin事件。我靠这个参数发现了好几处synchronized遗留代码。

5.5 性能对比数据

我在同一台机器(4核8G)上做了三组压测,大模型模拟延迟200ms/token,共50个token:

方案并发数P99响应时间吞吐量(req/s)CPU利用率
SseEmitter + 平台线程2008.2s2430%
Flux + 平台线程2007.8s2632%
Flux + 虚拟线程2001.2s9565%
Flux + 虚拟线程5002.5s18078%

数据很直观:虚拟线程让吞吐量翻了近4倍,响应时间降到1/7。这不是微优化,是数量级的提升。

6. 完整落地:从零搭建一个流式对话机器人

6.1 项目骨架和依赖

我用的技术栈:JDK21 + Spring Boot 3.5 + SpringAI 1.0 + React前端。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.0</version> </parent> <properties> <java.version>21</java.version> <spring-ai.version>1.0.0</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> </dependencies>

6.2 配置大模型客户端

spring: ai: openai: api-key: ${API_KEY} base-url: ${BASE_URL} chat: options: model: gpt-4o-mini temperature: 0.7 threads: virtual: enabled: true

6.3 Controller实现

@RestController @RequestMapping("/api/chat") public class ChatController { private final StreamingChatClient streamingChatClient; public ChatController(StreamingChatClient streamingChatClient) { this.streamingChatClient = streamingChatClient; } @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String message) { return streamingChatClient.prompt() .user(message) .stream() .content() .timeout(Duration.ofMinutes(5)) .onErrorResume(e -> Flux.just("[错误] " + e.getMessage())); } }

注意timeout和onErrorResume,这是生产环境必备。大模型可能超时或报错,不能让流直接断掉。

6.4 前端React接收SSE

const eventSource = new EventSource(`/api/chat/stream?message=${encodeURIComponent(msg)}`); eventSource.onmessage = (event) => { setAnswer(prev => prev + event.data); }; eventSource.onerror = () => { eventSource.close(); };

EventSource会自动重连,但大模型对话场景下重连会导致重复回答。所以要在onerror里手动close()。

6.5 中断控制:Abort的实现

用户可能想中途停止生成。前端调用eventSource.close()只是断开连接,服务端需要感知并取消大模型调用。SpringAI的Flux支持Disposable:

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String message, HttpServletResponse response) { return streamingChatClient.prompt() .user(message) .stream() .content() .doOnCancel(() -> log.info("客户端取消,停止生成")) .doOnTerminate(() -> log.info("流结束")); }

当客户端断开时,Reactor会触发cancel信号,doOnCancel执行清理。大模型客户端收到取消信号后会停止请求,节省token消耗。

7. 常见问题与排查技巧实录

7.1 流中断问题速查表

现象可能原因排查方法解决方案
60秒后断开Nginx超时查Nginx error.logproxy_read_timeout 300s
立即断开响应头不对查Content-Type确保text/event-stream
部分token丢失缓冲区问题查Nginx bufferproxy_buffering off
中文乱码编码问题查charset统一UTF-8
虚拟线程pinsynchronized加tracePinnedThreads换ReentrantLock
内存泄漏未取消订阅查堆dump注册onCompletion

7.2 三个我踩过的坑

坑一:Nginx缓冲导致“假流式”。Nginx默认会缓冲响应,导致SSE数据攒一批才发。表现是前端不是逐字显示,而是几秒蹦一段。解决:proxy_buffering off;。

坑二:虚拟线程下ThreadLocal失效。虚拟线程支持ThreadLocal,但数量多了内存暴涨。我用ThreadLocal存用户上下文,1000并发时内存涨了2G。改用ScopedValue(JDK21预览)或显式传参。

坑三:SpringAI的@Tool注解在流式下不生效。@Tool用于函数调用,但流式模式下工具调用结果不会实时推送。我的做法是:工具调用走非流式,拿到结果后再用流式输出最终回答。

7.3 生产环境配置清单

# Nginx location /api/chat/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; chunked_transfer_encoding on; }
# Spring Boot server: tomcat: connection-timeout: 300000 spring: mvc: async: request-timeout: 300000 threads: virtual: enabled: true

8. 一些个人体会和后续扩展方向

这套方案上线后,对话机器人的用户停留时长提升了40%,客服投诉归零。虚拟线程的引入让单机支撑的并发从200提升到800+,省了两台服务器。

后续我还在探索几个方向:一是用ScopedValue替代ThreadLocal做上下文传递,更适配虚拟线程;二是把SSE和RAG结合,检索阶段用虚拟线程并行查询多个数据源;三是研究JDK24对synchronizedpin问题的修复,彻底消除虚拟线程的最后一个短板。

如果你也在做类似项目,我的建议是:先用显式SseEmitter跑通流程,再迁移到SpringAI的Flux封装,最后开启虚拟线程。不要一上来就追求最优方案,分步走才能定位问题。另外,压测一定要做,虚拟线程的收益在低并发下不明显,高并发才是它的主场。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 23:23:04

Claude Code与Codex如何高效配合?分工、配置与提交前验证全攻略

1. Claude Code 和 Codex 不是竞品&#xff0c;是两种性格的工程师我在项目里同时装了 Claude Code 和 Codex 之后&#xff0c;被问得最多的一个问题就是&#xff1a;这两个到底有什么区别&#xff1f;是不是用一个就行了&#xff1f;说实话&#xff0c;一开始我也是这么想的。…

作者头像 李华
网站建设 2026/9/28 23:22:53

339张溺水图像小样本YOLO训练实战:从数据划分到BN崩溃排查

简介&#xff1a;这份资源是面向溺水检测场景的YOLO系列目标检测数据集&#xff0c;适合计算机视觉初学者、算法工程师及安全监控方向研究者使用&#xff0c;可用于训练和验证溺水、出水、游泳等水上行为的识别模型。压缩包共1018个文件&#xff0c;包含339张jpg图像、339个txt…

作者头像 李华
网站建设 2026/9/28 23:22:07

Univer 表格引擎实战:Canvas 渲染、Facade API 与 Node.js 协同开发指南

1. 从“univer”这个标题说起&#xff1a;它到底是什么&#xff0c;能解决什么问题第一次看到“univer”这个词&#xff0c;很多人会以为是“universe”的缩写&#xff0c;或者某个开源社区的花名。实际上&#xff0c;在表格与文档协同这个圈子里&#xff0c;Univer 指的是一套…

作者头像 李华
网站建设 2026/9/28 23:20:46

WPF+SQL Server实战:LIS系统分页、并发与动态表格

医院检验科的信息化系统&#xff08;LIS&#xff09;是个很容易让开发人员大意的地方&#xff0c;表面看是“WPF界面上放几个表格&#xff0c;SQL Server里存一堆检验结果”&#xff0c;但真实项目跑起来之后&#xff0c;你才会发现那些看起来人畜无害的功能&#xff0c;几乎每…

作者头像 李华
网站建设 2026/9/28 23:17:34

Agentic Runtime 与 Kubernetes 编排:智能体集群化部署的运行时设计

1. 从"ax"这个标题说起&#xff1a;一个被低估的运行时编排命题第一次看到"ax"这个标题&#xff0c;配合 agentic、orchestration、runtime、Kubernetes 这几个关键词&#xff0c;我脑子里第一反应不是某个具体产品&#xff0c;而是一类正在快速成型的工程…

作者头像 李华
网站建设 2026/9/28 23:16:30

基于Dify的大模型复盘工具:自动生成结构化团队复盘报告

"记录都留着&#xff0c;却没人复盘"&#xff0c;这是我在做 hindsight 这个项目时最想解决的一件事。hindsight 的英文原意是"后见之明"&#xff0c;听着像一句抱怨&#xff0c;但真正把它做成工具之后&#xff0c;我发现它是一种被严重低估的能力——把已…

作者头像 李华