☰
SSE协议原理与实战:大模型流式输出的底层机制
2026/10/21 17:57:21 网站建设 项目流程

先问个问题:你在网页上点开一个 AI 对话界面,看到文字一个字一个字地蹦出来,那种“打字机”效果到底是怎么实现的?如果你以为是前端做了个打字动画,那可就误会大了。实际上,大部分大模型应用都是把模型生成的 token 一边生成一边通过网络传给浏览器,而承担这个“边生成边传”任务的协议,就是标题里说的 SSE,全称 Server-Sent Events,也就是“服务端发送事件”。我最早接触 SSE 还是多年前做股票行情推送的时候,后来做大模型应用才发现,这套老协议在新场景里反而活得比谁都滋润。这篇文章我会把 SSE 的底层机制拆开讲透,包括它在 HTTP 协议栈里的位置、报文格式、浏览器的事件流解析逻辑,以及和大模型输出场景的匹配关系,也会分享一些实际接入时容易翻车的细节。

1. 为什么会是 SSE:大模型输出的天生“长耗时”属性

1.1 从一次普通 HTTP 请求说起

要理解 SSE 为什么能成为大模型流式输出的主流选择,得先回到最基础的问题:HTTP 请求到底是怎么工作的。一个标准的 HTTP 请求,客户端发出去之后,服务端会把完整的响应体构造好,然后一次性返回给客户端。浏览器拿到响应体之后,解析渲染,请求结束,连接关闭。这套模型在绝大多数场景下都没有问题,因为它假设了一件事:响应体是“完整”的。

但大模型生成文本非常不符合这个假设。你丢给它一句“给我写一篇文章”,服务端调用模型接口之后,模型并不是等整篇文章全部生成完毕才给出结果,而是像人说话一样,一个字、一个词地往外吐。有的模型首字延迟只有几百毫秒,但整段生成可能要几十秒甚至更久。如果按照传统 HTTP 的方式,客户端只能干等,等到模型完全跑完才能拿到全部内容。这样带来的问题有两个:一是用户的等待感非常糟糕,面对空白屏幕几十秒,焦虑感直接拉满;二是如果中间模型推理失败或者网络断了,用户连一点已生成的中间结果都看不到,体验就更差了。

所以,最合理的思路是:服务端每生成一个 token,就立刻把它“塞”给客户端,让浏览器边收边显示。这本质上就是“流式响应”,而 SSE 恰恰是跑在 HTTP 之上、专门做服务端向客户端单向流式推送的一套标准机制。它不需要什么复杂的二进制协议,也不需要额外维护一套连接状态,只要遵循固定的文本格式,就能把一个长任务的中间过程分块送达。

1.2 大模型的逐 token 生成逻辑

再往深一层看,大模型本身就是天然适合流式输出的。现在主流的生成式大模型,无论底层是哪种架构,核心都是“自回归生成”:模型根据已经生成的内容,预测下一个 token 的概率分布,然后采样得到下一个 token,把这个 token 拼接到已有文本中,再继续预测下一个。这个循环会一直持续,直到遇到结束符或者达到最大长度限制。

这个机制决定了,模型的输出天然是“顺序生成、逐步可用”的。前面生成的内容一旦产生,就不会再改变(除非用了某些特殊的重写机制),完全可以在生成后续内容的同时同步推送给用户。所以,把大模型接进应用时,几乎所有成熟的框架和云服务都会在 N 层接口里预置流式输出模式,也就是在参数里加一个"stream": true之类的开关。服务端拿到这个参数后,模型每产出一点内容,就通过 SSE 格式推送出来。

我在本地部署过几款开源模型,也调用过几家云端推理 API,可以负责任地说,只要对实时性有要求的对话类应用,流式基本是标配。你要是不用流式,就等着被用户吐槽“转圈转半天”。所以说,理解 SSE 不是学一个冷门协议,而是理解大模型应用开发里最基本的一环。

2. 核心机制:event-stream 格式与连接生命周期

2.1 响应头与媒体类型

SSE 之所以能在 HTTP 之上实现“服务端持续推送”,关键在于响应头里的一个字段:Content-Type: text/event-stream。只要服务端把这个媒体类型返回给浏览器,浏览器就会进入一种特殊模式:它不会等待响应体结束,而是把连接保持打开,持续读取新到达的数据块。

这里有个容易混淆的点:SSE 并不是一个新的传输层协议,它仍然是标准的 HTTP,走的还是 80 或 443 端口。和普通请求相比,区别只是“响应不立即关闭”。从 TCP 的角度看,连接建立之后,服务端可以持续往同一个 socket 里写数据,客户端持续读。HTTP/1.1 里有一种叫 chunked transfer encoding 的机制允许这种不预先声明 Content-Length 的响应,SSE 通常会配合它,或者干脆使用分块帧来维护连接。不过在实际开发中,你不需要手动去处理分块编码的细节,框架和浏览器已经把这一层屏蔽掉了。你真正要处理的,是 data 字段和事件汇逻辑。

另外,还有一个一般很少有人注意但实际很关键的字段:Cache-Control: no-cache。如果不用这个头,某些浏览器或者中间层缓存可能会把整个流缓存下来,直接破坏流式的实时性。再有就是Connection: keep-alive,这个在 HTTP/1.1 里是默认行为,但在某些网关配置下需要显式声明,否则连接可能被意外关闭。

2.2 数据帧格式与字段规则

SSE 的报文格式看起来非常像配置文件,每一条消息由若干“字段: 值”的行组成,字段名主要有四种:data、event、id、retry。消息之间用空行分隔。举个例子,一条最简单的 SSE 消息长这样:

data: 你好 data: 世界

浏览器在解析时,会把空行之前的所有data:行拼起来,作为一个完整的消息内容,然后触发onmessage事件。如果你需要区分消息类型,可以用event:行自定义事件名,比如:

event: delta data: {"content":"你好"} event: done data: 【END】

前端这边,可以通过addEventListener('delta', callback)这样监听自定义事件,也可以用onmessage统一接收所有消息。官方规范里,data可以有多行,多行会被拼成一个整体,用换行符连接;event只有一个值,用来标识消息类型。这个设计在推送聊天片段、工具调用指令、运行状态变更等场景里很实用。我自己的做法是把事件分三类:delta负责增量文本,status负责上报处理进度,done负责标记结束,这样前端解析逻辑就非常清晰。

还有一个容易踩的细节:SSE 消息里的换行符是\n,不是 Windows 习惯的\r\n。虽然现代浏览器对\r\n也做了兼容处理,但如果你自己写协议解析器(比如在 Node.js 环境里手动解析),最好统一按\n处理,在读取时把\r去掉,否则最后一个字符可能总会有个莫名的回车。

2.3 事件 id 与断线重连机制

SSE 规范里自带断线重连的设计,这正是它相比普通 HTTP 请求最大的优势之一。每次推送消息时,你可以给消息带一个id:字段,比如:

id: 42 data: {"content":"xxx"}

浏览器内部会记住最后收到的那个 id。如果连接意外断开,浏览器重新发起连接时,会在请求头里带上Last-Event-ID字段,值是上一次收到的 id。服务端读到这个值后,就能知道客户端已经收到了哪一条消息,从而从断点继续推送而未送达的部分。这套机制实现起来很简单,但对顺序敏感的大模型输出场景来说非常重要。

retry:字段则用来控制重连间隔,单位是毫秒。服务端可以主动告诉客户端“如果断了,请等 3000 毫秒再重连”。我在接入时试过把 retry 设置成毫秒级,但在弱网环境下重连太频繁会加剧拥塞,所以还是建议给一个合理的折中值,比如 1000 到 3000 毫秒。

需要特别说明的是,浏览器的自动重连依赖原生 SSE 的实现。在原生 EventSource 里,这些机制是开箱即用的。一旦你改用 fetch 配合 ReadableStream 做流式解析,就得自己处理重连逻辑,这是后话,会在下面专门展开。

3. 大模型场景里的方案选型:为什么不是 WebSocket 也不是轮询

3.1 轮询的性能账

很多人一开始想到的是轮询:前端每隔一两秒请求一次,问服务端“有没有新的内容”。本质上,这只是把“等待响应”换成了“反复询问”。在数据量小、并发低的时候,轮询确实能跑通,但大模型场景里的并发连接数通常不低,轮询会产生大量根本没用返回内容的空请求,对网关和模型服务本身都是负担。举个例子,一个用户等待一段 30 秒的生成,如果每 1 秒轮询一次,就要发 30 个 HTTP 请求,而其中可能有一半以上没有新数据。如果同时在线的用户超过几千,这些请求就会把入口网关的带宽和连接占用豁豁地打满。

轮询还有一个难处理的问题:新数据的延迟不可控。哪怕模型已经在第 2 秒生成了第一批 token,如果前端恰好第 3 秒才来查询,用户就要白等这一秒;如果查询间隔更长,延迟就更明显。这种“以时间片换实时性”的策略,在要求流畅对话体感的场景里很难让人满意。

3.2 WebSocket 的双向性与大模型场景的错配

提到流式,很多人紧接着想到 WebSocket。毕竟 WebSocket 是真正的全双工协议,客户端和服务端都可以随时推送数据,很多聊天室、协作编辑、游戏应用都拿它做实时通信。但放到大模型输出场景里,WebSocket 其实属于“杀鸡用牛刀”,而且这把牛刀还可能带来一些不适配。

WebSocket 的第一个问题是它需要专门的协议握手,走的是ws://或wss://协议,而不是普通的 HTTP。这意味着一旦使用了 WebSocket,整个链路上的网关、负载均衡、日志系统都需要额外适配。很多云厂商的负载均衡器默认不支持 WebSocket 的升级请求,你还得专门去开开关。相比之下,SSE 依然是一个标准的 HTTP 响应,只是一直不结束而已,所有现有的 HTTP 中间件、网关、日志分析工具都能原样工作。

第二个问题在于大模型输出本身是单向的:大部分场景里,用户只需要“看”模型输出,不需要持续向服务端发送高频数据。用户输入一条指令,后续就是被动接收 token 流。WebSocket 的双向通道基本是浪费的,反而带来额外的状态维护成本。虽然有的应用需要支持“用户随时打断生成”这类功能,但只要服务端单独提供一个取消接口即可,不需要为此专门建一条全双工连接。所以我的结论是:如果只是为了展示大模型输出,优先用 SSE;如果应用本身就有双向实时交互需求,比如多人协作编辑画板,那再考虑 WebSocket。

3.3 分块传输编码与 SSE 的关系

其实 SSE 的“流”最终是靠 HTTP 的 chunked transfer encoding 在底层支撑的。当服务端响应一个text/event-stream类型时,HTTP 层无法预先知道响应总长度,因此Content-Length不会被设置,传输层自动采用分块编码:每个数据块前有十六进制长度标识,块结束后发一个空的终止块。你可以用curl抓一下看实际报文,会看到诡异的一串十六进制数字出现在响应体前面,那就是长度标识。

理解了这一点,你就能明白为什么 SSE 不能和 HTTP/2 的某些特性简单混用——HTTP/2 的多路复用把连接粒度切得更细,SSE 的流也经常被当成一个单独的流帧来传输,但好在大部分现代浏览器和 Web 服务器已经替你处理好了这些细节。只需要明确一点:SSE 不是一个新协议,它只是利用了 HTTP 本身就有的“响应不立即结束”能力,加上一套简单的文本事件格式。底层传输的细节,交给协议栈就好。

4. 端到端接入实战:从服务端分块推送到前端逐字渲染

4.1 服务端:按 token 推送的实现思路

服务端要做的事情,核心就是一句话:在流式生成模式下,拿到模型产出的 token,把 token 包装成 SSE 格式写进响应体。用什么语言和框架不重要,关键是这个 loop。我拿一个 Python 版的示意来说明:

from fastapi import FastAPI from fastapi.responses import StreamingResponse app = FastAPI() def mock_model_stream(prompt: str): # 模拟大模型逐个 token 生成 for piece in ["你", "好", ",", "这", "是", "一", "个", "流", "式", "示", "意"]: yield piece @app.get("/chat") async def chat(prompt: str): def event_stream(): yield 'event: status\ndata: {"stage": "start"}\n\n' for token in mock_model_stream(prompt): payload = f'event: delta\ndata: {token}\n\n' yield payload yield 'event: done\ndata: 【END】\n\n' return StreamingResponse(event_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})

这里有两个必须注意的地方。第一个是media_type必须等于text/event-stream,否则浏览器不会进入流式解析模式。第二个是每条消息结束必须有一个空行,即两个\n,这个空行是消息边界。很多刚接触 SSE 的人,经常写了data: xxx\n就以为完事了,结果前端迟迟收不到onmessage,其实是漏了空行。

在实际的大模型接入中,你不会直接 mock token,而是调用模型服务的流式接口。比如用 OpenAI 兼容的接口时,会让stream=True,然后遍历返回的事件,把choices[0].delta.content取出来转发给前端。这里有一个小的工程化技巧:最好在服务端做一层透传缓冲,比如积攒 20 到 50 毫秒的 token 再批量推送,防止每个 token 都产生一次网络写操作。否则模型生成速度快的时候,几十个小包会在短时间内涌入,白白增加网络开销。

4.2 前端:原生 EventSource 与 fetch 流式读取的选择

