SSE与大模型流式输出:从SseEmitter到EventSource实战指南 你可能在某个大模型对话界面里见过那种“打字机效果”回答不是一口气完整地蹦出来而是几个字几个字地往外吐伴随着光标闪烁看得人挺舒服。这个体验背后用的就是SSEServer-Sent Events服务端推送事件。SSE跑起来大概是这样前端打开一个连接挂在服务端服务端把数据切成小块按时间顺序一块一块往客户端推推到哪显示到哪。大模型的接口天然就是逐字生成回答的输入一段Prompt模型从“头一个字”开始往后推理这一过程跟SSE简直是天作之合。很多人第一次以为这种流式输出必须上WebSocket其实不然。SSE更轻、更简单还支持断线自动重连在没有双向通信刚需的场景里是比WebSocket更合适的选择。这篇文章我把一个完整的大模型流式输出项目从零到跑通讲清楚。你会看到服务端怎么把大模型一帧一帧吐出来的数据转成SSE事件前端怎么用EventSource和fetch两条路线消费这些事件以及我在实际调试中踩过的那几个坑——比如首字迟迟不出来、乱码、断线不重连这类真问题。适合正在做AI应用、聊天机器人之类项目的同学参考。1. 为什么大模型流式输出绕不开SSE1.1 先搞懂SSE和大模型流式的对应关系SSE是HTML5里定义的一种服务端推送协议。它的通信方式非常朴拙客户端用EventSource发起HTTP请求服务端把响应头里的Content-Type设为text/event-stream然后就往这条HTTP连接上持续写数据块。每个数据块以data:开头后跟具体内容块与块之间用两个换行符\n\n分隔。客户端每收到一个data:就会触发一次消息事件把数据取走。就这么简单。那大模型的流式输出是什么形态呢以目前主流的大模型API兼容接口为例你发送一个带stream: true参数的请求模型返回的不再是一个完整的JSON而是一串连续的数据行每行都是一个data: {JSON片段}的格式最后以data: [DONE]收尾。这些JSON片段每次只携带几个token的增量信息拼起来就是完整回答。也就是说模型接口本身就流式地在吐数据你要做的就是在中间架一层SSE管道把这些增量实时转发给浏览器。所以你看这个过程里SSE干的活特别纯粹它不需要像WebSocket那样先握手升级协议也不需要你写多余的前后端消息格式只要两边都认text/event-stream这个规范数据就能像水龙头一样一直往外流。1.2 为什么选SSE而不是WebSocket我做这个项目之前也有个误区以为所有“服务端主动推数据”的场景都得用WebSocket。实际对比下来SSE在这个场景里有几个很明显的优势。第一开销小。SSE基于普通的HTTP不需要像WebSocket那样经过握手、协议升级、帧编解码这些步骤。如果只是服务端单向推送SSE的字节数和请求成本都更省。第二天生带重连和断点续传。EventSource内置了断线自动重连机制而且只要你返回的事件里带上id字段重连时浏览器会自动把Last-Event-ID请求头发给服务端服务端可以根据这个ID跳出错过的数据重新推给你。WebSocket要实现这层逻辑全都得自己写。第三也是比较现实的一点大模型的推理过程是“不可中断的串行生成”客户端并不需要一边看一边发消息改参数单向推送完全够用。既然不用双向通信就没必要搬出WebSocket那套重型武器。这不是说WebSocket一文不值。如果你做的是需要双方频繁交互的实时协作编辑器、在线白板或游戏对战WebSocket还是要上的。但就“把大模型生成的内容流式展示给用户”这个需求而言SSE是性价比最高的方案。1.3 SSE的协议格式别再只盯着data字段了很多教程里只写了data:这一个字段但SSE协议其实定义了四个字段实际场景里都很管用。data推送的消息内容。可以有多行多行会被合并成一个完整消息体中间以换行符相连。id事件ID。用于客户端断线重连时通过Last-Event-ID告诉服务端“你从哪开始续传我”。event自定义事件类型。默认是message如果指定了event: foo前端就需要用addEventListener(foo, handler)来监听。retry重连间隔毫秒。不设置的话浏览器默认大概3秒。字段与字段之间用单个换行分割事件与事件之间用空行即两个换行符分割。写后端代码的时候很多人栽在格式上。比如只写了data: xxx就完事忘了结尾的\n\n就会导致前端迟迟不触发message事件再比如发的是中文内容但没注意编码前端收到的全是乱码。这些细节后面实操环节我会专门展开。2. 服务端实现从SseEmitter到可落地的流式接口2.1 准备工作与核心依赖服务端我以Java Spring Boot为例因为企业里的老项目大多跑在这个框架上SSE支持也比较方便。Spring从4.2版本开始引入了一个叫SseEmitter的类它就是专门用来干这事的——你可以把它理解成一个挂在服务端的“响应通道”往里面塞数据数据就沿着HTTP响应流推给客户端。项目中需要引入的基础依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency注意如果项目里本来就有spring-boot-starter-web再加WebFlux的时候可能会因为WebMVC和WebFlux的自动配置冲突导致启动失败。我实操下来最省心的做法是只是写SSE接口的话仅保留starter-web就够了不需要引入WebFlux。WebFlux仅在你要用响应式WebClient以非阻塞方式去调大模型接口时才有意义普通场景下同步调用加并发控制完全没问题。还需要一个HTTP客户端去调大模型接口。我推荐用适合当前版本JDK的Java原生HttpClient不需要额外引入依赖。老项目里如果还在用RestTemplate也能跑但要注意它的流式响应处理对text/event-stream格式支持一般需要额外读取ResponseBody的InputStream有点费劲。2.2 设计对接大模型的流式服务端先看整体代码结构。我需要写一个Controller接口路径上指定produces为text/event-stream;charsetUTF-8返回类型就是SseEmitter。RestController RequestMapping(/api/sse) public class ChatStreamController { private final ChatService chatService; public ChatStreamController(ChatService chatService) { this.chatService chatService; } GetMapping(path /chat, produces text/event-stream;charsetUTF-8) public SseEmitter chat(RequestParam(message) String message) { SseEmitter emitter new SseEmitter(180_000L); chatService.streamChat(message, emitter); return emitter; } }这个接口收到前端的请求后立刻创建一个SseEmitter返回给Spring容器。Spring拿到这个返回对象会意识到它是个异步响应载体于是让出当前Servlet线程等待后续往这个Emitter里塞数据。在ChatService里我们开辟一个线程去调大模型接口拿到流式数据再一块一块地往Emitter里发。这里有一个容易踩的坑不要在每个chunk里一股脑塞原始的大模型返回体。模型接口返回的事件流你当然可以原样转发但那样前端就要解析两层的SSE格式而且会给前端泄漏一些无用的元信息。更合理的做法是——服务端解析大模型的流式事件只把增量文本部分提取出来封装成前端真正需要的JSON结构再通过SSE推给前端。这样前端拿到的就是一个干净、直接可渲染的数据格式。2.3 把字节流转换成SSE事件再推给前端我写一个轻量级的处理线程用它来处理大模型返回的流式数据。这样即使以后换别的模型平台也只需要替换这一个处理器。Component public class OpenAiSseProcessor { public void processAndPush(BufferedReader reader, SseEmitter emitter) throws IOException { String line; while ((line reader.readLine()) ! null) { // 空行忽略事件分隔符 if (line.isEmpty()) { continue; } if (line.startsWith(data:)) { String data line.substring(5).trim(); if ([DONE].equals(data)) { emitter.send(SseEmitter.event() .name(done) .data({\finish\:true})); break; } // 这里按自己的业务从JSON中提取增量文本 String delta extractDelta(data); if (delta null || delta.isEmpty()) { continue; } // 自定义一个事件类型方便前端区分普通内容和结束信号 emitter.send(SseEmitter.event() .id(UUID.randomUUID().toString()) .name(message) .data({\content\:\ escapeJson(delta) \})); } } emitter.complete(); } private String extractDelta(String jsonStr) { // 解析JSON取出choices[0].delta.content字段这里略作简化 return JsonPath.parse(jsonStr) .read($.choices[0].delta.content, String.class); } }几个细节值得解释。emitter.send(SseEmitter.event().id(...).name(...).data(...))这一串链式调用的意思是我要构造一个完整SSE事件包含自定义事件ID和事件名。事件名用message而不用默认是给前端一个语义信号前端addEventListener(message, ...)接收结束时发一个done事件前端拿到后就知道流已经结束。为每个增量包都配上ID则是为了让浏览器重连时能带上Last-Event-ID做断点续传。另一个必须提的是escapeJson方法。因为大模型吐出来的文本可能包含双引号、换行、反斜杠等字符如果直接拼接进JSON字符串很容易拼出一个非法JSON前端JSON.parse直接报错。我用的方案是引入一个JSON序列化工具把delta先做成对象再序列化成字符串而不是手动拼JSON。比如用Jackson的ObjectMapper把MapString, Object序列化后作为data内容这样既保证JSON合法又不用自己处理转义。2.4 超时、并发和线程释放问题SseEmitter创建时可以传超时时间比如上面写的180秒。大模型推理时间不定一般长文本生成完全可能超过60秒默认的超时配置通常不够用。所以你需要在配置里把这个值调大或者干脆在场景允许的情况下不设超时让它一直挂到模型流结束。但超时不是一个简单的“设大就好”。如果服务端进程被重启或者反向代理层比如Nginx有默认的读超时即使SseEmitter内部没超时整个连接也可能被中间层掐断。所以生产环境里一个常规做法是加心跳每15到20秒推送一个注释行SSE规范里注释行以冒号开头比如: heartbeat既能让连接保持活跃也不会触发前端的业务逻辑。线程释放也值得注意。虽然SseEmitter是异步的但如果你在processAndPush里用的是同步阻塞方式读大模型的InputStream那么每个流式请求都会占一个工作线程。如果同时有几百个用户并发线程池会迅速被打满。我踩过一次这个坑——某次压测并发到100路时Tomcat线程池直接耗尽其他普通接口跟着遭殃。解决办法有两个方向一是把processAndPush换成非阻塞的异步写法比如WebClient接入二是给线程池单独隔离配置设一个合理的最大并发数防止被流式请求拖垮整个应用。3. 前端消费EventSource与fetch两条路怎么选3.1 EventSource最省事但有三个限制如果后端允许用GET方法提供SSE接口前端最简单的接入方式就是EventSource。const eventSource new EventSource(/api/sse/chat?message encodeURIComponent(msg)); eventSource.addEventListener(message, (event) { const data JSON.parse(event.data); // 把data.content追加到对话区域 }); eventSource.addEventListener(done, () { eventSource.close(); }); eventSource.onerror (err) { // 网络异常会触发EventSource也会自动重连 console.error(SSE连接异常, err); };EventSource的好处是重连、ID续传、事件类型这些机制浏览器全给你内置了你几乎不用写任何连接管理代码。但它也有几个限制在对接大模型场景下特别突出。第一EventSource只能用GET方法发请求。如果你想往服务端传一大段Prompt或者传一些敏感参数如用户身份tokenGET的URL长度限制和日志暴露风险都可能成为问题。第二无法自定义请求头。EventSource的请求是浏览器直接发的你没法在上面加Authorization之类的鉴权头。如果你的服务端接口需要鉴权就得换用别的方案。第三如果你用某些代理或CDNEventSource的默认重连劫了好几次都劫不对那大概率是因为事件流的头没设置对。3.2 fetch ReadableStream 实现流式消费需要传POST body或者自定义请求头时走fetch这条路更合适。关键思路是用fetch发起请求拿到响应体的ReadableStream然后不停地从里面读取数据块解析出SSE格式的消息。async function streamChat(message) { const response await fetch(/api/sse/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer xxx }, body: JSON.stringify({ message }) }); if (!response.ok) { throw new Error(请求失败: response.status); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按SSE事件分隔符双换行切分完整事件 const events buffer.split(\n\n); buffer events.pop(); // 最后一个可能不完整留到下一轮 for (const rawEvent of events) { processRawEvent(rawEvent); } } }这个方案的核心在split(\n\n)的处理。因为一次read()拿到的字节块可能只包含半个事件也可能包含好几个完整事件我们需要维护一个buffer把不完整的那部分留在手里和下次读取的数据拼接后再解析。如果你不这么做直接按chunk解析大概率会看到断成一截一截的内容甚至JSON解析报错。3.3 手写一个无依赖的SSE解析函数很多前端项目里没有引入SSE解析库其实解析逻辑完全可以手写。我贴一下自己用的一个小工具足够处理常见的data:格式。function parseSSEChunk(text) { const lines text.split(\n); const dataLines []; let eventName message; for (const line of lines) { if (line.startsWith(:)) { // 注释行忽略 continue; } if (line.startsWith(event:)) { eventName line.slice(6).trim(); } else if (line.startsWith(data:)) { dataLines.push(line.slice(5).trim()); } else if (line.startsWith(id:)) { // 业务上暂时不需要记录ID时可以先忽略 } } if (dataLines.length 0) { return null; } return { event: eventName, data: dataLines.join(\n) }; }用的时候把上一节processRawEvent里收到的字符串扔进这个函数返回的就是一个带event和data的对象。然后判断event类型分别处理就好。这里面有个常见问题服务端一次如果推了多行data:按SSE规范它们应该合并成一个事件中间以换行符连接。我上面用join(\n)处理了。如果你的服务端每次只推一行data那这条逻辑也不会有负面影响。3.4 边接收边渲染的小技巧流式内容到手之后怎么渲染也有一点讲究。最朴素的做法是每收到一个增量就把content追加进已有的文本末尾然后更新innerHTML或textContent。实测下来频繁操作DOM是最大的性能瓶颈。我的建议是不要在每次message事件里直接操作DOM而是把增量先拼进一个缓存字符串用requestAnimationFrame做节流统一批量更新。一秒钟最多更新大概几十次就够视觉上完全不会觉得卡CPU和内存开销也能显著降下来。还有一个刚需是Markdown渲染。如果大模型返回的是Markdown文本直接把它追加到DOM里会导致整段重新渲染有可能引发闪烁、滚动条跳动。我见过一个比较稳的做法是先只追加纯文本等流结束后再统一做一次Markdown渲染或者在流的末尾基于最终文本重新渲染。如果一定要实时渲染Markdown那就尽量让渲染函数对增量做“整体重渲染 合并旧文本”不要一个字符一个字符地调用Markdown解析器。4. 常见毛病与排查实录4.1 首字迟迟不出现八成是缓冲问题接好了前后端结果发现点发送之后要等好几秒屏幕上才蹦出第一个字或者干脆一个字都看不到等全部生成完才一次性显示。绝大多数情况下这是缓冲在作祟。应用服务器、反向代理Nginx、甚至浏览器中间的任何一层都可能开启缓冲它会把还没积累到一定量的数据攒着等到满了或者连接关闭才往外发。排查思路是这样的第一检查后端有没有正确flush。在Java的底层流式输出中每写完一块数据必须调用flush()否则数据滞留在应用服务器的缓冲区里。Spring的SseEmitter底层用的是ServletOutputStream每次send()后Spring一般会自动flush但如果你自己直接操作OutputStream就得注意。第二检查Nginx的缓冲配置。在Nginx代理SSE接口的location块里至少要加这两个配置location /api/sse/ { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ; proxy_http_version 1.1; }proxy_buffering off是核心。它告诉Nginx不要攒数据后端推一个块就转发一个块。proxy_read_timeout设成300秒以上是为了防止长推理过程中Nginx判定上游超时直接断连接。如果你用的是云厂商的负载均衡器也要去控制台把“响应缓冲”或“压缩”相关的开关关掉。4.2 中文乱码或者内容被截断这个问题的根源在“编码不一致”和“字节块切割错位”。编码方面请务必确保三处都是UTF-8HTTP响应头的Content-Type里带charsetUTF-8SSE的数据就是UTF-8编码的文本前端用TextDecoder(utf-8)解码。字节块切割错位则是一个很隐蔽的坑。大模型返回的某个字符可能是多字节的比如一个汉字由三个字节组成如果这些字节恰好分散在两个网络包里你按UTF-8解码时可能解出一个乱码字符。解决方法是前面提到过的解码时使用{ stream: true }参数让TextDecoder正确处理跨块的多字节字符不要简单地把每个块独立解码再拼起来。const decoder new TextDecoder(utf-8); const text decoder.decode(value, { stream: true });这个{ stream: true }会让解码器把未完成的多字节字符暂存在内部缓冲区等下一个块到来时再继续解码从根源上避免截断乱码。顺便说一句如果你用Node.js做服务端转发同样的问题也存在建议把Buffer的toString(utf8, start, end)统一改成StringDecoder来处理。4.3 断线后不重连重连逻辑不是开了就完事默认情况下EventSource断线后确实会自动重连但很多项目接完发现断了就不动了或者重连回来内容接不上。第一个常见原因是后端没给SSE事件设置id字段。没有id浏览器重连时就不会带Last-Event-ID头服务端也就不知道从哪里继续推只能从头开始。如果模型已经生成到一半从头开始推就会导致内容重复。第二个原因是后端主动complete()之后EventSource会看到连接正常结束它不会重连。比如服务端在模型推完最后一个字后立即调用了emitter.complete()这是正常的业务结束浏览器不会重连。所以如果你的预期是“连接保持到用户离开”就不要过早complete。我常用的一个套路是业务结束后发送一个带done事件名的信号然后complete()关闭连接前端收到done后主动eventSource.close()。如果连接是被网络切断的非正常结束EventSource会自动触发重连重连时带上Last-Event-ID。为了让这套逻辑真正生效我在服务端会把每个事件的ID设为递增序号或消息校验值而不只是随机UUID——随机UUID对断点续传没有意义服务端哪里知道该从哪个UUID之后开始推。4.4 并发高一点线程就被打满这是性能问题里最常见的一个。Java的Tomcat默认工作线程数在200左右如果你300个用户同时在等模型流式回复每个流式请求压一个线程那Tomcat线程池马上会被占光其他普通HTTP接口也会跟着卡死。我操过的一个方案是给流式接口单独设一个线程池并限制最大并发数。比如固定一个ExecutorService核心线程20最大线程50队列容量100超过就快速失败返回提示“当前排队人数较多请稍后再试”。这样既保护了整体服务也给了用户一个明确的排队反馈。如果并发需求很高且技术栈允许可以考虑把流式接入这块拆成独立的Stream Service部署或者用Spring WebFlux写一个响应式版本。响应式模型的优势在于流式请求本身不占线程可以用少量线程支撑大量连接。代价是写法和调试门槛高一些。我的建议是先评估真实的用户量别一上来就上WebFlux。在大多数内部系统、中型应用里线程池隔离方案已经完全够用了。4.5 一手避坑经验速查表现象常见原因解决方向首字迟迟不出、全部一起返回某层缓冲未关Nginx关proxy_buffering应用层加flush前端收到乱码编码不一致或跨块截断统一UTF-8TextDecoder加stream模式断线后不重连/内容重复缺少事件ID或重连无续传使用递增ID后端按Last-Event-ID续传连接总在60秒左右被切断网关/代理超时配置过短调大proxy_read_timeout加SSE心跳并发高时普通接口变慢流式请求占满线程池独立线程池隔离设最大并发上限服务重启后前端卡住不动连接被被动断开业务上做好重连与自动恢复可提示刷新这里再送一个我后来一直沿用的经验线上的SSE接口一定要加心跳注释。即使你设了很长的超时时间网络层里那些看不见的中间设备也可能把空闲连接收走。我是在服务端专门起了个定时任务每15秒往连接里写一个注释事件格式很简单: heartbeat这种只有冒号开头的注释事件浏览器收到后不会触发任何message事件所以对前端业务完全无感它就是纯纯的“保活信号”。加上之后我再没遇到过长连接无端被收走导致前端长时间无响应的情况。写在最后的一点体会这个项目做完之后我最大的感受是SSE这种东西看着文档很简单真正上生产时坑全藏在细节里。协议格式错一点某个字段漏写缓冲忘关编码不一致每一个小问题都能让你排查到怀疑人生。我自己的习惯是如果条件允许先在后端用命令行工具直接测接口的响应亲手看看事件流的原始报文长什么样再去排查前端代码。你可以先拿某个通用前端调试工具里的命令直接打一下流式接口观察每一行数据是否符合SSE规范。格式对了前端解析就是一个标准化流程格式不对前端再怎么写都是白费。后续如果还想继续扩展可以在这个项目上加几个东西给SSE事件加额外字段做上下文状态透传、把流式内容持久化时按块落库、做一套基于事件的消费统计。总之这套流式管道搭好之后能延展的玩法还挺多的你在自己项目里试过就知道了。