1. 为什么大模型一开口,前端就该想到SSE——流式输出的底层逻辑
先从一个很常见的场景说起。你第一次接入大模型应用,打开对话窗口,看到对面的文字一个字一个字往外蹦:你好、你好啊、你好啊,今天……那种感觉确实挺震撼的。但如果你只停留在“哇”的层面,后面做工程化的时候就会吃大亏,因为这种逐字蹦的效果,本质上是前端在持续接收服务端推送的文本流。而实现这种推送,目前最通用、最省事的方案就是SSE,全称Server-Sent Events,服务端发送事件。
很多人一听到“服务端推送”就下意识想到WebSocket,其实这两者在设计目标上有明显的不同。WebSocket是双向全双工通信,客户端和服务端都能随时发消息,适合实时聊天、在线协作这类需要你来我往高频互动的场景。而SSE走的是HTTP协议,是单向通道,只能服务端往客户端推,客户端通过一个长连接被动接收。在大模型对话这件事上,核心诉求恰恰是“用户问一句,服务端持续推一段”,客户端在等待期间不需要随时发指令,只有偶尔的取消操作。这种形态用SSE再合适不过。
那为什么不直接用轮询(Polling)呢?毕竟轮询的实现最简单,前端定时发HTTP请求,有新内容就拉回来。但轮询有个致命问题:你无法预测模型什么时候把第一句话憋出来。某些模型在思考阶段可能卡好几秒,轮询间隔设短了是白费请求,设长了用户看着空白页面干着急。更别提每个请求都有完整的HTTP头和TLS握手开销,模型生成过程可能持续几十秒,轮询期间的请求数会非常难看。
SSE的工作方式就没这么别扭。浏览器和服务器建立一条HTTP长连接,服务端可以把一段文本分多次写进响应体,每写一段就通过TCP发给客户端。浏览器收到后,通过EventSource对象解析出一个个事件。整个过程对前端来说几乎零成本——不需要额外引入依赖,不需要自己管理底层连接,协议层面还自带断线重连机制。你可以把它理解成一根水管,水龙头在服务端,出水量由模型生成速度决定,前端只需要在水管末端拿桶接。
关于SSE的另一个底层事实也值得说清楚:它本质上是“被压缩过的HTTP响应流”。整个连接依然是一次普通HTTP请求,状态码、响应头、Content-Type这些一个都不少。关键区别在于,普通的HTTP响应是一次性把body传完,而SSE连接把响应体拆成了很多小块,每块以特定格式书写,浏览器按约定去解析。这就是为什么SSE的调试经常能看到响应头里写着Content-Type: text/event-stream,以及Transfer-Encoding: chunked。理解这一点之后,后面排查Nginx缓冲、压缩导致不出字等问题就有方向了。
回到大模型场景,还有一层必须意识到:生成阶段不是匀速的。模型在一开始可能要“想一会儿”才吐出第一批token,中间还可能出现停顿,但连接不能断,用户界面上的loading也不能停。SSE协议里的注释行(以冒号开头的行)就是干这个用的——服务端可以周期性发送心跳注释,让浏览器确认连接还活着,同时不会把它包装成可见事件。这一招在生产环境里是保命手段。
2. 30分钟跑通一个最小可用的SSE流式对话
理解了原理之后,最好亲手把一个最小的SSE服务跑起来。我见过不少同学一上来就引入各种框架,结果被框架的缓冲策略坑得找不着北。最可靠的方式是用原生Node写一个极简服务,把协议细节看明白,再往框架上迁移就心里有底了。
2.1 极简Node服务端:手动控制响应流
用Node内置的http模块就能实现。核心逻辑是:不让res.end()结束响应,而是把消息用规定的格式通过res.write()写出去。下面是标准的event-stream格式:
const http = require('http'); http.createServer((req, res) => { // 必须设置的响应头 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' // 稍后解释这个头 }); // 模拟大模型分多次返回token const chunks = ['你好', ',', '很高兴', '认识你', '!']; let index = 0; const timer = setInterval(() => { if (index >= chunks.length) { res.write('data: [DONE]\n\n'); res.end(); clearInterval(timer); return; } // SSE 格式:data: 内容 + 两个换行 res.write(`data: ${chunks[index]}\n\n`); index++; }, 200); }).listen(3000);格式里的知识密度比表面看起来要高。每一条消息必须以data:开头,后面跟内容,然后以两个换行符\n\n结束。浏览器EventSource解析时,两个换行符就是一条消息的边界。一个响应体里允许塞多条data字段,浏览器会把它们拼成一条完整消息。而[DONE]这一行是OpenAI等平台约定俗成的结束标记,服务端主动发出来告诉前端“流结束了”。
2.2 前端消费:EventSource的三行代码体验
后端这么写完,前端消费几乎不需要动脑:
const eventSource = new EventSource('/api/chat-stream'); eventSource.onmessage = (event) => { const text = event.data; if (text === '[DONE]') { eventSource.close(); return; } // 把拿到的文本追加到界面上 appendToChat(text); }; eventSource.onerror = () => { // 浏览器会自动重连,但前端可以在这里提示用户 showConnectionHint(); };跑起来之后你会看到,浏览器每隔200毫秒收到一块文本,界面上就是逐字输出的效果。这个过程就是大模型对话产品最核心的交互形态。
有必要强调一下new EventSource(url)完成的不仅仅是发请求,它在浏览器层面注册了一个常驻的HTTP连接,并自动处理了重连逻辑。默认情况下,如果连接意外断开,浏览器会每隔几秒自动重新请求一次,服务端可以通过retry:字段指定重连间隔(单位毫秒)。这意味着,在没有任何前端代码的情况下,断网恢复后SSE能自动续上。这个能力是WebSocket原生不具备的。
2.3 连接生命周期的控制细节
EventSource虽然好用,但它的生命周期有几个容易被忽略的细节。首先,连接不是一次性的,页面不关、服务端不结束、EventSource.close()不被调用,连接就会一直挂着。这在长对话页面里没问题,但如果你做的是“一次性结果展示”功能,比如生成一份摘要后展示完毕,记得调用close()释放连接。
其次,SSE支持命名事件。服务端可以用event:字段指定事件名,前端用addEventListener('事件名', handler)来监听,而不只是统一走onmessage。在大模型流式场景里,我会建议这么设计:event: token推送增量内容,event: done表示结束,event: error表示出错。这种结构化设计比只用data字段+约定结束标记要清晰,业务扩展时好维护得多。
res.write('event: token\n'); res.write('data: 增量内容\n\n'); res.write('event: done\n'); res.write('data: 生成完毕\n\n');对应的前端就变成:
eventSource.addEventListener('token', (e) => appendText(e.data)); eventSource.addEventListener('done', (e) => handleDone()); eventSource.addEventListener('error', (e) => handleError());这里对onmessage的默认行为做个补充说明:服务端如果发了命名事件,那么这个事件不会触达onmessage处理器,只有不带event:字段的默认事件才会触发它。所以用命名事件时,前端要对每个事件单独注册。这是我在实际项目里见到的最常见误解之一:很多人写了命名事件,又希望onmessage能全部兜住,结果发现收不到消息,原地排查半天。
3. 接入真实业务时绕不开的三个改造点:POST、鉴权与断线恢复
熟悉上面的最小Demo后,你正式接入真实业务时会立刻撞上一堵墙:EventSource只支持GET请求,而且不能自定义请求头。可大模型对话应用里,我们几乎必须用POST来传用户问题、上下文、历史记录等复杂JSON,还必须带上Authorization头部做身份鉴权。这两个限制几乎让原生EventSource在大模型场景中“不可直接用”。
3.1 正视EventSource的先天限制:为什么大模型场景必须改造
先帮你理一理原生EventSource的限制到底在哪。它的构造器接收一个URL,浏览器内部发起GET请求。查询参数可以拼在URL上,但长度有限制;无法设置Authorization这种请求头;也无法取消然后携带自定义body重连。在大模型业务中,对话上下文可能几千字,塞进URL不仅丑,还容易被网关拦截。身份信息放cookie也许可行,但跨域环境下Cookie的SameSite策略会让你焦头烂额。
所以真实项目里,我更推荐用fetch()来驱动SSE的读流,而不是直接依赖EventSource。fetch本身是流式的,响应体的Reader可以一边接收一边解析,这就绕开了EventSource的所有限制。
3.2 fetch流式方案:一段可以抄作业的完整封装
用fetch驱动SSE,本质上是把“协议解析”这步自己接管。服务端返回的Content-Type仍然是text/event-stream,但我们不交给EventSource解析,而是自己读ReadableStream,按data:前缀和双换行符切分事件。下面是封装好的核心代码:
async function chatCompletion({ url, body, token, onToken, onDone, signal }) { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body), signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE消息以空行分隔,按行处理 const lines = buffer.split('\n'); buffer = lines.pop(); // 最后一段可能不完整,留到下一轮 for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const data = trimmed.slice(5).trim(); if (data === '[DONE]') { onDone?.(); return; } try { const parsed = JSON.parse(data); onToken?.(parsed); } catch { // 忽略无法解析的脏数据 } } } }这段代码有几个关键设计点值得展开。第一,使用TextDecoder而不是直接把Uint8Array转字符串,是为了正确解码多字节字符的边界情况——中文的UTF-8编码可能被TCP拆包分散在两个chunk里,直接用二进制转字符串会出乱码。第二,buffer的切分逻辑里,最后一段永远留给下一次读流拼接,防止一句data: {json}\n被切掉一半导致JSON解析失败。第三,解析成JSON是普遍做法,因为大模型平台通常不只返回增量文本,还会带上usage信息、finish_reason、消息id等结构化字段。
3.3 断线重连:业务级的补偿策略
fetch方案失去了EventSource自带的自动重连,这个能力必须自己补回来。我的实践是封装一个带指数退避的重连器。核心策略是:检测到网络错误或长时间没收到心跳数据时,重新发起一次同样的请求,但请求体里带上lastOffset这类游标参数,后端根据游标决定从哪个位置继续推。生成过程中的指数退避间隔可以设定为第1秒、第2秒、第4秒这样逐步递增(每次翻倍向上增长),最多重试5次。注意区分“需要重连的错误”和“不该重连的错误”:HTTP 401说明鉴权失败,重试没意义;网络中断值得重连;服务端推了[DONE]属于正常结束,更不能重连。
下面是重连器的简化版本:
function createReconnectExecutor({ maxRetries = 5 } = {}) { return async function executeWithRetry(run, { retry = 0 } = {}) { try { return await run(); } catch (err) { if (retry >= maxRetries || err.isFatal) throw err; const delay = Math.pow(2, retry) * 1000; await sleep(delay); return executeWithRetry(run, { retry: retry + 1 }); } }; }顺带提一个容易被忽略的细节:fetch流式响应里抛出的Error,类型往往是普通的Error,想区分网络错误和业务错误,最好在封装层自行标记(比如上面代码里的isFatal)。我把鉴权失败、参数错误这类响应状态码明确映射为isFatal,这样重连器就不会去做无用功。
3.4 前端还需处理好用户主动取消
用户输入完问题后,可能立刻反悔,想停止生成。fetch方案天然支持AbortController,前端代码里创建AbortController实例,把signal传入fetch即可。取消时调用controller.abort(),底层连接被终止,服务端自然收不到继续写的通道。
但这只是“半套”取消逻辑。真正完整的流程是:前端abort后,还要向服务端发送一个取消请求,让服务端清理掉正在跑的模型推理任务。因为单纯断开浏览器与服务端的连接,服务端如果没感知到,模型可能还会继续推理到结束,白白浪费算力。更好的设计是abort之后自动补发一个/cancel请求,服务端通过请求里的sessionId定位到推理任务并终止。这点在业务价值上很重要,等比提示处理的成本还高。
4. 服务端编排与代理层的缓冲陷阱:流式响应被“卡住”的真实排查过程
SSE开发中最磨人的不是代码逻辑,而是流式数据“卡住不出字”。明明后端已经write了内容,浏览器就是迟迟不显示。这类问题十有八九出在代理层和服务端框架的缓冲策略上。我在项目里遇到过这类事故,排查过程值得完整复盘一遍。
4.1 现象:页面像死了一样,后端日志却显示在正常输出
那次事故的现场是这样的:用户发出一条消息,前端调用流式接口,后端日志显示token一个个正常吐出,但浏览器端的状态码200已经返回,页面上一片空白,等了十几秒之后突然一次性刷出全部内容。首屏体验直接归零。
我当时的排查链路是这样的。第一步,检查后端代码,确认没有对res做缓冲处理,直接用的res.write推送。第二步,打开浏览器开发者工具查看响应体,发现初始状态下响应体一直为空,直到最后才一次性灌满整个内容。这两个信息一结合,矛头就指向了中间代理层——有个Nginx在前面做反向代理。
4.2 根因:Nginx的proxy_buffering和gzip压缩把流变成了一锅粥
Nginx默认会对上游响应做缓冲,整个响应体攒满一定量才转发给客户端。对普通接口这没问题,但对SSE流式响应就是灾难:后端明明推了第一个token,Nginx却把数据扣在缓冲区里不往下传,直到缓冲填满(或连接结束)才一次性吐出。用户看到的自然是“长时间静止、突然刷屏”。解决办法是在Nginx配置中关闭该路径的代理缓冲:
location /api/chat-stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_read_timeout 3600s; }第二个隐藏陷阱是gzip压缩。如果Nginx开启了gzip on;并设置了最小压缩阈值,流式响应会被压缩后再发送。服务器压缩本身没问题,但问题是压缩行为会隐含地做缓冲——必须攒够一段数据才能形成压缩块,这同样导致前端长时间收不到字节。SSE接口的正确姿势是关闭压缩:
location /api/chat-stream { gzip off; }4.3 服务端框架的隐性缓存:框架不会自动感知你“想流式”
即使代理层配置全部正确,服务端框架也可能在中间捣乱。很多Web框架默认会为响应体设置一个自动的Content-Length和缓冲策略。以Express为例,我见过的问题是框架层自动帮响应设置了压缩中间件,导致res.flushHeaders()没被调用前,数据全部积压在框架的流缓冲里。解决办法有两个:一是调用res.flushHeaders()立即发送响应头,二是显式关闭压缩中间件:
const compression = require('compression'); app.use(compression({ filter: (req, res) => { // 对event-stream请求禁用压缩,避免缓冲造成的不出字 return !req.path.startsWith('/api/chat-stream'); }}));我建议团队在SSE接口上加一道共同的防御手段——响应头里带上X-Accel-Buffering: no。这个字段本来是Nginx的扩展,能告诉任何读到该响应头的代理层:不要对这个响应做缓冲。后端代码里加上它,即使运维配置漏掉,也能减少一大部分缓冲问题。
4.4 心跳保活:为什么连接“看起来活着”但什么都没收到
还有一种情况更隐蔽:连接没断,心跳也在发,但客户端感觉“服务端没在生成”。排查过程中我发现不少SSE的实现只做了初始连接的成功返回,却没有建立周期性的心跳机制。如果模型推理卡了十几秒才吐出第一个token,前端无法区分“服务端还在思考”和“连接已经僵死”。
解决方向是服务端用定时器周期发送注释行(以: ping开头的一行),因为SSE协议规定冒号开头的行是注释,EventSource会自动忽略,不会触发任何事件,但底层TCP连接会持续有数据流动,相当于变相喂活了整个链路。心跳间隔建议设置为15到30秒。注意,心跳不能是一条空data:消息,那会被解析为一次空事件;要用心跳就用心跳专用的冒号注释写法:
const heartbeat = setInterval(() => { res.write(': ping\n\n'); }, 20000); res.on('close', () => clearInterval(heartbeat));这套排查链路走下来,你会发现所有“卡住不出字”的问题几乎都指向同一个核心:HTTP流式响应的底层通道里,谁在中间偷偷攒数据,谁就是凶手。代理层、框架中间件、压缩器,全部排查一遍,SSE的“瞬时性”就能保证。
5. 大规模并发时的性能取舍:SSE长连接对服务端的隐性压力
很多人以为SSE只是前端协议,眼睛盯着EventSource的用法,服务端随便一写就行。但真实生产环境里,SSE长连接对服务端的压力模型和普通HTTP接口完全不同,如果不做针对性调整,上线当天就能把服务压垮。
5.1 长连接的本质:一个用户占住一个进程/线程
普通HTTP接口是“请求-响应-断开”模式,服务端处理完就释放资源。SSE则是一个用户长时间占用一条连接。在Node这类单线程事件模型下,一个SSE连接本身不占太多CPU,但它对应的socket、响应对象、定时器全部常驻内存。假设你有1000个并发用户同时开对话,服务端就常驻了1000个响应流。如果每个响应流上还挂了几个定时器(比如心跳、超时控制),定时器数量也会线性增长。
应对策略第一点是:不要把SSE连接和业务处理逻辑绑定在同一个进程里。实际架构上通常分成两层:前端直连一个网关层,网关再把请求转发给真正执行模型调用的工作节点。这样即使某个工作节点重启,网关层的SSE连接不会立刻断开,前端感知不到底层节点变化。第二点是“空闲连接自动回收”:如果连续一段时间没有心跳数据也没有用户请求,服务端应该主动断开该连接,防止空转占用。
5.2 超时配置:比普通接口大一个数量级
运营同学经常拿着监控告警来问:“为什么chat-stream接口耗时900秒?”如果把SSE当成普通接口去配置超时,你会遇到两种尴尬:一是超时时间设短了,模型还在思考就被网关切断,用户看到一半的内容没了;二是超时时间设长了,恶意或异常用户一直挂着连接不消费也不断开,服务端资源被拖死。
我的经验是三层超时分别设置。TCP层:Nginx的proxy_read_timeout建议设到300秒以上(基础模型常见的最长首字延迟在这个范围内,但不做死配置);应用层:单个SSE连接的最长持续时间建议跟模型最大生成时长对齐,比如设定为5分钟,超时后SSE主动推一条timeout事件再断开;业务层:心跳超时单独计数,若连续两分钟没收到心跳,服务端主动回收连接。这里说明一下,Nginx侧我在配置里写了3600s,实际应该按业务真实情况来,不鼓励盲目照抄,生成长时间任务再做对应延长,短任务该收紧就收紧。
5.3 集群部署下的broadcast问题:Redis通道来兜底
SSE的架构陷阱在集群化后还会浮出水面。假设用户A的浏览器连接落在Node实例1上,模型推理任务跑在Node实例2上,实例2生成了token怎么推给实例1呢?这时候常见方案是中间加一个Redis Pub/Sub通道。实例2把token publish到某个业务频道,实例1订阅该频道并把消息转发给自己维护的SSE连接。这个方案的取舍是:增加了Redis这个组件的依赖,但是换来了SSE连接和推理节点的彻底解耦,节点扩缩容时前端无感知。
此外,两个细节值得注意。一是Redis Pub/Sub的消息不持久化,如果实例1订阅之前频道里已经推了几条消息,那些消息就丢了,需要考虑是否需要缓存补发机制。二是实例1向SSE连接转发消息时必须校验该业务ID是否属于当前连接对应的会话,避免出现用户A看到用户B内容的串线事故。
6. 我踩过的几个坑与最终的实践建议
最后把我实际踩过的几个坑集中列一下,每一条都是真金白银换来的教训。
6.1 不要在SSE事件里携带过大JSON
有段时间我图省事,每次token事件都把完整上下文塞进去推送,结果前端渲染越来越卡。原因在于大JSON每次都要做整包解析,对象化和DOM渲染都耗CPU。SSE推送讲究“增量”,单个事件推送的内容控制在小范围,需要完整数据时让前端在流结束后走一个独立查询接口补齐。
6.2 默认超时机制不是万能的
不少同学写完SSE后测试正常就上线了。结果真实用户网络环境里,移动网络切换(比如从WiFi切到5G)会让TCP连接静默中断,服务端和浏览器都感知不到。等到用户下次发消息,浏览器重连才发现旧连接已经废了。应对方案是前端在发送新消息前,主动close掉旧连接再重建,不要指望底层TCP能自动恢复。
6.3 后端一定要区分“正常断开”和“异常断开”
事件流结束时,客户端或服务端谁先断开连接,对埋点统计影响很大。我建议在服务端监听响应对象的close事件,通过context判断是否已经写过[DONE]标记,如果没写过就说明是异常断开,在日志里记一条warning。否则你统计出来的“用户放弃率”会严重失真——大量用户可能是看着报错退出的,在日志里却看不出任何区别。
res.on('close', () => { if (!isDone) { logger.warn('SSE connection closed unexpectedly'); } });6.4 浏览器事件循环导致的多实例问题
一些项目中在页面初始化时就创建了EventSource实例,路由切换或弹窗关闭时忘记销毁。长期跑下来,多个SSE实例同时挂着,服务端会看到同一个用户维护了多条连接,数据被重复推送。我的习惯是封装一个SSE管理类,显式提供open/close接口,并在路由变化时统一释放。这个封装在React/Vue里都能顺利接入业务生命周期。
6.5 给“错误提示”安排优先级
大模型流式接口的错误一般偏后置:前面已经流式输出了几百个字,然后突然抛错,如果直接在界面上把已生成的内容全部清空,换成错误页,用户的观感会非常差。我的实践策略是保留已生成内容,错误通知以浮层形式提示“内容生成中断”,同时保留重试按钮。错误提示永远比内容本身优先级低一档,这个策略在用户反馈中效果很好。
最后再说一句私货:SSE这个协议已经稳定了很多年,但每次大模型浪潮来的时候,它都会重新回到前端开发的技术视野中心。希望这篇内容能帮你把从原理到生产落地的这条路走通,少走一些我走过的弯路。