前端接到 SSE 有两条路:原生EventSource和fetch配合ReadableStream。原生EventSource用起来最简单,代码量最少:

const es = new EventSource('/chat?prompt=你好'); es.addEventListener('delta', (e) => { const text = e.data; document.getElementById('output').textContent += text; }); es.addEventListener('done', () => { es.close(); }); es.onerror = () => { console.error('连接异常'); };

这套代码跑起来很顺,而且自带断线重连。但它的局限也很明显:EventSource 只能发 GET 请求,不能自定义请求头。一旦你的鉴权方式是通过Authorization头实现(比如 Bearer Token),那就没办法用 EventSource 了。另外,EventSource 不支持 POST 请求体,如果你想通过 POST 发送比较长的 prompt,也不方便。

这时候就需要用 fetch 来做流式读取。思路是这样:fetch 发出请求后拿到response,但不要急着response.json(),而是取response.body,通过getReader()拿到一个ReadableStreamDefaultReader,然后循环read()读取底层字节块,再把字节块按照 SSE 格式手动切分成消息:

const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer xxx' }, body: JSON.stringify({ prompt: '你好' }) }); 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 }); const messages = buffer.split('\n\n'); buffer = messages.pop(); for (const msg of messages) { parseSSEMessage(msg); } }

这段代码是我自己常用的骨架。需要注意TextDecoder加{ stream: true }参数,防止一个多字节 UTF-8 字符正好被拆成两个 chunk 时出现乱码。每次读取后先把收到的字符串拼到buffer里,再按\n\n分块,最后一块不是完整消息,要先暂存在buffer里,等下次数据到达再继续拼接。这是流式解析里最常见的细节,我见过不少人漏掉一次,最后第一个中文总是乱码。

4.3 实测中的验收清单

接入完成后,别着急上线,先用一种工具实测一遍。我自己有一个常用的验收清单:

  • 用curl -N直接请求服务端接口,确认响应头是Content-Type: text/event-stream,并且能看到数据是一个个吐出来的,而不是卡到最后一起出现。
  • 检查每条消息之间是否严格遵循空行分隔。
  • 故意在生成到一半时断网(比如拔网线或直接关进程),确认重连后能拿到增量续推,或至少能优雅降级显示“连接中断”。
  • 连续生成一大段文本,确认中文没有乱码、尾巴没有多余字符。

这个清单看起来简单,但每一条背后都可能藏着一个隐藏很深的 bug。服务端如果忘了正确设置响应头,前端可能收到的是text/plain类型,浏览器不会正常触发事件,甚至直接把它当成下载内容处理。

5. 实战踩坑记录:代理缓冲、断流重连与中文编码

5.1 Nginx 代理缓冲吞掉首字节

上线时最容易遇到的一个问题,就是前面明明在本地跑得好好的,一放到公网就变成“一秒内全量输出”。排查到最后,罪魁祸首往往是 Nginx 的缓冲。Nginx 默认会对上游的响应做缓冲,也就是它会把上游分批吐出来的内容先攒到自己的缓冲区里,等攒够了或者达到一定超时时间后,再一次性发给客户端。对于 SSE 这种细水长流的响应,默认的缓冲策略非常致命,直接把流式效果给吞了。

解决办法是在 Nginx 配置里针对 SSE 请求的 location 关闭缓冲:

location /chat { proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding on; proxy_read_timeout 3600s; }

这里proxy_buffering off是核心,proxy_read_timeout调成比较大的值是为了防止长时间没有新数据时被网关掐断连接,而proxy_http_version 1.1是为了确保上游和下游之间能正常使用长连接和分块编码。部署完这一步,再验证一下首字节延迟,就会发现恢复成了“逐字蹦出”的效果。类似的坑在其它反向代理服务里也存在,原则都一样:只要中间有缓存层,就要显式关掉缓冲。

5.2 前端增量渲染的粘包与错位

还有一个容易出问题的点,是前端的“增量文本”如果不直接拼接而是通过innerHTML插入,可能会被浏览器重新解析,导致已渲染的内容和后续内容之间的空白和换行异常。我建议在对话展示区尽量用纯文本节点追加的方式,比如:

const span = document.createElement('span'); span.textContent = deltaText; container.appendChild(span);

用textContent而不是innerHTML追加,一方面避免编辑器自身的安全风险,另一方面也能保证后端发来的文本原样显示,不会被 HTML 标签语义误解。如果你需要让局部内容支持富文本加粗、代码高亮,则复杂很多,通常会先缓存原始增量文本,再用一个定时器做节流渲染。这类问题不属于协议本身,却直接影响输出观感,很容易被忽视。

