AI流式输出为何偏爱SSE?协议原理、实战踩坑与选型指南
1. 为什么 AI 应用绕不开 SSE 这条“单行道”如果你最近半年写过任何跟大模型沾边的代码不管是接 API 做个聊天框还是搭一套 Agent 工作流你大概率已经跟 SSE 打过照面了。它不像 HTTP 那样天天被挂在嘴边也不像 WebSocket 那样自带“实时通信”的光环但偏偏在 AI 这个圈子里SSE 成了那个最不起眼却最普遍的协议。你打开任何一个主流大模型的流式接口文档返回格式那一栏写的几乎都是text/event-stream这就是 SSE。先把概念说清楚。SSE 全称 Server-Sent Events中文一般叫“服务器推送事件”。它做的事情非常单一让服务器通过一条已经建立的 HTTP 连接持续不断地往客户端单向推送文本数据。注意关键词——单向。客户端发一次请求服务器就开始源源不断地吐数据直到它主动关闭或者连接超时。这个特性放在传统 Web 开发里可能显得有点鸡肋因为大部分场景需要双向通信但放在 AI 对话这个场景里它简直是为流式输出量身定做的。为什么这么说你想想大模型生成回答的过程。用户问一个问题模型不是瞬间把整段答案算好再返回而是一个 token 一个 token 地往外蹦。如果不用流式用户就得盯着一个空白页面等十几秒甚至几十秒体验极差。而用了 SSE模型每生成一小段服务器就立刻推给前端用户看到文字像打字机一样一个个冒出来心理上会觉得“它在思考、它在回应”等待焦虑瞬间被化解。这就是 SSE 在 AI 时代翻红的核心原因——它用最低的成本解决了流式输出的传输问题。再往深一层看SSE 之所以比 WebSocket 更适合 AI 场景还有一个容易被忽略的原因基础设施兼容性。WebSocket 需要协议升级握手很多企业网关、负载均衡、CDN 对它支持得并不好动不动就断连或者被拦截。而 SSE 本质上就是一个普通的 HTTP 长连接Content-Type 设成text/event-stream就行现有的 HTTP 基础设施几乎不用改就能跑。对于要快速上线 AI 功能的团队来说这个优势太致命了。你不需要运维去调网关配置不需要前端引入额外的 WebSocket 库一个EventSource对象或者一行fetch就能搞定。当然SSE 也不是没有短板。它是单向的客户端没法通过同一条连接给服务器发消息它是纯文本的二进制数据得自己编码它还有连接数限制浏览器对同域名的 SSE 连接数通常卡在 6 个左右。但这些限制在 AI 对话场景里基本不构成障碍因为对话本身就是“一问一答”的节奏用户发消息用普通 POST 请求服务器回消息用 SSE 推送两者配合得天衣无缝。所以你看不是 SSE 有多完美而是 AI 这个场景刚好把它的缺点都避开了把它的优点都放大了。2. SSE 协议格式拆解那些文档里不会细说的字段很多人用 SSE 就是直接调库EventSource一挂onmessage一写能收到数据就完事。但如果你想在 AI 场景里处理各种边界情况比如断线重连、错误恢复、多事件类型区分那就必须把 SSE 的协议格式吃透。这一节我把 SSE 的数据帧格式掰开揉碎讲一遍这些都是实战里踩过坑才会真正理解的东西。2.1 一条 SSE 消息的完整结构SSE 的传输格式其实非常简单它规定服务器推送的每条消息由若干行文本组成行与行之间用换行符分隔一条消息以两个连续换行符结束。每一行都有固定的字段前缀常见的有四种data:后面跟的是消息正文这是最核心的字段可以有多行多行 data 会被拼接成一条消息中间用换行符连接。event:用来指定事件类型前端可以通过addEventListener监听特定类型的事件而不是只能走onmessage。id:给这条消息设置一个标识符浏览器在断线重连时会自动带上Last-Event-ID请求头服务器可以根据这个 ID 决定从哪继续推。retry:告诉浏览器重连的等待毫秒数比如retry: 3000就是断线后等 3 秒再重连。一个典型的 AI 流式响应长这样event: message id: 42 data: {choices:[{delta:{content:你}}]} data: {choices:[{delta:{content:好}}]} data: [DONE]注意最后那个data: [DONE]这是 OpenAI 系接口的约定用来告诉客户端流结束了。很多新手写代码时忘了处理这个标记结果前端一直在等下一条消息界面上的加载动画转个不停。这个坑我后面还会细说。2.2 为什么 AI 接口偏爱 JSON 塞进 data 字段你会发现几乎所有大模型的 SSE 接口data:后面跟的都不是纯文本而是一段 JSON。为什么不直接推文本因为流式响应需要携带的信息远不止内容本身。以 OpenAI 的格式为例每个 chunk 里除了delta.content还有finish_reason、index、role等字段。用 JSON 包裹可以保证结构清晰前端解析时按字段取值不容易出错。但这里有个细节值得注意不同厂商的 JSON 结构差异很大。OpenAI 用choices[0].delta.contentAnthropic 用delta.text国内一些模型用output.text或者直接content。如果你要做一个兼容多家模型的网关就必须在服务端做一层适配把不同格式统一成自己的内部格式再推给前端。否则前端代码里会塞满if vendor openai这种判断维护起来非常痛苦。2.3 换行符的坑\n和\r\n都能用但别混SSE 规范里说行分隔符可以是\n、\r或者\r\n浏览器解析时都能识别。但实际开发中如果你自己手写服务端推送逻辑最好统一用\n。我见过一个案例服务端在 Windows 环境下拼接字符串时用了\r\n结果某些前端库解析时把\r当成了数据的一部分导致 JSON 解析失败。这种问题排查起来极其费劲因为抓包看到的字节流“看起来”是对的但解析就是报错。统一用\n能省掉很多麻烦。另外data:后面如果跟的是空字符串也就是data:\n\n这会被解析成一条空消息。有些服务端在心跳保活时会发这种空消息前端收到后如果直接JSON.parse就会抛异常。所以前端处理逻辑里一定要先判断内容是否为空再决定要不要解析。3. 从零手写一个 SSE 服务端Java 和 Node 两种实现对比光讲协议不够直观这一节我直接带你手写 SSE 服务端。选 Java 和 Node 两个版本是因为这两种技术栈在实际项目里最常见。Java 那边 Spring 生态有SseEmitterNode 那边原生http模块就能搞定。我会把关键代码和背后的设计考量都讲清楚你照着抄就能跑。3.1 Java 方案Spring 的 SseEmitter 到底怎么用Spring MVC 从 4.2 开始提供了SseEmitter用起来相当顺手。核心思路是Controller 方法返回一个SseEmitter对象Spring 会保持这个请求的连接不关闭你可以在任意线程里往这个 emitter 里send数据。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestParam String question) { SseEmitter emitter new SseEmitter(180_000L); // 3分钟超时 executor.execute(() - { try { // 模拟调用大模型逐段返回 ListString chunks callLLM(question); for (String chunk : chunks) { emitter.send(SseEmitter.event() .data(chunk, MediaType.APPLICATION_JSON)); Thread.sleep(50); // 模拟生成间隔 } emitter.send(SseEmitter.event().data([DONE])); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }这段代码有几个关键点必须注意。第一超时时间一定要设默认值可能只有 30 秒大模型生成长回答很容易超时我一般设 3 到 5 分钟。第二emitter.send是线程安全的但如果你在多个线程里同时 send顺序无法保证所以最好在单线程里顺序推送。第三complete()和completeWithError()必须调用否则连接会一直挂着时间长了会耗尽线程池。还有一个隐藏的坑Spring 的SseEmitter在底层用的是异步 Servlet如果你的项目里配了过滤器或者拦截器可能会在异步 dispatch 时出问题。我遇到过一次一个自定义的日志过滤器在异步请求二次 dispatch 时抛了IllegalStateException原因是它试图读取已经关闭的输入流。解决办法是在过滤器里判断request.isAsyncStarted()如果是异步请求就直接放行。3.2 Node 方案不引库二十行代码搞定Node 原生http模块实现 SSE 更简单因为 Node 本身就是事件驱动、流式处理的模型跟 SSE 的气质天然契合。const http require(http); http.createServer((req, res) { if (req.url /chat/stream) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no // 关键禁用 Nginx 缓冲 }); let count 0; const timer setInterval(() { if (count 10) { res.write(data: [DONE]\n\n); clearInterval(timer); res.end(); return; } const chunk JSON.stringify({ content: 片段${count} }); res.write(data: ${chunk}\n\n); count; }, 200); req.on(close, () { clearInterval(timer); console.log(客户端断开连接); }); } }).listen(3000);这段代码里最值得说的是X-Accel-Buffering: no这个响应头。如果你用了 Nginx 做反向代理默认情况下 Nginx 会缓冲响应数据导致 SSE 消息被攒成一批才发给客户端流式效果完全消失。加上这个头就是告诉 Nginx 别缓冲来一条发一条。这个坑我在生产环境踩过本地测试一切正常一上服务器就变成“等半天然后一次性全出来”排查了大半天才定位到 Nginx 配置。另外req.on(close)里的清理逻辑必不可少。客户端关闭页面或者网络断开时服务端的定时器如果不清理就会一直空转时间长了内存泄漏。Node 里这种问题特别隐蔽因为进程不会崩只是内存慢慢涨等你发现时已经跑了好几天了。3.3 两种方案的选型建议对比维度Java SseEmitterNode 原生 http上手难度需要理解 Spring 异步机制直接操作 res 对象更直观线程模型每个连接占一个线程可配异步单线程事件循环天然高并发生态集成跟 Spring Security、拦截器无缝配合需要自己处理鉴权、日志适用场景已有 Java 后端体系快速接入轻量级服务、BFF 层、原型验证我的经验是如果你的团队主栈是 Java直接用SseEmitter别为了 SSE 单独起一个 Node 服务运维成本不划算。反过来如果你在做前端 BFF 层或者快速验证一个 AI 想法Node 方案更轻更快二十行代码就能跑起来。4. 前端消费 SSEEventSource 和 fetch 的取舍服务端推得再溜前端接不住也是白搭。消费 SSE 有两种主流方式浏览器原生的EventSource和基于fetch的手动流读取。两者各有适用场景选错了会在某些边界情况下吃大亏。4.1 EventSource省心但不够灵活EventSource是浏览器内置的 SSE 客户端用法极其简单const es new EventSource(/chat/stream?question你好); es.onmessage (event) { if (event.data [DONE]) { es.close(); return; } const chunk JSON.parse(event.data); appendToChat(chunk.content); }; es.onerror (err) { console.error(SSE 错误, err); // EventSource 会自动重连但 AI 场景下重连可能重复请求 };它的优点是自动重连、自动处理Last-Event-ID你几乎不用管底层细节。但缺点也很明显第一它只能发 GET 请求没法带复杂的请求体你只能把参数塞在 URL 里长文本问题很容易超长。第二它不能自定义请求头如果你的接口需要Authorization鉴权EventSource无能为力。第三它的自动重连在 AI 场景下可能是灾难——连接断了它自动重发请求模型又重新生成一遍用户看到重复内容。所以EventSource只适合那种简单的、无需鉴权的、GET 就能搞定的场景。一旦涉及 POST 请求体或者自定义头就得换方案。4.2 fetch ReadableStream灵活但得自己造轮子用fetch发 POST 请求然后手动读取响应流是目前 AI 聊天前端最主流的做法const response await fetch(/chat/stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ question: 你好 }) }); const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop(); // 最后一段可能不完整留到下次 for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); if (data [DONE]) return; const chunk JSON.parse(data); appendToChat(chunk.content); } } }这段代码里最关键的是buffer的处理逻辑。SSE 消息以\n\n分隔但网络传输是字节流你read()一次拿到的数据可能刚好在一条消息中间断开。如果不做缓冲拼接直接对每个 chunk 做split就会解析到半截 JSON 然后报错。我见过太多新手在这里翻车表现就是“偶尔丢字”或者“随机报 JSON 解析错误”其实就是没处理流的分片。另外decoder.decode(value, { stream: true })里的stream: true也很重要。UTF-8 编码的中文字符占 3 个字节如果刚好在字符中间断开不加这个参数会解码出乱码。这个细节文档里很少提但处理中文 AI 回答时必现。4.3 断线重连到底该不该做AI 场景下的断线重连是个需要仔细权衡的问题。EventSource默认会自动重连但重连意味着重新发一次请求模型会重新生成回答。如果用户已经看到了一半内容重连后从头开始体验非常割裂。我的做法是前端记录已经接收到的内容长度重连时把已收到的内容作为上下文传给服务端让模型接着生成。但这需要服务端配合实现起来比较复杂。更简单的方案是不做自动重连断线后提示用户“连接中断点击重试”由用户手动触发。这样虽然不够智能但至少不会出现内容重复的混乱局面。还有一种情况是stream disconnected before completion: idle timeout waiting for sse这类错误本质是连接空闲太久被中间层断开了。解决办法是服务端定期发送心跳注释行比如: heartbeat\n\n以冒号开头的行会被客户端忽略但能保持连接活跃。心跳间隔一般设 15 到 30 秒太频繁浪费带宽太稀疏起不到保活作用。5. SSE 在 Agent 工作流里的进阶玩法如果你只把 SSE 当成聊天框的打字机效果那就太小看它了。在 AI Agent 场景里SSE 承担的角色要重要得多——它是 Agent 执行过程对外暴露的“实时日志通道”。这一节聊聊 SSE 在 Agent 工作流里的几种进阶用法这些是我在实际项目里验证过的方案。5.1 用 event 字段区分 Agent 的不同阶段一个 Agent 执行任务时通常会经历“思考→调用工具→观察结果→再思考→输出”这样的循环。如果全部用data字段推给前端前端没法区分哪段是思考、哪段是工具调用结果。这时候event字段就派上用场了event: thinking data: {content:我需要先查询天气} event: tool_call data: {tool:weather_api,args:{city:北京}} event: tool_result data: {temperature:25°C,condition:晴} event: answer data: {content:北京今天晴25度}前端可以针对不同 event 类型做不同的 UI 渲染thinking 显示成灰色斜体tool_call 显示成可折叠的卡片answer 显示成正常对话气泡。这样用户能直观看到 Agent 在干什么而不是干等一个最终答案。这种“过程可见”的设计对建立用户信任非常重要尤其是当 Agent 执行时间较长时。5.2 多 Agent 协作时的消息路由当你同时跑多个 Agent 协作完成任务时SSE 流里会混杂多个 Agent 的输出。这时候需要在 data 里带上 Agent 标识{agent_id: researcher, type: thinking, content: 正在搜索资料} {agent_id: writer, type: thinking, content: 等待研究结果} {agent_id: researcher, type: result, content: 找到3篇相关论文}前端根据agent_id把消息分发到不同的展示区域用户就能看到多个 Agent 各司其职、协同工作的过程。这种多路复用的设计比开多个 SSE 连接要优雅得多因为浏览器对同域名 SSE 连接数有限制开太多连接会阻塞。5.3 背压处理当模型生成速度超过前端消费速度这是一个容易被忽视的问题。模型生成 token 的速度可能很快比如每秒几十个 chunk而前端渲染 UI 需要时间。如果前端消费速度跟不上消息就会在缓冲区里堆积最终导致内存暴涨或者页面卡死。解决办法是在前端做节流收到 chunk 后不立即渲染而是攒到一个队列里用requestAnimationFrame按帧批量更新。这样既能保证视觉上的流畅又不会因为频繁 DOM 操作拖垮页面。具体做法是维护一个pendingText变量每帧把累积的文本一次性追加到界面上而不是每个 chunk 都触发一次渲染。服务端也可以做限流比如每 50 毫秒最多推一次把多个 token 合并成一个 chunk。这样虽然牺牲了一点实时性但大幅降低了传输和渲染压力。具体间隔设多少要根据模型生成速度和前端渲染能力实测调整没有万能值。6. 那些年我踩过的 SSE 坑排查链路完整还原这一节我挑三个最典型的 SSE 生产事故把从现象到根因的完整排查过程写出来。这些坑的共同特点是本地测试完全正常一上生产就出问题而且报错信息往往具有误导性。6.1 现象流式输出变成“一次性全出来”第一次遇到这个问题时我以为是前端解析逻辑写错了。本地开发环境用node server.js直接跑流式效果完美文字一个个往外蹦。部署到测试环境后变成等十几秒然后整段答案突然出现打字机效果完全消失。排查第一步确认服务端是否真的在流式推送。我在服务端的send方法里加了日志发现日志是逐条打印的间隔几十毫秒说明服务端没问题。那问题就出在传输链路上。排查第二步绕过 Nginx 直连后端服务。把前端请求地址从域名改成后端 Pod 的 IP 加端口流式效果恢复了。这就锁定了问题在 Nginx。排查第三步查 Nginx 配置。发现proxy_buffering默认是onNginx 会把后端响应缓冲起来攒够一定大小或者等响应结束才发给客户端。解决办法有两个一是加响应头X-Accel-Buffering: no二是改 Nginx 配置proxy_buffering off。我选了第一种因为不用改运维的配置服务端自己就能控制。这个坑的教训是SSE 的流式效果依赖整条链路上每一环都不缓冲。除了 Nginx还要注意 CDN、API 网关、甚至某些云厂商的负载均衡都可能默认开启缓冲。排查时用“逐段绕过”的方法从客户端到服务端一层层排除很快就能定位。6.2 现象中文回答随机出现乱码这个问题更隐蔽表现是大部分中文正常但偶尔某个字变成“锟斤拷”或者问号。出现频率不高但每次出现都在不同位置很难复现。排查第一步抓包看原始字节。用浏览器开发者工具的 Network 面板看 SSE 响应的原始数据发现乱码位置的字节流确实有问题——一个 UTF-8 中文字符的 3 个字节被拆到了两个 TCP 包里。排查第二步检查前端解码逻辑。发现代码里用的是new TextDecoder().decode(value)没有加{ stream: true }参数。不加这个参数时decode方法会把每个 chunk 当成独立的完整数据来解码如果 chunk 边界刚好切在字符中间就会解码失败。加上{ stream: true }后问题消失。这个参数的作用是告诉解码器“后面还有数据遇到不完整的字符先缓存着”下次 decode 时再拼接。这个细节在 MDN 文档里有提但很容易被忽略因为大部分时候 chunk 边界不会刚好切在字符中间所以问题只是“偶尔”出现。6.3 现象连接数一多就卡死压测时发现并发 10 个 SSE 连接时一切正常到 50 个左右开始出现连接建立缓慢到 100 个时新连接直接超时。排查第一步看服务端线程池。Java 服务用的是 Spring 的默认线程池核心线程数只有 10 个。每个 SSE 连接虽然用的是异步 Servlet但SseEmitter的 send 操作如果是在业务线程里执行的就会占用线程池资源。连接一多线程池被占满新请求排队等待表现就是“卡死”。解决办法是给 SSE 推送单独配一个线程池跟业务线程池隔离。或者更彻底一点用响应式编程模型Spring WebFlux用少量线程支撑大量连接。但改造响应式成本较高如果并发量不是特别大单独配线程池就够了。排查第二步看浏览器端连接数限制。Chrome 对同域名的 HTTP/1.1 连接数限制是 6 个SSE 连接也受这个限制。如果你在一个页面里开了多个 SSE 连接第 7 个就会一直 pending。解决办法是升级到 HTTP/2HTTP/2 的多路复用可以让多个 SSE 流共享一条 TCP 连接不受 6 个的限制。或者用前面提到的多路复用方案一个 SSE 连接里推多种事件类型而不是开多个连接。7. SSE 和 WebSocket 在 AI 场景下的选型对照每次聊到 SSE总有人问“为什么不用 WebSocket”。这个问题值得认真回答因为选错了协议后期改造成本很高。我把两种协议在 AI 场景下的关键维度拉出来对比一下。对比维度SSEWebSocket通信方向服务器→客户端单向双向协议基础普通 HTTP无需升级需要 HTTP Upgrade 握手浏览器兼容除 IE 外全支持全支持自动重连原生支持需自己实现二进制支持不支持需 Base64 编码原生支持连接数限制HTTP/1.1 下同域 6 个基本无限制代理/网关兼容极好就是 HTTP部分网关不支持实现复杂度低中在 AI 对话场景里通信模式天然就是“用户发一次服务器回一串”单向推送完全够用。WebSocket 的双向能力在这里是浪费的反而带来了握手升级、心跳维护、重连逻辑等额外复杂度。而且 WebSocket 在很多企业网络环境里会被代理拦截SSE 则几乎不会遇到这个问题。但有一种情况必须用 WebSocket如果你要做实时语音对话音频数据是二进制的SSE 处理二进制需要 Base64 编码体积膨胀 33%延迟也增加。这种场景下 WebSocket 的二进制帧支持就是刚需。另外如果你要做多人协同编辑 AI 生成的内容需要客户端之间实时同步那 WebSocket 的双向广播能力也更合适。所以选型逻辑很简单纯文本的 AI 流式输出用 SSE涉及二进制或双向实时交互用 WebSocket。不要因为 WebSocket“看起来更高级”就无脑选它在 AI 对话这个具体场景里SSE 的简单可靠才是最大的优势。8. 生产环境部署 SSE 的检查清单最后分享一份我在多个项目里沉淀下来的 SSE 上线检查清单。每次部署前过一遍能避开 90% 的常见问题。服务端侧响应头是否设置了Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive如果前面有 Nginx是否加了X-Accel-Buffering: no或者配置了proxy_buffering off超时时间是否设置合理建议 3 到 5 分钟太短会被中断太长会占用连接资源是否有心跳机制建议 15 到 30 秒发一次注释行保活客户端断开时是否清理了定时器和线程资源线程池是否跟业务线程隔离避免 SSE 连接占满业务线程客户端侧流读取是否处理了 chunk 边界有没有做 buffer 拼接TextDecoder是否加了{ stream: true }参数是否处理了[DONE]标记收到后主动关闭连接是否处理了空消息和异常 JSON避免解析报错导致整个流中断断线重连策略是否明确是自动重连还是提示用户手动重试页面关闭时是否调用了reader.cancel()或es.close()避免连接泄漏监控侧是否监控了 SSE 连接数和平均持续时间是否记录了流中断的频率和原因是否有首字节到达时间的监控这个指标直接反映用户感知的响应速度这份清单里的每一项都是我实际踩过坑之后加上的尤其是 Nginx 缓冲和 TextDecoder 的 stream 参数这两个几乎每个新项目都会遇到。SSE 本身不复杂复杂的是它依赖的整条链路——从服务端框架到反向代理到浏览器解析任何一环出问题都会表现为“流式失效”。排查时记住一个原则逐段绕过从客户端到服务端一层层排除很快就能定位到罪魁祸首。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →