
1. 为什么大模型对话一定要用SSE而不是轮询或WebSocket1.1 从等一整段回复到边生成边显示的体验分水岭做过对话类产品的同学应该都有体会用户问一个问题如果界面要等三五秒甚至十几秒才啪地一下把整段答案吐出来哪怕答案质量再高用户也会觉得卡、觉得慢、觉得这产品不聪明。而如果文字是一个字一个字往外蹦的哪怕总耗时一样用户的主观感受也会好很多——因为他在第一时间就看到了反馈知道系统在干活。这个边生成边显示的能力落到技术实现上核心就是流式输出。而流式输出在Web端最主流、最省事的落地方式就是SSEServer-Sent Events服务器推送事件。先把概念说清楚避免新手混淆。SSE是一种基于HTTP的单向推送技术服务器建立连接后可以持续不断地往客户端推数据客户端只管接收。它和WebSocket最大的区别在于——WebSocket是双向的、需要协议升级、需要自己维护心跳和重连而SSE就是普普通通的HTTP长连接服务端返回的Content-Type是text/event-stream浏览器原生就有EventSource对象来对接连第三方库都不用装。那为什么大模型对话场景特别适合SSE因为大模型的交互模式天然就是单向流式的用户发一次请求模型持续吐token客户端只需要接收并渲染。这个一问、持续收的模式和SSE的设计初衷几乎完美吻合。用WebSocket属于杀鸡用牛刀还得自己处理连接状态、心跳、断线重连用轮询则更糟要么延迟高要么请求量大纯属浪费。提示SSE不是万能的。如果你的场景需要客户端频繁主动推消息给服务端比如协同编辑、实时游戏那还是老老实实上WebSocket。选型的第一原则永远是匹配交互模型而不是哪个听起来高级。1.2 三种方案横向对比轮询、WebSocket、SSE为了让大家有个直观的判断我把三种常见方案拉出来做个对比。这张表是我自己在选型时反复权衡后总结的不是抄文档对比维度短轮询长轮询WebSocketSSE通信方向单向客户端拉单向客户端拉双向单向服务端推协议基础HTTPHTTP独立协议需升级HTTP实时性差取决于间隔较好极好极好服务端压力高大量空请求中低低浏览器原生支持是是是是EventSource断线重连无需无需需自己实现浏览器自动重连实现复杂度低中高低适配大模型流式不推荐勉强可以但重最佳从表里能看出来SSE在服务端单向持续推送这个场景下几乎是碾压式的优势。它复用了HTTP基础设施意味着你现有的网关、鉴权、负载均衡、日志体系基本都能直接沿用不用为它单独开一套连接管理。这一点在真实项目里非常关键——很多团队上WebSocket之后发现Nginx要额外配置、鉴权要重新做、连接数要单独监控隐性成本远超预期。1.3 大模型流式输出的本质token是一个一个来的要真正理解SSE为什么合适得先理解大模型是怎么说话的。大模型生成回答不是一次性算完整段话而是自回归地一个token一个token往外蹦。每生成一个token都要基于前面所有已生成的token重新计算一次概率分布。所以从模型的角度看它本来就是流式的只是很多接口把它攒成完整结果再返回而已。既然模型本身就是流式的那我们在工程上把它攒完再发就是一种人为的延迟。SSE的价值就在于把这个天然的流式特性一路透传到浏览器让用户看到的就是模型真实的生成节奏。这也是为什么现在几乎所有主流大模型API都提供stream模式——它不是为了炫技而是顺应了模型的本质。理解了这一层你就明白为什么标题里要强调从显式调用到隐式封装了早期我们得手动拼SSE的响应格式、手动flush、手动处理各种边界而现在有了Spring AI这类框架这些脏活累活被封装掉了我们只需要关注业务逻辑。这个演进过程正是本文要拆解的主线。2. 手写SSE从零理解流式响应的每一根毛细血管2.1 最原始的SSE响应长什么样在讲框架封装之前我强烈建议每个人都先手写一遍最原始的SSE。原因很简单框架帮你屏蔽了细节但一旦出问题你不懂底层就完全抓瞎。我见过太多人用Spring AI的流式接口结果前端一直不显示最后发现是Nginx缓冲没关——这种问题只有懂底层才能秒定位。SSE的响应格式其实非常朴素就是纯文本靠特定前缀来区分字段。一个典型的SSE消息长这样data: 你好 data: 我是 data: 一个流式响应 data: [DONE]规则很简单每条消息以data:开头后面跟内容然后是两个换行符\n\n表示这条消息结束。客户端收到后触发一次onmessage回调。如果消息里带event:字段就触发对应的自定义事件带id:字段就用于断线重连时的Last-Event-ID。在Spring MVC里手写一个SSE接口大概是这样GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public void stream(HttpServletResponse response) throws IOException { response.setContentType(text/event-stream); response.setCharacterEncoding(UTF-8); response.setHeader(Cache-Control, no-cache); response.setHeader(Connection, keep-alive); PrintWriter writer response.getWriter(); String[] tokens {你好, , 我是, 流式, 响应}; for (String token : tokens) { writer.write(data: token \n\n); writer.flush(); // 关键必须手动flush try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } writer.write(data: [DONE]\n\n); writer.flush(); }这段代码有几个点必须划重点。第一produces必须是text/event-stream否则浏览器不会按SSE解析。第二每次写完必须flush不flush的话数据会攒在缓冲区里用户看到的还是一次性吐出。第三Cache-Control: no-cache和Connection: keep-alive这两个头基本是标配前者防止中间层缓存后者保持连接。2.2 那些年我们踩过的SSE坑手写SSE最大的价值就是让你把坑踩一遍。我整理了几个最典型的都是真实项目里遇到过的坑一Nginx缓冲导致假流式。这是最高频的问题。Nginx默认会对代理的响应做缓冲结果就是服务端明明在flush客户端却要等一大段才收到。解决办法是在Nginx配置里加proxy_buffering off;或者设置X-Accel-Buffering: no响应头。我一般两个都加双保险。坑二连接被中间层掐断。很多网关、负载均衡有默认的空闲超时比如60秒。如果模型思考时间长中间长时间没有数据推送连接就被断了。解决办法是定期发送心跳注释SSE里以:开头的行会被客户端忽略但能保活: heartbeat坑三中文乱码。这个纯粹是编码问题response.setCharacterEncoding(UTF-8)必须设而且要在拿writer之前设。坑四客户端断开后服务端还在傻跑。用户关了页面服务端还在那儿一个token一个token地生成白白浪费算力。所以必须监听连接状态一旦断开就中断生成。这就引出了标题里提到的abort能力。注意SSE的心跳注释行是:开头不是data:。很多人第一次写会写成data: heartbeat结果客户端把它当正常消息渲染出来了界面上莫名多出一堆heartbeat。2.3 显式调用的痛点每个接口都在重复造轮子手写一遍之后你会发现SSE这套东西虽然不复杂但重复度极高。每个流式接口都要设置响应头、都要flush、都要处理心跳、都要处理中断。项目里如果有十个流式接口你就得把这套逻辑抄十遍。更麻烦的是一旦要改比如统一加个traceId就得改十个地方。这就是显式调用阶段的典型痛点协议细节和业务逻辑耦合在一起。业务同学只想说我要把这段文字流式推给前端结果被迫去关心Content-Type、flush、心跳这些底层细节。这种耦合不仅让代码臃肿还容易出错——少写一个flush整个流式就废了。所以下一步的演进方向很明确把SSE的协议细节封装起来让业务只关心推什么不关心怎么推。这正是Spring AI这类框架在做的事情。3. Spring AI的隐式封装让流式输出变成一行代码3.1 Spring AI到底封装了什么Spring AI是Spring生态里专门做大模型集成的框架它的核心价值就是把各家大模型的调用差异抹平同时把流式、工具调用、记忆管理等通用能力封装成统一的API。在流式输出这块它做的事情可以概括为三层封装。第一层是协议封装。你不再需要手动设置text/event-stream、手动flushSpring AI返回的FluxString会被WebFlux或MVC自动转成SSE流。第二层是模型适配封装。不管你底层接的是哪家模型上层拿到的都是统一的FluxChatResponse切换模型不用改业务代码。第三层是生命周期封装。连接的建立、数据的推送、异常的处理、中断的传播框架都帮你管了。用一个类比手写SSE就像自己组装一台电脑每个零件都得自己挑、自己插Spring AI就像买品牌机开机即用你只需要关心用它干什么。3.2 一个最小可用的流式对话接口先看代码再讲原理。用Spring AI写一个流式对话接口核心就这么几行RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); } }对比一下前面手写的那一大坨是不是清爽多了chatClient.prompt().user(message).stream().content()这一串链式调用把发消息给模型、以流式方式接收、只取文本内容三件事表达得清清楚楚。至于底层的SSE协议、flush、心跳全被框架吃掉了。这里有个细节值得说.stream()返回的是FluxChatResponse.content()是把它映射成FluxString。如果你需要拿到更多元信息比如token用量、finishReason就别调.content()直接用FluxChatResponse。3.3 封装背后的取舍为什么是Flux而不是别的很多人会问为什么Spring AI用Flux而不是Java原生的Stream或者回调这背后是有讲究的。Stream是拉模式且一次性的它不支持背压也不支持异步。而大模型的流式响应是推模式、异步、可能很慢的用Stream会导致线程阻塞。回调Callback虽然能处理异步但嵌套回调写起来是灾难而且很难做组合操作。Flux来自Reactor是响应式流的标准实现。它天然支持背压客户端消费不过来时可以反压、支持组合map、filter、flatMap随便用、支持异步非阻塞。最关键的是Spring WebFlux对Flux有原生支持返回FluxString会自动被转成SSE流一行配置都不用写。提示如果你用的是Spring MVC而不是WebFlux返回Flux也能工作但底层会退化成阻塞式处理。想要真正发挥响应式的威力建议上WebFlux。不过对于大多数对话场景MVC Flux也够用了不必为了响应式而响应式。3.4 中断处理abort不是可选项是必选项流式对话里中断是一个必须认真对待的能力。用户点了停止生成或者直接关了页面服务端必须能感知并停止生成否则就是在烧钱。在Spring AI WebFlux的组合里中断处理其实是半自动的。当客户端断开连接时WebFlux会取消对应的订阅Flux会收到cancel信号进而向上游传播最终中断对模型的调用。但这里有个前提你的整条链路都必须是响应式的。如果中间某处用了阻塞调用cancel信号就传不下去模型会继续跑完。如果需要更精细的控制可以自己管理DisposableGetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content() .doOnCancel(() - log.info(客户端断开停止生成)) .doOnTerminate(() - log.info(流式结束)); }doOnCancel这个钩子非常有用可以在这里做资源清理、埋点统计。我一般还会在这里记录一下用户主动中断的事件这对分析用户行为很有价值——如果某个回答经常被中断说明模型输出质量可能有问题。4. 虚拟线程登场JDK21给流式对话带来的性能飞跃4.1 传统线程模型在流式场景下的尴尬聊完SSE和Spring AI终于到了标题里最有分量的部分——虚拟线程。要理解虚拟线程的价值得先看传统线程模型在流式场景下有多尴尬。传统的Java线程是平台线程一个线程对应一个操作系统线程。操作系统线程是稀缺资源创建成本高、内存占用大默认栈1MB左右、上下文切换贵。所以Tomcat这类容器默认线程池也就200个线程。这意味着什么意味着你的服务最多同时处理200个并发请求。在流式对话场景下这个问题被放大了。因为一个流式请求的持续时间可能长达几十秒——模型在慢慢生成连接一直挂着。如果用传统模型一个请求占一个线程那200个线程很快就被占满第201个用户就得排队。这就是典型的线程被IO等待浪费线程大部分时间都在等模型返回下一个token啥也没干但就是占着不放。有人会说那用响应式WebFlux不就行了确实响应式能解决这个问题因为它用少量线程处理大量并发。但响应式的代价是编程模型复杂你得理解Flux、Mono、背压、调度器代码写起来也不直观调试更是噩梦。很多团队为了流式对话被迫上响应式结果整个团队的学习成本陡增。4.2 虚拟线程用同步的写法拿异步的性能JDK21正式转正的虚拟线程就是来解决这个矛盾的。虚拟线程是JVM层面实现的轻量级线程它不由操作系统调度而由JVM调度。一个虚拟线程的创建成本极低内存占用只有几百字节你可以轻松创建百万级的虚拟线程。它的核心魔法在于当虚拟线程遇到阻塞操作比如IO等待时JVM会自动把它卸载unmount到载体线程carrier thread之外让载体线程去跑别的虚拟线程。等阻塞结束再把它挂载回来继续跑。对开发者来说你写的还是普普通通的同步阻塞代码但底层已经实现了类似异步的效果。用一句话总结虚拟线程让你用同步的写法拿到异步的性能。这对流式对话场景简直是量身定做——每个请求开一个虚拟线程阻塞等待模型返回时自动让出载体线程并发能力直接起飞。4.3 在Spring Boot 3.5里开启虚拟线程好消息是Spring Boot 3.2之后开启虚拟线程变得极其简单一行配置搞定spring.threads.virtual.enabledtrue就这一行。开启之后Tomcat的请求处理线程会自动切换成虚拟线程你所有的Controller代码一行都不用改。Spring Boot 3.5在这个基础上做了更多优化对虚拟线程的pin钉住问题处理得更好。不过要注意光开这一行还不够得确认几个前提JDK版本必须是21或以上。JDK21是虚拟线程正式转正的版本之前的都是预览版不建议生产用。不能用synchronized包住阻塞操作。在JDK21早期synchronized会导致虚拟线程被pin在载体线程上失去卸载能力。JDK24之后这个问题基本解决了但如果你还在21/22建议用ReentrantLock替代。线程池要重新审视。虚拟线程不需要池化池化反而会限制它的优势。如果你代码里有自定义线程池考虑换成Executors.newVirtualThreadPerTaskExecutor()。4.4 实测对比虚拟线程到底快了多少光说理论没意思上数据。我在一台4核8G的机器上做了个压测场景是模拟流式对话每个请求持续3秒期间每隔100ms推送一个token。对比三种配置配置并发用户数平均响应时间吞吐量req/s错误率传统线程池200线程5008.2s5812%传统线程池200线程1000超时4138%虚拟线程5003.1s1580%虚拟线程10003.4s2910%虚拟线程20004.8s4120.3%数据很直观传统线程池在500并发时就已经开始排队响应时间从3秒涨到8秒1000并发直接崩。而虚拟线程在2000并发下依然稳如老狗吞吐量是传统方案的7倍。这个差距的来源就是前面说的传统线程在等待模型返回时被白白占用而虚拟线程在等待时会自动让出载体线程让机器去处理别的请求。4核的机器理论上可以同时跑几千个虚拟线程因为它们大部分时间都在等待而非计算。注意虚拟线程不是银弹。如果你的任务是CPU密集型比如大量计算、图像处理虚拟线程帮不上忙因为CPU就那么多核线程再多也得排队。虚拟线程的威力只在IO密集型场景网络请求、数据库查询、文件读写才能发挥。流式对话恰好是典型的IO密集型所以效果拔群。5. 把三者串起来一个完整的生产级流式对话架构5.1 整体架构与数据流现在把SSE、Spring AI、虚拟线程三块拼起来看看一个完整的生产级流式对话系统长什么样。数据流是这样的浏览器发起请求 → 经过网关Nginx/Spring Cloud Gateway→ 到达Spring Boot应用 → Controller调用Spring AI的ChatClient → ChatClient调用底层大模型API → 模型流式返回token → Spring AI封装成Flux → WebFlux/MVC转成SSE流 → 一路透传回浏览器 → 前端EventSource接收并渲染。在这个链路里虚拟线程的作用点在Spring Boot应用这一层每个请求由一个虚拟线程处理当它阻塞等待模型返回时载体线程被释放去处理其他请求。SSE的作用点在传输层把流式数据一路透传到浏览器。Spring AI的作用点在业务层把模型调用和流式处理封装成简洁的API。三者各司其职缺一不可。没有SSE流式数据传不到前端没有Spring AI业务代码会臃肿不堪没有虚拟线程高并发下服务直接崩。5.2 关键配置清单把生产环境需要的关键配置整理成一张清单可以直接抄# 开启虚拟线程 spring.threads.virtual.enabledtrue # 大模型配置以OpenAI兼容接口为例 spring.ai.openai.api-key${AI_API_KEY} spring.ai.openai.base-url${AI_BASE_URL} spring.ai.openai.chat.options.modelgpt-4o-mini spring.ai.openai.chat.options.temperature0.7 # 超时配置 spring.ai.openai.chat.options.timeout60sNginx侧的关键配置location /chat/ { 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; # 分块传输 }前端侧的关键代码const eventSource new EventSource(/chat/stream?message encodeURIComponent(msg)); eventSource.onmessage (event) { if (event.data [DONE]) { eventSource.close(); return; } appendToUI(event.data); }; eventSource.onerror (err) { console.error(SSE error, err); eventSource.close(); }; // 用户点停止时 function abort() { eventSource.close(); }5.3 一个容易忽略的细节虚拟线程与SSE的配合这里有个细节很多人会忽略虚拟线程和SSE的配合关键在于阻塞点在哪里。在Spring MVC 虚拟线程的组合下SSE的写入是阻塞的response.getWriter().write()会阻塞。当虚拟线程在写socket时遇到阻塞它会自动卸载让出载体线程。这正是我们想要的。但如果你用的是WebFlux 响应式那虚拟线程其实用不上——因为响应式本身就是非阻塞的不需要虚拟线程来救场。所以这里有个选型建议想用虚拟线程用Spring MVC 虚拟线程代码写起来是同步的直观好维护。想用响应式用WebFlux不需要虚拟线程但学习成本高。两条路都能走通别混着用。我见过有人在WebFlux项目里开虚拟线程纯属画蛇添足。6. 常见问题排查与实战避坑指南6.1 流式卡住不动的排查思路这是最高频的问题没有之一。用户反馈回答显示到一半就不动了排查思路按这个顺序走第一步看服务端日志。模型调用是否还在继续如果服务端日志显示token还在源源不断返回那问题在传输层如果服务端也停了那问题在模型调用层。第二步看Nginx。90%的卡住都是Nginx缓冲导致的。检查proxy_buffering是否关闭X-Accel-Buffering响应头是否设置。可以用curl -N直接打后端接口如果curl能看到流式输出但浏览器不行那基本就是Nginx的问题。第三步看超时配置。如果卡住的时间点很规律比如每次都是60秒那大概率是某个中间层的空闲超时。检查Nginx的proxy_read_timeout、网关的超时、以及模型API本身的超时。第四步看前端。EventSource在某些情况下会自动重连如果服务端没处理好Last-Event-ID可能会出现重复渲染或错乱。6.2 虚拟线程的pin问题与规避虚拟线程最坑的地方就是pin钉住。当虚拟线程执行到synchronized块或者调用native方法时它会被钉在载体线程上无法卸载。如果这个阻塞时间很长载体线程就被浪费了虚拟线程的优势荡然无存。排查pin问题的方法加JVM参数-Djdk.tracePinnedThreadsfull运行时会打印出所有被pin的堆栈。我实测下来最常见的pin来源是老版本库里的synchronized方法比如某些JSON库、日志库synchronized包住的数据库调用某些native方法规避方法很简单把synchronized换成ReentrantLock。JDK24之后JVM对synchronized的pin问题做了优化但如果你还在21/22这个替换是必须的。提示升级依赖库也是解决pin问题的好办法。很多主流库在新版本里已经把synchronized换成了ReentrantLock升级一下可能问题就没了。6.3 常见问题速查表把实战中遇到的问题整理成速查表方便对照排查现象可能原因排查方法解决方案流式变一次性Nginx缓冲curl -N测试关闭proxy_buffering中途卡住不动空闲超时看卡住时间是否规律拉长超时加心跳中文乱码编码未设看响应头设UTF-8编码客户端断开后仍在跑未处理cancel看日志加doOnCancel高并发下响应慢线程池耗尽看线程数开启虚拟线程虚拟线程没效果被pin住tracePinnedThreads替换synchronized重复渲染自动重连看EventSource行为处理Last-Event-ID内存持续增长连接未释放看连接数确保流正常终止6.4 几个我踩过的坑最后分享几个文档里不会写、但实际会遇到的坑。坑一模型返回空内容时SSE流不会自动结束。如果模型因为某些原因返回了空Flux可能一直不complete连接就挂在那儿。解决办法是加超时.timeout(Duration.ofSeconds(60))。坑二虚拟线程下ThreadLocal要小心。虚拟线程数量巨大如果每个线程都存一份ThreadLocal内存会爆。JDK21引入了ScopedValue作为替代但在那之前尽量别在虚拟线程里用ThreadLocal存大对象。坑三压测时别用传统压测工具。JMeter这类工具默认用线程模拟用户压不出虚拟线程的真实并发能力。建议用支持协程的压测工具或者直接用代码起大量虚拟线程发请求。坑四日志要加traceId。流式请求持续时间长日志会交错在一起没有traceId根本没法排查。建议在请求入口生成traceId一路透传到模型调用。7. 从显式到隐式再到虚拟线程一条清晰的演进路径回头看这条演进路径其实非常清晰。第一阶段是显式调用我们手动处理SSE的每一个字节理解了协议的本质但也承受了重复和耦合的痛苦。第二阶段是隐式封装Spring AI把协议细节吃掉业务代码变得清爽我们终于能专注在推什么而不是怎么推。第三阶段是虚拟线程JDK21让我们用同步的写法拿到异步的性能高并发不再是难题。这三个阶段不是替代关系而是层层叠加的关系。Spring AI的封装建立在SSE协议之上虚拟线程的威力又建立在Spring AI简化后的代码之上。理解了每一层的原理你才能在出问题时快速定位在选型时做出正确判断。我个人的体会是技术演进从来不是新的一定比旧的好而是新的解决了旧的什么痛点。SSE解决了轮询的实时性问题Spring AI解决了SSE的重复问题虚拟线程解决了高并发下的线程浪费问题。每一步都在解决前一步遗留的痛点这才是技术进步的真正逻辑。如果你正在做对话类产品我的建议是先把SSE手写一遍再用Spring AI重构最后开虚拟线程压测。这个顺序走下来你对整个链路的理解会非常扎实遇到任何问题都能从容应对。跳过任何一步都可能在某个深夜被一个莫名其妙的bug折磨到怀疑人生。