☰
SSE流式输出实战:从HTTP长连接到前端解析与踩坑指南
2026/10/12 13:20:50 网站建设 项目流程

最近在打磨一个对话类产品,接入大模型之后发现一个很现实的问题:如果等模型把回答完整生成完再返回给用户,最坏情况下界面要白屏十几秒,用户早就关掉页面了。这时候大部分人都会提到一个词——SSE协议,它就是大模型流式输出背后的底层机制。我花了一段时间把SSE的原理、报文格式、前后端实现和常见坑都整理了一遍,这篇就当是给正在做AI应用的朋友一份可以直接参考的实操笔记。不管你是后端工程师、前端开发者,还是刚打算自己动手做点AI小工具的同学,弄懂SSE的工作方式,能帮你省下不少本来要踩的坑。

1. 大模型为什么离不开SSE这种流式输出

1.1 等全部结果返回,体验上几乎不可接受

先看一个最朴素的场景:你调用一个大模型接口,把一段Prompt发过去,等待模型把回答全部生成完再一次性返回。这种方式在简单问答里可能还凑合,但只要回答稍微长一点,问题就来了。

我实测过一个中等规模的模型,生成速度大概每秒二十个左右的token,如果回答是一个五百字左右的段落,用户要等上二十多秒才能看到第一个字。而且大模型不是“先构思好再写”,它是真正一个词一个词地往后推的,所以“等全部生成完再返回”意味着用户会对着一个空白的加载状态等待很久。空白页面本身就是一种“系统已死”的暗示,用户的耐心通常只有几秒,这种体验放在任何产品里都是灾难。

相比之下,流式输出的核心思路是:模型生成了一部分内容,就把这部分内容先发给用户;再生成一部分,再发一次。整个过程从用户视角看,就是文字像打字机一样逐字蹦出来。这种方式下,第一个字到达屏幕的时间被压缩到几百毫秒级别,用户感觉“系统在思考,然后在输出”,哪怕整体生成时间没有缩短,心理等待感也会明显下降。

1.2 直接复用HTTP连接,刚好匹配“单向推送”场景

大模型的生成过程有一个非常鲜明的特点:信息流向是单向的。客户端给服务端发一个请求,然后服务端持续把模型产出推回客户端,中间用户基本不需要再上传额外的数据(除了“停止生成”这类控制信号)。

这不就是标准的服务端推送场景吗?而且方向单一,不需要客户端往同一个连接里塞数据。WebSocket当然能做这件事,但它是一种全双工协议,连接建立和状态管理都比普通HTTP请求重得多。SSE则是站在HTTP肩膀上实现的服务端推送方案:请求还是普通的HTTP请求,服务端返回时把Content-Type改成text/event-stream,然后保持连接不关闭,在一段时间内持续向客户端写数据。浏览器原生支持EventSource接口来接收这种流,解析逻辑都不用自己写。

选择SSE而不是WebSocket,本质上是在选“最符合场景的最小方案”。大模型对话应用的数据流是天然单向的,用SSE少了一套复杂的状态同步和维护成本。

1.3 流式输出带来的产品玩法升级

流式输出不只是让页面不白屏那么简单,它还改变了用户和模型之间的交互节奏。

一个是“可控的中断感”。用户看到模型已经跑偏了,可以直接点击停止按钮,前端立刻断开请求,后端的生成循环也随之终止。这种“随时打断”的能力在非流式模型上很难做,因为整个回答是一次性打包的,你没法裁掉后半段。

另一个是“产品调性”的变化。逐字输出的“打字机效果”会让人觉得模型在思考、在斟酌措辞,这种微妙的感知在很多产品里被当成一个卖点。有些产品还故意把速度调慢一点,让用户觉得回答更“人性化”。这些都不是协议本身的要求,但都是流式输出的能力延伸出来的设计空间。

2. SSE协议的底层设计与报文细节

2.1 本质是HTTP长连接上的一段流式文本

很多人第一次看SSE会觉得它像一个“黑魔法”,实际上它非常朴素。SSE就是客户端发起一个普通的HTTP GET请求,服务端收到后不马上结束响应,而是把响应头设置为text/event-stream,然后通过socket持续写出以特定格式组织的文本块。

关键点在于“连接不关闭”。HTTP协议允许响应体分段传输,SSE利用了这一点,把每一次需要推送的事件写成一个独立的文本块,然后通过空行分隔。浏览器收到这类响应后,不会像处理普通JSON一样等数据全部到达再回调,而是在每次数据块到达时触发相关逻辑。

我在第一次实现时理解为“一个一直不结束的HTTP响应”,这比试图把它想象成某种特殊的长连接通道要直观得多。它不需要单独的端口,不需要升级协议,也不要求客户端和服务端实现复杂的握手流程,它用的就是最成熟的HTTP基础设施。

2.2 报文格式拆解:data、id、event、retry

SSE协议在规范层面定义了几种字段,实战中最常用的就是data和id,偶尔会用event和retry。每条事件由一个或多个字段行组成,字段行格式是“字段名: 内容”,事件之间用一个空行隔开。注意冒号后面那个空格是语法的一部分,虽然有些实现不写也能解析,但按规范写最稳妥。

最基础的推送长这样:

data: {"message": "你好"}

这代表一条事件,内容是“data: {json}”,后面跟一个空行代表事件结束。如果一条事件里需要包含多行文本,可以连续写多个data行,它们会被按换行符拼接起来:

data: 第一行 data: 第二行

拼接后收到的内容是“第一行\n第二行”。这个设计让事件数据可以携带换行符,不至于因为内容里出现回车而破坏协议边界。

id字段用来给事件一个编号,客户端断开重连时可以带上Last-Event-ID请求头告诉服务端“我从哪条事件之后开始补发”,这在弱网环境下很实用。

retry字段用来告诉浏览器断线后等待多少毫秒再重连,单位是毫秒。event字段则定义了事件的类型名称,配合addEventListener可以针对不同类型的事件做不同处理,不过在大多数大模型流式请求里,我们不区分事件类型,只会用到默认的message事件。

2.3 注释行与心跳保活

SSE还有一个容易被忽略的语法:以冒号开头的行是注释行,不会触发事件。比如“: ping”这一行会被客户端忽略。这看起来没什么用,但它其实是保活的重要手段。

很多代理服务器、负载均衡器会主动断开长时间空闲的连接。如果模型生成速度比较慢,两次事件之间隔了几十秒,中间层的空闲连接清理机制就可能把连接关掉。服务端需要定期写一个注释行来活跃连接,告诉中间层“这个连接还活着”。我习惯每十五秒发一次注释行,实测下来连接稳定很多。

2.4 SSE与WebSocket的选型对比

把SSE和WebSocket放在一起对比,能更清楚地看到各自边界:

维度SSEWebSocket
通信方向服务端到客户端单向推送全双工双向通信
底层协议普通HTTP(text/event-stream)独立于HTTP的WS协议
连接建立普通GET请求,无额外握手需要升级握手和协议协商
自动重连浏览器原生支持,带断点续传需要自己实现重连逻辑
传输格式文本(默认UTF-8)文本或二进制帧
实现复杂度低,服务端写Header+持续write即可高,需要处理帧解析、心跳、状态管理
浏览器兼容现代浏览器原生支持EventSource现代浏览器原生支持,但需封装

大模型对话这个场景,信息流主方向是模型到用户,用户到模型的上行信号非常少,所以SSE在架构上和场景高度匹配。如果未来要做实时语音对讲、多人协作白板这种需要高频双向交互的场景,再换成WebSocket也不迟。

3. 动手写一个能跑起来的SSE接口

3.1 服务端:Node.js模拟大模型流式生成

我习惯用Node.js做演示,因为代码量最小,而且和前端语言同栈,理解起来更直接。下面这个路由会在收到请求后立即建立SSE响应,然后每两百毫秒推送一段模拟生成的文字。

const express = require('express'); const router = express.Router(); router.get('/api/stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); const content = '这是模拟大模型生成的回答内容,每一小段会按顺序推送。'; let index = 0; const timer = setInterval(() => { if (index >= content.length) { res.write('data: [DONE]\n\n'); clearInterval(timer); res.end(); return; } const chunkData = JSON.stringify({ content: content.slice(index, index + 4) }); res.write(`data: ${chunkData}\n\n`); index += 4; }, 200); req.on('close', () => { clearInterval(timer); }); });

这里面有几个细节值得说明清楚:

Content-Type必须是text/event-stream,少了这个头浏览器不会按SSE解析,只会把整个流当成普通文本放到response body里等待结束,页面会一直空白。Cache-Control设为no-cache是为了防止中间节点把流式响应缓存起来。Connection: keep-alive提醒代理层保持连接。X-Accel-Buffering是给Nginx这种反向代理看的,告诉它别在这层缓冲,否则前面的工作都白搭。

req.on('close')这个回调很重要。用户一旦中断请求(比如点了停止按钮),服务端必须立刻清理定时器,否则生成循环还在跑,白白浪费资源。很多新手写流式接口时会漏掉这一步,导致断开后服务端还在拼命写数据。

3.2 前端要想带Authorization头,就得用fetch

浏览器原生有一个EventSource接口,用起来几乎零成本:

const source = new EventSource('/api/stream'); source.onmessage = (e) => { console.log(e.data); };

但它有两个硬限制:只能发GET请求,而且没法自定义请求头。实际对接大模型平台时,鉴权信息基本都要放在Authorization头里,这就让EventSource很尴尬。所以我在生产项目里会优先用fetch加流式读取,既解决了header问题,也能随时通过AbortController中止请求。

核心代码如下:

const controller = new AbortController(); async function startStream() { const res = await fetch('/api/stream', { method: 'GET', headers: { 'Authorization': 'Bearer your-token-here' }, signal: controller.signal }); const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value, { stream: true }); handleSSEChunk(text); } } function stopStream() { controller.abort(); }

fetch返回的res.body是一个ReadableStream,通过reader.read()可以逐段拿到二进制数据。注意这里用了TextDecoder的stream:true模式,这个细节后面专门讲,它和中文乱码问题直接相关。

3.3 自己写一个小的SSE事件解析器

用fetch接收流式数据,意味着浏览器不会自动帮你解析SSE事件格式。数据块可能会在一次网络包中带多条事件,也可能一条事件被拆成两个数据块到达,所以必须自己维护一个缓冲区来切割事件边界。

我常用的解析思路是:把收到的文本追加到一个buffer里,然后按空行(\n\n)切分,切出来的是完整事件行,再对每行事件解析data字段。buffer里留下的半截继续等下一个网络包。

let buffer = ''; function handleSSEChunk(chunk) { buffer += chunk; const parts = buffer.split('\n\n'); buffer = parts.pop() || ''; for (const part of parts) { const lines = part.split('\n'); const dataLines = []; for (const line of lines) { if (line.startsWith('data:')) { dataLines.push(line.slice(5).trimStart()); } } if (dataLines.length > 0) { const data = dataLines.join('\n'); handleEvent(data); } } } function handleEvent(data) { if (data === '[DONE]') { // 流式响应结束 return; } try { const json = JSON.parse(data); // 从json中取出增量内容并渲染 renderContent(json); } catch (err) { // 数据不完整时的容错处理 console.error('解析失败', err); } }

这里面有一个隐含的边界情况:如果一条事件恰好被TCP分包从中间切断,未解析的部分会留在buffer里,等下一块数据到达后再拼接。正是这个buffer机制保证了事件不会丢也不会错乱。

4. 大模型流式响应中的数据格式与解析

4.1 一次流式响应长什么样

各个大模型厂商的流式响应格式不完全相同,但目前的行业惯例是大致照搬了一种“增量JSON块”规范,也就是把每次模型产出的增量放在data字段里,最终用一个特殊标记表示结束。

一个典型的流式响应长这样:

HTTP/1.1 200 OK Content-Type: text/event-stream; charset=utf-8 data: {"id":"chatcmpl-001","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]} data: {"id":"chatcmpl-001","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":",世界"},"finish_reason":null}]} data: {"id":"chatcmpl-001","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}]} data: [DONE]

每条data都是一个JSON对象,里面核心字段是choices数组。delta里装的是这次新增的内容,每次只有一小段,把多次delta.content拼接起来就是完整回答。finish_reason的值为null时表示生成还没结束,值变为stop代表模型正常结束。

最后那个[DONE]原先是某个特定字符串,现在已经变成一种事实上的约定,代表整个流式响应结束。不同厂商可能用不同标记,对接时先看一眼对方文档确认一下就行。

4.2 解析增量内容并渲染到页面

拿到流式数据之后,渲染逻辑并不复杂。维护一个完整的文本变量,每次解析出新的delta.content就追加进去,然后更新到页面上的展示位置。如果要支持Markdown渲染,可以在完整文本更新后再跑一遍渲染流程,不过要注意避免每次都重新渲染整个列表导致性能问题。

let fullAnswer = ''; function renderContent(json) { const delta = json.choices?.[0]?.delta?.content; if (!delta) return; fullAnswer += delta; outputElement.textContent = fullAnswer; }

这里有一个实际经验:不要在解析SSE事件时直接调用高开销的渲染函数,比如重新编译Markdown全文。大模型流式输出频率很高,每秒可能推送十几次甚至几十次增量,每次全量重渲染会造成明显的卡顿。我通常会把增量追加和渲染操作合并成一次异步任务,用requestAnimationFrame或者简单的节流函数来控制UI更新频率。

4.3 中文编码的隐藏大坑:拆包乱码

这部分值得单独拿出来讲,因为它是我实际对接时踩过最隐蔽的一个坑。

SSE事件流默认是按UTF-8编码传输的。UTF-8里的中文字符大多占三个字节,比如“你”这个字的二进制表示为三个字节。网络数据包在传输过程中并不会管字符边界,一个完整的字符可能被拆到两个包里面。

如果用TextDecoder的默认模式,也就是不传stream参数,每次解码时都会把这个字符的前几个字节当成一个不完整的字节序列来处理,解码结果会出现替换字符(通常是那个问号样式的字符)。更严重的是,这会把一个完整字符的字节序列错位,导致后续所有中文一起乱码。

解决办法就是前面代码里写的:把TextDecoder的decode第二个参数设成{stream: true}。这样解码器会把未完成的字节序列缓存起来,等下一个数据包到达时,自动和缓存字节拼接成一个完整字符再解码输出。

我遇到过一位同事调试了很久都没解决这个问题,他的现象是前端会间歇性出现“�”这种符号,偶尔还会有半个字符消失。排查到最后发现就是这里漏了stream参数。这个细节在官方文档里就一句话,但实际影响非常大。

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

5.1 前端半天不出数据:Nginx缓冲没关

症状非常典型:后端的SSE日志显示内容一直在推,但前端页面迟迟不显示,等好几秒后一下子涌出一大段内容。这不是流式失效,而是数据被中间的反向代理缓冲住了,等缓冲满了才一次性发给客户端。

Nginx以及部分云负载均衡服务默认开着proxy_buffering,这会让响应内容在代理层攒着。解决方式是在SSE接口对应的location里关掉缓冲,并关掉相关的压缩缓存机制:

location /api/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; chunked_transfer_encoding off; }

另外Nginx还会尊重上游返回的X-Accel-Buffering: no响应头,所以后端代码里写这个头也是一种防线。我在部署时通常会两端都设置,保证无论走哪条链路都不会被缓冲。

5.2 连接过半就断了:中间层空闲超时

症状是流式内容输出到一半,连接莫名其妙关闭,浏览器自动重连后又要重新开始生成。这是因为大模型生成回答时,如果某两段输出之间间隔较长,代理服务器或云厂商的网关会把这条空闲连接判定为不活跃连接并回收。

