做AI应用开发的人,大概率都遇到过这种场景:调用大模型接口时,原本以为要等上好几秒,然后一次性收到一大段完整答案;结果请求发出去不到半秒,接口就开始往回调里吐token,一个字、一个词地往外冒,屏幕上的回答像真人打字一样,边生成边显示。这个“边生成边显示”的体验,背后就是SSE在干活。
SSE 全称 Server-Sent Events,翻译过来是“服务端事件推送”。它很不显眼,因为浏览器、HTTP协议栈早就把它当普通响应在处理,大部分前端同学甚至没直接听过这个名词,只会说“用EventSource接的流”。但从我这几年的实战经验看,想在生产环境把大模型流式输出做好,把SSE协议彻底吃透是绕不开的一步——服务端为什么要按这种格式吐数据,代理层为什么会把流“吞掉”,前端怎么解析增量,重连语义怎么处理,每个坑都藏在协议细节里。
这篇文章我按自己排查问题时的思路来写:先把SSE解决的问题讲清楚,再一个字段一个字段拆协议报文,然后给出一套能直接抄的大模型流式接入方案,最后把我在线上踩过的坑和排查方法整理成实录。适合后端工程师、客户端同学,以及所有想搞清楚“大模型接口为什么这样设计”的读者。
1. 为什么要流式,SSE解决了什么问题
1.1 传统HTTP响应模型为什么不适合大模型
先回到最朴素的HTTP请求-响应模型。浏览器发一个请求,服务器计算出完整结果,把Content-Length算好,一次性把响应体返回给客户端。这个过程是“全有或全无”:客户端拿到的是一块完整的数据,在它到来之前,客户端只能干等。
大模型生成内容的时候,这个模型就很不舒服。模型必须一个token一个token地做自回归生成,哪怕生成速度很快,一个几百字的回答也要跑好几秒。如果用传统模型,用户发出问题之后看到的就是一个转圈圈或者一个空页面,等三秒、五秒、甚至十几秒,屏幕上才突然蹦出整篇回答。这个等待过程在真实产品里是不能忍的,用户会以为系统卡死了,或者触发超时重试。
我第一次接大模型API时就在想:能不能把“生成一点、发送一点”变成可能?答案就是流式响应。服务器不计算完再返回,而是边算边把已经生成的增量推给客户端。用户看到的“打字机效果”背后,就是每个token从模型出来之后,经SSE通道被推送到了前端。
这跟餐厅上菜有点像。传统HTTP是“一桌菜全部做齐再上桌”,SSE是“做完一道上一道”。用户不需要等到最后一刻才能动筷子,第一道菜端上来的时间,远比整桌菜全部齐了要早,这个“第一道菜的时间”在大模型场景里有个专门名词叫TTFT(Time To First Token),是衡量流式体验的核心指标。
1.2 为什么推送这件事偏偏选了SSE
既然要流式推送,可选的技术方案并不少。WebSocket也能推,WebSocket也已经很成熟,为什么大模型服务几乎都默认选择了SSE?
第一个原因是“简单”。SSE不是一套新协议,它就是在HTTP响应里按约定格式持续写数据。不涉及握手升级、不涉及帧协议解析、不需要专门维护连接状态机。服务端想要实现一个流式接口,三五行代码就能推起来;客户端在浏览器里直接用EventSource就能接,连解析都不用自己写。
第二个原因是“穿透性”。WebSocket需要HTTP Upgrade到特定协议,很多企业内网的代理、网关、防火墙对Upgrade不友好,中间设备一多就容易出问题。SSE走的是普通HTTP响应,代理、负载均衡、HTTPS终端都不需要特殊处理,只要让请求保持连接不关闭就行。对To B、私有化部署场景,这个优势极其重要。
第三个原因是“语义匹配”。大模型输出本质上就是服务器往客户端单向吐数据,不需要客户端频繁往服务端发消息(除了最开始的那次请求)。SSE本身就是单向通道,服务端推给客户端,客户端不需要维持上行长期连接。那种“为了双向而双向”的过度设计,在这里没有意义。
所以一句话总结:不是WebSocket不够好,而是SSE在“单向推送、基于HTTP、低复杂度”这三个维度上,刚好卡在大模型流式输出的需求点上。
2. SSE协议的底层细节:报文、字段与连接
2.1 报文的最小规范:data、换行与事件边界
SSE协议从外面看,就是一个HTTP响应,关键在响应头里那个Content-Type: text/event-stream。只要这个类型声明了,客户端(尤其是浏览器)就会进入“事件流模式”,不再把响应体当做一个普通文本去等完整返回,而是按行解析后续到达的数据。
除了Content-Type,一般还会带上Cache-Control: no-cache。SSE是动态连续流,连缓存都不能要。HTTP/1.1下默认请求会保持连接,不用专门写Connection: keep-alive,但很多框架为了明确语义会主动带一个。
真正的协议内容在响应体里。格式看起来像纯文本,但每行都有含义:
data: {"message":"你好"} data: {"message":"世界"}每一行以“字段名: 空格”开头,带data:前缀的行表示一条事件荷载。一个SSE消息以空行结束,也就是连续两个换行符\n\n。服务端推送一条消息,就是先写一行或几行data:,然后补一个空行。
这里有个细节经常踩坑:一个事件可以有多个数据行。协议规定,同一个事件的多个data:行会被合并,以换行符连接,所以服务端如果想推一段多行文本,不要自作聪明地去掉换行,直接分多条data:发,客户端拼出来自然是对的。
协议还允许以冒号开头的注释行,比如: ping。注释行不会被当作事件,它的作用是“保活”,很多服务端拿它当心跳用。
2.2 事件元数据字段:id、event、retry到底干什么
除了data,SSE协议还定义了三个元数据字段,理解它们是掌握重连语义的关键。
id:是事件ID。服务端可以在每条消息里带一个自增或业务ID。客户端从流中断开时,浏览器会自动在重连请求里带上Last-Event-ID头,值就是最后收到的那个事件ID。服务端读到这个头,就知道客户端已经处理到哪了,可以从下一个事件继续发。这是SSE“断点续传”的实现基础。
event:是事件类型。可以不写,默认是message,浏览器里用onmessage或addEventListener('message')接收。如果要自定义事件名,可以写成event: update,客户端对应监听update事件。大模型场景里用得不多,但在流式日志、任务进度等业务场景里很有用。
retry:是重连时间。单位是毫秒,告诉浏览器如果连接断了,隔多久重连。不写的话浏览器各实现有自己的默认值,生产环境里我建议服务端主动发,比如retry: 3000,避免不同浏览器行为不一致。
一个完整的SSE事件长这样:
id: 1 event: message data: {"choices":[{"delta":{"content":"你"}}]}字段之间顺序无所谓,空行才是一件事的终止符。有个容易忽略的点:如果一行以未知字段名开头,浏览器直接忽略这一行,协议兼容性做得很好,不会因为一行坏数据整个流崩溃。
2.3 底层传输:没有Content-Length的HTTP怎么做到“发一点收一点”
普通HTTP响应一到,客户端第一件事就是找Content-Length,好知道响应体有多长。但SSE的响应长度是动态的、服务端自己也不知道最终会推多少字节,所以响应里不会带Content-Length。HTTP/1.1在这种情况下自动采用分块传输编码(Transfer-Encoding: chunked),响应体会被切成一块一块,每块自带长度,服务端每写完一小块,就往连接上刷一次,客户端就能立刻收到。
这个机制解释了一个常见现象:为什么SSE流在客户端看起来是“小块小块”到达的,而不是积攒到某个缓冲区大小才一次性出来。因为服务端只要主动调用响应对象的write/flush,数据就会沿着TCP连接发出去。
SSE连接的存续,完全由“不断开”决定。服务端推完所有数据,可以直接结束响应;也可以发一个业务层面的结束标记(比如大模型接口里常见的data: [DONE]),再优雅关闭。哪怕服务端不主动关,浏览器也始终认为连接活着,直到网络断开或超时。
从协议层面讲,SSE就是一行行纯文本加上一个保持打开的HTTP响应。没有复杂的编解码,没有二进制帧,正是这种朴素,让它在链路中能轻松穿透各类代理。
3. 大模型流式输出里的SSE实战
3.1 大模型接口返回的SSE流长什么样
大模型API的流式返回,各家实现大同小异,核心思想是一致的:每个SSE事件推送一个增量块,里面只包含相较于上一个事件的“变化量”。拿对话补全类接口举例,返回流大致长这样:
data: {"id":"123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} data: {"id":"123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]} data: {"id":"123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"好"},"finish_reason":null}]} data: [DONE]第一个事件往往带delta.role,通知客户端“助手开始说话了”;后续每个事件带一个delta.content,就是一个增量token,可能是半个词、一个字,甚至一个标点;最后一个事件是data: [DONE],标记流结束。
把整个流拼起来,客户端要做的事情是:初始化一条空的助手消息,然后把每个事件里的delta.content追加到这条消息里。不是替换,是追加。这个细节搞错的话,答案会只显示最后一个字。
finish_reason也是判断结束的重要信号。正常结束为stop,内容过长截断为length,主动停止为cancelled。业务层应该区分对待,尤其是length,前端一般要提示“回答超长被截断”。
3.2 服务端:几行代码实现一个SSE端点
自己写SSE端点,核心是把响应对象持续写而不关闭。我以Python FastAPI和Node.js为例各写一个典型版本。
FastAPI里最省事的是StreamingResponse,配合异步生成器:
from fastapi import FastAPI from fastapi.responses import StreamingResponse import json app = FastAPI() def generate_chunks(): # 模拟模型增量输出 for token in ["你", "好", ",", "世", "界"]: payload = {"delta": {"content": token}} yield f"data: {json.dumps(payload)}\n\n" @app.get("/sse") async def sse(): headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", # 让Nginx不要缓冲 } return StreamingResponse(generate_chunks(), media_type="text/event-stream", headers=headers)Node.js里用原生的HTTP响应对象更直接:
const http = require('http'); http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); const tokens = ['你', '好', ',', '世', '界']; let idx = 0; const timer = setInterval(() => { if (idx >= tokens.length) { res.write('data: [DONE]\n\n'); clearInterval(timer); res.end(); return; } const payload = JSON.stringify({ delta: { content: tokens[idx++] } }); res.write(`data: ${payload}\n\n`); }, 100); });几个服务端关键点:
- 不要调用
res.end(),直到全部数据推完。 - 如果想做心跳,隔几十秒写一个冒号注释行
:\n\n就行,不要推空事件,空事件会让前端触发一次空消息,容易引起业务误解。 - 框架的GZip压缩别开。压缩会引入缓冲,流式效果会被破坏。
- 客户断开时,连接会收到close事件,一定要清理定时器和生成器,避免后台任务泄漏。
3.3 客户端:EventSource与fetch两条路线
浏览器原生支持EventSource,用法极其简单:
const es = new EventSource('/sse'); es.onmessage = (event) => { const data = JSON.parse(event.data); appendDelta(data.delta.content); };但它有一个硬伤:EventSource只支持GET请求,无法自定义请求头。大模型API绝大多数要求POST加上Authorization: Bearer xxx。直接拿EventSource去接大模型API,是接不上的。
生产环境的主流做法是:用fetch请求接口,拿回一个ReadableStream响应体,自己按SSE格式逐行解析。
const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ messages: [...] }) }); const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8', { stream: true }); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // 最后一段可能不完整,留到下一次 for (const line of lines) { if (!line.startsWith('data:')) continue; const payload = line.slice(5).trim(); if (payload === '[DONE]') { reader.cancel(); return; } const json = JSON.parse(payload); appendDelta(json.choices?.[0]?.delta?.content || ''); } }这里decoder.decode(value, { stream: true })非常重要。SSE规范规定文本是UTF-8编码,而一个中文字符在UTF-8里占3个字节,网络分块可能刚好把一个字劈成两半。stream: true让解码器把不完整的字节留在内部,下一块数据到达时再拼接,不会出现�乱码。
4. 工程实测:流式输出的坑与排查实录
4.1 症状“流而不动”:代理、网关在帮你攒包
最典型的线上事故:“接口通了,代码看着没问题,但前端回复不是一字一字冒出来的,而是几秒后一次性蹦出一大段。”
这不是代码问题,是中间链路缓冲问题。Nginx默认开启proxy_buffering,它会尽量把上游响应攒在自己的缓冲区里,攒够大小或等到关闭才转发给客户端。在SSE场景里,这意味着事件流被Nginx“截胡”成了全量响应。
排查方法很简单:用curl -N直连后端服务,如果流式正常;再通过Nginx访问,如果变成一次性返回,基本就是缓冲问题。
解决方式有两招。或在Nginx配置里关掉缓冲:
proxy_buffering off;或后端在响应头里加X-Accel-Buffering: no,Nginx读到这个头会自动对这个请求禁用缓冲。我的习惯是后者,因为可以通过后端代码控制特定接口行为,不用动全局配置。
CDN节点同理。虽然大多数CDN能识别text/event-stream并关闭缓冲,但私有化部署里的企业网关、API网关不一定认识,常常会做全量缓冲。接入流式接口前,最好在测试环境先做一轮“全链路直连对比”,确认每一个中间节点都不缓冲。
另外,负载均衡器和网关的read timeout也要调大。SSE连接可能持续数分钟甚至更长,默认的60秒空闲超时会把连接掐断。对长思考时间的模型来说,超时阈值建议至少300秒,心跳保活则是双保险。
4.2 中文乱码:多字节字符被切在分块边界
用fetch流式读大模型输出,如果看到“�”或者间歇性缺字,就是解码姿势不对。本质是响应分块边界和字符编码边界不重合,一个UTF-8字符的3个字节,分到了两个网络包里。
正确的做法在前面代码里已经体现:始终用带{ stream: true }的TextDecoder。如果你的解析方式是reader.read()拿到原始字节后直接String.fromCharCode或者转成字符串再拼,就一定会遇到这个坑。
服务端同理。如果服务端不是真流式,而是先攒一个大的JSON字符串再一次写入,那中文不会乱码;但一旦变成“攒一小块写一块”,就必须保证每次写入的块边界落在完整字符边界上。Python里文件默认按字节写,用json.dumps之后再encode('utf-8'),一般不会切在中间;但手动切割字符串再编码时就要小心。
我在项目里干脆把前端SSE解析器封装成了一个模块:输入是字节流,内部维护字符串缓冲、字符解码、事件行切分,输出是格式化后的JSON对象。这样处理乱码、半行、多data合并的问题都集中在一处。
4.3 断线重连:Last-Event-ID没那么万能
浏览器原生的EventSource有自动重连能力,断线后会自动携带Last-Event-ID重新连接,服务端通过它续传。这在新闻推送、日志流场景下很好用。但放到大模型对话场景,很多团队发现这套机制不够用。
原因在于:大模型的对话流通常是“一次请求一次事件流”,流结束[DONE]之后这条SSE连接就结束了,不存在“中间断了从断点续传”的语义。如果用户网络闪断,最合理的方案不是SSE自动重连去追增量,而是业务层发一次新的完整请求,让模型重新生成,同时前端尽量保留已显示内容,避免用户感知明显跳变。
所以我的建议是:理解Last-Event-ID作为协议能力,但不要硬套到大模型场景。协议层重连属于“尽力而为”,业务层要做到的是“幂等重试”——前端记录已渲染的文本长度,重试时拿到全新流后,只展示超出部分,或者干脆从头展示并提示“网络不稳定,重新连接中”。
还有一个服务端容易忽略的问题:客户端断开后,服务端还在继续调模型、继续生成token、继续往已死连接上写数据。这些写操作最终会触发异常,但如果没监听,后台定时器和生成器会一直挂着。服务端务必在流的close事件里做清理,把模型调用也一并取消,既省钱又省资源。
4.4 鉴权、跨域与心跳:三个客户端侧的隐藏问题
EventSource无法自定义Authorization头,这在接入需要鉴权的大模型API时很麻烦。成熟的解法有三种。
一是后端做代理(BFF),前端只连自己的服务端,鉴权信息由后端代管,前端EventSource或fetch都不用直接暴露密钥。二是URL携带一次性token,适合短生命周期场景,但注意token会出现在网关日志里,别长期有效。三是放弃EventSource,直接用fetch携带Header,这也是我主要采用的方式。
跨域方面,SSE同样受CORS约束。服务端要配置Access-Control-Allow-Origin,如果前端用EventSource且需要带Cookie,还得设置EventSource的withCredentials: true,服务端同时返回Access-Control-Allow-Credentials: true,而且不能使用*通配Origin。这组条件配不好,前端会看到连接直接失败或事件能到但Cookie不带。
心跳的意义在于防止网络设备空闲断开。很多企业防火墙对“长时间无数据的连接”有清理策略。大模型思考时间稍长,可能连续几十秒没有任何输出,流连接看起来就像“死”了。服务端每15到30秒发一行注释:或一个自定义ping事件,就能让连接保持活跃。前端收到心跳事件时只需忽略,不要渲染。
5. SSE的选型边界与扩展玩法
5.1 SSE、WebSocket、长轮询怎么选
| 维度 | SSE | WebSocket | 长轮询 | 定时轮询 |
|---|---|---|---|---|
| 方向 | 服务端到客户端单向 | 双向 | 客户端到服务端(模拟推送) | 客户端到服务端 |
| 协议基础 | 纯HTTP | 独立协议,HTTP升级握手 | 纯HTTP | 纯HTTP |
| 自动重连 | 浏览器内置 | 无,需自己实现 | 无 | 天然反复请求 |
| 请求头自定义 | EventSource不支持;fetch可自己解析 | 支持 | 支持 | 支持 |
| 延迟 | 低 | 最低 | 较高 | 高 |
| 复杂度 | 低 | 高 | 中 | 最低 |
| 适用场景 | 单向事件流、大模型输出、通知 | 实时聊天、游戏、协同编辑、真正需要双向 | 老系统临时改造 | 低频弱实时需求 |
从我的实践看,选择标准其实很简单:如果是“服务端主动往客户端推”,并且客户端不太需要给服务端回消息,优先SSE。如果是“客户端和服务端都要随时发言”,才需要WebSocket。大模型输出是典型单向流,SSE在复杂度上优势明显。
5.2 不只能推大模型:SSE的通用变体与场景
SSE不止能推token。我把这类协议按数据形态分成几种通用格式:
- 标准SSE,用
data: ...\n\n分割事件,适合浏览器和规范约定。 - NDJSON变体,每一行一个JSON对象,用
\n分割,格式更简单,适合后端对后端、或者自己写的客户端解析。 - 二进制流,服务端直接写原始字节,前端用fetch加ReadableStream读,不走SSE格式,适合音频流。
在大模型产品里,我就用SSE推过多种业务事件:除了delta(增量token),还有status(任务状态:排队中、生成中、完成)、tool_call(Agent工具调用参数)、error(错误信息)。这些如果都塞在同一个data事件里,前端就得靠JSON字段区分类型;利用SSE的event字段,直接定义event: status、event: delta,前端监听不同事件名,代码清晰很多。
流式能力还能复用到很多场景:CI/CD的构建日志逐行外推、商品库同步进度、服务端指标看板实时刷新。SSE就是一个“服务器想说话就能随时说”的通道。
5.3 警惕SSE的边界:单向、连接数与HTTP版本
SSE的短板也很明确。
单向性决定它做不了“流式上传+流式输出”的双向实时会话。语音助手那种边说话边被打断、边听边回的场景,仍然得用WebSocket。还有一层限制:浏览器对同一个域名的并发连接数有限制,HTTP/1.1下大概是6个。如果页面同时打开多个SSE连接,第7个会一直等待。这个问题在HTTP/2到来后缓解,HTTP/2支持多路复用,一个TCP连接可以承载多个SSE流,这也是新协议能提升SSE体验的底层原因。
代理兼容性也要注意。非常老的中间组件遇到长时间不结束的HTTP响应,可能直接按超时处理;遇到Transfer-Encoding: chunked,也可能错误地缓冲。上线前对每个中间环节做一次真实的流式压测,比在代码里找半天问题有效得多。
最后,SSE没有规定消息编码,但实际约束一定是UTF-8。非UTF-8文本都会出乱码,服务端生成内容时,先统一做好编码转换,再进入流。
我个人做流式接入最大的体会是:SSE本身极其简单,真正的复杂度全在链路。协议规范读一遍就能懂,但Nginx缓冲、CDN策略、浏览器解码、鉴权方式、重连语义、心跳设计,每一环都可能把简单的事情弄复杂。所以越早把这一整条链路摸清,后面排查问题就越顺手。如果你即将接手一个流式大模型应用,我的建议是先写一个透传SSE的代理Demo,用curl压一遍直连和经过网关两条路径,把链路里的“隐形缓冲区”全部找出来,再开始写业务代码。这样后面每一步,你都清楚地知道数据是怎么从模型流到用户屏幕上的。