5.3 断线重连后回到起点

利用原生 EventSource,断线重建是自动的。但如果你用了 fetch 自建流式解析,就一定要手动处理重连。最简单的做法是在reader.read()返回done,或网络异常触发catch时,判断当前是否已经收到终止事件,如果没收到,就延迟一段时间重新发起请求,同时带上Last-Event-ID(如果服务端支持)或者记录已渲染的字符串长度,让服务端决定如何续推。最简单可靠的方案是:不做增量续推,直接重新请求整段生成结果,然后把渲染区清空重来。

为什么大模型场景里重连这么必要?因为模型生成时间长,网络波动几乎是必然的。而且在某些移动网络环境下,代理或运营商会静默断开空闲连接,导致连接看似正常但实际已经不收数据。应对办法是在协议层定期发送一个心跳消息,利用data: ping之类的注释行保活,或者用前端定时器检测“超过 N 秒没有收到任何数据”,主动触发重连。可以说,把断流处理做好,流式体验才算真正的闭环。

6. 进阶优化:把 SSE 用得更贴近生产环境

6.1 动态停止生成与用户打断

对话类应用几乎都要支持“用户点击停止按钮,立刻打断生成”。在原生 EventSource 模式下,你可以通过es.close()关闭连接的接收端,但服务端可能还在继续生成,白烧算力。更优雅的做法是提供一个控制接口:

  • 服务端在收到模型 token 时,同时检查一个全局的取消标记;
  • 前端在用户点击“停止”时,调用POST /cancel接口通知服务端;
  • 服务端把取消标记置位,模型流式循环内部感知到后,主动结束循环,并推送一条event: cancelled消息告知前端。

这个方案比单纯关掉连接要稳妥。因为关闭连接后,客户端不再收消息,但服务端生成进程如果没有及时终止,资源会一直占着。尤其是在大模型推理服务并发很贵的情况下,状态清理做得不好,成本控制就成了问题。

6.2 分层组件复用与统一解析库

在一个稍微复杂一点的项目里,多个页面可能都要消费 SSE 流,比如对话页、代码生成页、Agent 任务执行页。此时如果每个页面都写一遍 fetch 解析逻辑,非常容易出错。我的建议是封装一个统一的“流式事件源”工具类,对外暴露onDelta、onStatus、onDone之类的回调,内部维护连接状态、缓冲区和重连逻辑。之后页面只需要关心回调里的数据,不需要触碰协议的细节。

除此之外还有一个小技巧:如果不想在前端手动解析 SSE 格式,也可以利用框架的现成组件。不过我还是建议至少手写一遍解析流程,因为只有亲手处理过分块、UTF-8 解码和Last-Event-ID,你才能真正理解 SSE 的边界在哪。以后遇到奇奇怪怪的乱码现象或者丢消息,你才有排查的方向。

6.3 监控与日志沉淀

生产环境里,SSE 流的稳定性不只是“能不能连上”的问题,还包括首字节时间、单次生成吐字速度、断线率、平均重连次数这些指标。建议在封装层里埋日志:每收到一条消息,记录当前时间戳和累计收到的字符数。这样在事后回放问题时,能清楚看到“第 20 秒到第 25 秒之间没有新数据”,就可以推测是模型卡住还是网络问题。这些日志的成本很低,价值却很大。

写在最后:几个让我印象深刻的经验

在实际接入 SSE 的过程中,我最大的感受是:协议本身极其简单,难点全在工程细节。比如 Nginx 默认缓冲这种问题,你要是不了解反向代理的工作原理,可能排查一整天都找不到方向;断线重连这种机制,只要忽视一次客户端代理的自动重连,用户就会在最关键时刻看到“转圈卡死”。

另外还有一个非常实用的建议:在服务端写 SSE 推送时,记得给每条消息都加上连接序号或时间戳。这乍看是多余字段,但排错时能帮你快速定位消息顺序是否错乱。我做过一次联调,那边反馈“收到的内容顺序不对”,最后发现是他们的网关改了包顺序,如果没有序号字段,这个问题几乎不可能发现。

如果你现在正打算给自己的大模型应用上流式输出,我的建议很直接:先按原生text/event-stream把端到端跑通,再用 curl 验证协议细节,最后再考虑封装组件和重连逻辑。别一上来就追求复杂方案,SSE 能成为大模型流式输出的底层事实标准,恰恰因为它在简单和够用之间找到了很好的平衡。

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

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

立即咨询