解决办法就是前面提到的心跳注释行。服务端每隔十五秒左右主动写一行注释:

const heartbeat = setInterval(() => { res.write(': keep-alive\n\n'); }, 15000);

注释行不会触发浏览器事件,但能让中间层看到数据在流动,从而保持连接存活。如果发现自己场景里生成间隔很长,可以把心跳间隔缩短到十秒。

5.3 断线重连后数据不连贯:Last-Event-ID怎么配合

当SSE连接断开时,浏览器原生的EventSource会自动重连,并且会把服务端上次推送的id带在请求头里。但如果用fetch自己读流,就没有这个自动能力,需要自己实现断点续传。

服务端在写数据时可以带上id字段,思路是记录当前已推送的字符位置:

let sequence = 0; res.write(`id: ${sequence}\ndata: ${chunkData}\n\n`); sequence++;

客户端记录最后一次收到的id,重新发起请求时把它传到服务端:

headers: { 'Last-Event-ID': lastEventId }

服务端读取这个请求头,找到断点位置,从头继续推。不过在真实的大模型应用里,我更推荐一个简化方案:前端先把当前已经显示的完整文本作为上下文传给后端,后端重新生成剩下的内容。因为SSE的id断点机制需要服务端保存非常细粒度的生成状态,对无状态服务来说实现成本偏高。

5.4 解析时莫名的报错:注释行、BOM头和未知字段

有些服务端框架或网关会在流的最开头加一个UTF-8 BOM(字节顺序标记),或者在某些时刻插入无关的注释行。解析器见到这些内容,如果不处理,第一行就会变成一条奇怪的事件导致JSON解析失败。

我的解析器里有两道保障:第一,切割事件前的空行时把所有空白字符trim掉;第二,只处理以data:开头的行,其他所有行全部忽略。这样的容错逻辑基本能兼容各种奇怪网关的输出。

5.5 鉴权怎么放都对不上

再强调一次EventSource的局限:没法自定义请求头。直接用EventSource对接需要鉴权的大模型接口,一般有三条路可以选。

第一条是用fetch方式自己读流,请求头随便加,这也是我推荐的做法。第二条是把token放到URL的query参数里,服务端允许这样接收就行。但这么做有token泄露风险,URL可能会出现在访问日志或历史记录里,生产环境要格外谨慎。第三条是走Cookie认证,浏览器在发起EventSource时会自动带上当前域名下的Cookie,前提是接口和前端同域,或者处理好跨域Cookie传递。

我的意见是:只要条件允许,优先用fetch加Authorization头的方案,这是安全性、灵活性和代码可维护性最好的组合。

5.6 表格速查:遇到问题先看哪种

现象可能原因优先检查项
页面长时间空白后一次性输出代理层缓冲Nginx的proxy_buffering、响应头Cache-Control
中文偶发乱码或问号TextDecoder未开启stream模式decode第二个参数是否传{stream:true}
流式输出中途断连空闲连接被回收服务端心跳注释行间隔
断线后重复生成重复内容客户端与服务端缺少断点状态Last-Event-ID或重新生成策略
JSON解析偶发失败事件被分包或掺杂注释行解析器buffer与容错逻辑
接口一请求就跨域报错缺少CORS响应头Access-Control-Allow-Origin等头配置

写在最后

做流式输出这段时间,我最大的体会是SSE本质上是一个很朴素的方案,没有WebSocket那么多概念和状态,但它恰好踩在了大模型应用最关键的节点上。你把协议格式记熟,把Nginx这类缓冲点关掉,再把TextDecoder的stream参数写对,一个可用的流式对话界面基本就成型了。

最后再分享一个小技巧:调试SSE接口时,直接用curl看原始报文效率极高,能第一时间分辨是服务端没推数据、中间件缓冲还是前端解析有问题。命令很简单,连上之后内容会在终端一行一行蹦出来,能直观感受到流的节奏。希望这篇笔记能帮你快速绕过我踩过的那些坑,少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询