云计算百科
云计算领域专业知识百科平台

一文搞懂 SSE:大模型逐字输出的背后,服务器是怎么“主动说话“的?

和大模型聊天的时候,回答不是一次性给出,而是一个字一个字往外流;打开股票行情页,价格自己跳,不用刷新;网页版微信里,新消息自己弹出来。这三个场景背后是同一个问题:浏览器和服务器之间,话是怎么传的? 干这件事的协议主要有三个:HTTP、WebSocket 和 SSE。它们不是谁取代谁,而是一条演进线上的分工:HTTP 定下"一问一答"的基本盘;轮询和长轮询是老框架里的变通;WebSocket 和 SSE 是后来定稿的两个正式答案,一个管双向,一个管单向。大模型的逐字输出,用的正是其中的 SSE。

HTTP:一问一答的世界

HTTP 的规矩一句话就能说完:客户端问,服务器答;不问,不答。 打开一个网页,浏览器发出一个请求:第一行写清方法(GET、POST……)和路径,后面跟着一串头部字段,交代自己是谁、能接受什么格式。服务器回一个响应:第一行是状态码(200 成功、404 没找到、500 服务器出错),后面是头部和正文。 两个特点,决定了后面整条演进线的走向:

  • 无状态:每次问答相互独立,服务器天生不记得“上次那个谁”。登录态靠的是客户端每次主动掏出凭证(Cookie、令牌),而不是连接本身有记忆。
  • 客户端先开口:响应永远挂在某个请求后面,服务器没法主动找到浏览器说“我这有新东西”。

连接层面,HTTP 一直在给自己提速:HTTP/1.0 时代每问一次都要重新建一次 TCP 连接;1997 年的 HTTP/1.1 默认开启 keep-alive,一条连接可以连着问好几次;2015 年的 HTTP/2 在一条连接上多路复用,多个请求响应交错传输、互不排队;2022 年的 HTTP/3 干脆把底层的 TCP 换成了 QUIC。但这些进化省的都是“问答”的成本,“只能客户端先开口”这条规矩,一个字没动过。

服务器想主动开口:轮询与长轮询

早期网页需要"新消息提醒"时,工程师只能在 HTTP 的规矩里想办法。既然服务器不能主动说,那就让客户端勤快点问。

轮询:每隔几秒发一个请求——“有新消息吗?”"没有。"过几秒再问:“有新消息吗?”“没有。”……实现简单,代价也直白:绝大部分请求是空跑的,白白消耗带宽和服务器资源;消息真来的时候,也要等下一个问点才能被发现,时效性取决于间隔长短。间隔调短,空跑更多;调长,消息更慢。

长轮询把空跑压了下去:客户端发问后,服务器不急着答,把请求挂在那儿;什么时候有消息了,什么时候才把响应发回来;要是一直没有,拖到超时(比如 30 秒)就回个空的,客户端收到后立刻再问一轮。消息送达的延迟从"半个轮询间隔"降到接近零,空跑也少了。但本质上每条消息仍要完成一次完整的一问一答,挂起的请求占着服务器的连接资源,实现上还要自己处理超时、重连、消息顺序这些细节。

这类技巧当年有个统称叫 Comet。能用,但说到底,这是在"一问一答"的框架里打补丁。 在这里插入图片描述

WebSocket:一次握手,换来一条双向专线

2011 年 12 月,RFC 6455 定稿,WebSocket 成为正式标准。它的思路是不再打补丁:既然"一问一答"不够用,那就通过一次握手,把连接升级成一条双向专线。

**握手本身借 HTTP 完成。**客户端先发一个看似普通的 HTTP 请求,只是头部带了两个特殊字段:

GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Upgrade: websocket 的意思是:这次问答结束后,这条连接想换个协议继续用。服务器如果同意,就回一个 101(Switching Protocols):

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Accept 是把客户端发来的 Key 拼上一段固定的 GUID,算一遍 SHA-1 再做 base64。这不是加密,而是一次对暗号:双方以此确认对方真的懂 WebSocket,而不是恰好回了个奇怪的响应。

握手完成之后,同一条 TCP 连接上的规矩就换成了 WebSocket 的:通信单位变成一个个帧,文本、二进制都行;双方在任何时刻都可以主动发帧,不用等对方先问——客户端和服务器从"一问一答"变成了平等对话。协议里还有专门的心跳帧(ping/pong)用来保活:长时间没人说话,连接也不会被中间设备悄悄掐掉。

有一个容易被忽略的规定:客户端发出的帧必须做掩码处理。原因是浏览器里的脚本不可信——掩码让发出的字节流带上随机性,防止恶意代码精心构造出长得像 HTTP 请求的报文,污染链路中间的缓存代理。

代价也要看清:WebSocket 是独立于 HTTP 的一套协议,反向代理、负载均衡都得认得它;连接常驻,十万百万级的长连接对服务器是实打实的负担;断线重连、消息确认这些,协议不管,应用层自己补。

它适合的场景因此很清楚:双向、高频、低延迟——聊天室、协同编辑、多人游戏、实时行情。

在这里插入图片描述

SSE:服务器单方面的"长篇连载"

实际上,很多"推送"场景并不需要双向:消息通知、任务进度、日志输出……客户端从头到尾只想安静地听。为这种场景拉一条双向专线,有点浪费。

同一时期的 HTML5 标准里给出了另一个答案:SSE(Server-Sent Events)。做法很直接:客户端发一个普通的 HTTP 请求,服务器回一个普通的 HTTP 响应,只是 Content-Type 是 text/event-stream,然后这个响应不结束——服务器有新内容,就按格式往里写一段:

id: 1
data: {"text": "你"}

id: 2
data: {"text": "好"}

一行一个字段,空行把一个个事件分隔开:data 是内容,id 是编号,还可以用 event 指定事件类型。一个事件里写多行 data:,浏览器会用换行符把它们拼成一整段。

规范定义的字段一共四个:上面三个,外加一个服务断线重连的 retry;写其他字段名,浏览器会直接忽略——这是给将来预留的扩展位。字段之外还有一种特殊的行:以冒号开头的是注释,不解析、不触发事件。它不是摆设:SSE 连接长时间没有事件,可能被链路中间的代理当成死连接掐掉,服务器每隔一阵发一行 : ping,连接就能一直保活。

服务器端写出这样的流,用 Python 只要一个生成器(先 pip install fastapi uvicorn):

import asyncio
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

@app.get("/stream")
async def stream():
async def events():
yield "retry: 5000\\n\\n" # 可选:断线后 5 秒再重连(默认约 3 秒)
for i, text in enumerate(["你", "好", ",世界"], start=1):
data = json.dumps({"text": text}, ensure_ascii=False)
yield f"id: {i}\\ndata: {data}\\n\\n" # 每个事件都写上编号
await asyncio.sleep(0.5) # 慢慢推,模拟逐字生成
notice = json.dumps({"msg": "推送结束"}, ensure_ascii=False)
yield f"event: notice\\ndata: {notice}\\n\\n" # 自定义事件:名字就是在这行起的
return StreamingResponse(
events(),
media_type="text/event-stream",
# 防止 nginx 把响应攒在手里
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)

# 运行:uvicorn main:app

生成器每 yield 一次,浏览器就收到一个事件;代码里从头到尾没出现"流"的 API,产出的就是上面那段格式。事件的编号同样没有专门的设置接口——id: {i} 就是写进事件文本里的一行,浏览器收到带 id 的事件,会自己记住这个编号。而且这段代码是异步的——同时挂几千条流,也不用开几千个线程。浏览器侧的代码更短:

const source = new EventSource("/stream");

source.onmessage = (event) => {
console.log(event.data); // {"text": "你"}
console.log(event.lastEventId); // "1"
};

source.addEventListener("notice", (event) => {
console.log(event.data); // {"msg": "推送结束"}
source.close(); // 收到约定的收尾标记,主动断开(不然会被当成断线,自动重连)
});

// 网络断线不用写代码,浏览器会自动重连

事件的名字是服务器在 event: 行里起的,浏览器按这个名字分发;没写 event: 的事件一律按 message 处理,走 onmessage。格式本身平淡无奇,妙处要在断线的时候才显现出来。

断线之后怎么接上:id 与 Last-Event-ID

网络抖动、服务器重启,长连接断开是迟早的事。EventSource 的第一反应是自动重连,不需要任何额外代码;但它不是简单地"再连一次",而是能从中断的地方接着收。靠的就是上面的 id 字段和一个专用的请求头。

浏览器每收到一个带 id 的事件,就记住这个编号。假设连接在 2 号事件之后断了:EventSource 等大约 3 秒(服务器可以在流里写一行 retry: 5000 改掉这个时长),自动重新发起请求——这次的请求会多带一个头部:

GET /stream HTTP/1.1
Last-Event-ID: 2

意思是"我收到 2 号了"。这里按 HTTP/1.1 的文本格式写出来是为了好认——在 HTTP/2 上,请求并没有这样的文本行,方法和路径装进 HEADERS 帧的 :method、:path 伪头部,Last-Event-ID 则原样带上:这套机制与 HTTP 版本无关。服务器看到这个头,从自己留存的消息记录里把 2 号之后的事件补发一遍,然后接着推新的:

id: 3
data: {"text": ",世界"}

对客户端代码来说,这一切完全无感,就像流从来没有断过。

服务器端要做的配合,只是开头看一眼那个头:

from fastapi import Request

@app.get("/stream")
async def stream(request: Request):
last_id = int(request.headers.get("last-event-id", 0)) # 首次请求没有,按 0 算
async def events():
for i, text in enumerate(messages, start=1):
if i <= last_id:
continue # 断线重连:跳过对方已经收到的
data = json.dumps({"text": text}, ensure_ascii=False)
yield f"id: {i}\\ndata: {data}\\n\\n" # 编号照常写上
return StreamingResponse(events(), media_type="text/event-stream")

真实实现里,messages 会换成消息日志或队列的游标,意思不变。

这套机制有几个边界,用的时候心里要有数:

  • 补发能力在服务器手里:服务器要把最近的事件留一份(通常是内存里的一段窗口),才能响应 Last-Event-ID。客户端断得太久、id 已经滑出窗口,服务器就从最新的内容接着推——断点续传保证的是"尽量接上",不是消息队列式的可靠投递。
  • id 只在页面存活期内有效:最后收到的 id 存在 EventSource 对象里,刷新页面就清零,重新打开是一个全新的流。
  • 没收到过 id,就没有这个头:流里一直没出现 id 字段时,重连请求里也不会有 Last-Event-ID;反过来,不带 id 的事件也不会冲掉浏览器已经记住的编号。
  • 服务器有办法让客户端停下来:响应一个 204 No Content,EventSource 收到后就不再重连——这是规范里唯一的"停止"信号。

一条连接,上百条流:SSE 怎么跑在 HTTP/2 上

SSE 的响应“永不结束”,而 HTTP/1.1 里一条 TCP 连接同一时间只能服务一个请求-响应——响应不结束,连接就一直被占着。开几条 SSE 就占几条连接;浏览器给每个域名只留 6 条左右的连接额度,一旦占满,这个域名下的普通请求也只能排队。听上去 SSE 和浏览器天生犯冲,但现实中这个问题几乎不存在,因为今天的 SSE 大多跑在 HTTP/2 上。

HTTP/2 换了一套玩法:一条 TCP 连接里划出许多条逻辑上的“流”。每个请求-响应占一条流,数据被切成带流编号的帧,多条流的帧在连接里交错传输、互不阻塞,接收方按编号把帧归位。SSE 响应只是其中一条迟迟不发"结束"标志的流——它挂多久都行,同域名的其他请求照常在别的流上跑。

在这里插入图片描述

并发能力的口径也随之改变:HTTP/1.1 数的是连接(6 条),HTTP/2 数的是流——服务器在握手时通过 SETTINGS 帧宣告每条连接的最大并发流数,常见配置是上百条。一个标签页同时挂几十条 SSE,对浏览器来说仍然只是一条 TCP 连接的事。

想亲眼确认很简单:打开浏览器开发者工具的 Network 面板,把 Protocol 列调出来——这一列里的 h2 就是 HTTP/2 的协议代号。显示 h2 的站点,所有请求(包括 SSE)都复用在同一条连接上。api.openai.com 和 api.anthropic.com,显示的都是 h2。

自己的服务要怎么开 HTTP/2?有个反直觉的点:这事通常轮不到应用框架操心。浏览器只在 TLS 上协商 HTTP/2(握手时通过 ALPN 扩展选定协议),所以前提是有 HTTPS;剩下的交给前面的反向代理或 CDN——nginx 在 listen 443 ssl 之外加一行 http2 on;(1.25 之前的写法是 listen 443 ssl http2;),Cloudflare 这类 CDN 则默认开启。

代理把浏览器那条 h2 连接里的流,逐条翻译成普通的 HTTP/1.1 请求转发给后端,FastAPI 应用继续跑 1.1 就好;前面示例里的 X-Accel-Buffering: no,防的正是这一层的响应缓冲。想让 Python 服务自己说 h2 也行:把 uvicorn 换成 Hypercorn 就可以。不过生产环境的主流,仍是"反代终结 TLS 和 h2,应用照旧"的架构。

HTTP/3 把这套思路又推进了一步:底层从 TCP 换成 QUIC,多路复用照旧,还顺带解决了 TCP 层面的队头阻塞——一条流丢包,不再拖累其他流。

这里有一段插曲:HTTP/2 当年自带了 Server Push 功能,想让服务器主动把资源推给浏览器。但因为缓存协调复杂、实际收益有限,Chrome 在 2022 年移除了对它的支持,这个功能就此淡出了主流。正式的"推送"没留下来,反倒是朴素的 SSE 一直用到了今天。

限制与鉴权

SSE 的限制也写得明明白白:只能从服务器往客户端发,客户端想回话就另发普通请求;只能传 UTF-8 文本,二进制要自己编码;原生 EventSource 不能自定义请求头,需要鉴权的场景得改用 fetch 手动解析数据流:

const resp = await fetch("/v1/chat/completions", {
method: "POST",
headers: {
Authorization: "Bearer " + apiKey, // OpenAI 的 API key 就放在这里
"Content-Type": "application/json",
},
body: JSON.stringify({ stream: true }), // 请求体也是 EventSource 给不了的
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 按空行切出事件,再逐行解析 data:、id:
}

OpenAI 的流式接口正好是这个组合:POST、Bearer 头、请求体,三样 EventSource 都给不了,所以它的官方 SDK 内部就是用 fetch 读流、自己按 SSE 格式逐行解析。自己解析意味着 event 分发、自动重连、Last-Event-ID 这些便利也得自己补——这也是大多数场景仍然首选 EventSource 的原因。还有一个现实约束:API key 写进浏览器代码等于公开,生产环境通常是浏览器连自家后端——自家接口可以用 cookie 鉴权加 GET,EventSource 就够用了——再由后端带着 key 去调 OpenAI。

如果自家后端也不想用 cookie,还有一条偷懒的路:把 token 拼进 URL——new EventSource("/stream?token=…"),服务器从查询参数里读。能跑,但代价要清楚:URL 会走进代理和服务器的访问日志、监控和报错系统,明文凭证到处留痕(OpenAI 官方接口也不支持这种传法,它只认 Authorization 头)。稳妥一点的做法是先拿真凭证换一张短时效的一次性门票,再带着门票开流——就算留了痕,泄露窗口也只有门票的有效期。

性能与边界条件

把 SSE 接进生产环境之前,有几个数字和坑值得先心里有数:浏览器到底允许挂多少条连接、服务器扛住大量长连接要花多少资源、消息推不过来时怎么办,以及中间那层代理和 CDN 会不会悄悄使绊子。

浏览器并发连接数:HTTP/1.1 与 HTTP/2 的差别

浏览器对 SSE 的并发限制,本质上是"每个域名能开多少条连接"的限制。HTTP/1.1 时代,一条 TCP 连接同一时间只能服务一个请求-响应,而 SSE 的响应永不结束,所以每条 SSE 都要独占一条连接。浏览器给每个域名只留 6 条左右的连接额度,一旦 SSE 占满,同域名下的普通请求也只能排队。这意味着在 HTTP/1.1 下,一个页面同时开 6 条 SSE 就是上限,再多就得换域名或用子域名来“扩容”。

HTTP/2 把口径从"连接"换成了"流":一条 TCP 连接里可以划出上百条逻辑流,SSE 只是其中一条迟迟不发结束标志的流。于是并发上限从 6 条连接变成了上百条流——一个标签页同时挂几十条 SSE 对浏览器来说仍然只是一条 TCP 连接的事。这也是今天 SSE 大多跑在 HTTP/2 上的根本原因:不是协议本身变了,而是多路复用把并发天花板抬高了两个数量级。

服务器端维持大量长连接的开销估算

SSE 长连接对服务器最直观的消耗是文件描述符:每一条连接都要占一个 socket,也就是一个 fd。Linux 默认的 ulimit -n 通常是 1024,生产环境一般会调到几万甚至几十万,否则连接数一上来,最先爆掉的就是这个。内存方面,每条空闲连接的内核缓冲区加应用层对象,通常只有几十 KB 的量级——一万条连接大约几百 MB 内存,对现代服务器不算夸张。真正的成本在别处:

  • 线程模型:如果用"一连接一线程"的同步模型,一万条连接就是一万个线程,光是线程栈(默认 8 MB 虚拟内存)就够呛。所以 SSE 服务端几乎必须走异步/事件驱动模型——前面示例里的 FastAPI 生成器就是异步的,同时挂几千条流也不用开几千个线程。
  • 心跳与保活:长时间没有事件,连接可能被中间设备掐掉。服务器要定期发 : ping 注释行保活,这本身是持续的 CPU 和带宽开销,连接越多越明显。
  • 事件分发:推送一条消息给一万个订阅者,就是一万次写操作。如果每条消息都要遍历一遍订阅表、逐连接写缓冲,CPU 会随订阅数线性增长。常见的优化是把订阅者按连接分组、批量写,或者用发布-订阅中间件把推送压力分摊出去。

消息积压与背压处理

SSE 是"服务器推、客户端收",没有客户端主动确认的机制。如果生产速度超过消费速度,消息就会在服务器端的发送缓冲区里积压。缓冲区写满之后,写操作会阻塞或报错——这就是背压(backpressure)信号,处理不好,轻则内存暴涨,重则拖垮整个进程。

应对思路大致有三层:

  • 限流与丢弃:对实时性要求不高的场景(如行情快照),可以只保留最新状态,丢弃中间过程——客户端下次收到的是最新值,而不是积压的历史。这跟"断点续传保证尽量接上"是同一个哲学:SSE 不是可靠消息队列。
  • 队列削峰:把要推送的消息先放进队列,由独立的消费者按节奏写流。队列本身要有长度上限,满了就按策略丢弃或降级,避免无界积压拖垮内存。
  • 感知客户端状态:客户端断线、页面不可见时,推送其实是在做无用功。可以在应用层跟踪连接状态,对长时间不活跃的连接主动断开,让 EventSource 走自动重连,而不是一直往死连接里写。

生产环境中的代理、防火墙与 CDN 问题

SSE 的响应"永不结束",这跟很多中间件的默认行为是冲突的,常见的坑和对应解法:

  • 代理缓冲:nginx 默认会缓冲上游响应,攒够一定量才发给客户端——对 SSE 来说,这等于把逐字输出变成了"憋一大段再吐"。解法是在响应头里加 X-Accel-Buffering: no,或者把 proxy_buffering off; 写进 location 配置。
  • 超时掐断:代理和防火墙对长时间没有数据的连接,会按空闲超时把它掐掉。解法是前面提到的 : ping 注释行——它不触发事件,但能让连接一直有数据流动,骗过空闲检测。SSE 规范里的 retry 字段则控制断线后多久重连,配合使用能显著减少"莫名掉线"的体感。
  • CDN 缓存:CDN 默认会缓存响应,而 SSE 是动态流,缓存会让所有客户端收到同一份过期内容。必须在响应里带 Cache-Control: no-cache,并确保 CDN 配置不缓存 text/event-stream 的响应。
  • HTTP/2 与代理的配合:浏览器只在 TLS 上协商 HTTP/2,所以前提是有 HTTPS。生产环境通常是"反代终结 TLS 和 h2,应用照旧跑 HTTP/1.1"——代理把 h2 连接里的流逐条翻译成普通请求转发给后端,应用层不用感知。但要注意,代理本身必须支持流式转发,否则它会把响应整个攒在手里,SSE 就变成了"长轮询"。

大模型流式输出:SSE 最广为人知的用武之地

OpenAI 的 API 加上 stream: true,返回的就是 text/event-stream——模型每生成一小段,服务器就推一个 data: 块过来,最后以一行 data: [DONE] 收尾;Anthropic 的 API 同理。

为什么要专门约定一行收尾?因为 EventSource 拿不到“发完了”这个信号。响应结束在传输层当然有表示——HTTP/1.1 是 chunked 编码的结束块,HTTP/2 是带 END_STREAM 标志的帧——但 EventSource 收到后只会按断线处理:等上几秒,自动重连。它分不清“服务器发完了”和“网络断了”,也没有 oncomplete 之类的回调。所以“发完”只能靠应用层自己约定:服务器最后推一行 data: [DONE],客户端在 onmessage 里认出这一行就 source.close() 主动断开;不这么做,EventSource 会把结束当成断线,一遍一遍地重连。前面示例里那条 event: notice,也是同一个思路。

204 那个“停止”信号同样帮不上忙:状态码只能在响应开头给,结束不了一条正在推的流。fetch 手动解析的方案倒是没有这个困扰——read() 返回的 done,就是传输层的结束信号。

你在网页上看到的逐字输出,走的就是这条路。 在这里插入图片描述

一张表看清分工

HTTP(请求-响应)WebSocketSSE
通信方向 客户端问,服务器答 双向,随时互发 只从服务器到客户端
连接形态 每次问答独立,连接可复用 一次握手,长连接 一次请求,响应不结束
数据格式 任意 文本 + 二进制帧 仅 UTF-8 文本
协议开销 每次请求都带完整头部,开销随请求数线性增长 握手后头部极小,帧开销低,但连接常驻占资源 复用 HTTP 头部,长连接下摊销后开销很低
浏览器兼容性 全平台原生支持 全平台原生支持 除 IE/Edge(旧版)外均支持,现代浏览器全覆盖
典型延迟 毫秒级(受网络往返影响) 亚毫秒级,双向实时 亚毫秒级(单向),推送即达
断线重连 不涉及 需应用自己实现 浏览器内置,断点续传
基础设施 最普适 代理、网关需支持 Upgrade 就是 HTTP,天然兼容
适用场景细分 网页、表单、REST API、静态资源 聊天、协同编辑、多人游戏、实时行情、在线白板 消息通知、任务进度、日志流、大模型流式输出、行情推送

选型其实是三句话:普通的查数据、提表单,用 HTTP 本身;双向高频互动,上 WebSocket;只需要服务器单方面推,SSE 最省事。

结尾

三种方案不是谁取代谁:HTTP 的"一问一答"至今是 Web 的基本盘,WebSocket 补上了双向实时,SSE 把单向推送做到了最简——断点续传是现成的,多路复用由 HTTP/2 顺带提供。轮询和长轮询也没有彻底消失,在不值得维护长连接的场合,它们仍然是最简单的解法。打开一个大模型对话框,看着回答逐字流出——那条连接,就是这条演进线上最轻的一个答案。

延伸阅读

  • HTML Standard:Server-sent events——SSE 与 EventSource 的规范原文,断线重连与 Last-Event-ID 都在这里定义
  • RFC 9113:HTTP/2——流、帧与多路复用的规范
  • RFC 6455:The WebSocket Protocol——WebSocket 的正式标准
  • MDN:Server-sent events——SSE 的用法手册
  • RFC 9110:HTTP Semantics——HTTP 语义的现行标准
赞(0)
未经允许不得转载:网硕互联帮助中心 » 一文搞懂 SSE:大模型逐字输出的背后,服务器是怎么“主动说话“的?
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!