SSE:大模型为什么都用它做流式
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
在模型调用 API 那篇里,你已经用 stream=true 调过大模型 API,也写过 BufferedReader 逐行读取 data: 的代码。当时的代码能跑,效果也不错——逐字输出,打字机效果。
但你有没有想过几个问题:
- 这个
data:开头的格式叫什么?是谁定义的? - 为什么大模型 API 都用这个格式,而不用 WebSocket?
- 如果网络不好,连接断了怎么办?
- 你写的那段 BufferedReader 代码,在生产环境扛得住吗?
当时那篇的定位是够用就行——能调通 API、能看到流式效果。但如果你要在生产环境用 SSE(不管是消费大模型 API 还是自己做 SSE 服务端),够用就行是不够的。你需要搞清楚 SSE 协议本身的完整规范,理解连接的生命周期,知道怎么处理异常情况。
这篇就来把 SSE 这个东西讲透。
SSE 协议:不只是 data: 开头的文本
1. SSE 是什么
SSE 全称 Server-Sent Events,直译就是服务端发送的事件。它是 HTML 标准的一部分,目前的权威规范在 WHATWG HTML Living Standard 里,定义了 text/event-stream 这套事件流格式和浏览器端的 EventSource API。
一句话 总结:基于 HTTP 的单向服务端推送协议。
客户端发起一个普通的 HTTP 请求,服务端保持连接不关闭,持续向客户端推送事件。注意是单向的——只有服务端往客户端推,客户端不能通过同一个连接往服务端发数据。
和普通 HTTP 请求的区别很直观:
- 普通 HTTP:客户端发请求 → 服务端返回完整响应 → 连接关闭。一问一答。
- SSE:客户端发请求 → 服务端持续推送事件 → 推完了或客户端主动断开 → 连接关闭。一问多答。
打个比方:普通 HTTP 像发短信,你发一条我回一条,每次都是独立的。SSE 像打电话,拨通之后对方一直在说(你只需要听),说完了再挂。
SSE 响应有一个标志性的 HTTP 头:
Content-Type: text/event-stream
浏览器和 HTTP 客户端看到这个 Content-Type,就知道这是一个 SSE 流,我要按 SSE 的规则来解析。
2. SSE 的完整字段格式
之前你只接触了 data: 字段。其实 SSE 规范一共定义了四个字段和一个注释机制,每个都有明确的用途。
一个完整的 SSE 事件流长这样:
retry: 3000
: 这是一条注释,客户端会忽略
id: 1
event: message
data: {"content": "你好"}
id: 2
event: token
data: {"content": ","}
id: 3
event: token
data: {"content": "有什么可以帮你?"}
id: 4
event: done
data: {"status": "completed", "total_tokens": 128}
逐个来看。
2.1 data: 字段——事件数据
你最熟悉的字段。每行以 data: 开头,后面跟的是事件的数据内容。
data: {"content": "你好"}
一个事件可以有多行 data:,它们会被换行符 \n 拼接起来:
data: 第一行
data: 第二行
data: 第三行
客户端收到的数据是 第一行\n第二行\n第三行。
一个事件以两个连续换行(空行)结束。这是 SSE 的事件边界标志——解析器看到空行就知道上一个事件结束了,该处理它了。
2.2 event: 字段——自定义事件类型
如果不写 event: 字段,事件的类型默认是 message。通过 event: 字段可以给事件指定自定义类型:
event: token
data: {"content": "你好"}
event: error
data: {"code": 429, "message": "rate limit exceeded"}
event: done
data: {"total_tokens": 128}
这样客户端可以根据事件类型做不同的处理——收到 token 事件就拼接内容,收到 error 事件就展示错误,收到 done 事件就结束流。
大模型 API(OpenAI、SiliconFlow 等)通常不使用 event: 字段,所有事件都是默认的 message 类型,靠 data: 里的 JSON 内容来区分。但如果你自己搭建 SSE 服务端,event: 字段就很有用了——下一篇 Spring Boot SSE 服务端实战会用到。
2.3 id: 字段——事件 ID
每个事件可以有一个 ID:
id: 42
data: {"content": "这是第 42 个事件"}
id: 字段的核心作用是支持断线重连。机制是这样的:
- 服务端给每个事件编一个
id: - 客户端内部会记住最后收到的事件 ID
- 如果连接断开,客户端重连时会在 HTTP 请求头里带上
Last-Event-ID: 42 - 服务端看到这个头,就知道客户端已经收到了 ID 42 之前的所有事件,从 ID 43 开始继续推送
这个机制让 SSE 天然支持断点续传——连接断了不用从头来,从上次断开的地方接着推。
不过大模型 API 通常不使用 id: 字段。因为大模型的流式生成是一次性的——内容是实时生成的,断了就没法从断点接着生成,只能重新请求。所以在消费大模型 API 的场景下,id: 字段基本用不到。但在自建 SSE 服务的场景下(比如推送通知、实时数据流),id: 就很有价值了。
2.4 retry: 字段——重连间隔
服务端可以通过 retry: 告诉客户端如果连接断了,等多久再重连:
retry: 5000
data: {"content": "连接已建立"}
retry: 5000 的意思是如果连接断了,等 5000 毫秒(5 秒)再尝试重连。这个字段通 常只在连接建立后发送一次。
浏览器原生的 EventSource API 会自动处理重连(默认间隔约 3 秒)。但在 Java 客户端里,你需要自己实现重连逻辑——OkHttp 的 HTTP 客户端不会自动按 retry: 的值重连。
2.5 注释行——心跳保活
以冒号 : 开头的行是注释,客户端会直接忽略:
: this is a comment
: keepalive
注释看起来没什么用,但在生产环境有一个很重要的作用——心跳保活。
问题背景:SSE 连接在数据推送的间隙可能长时间没有数据传输(比如大模型在思考,几秒甚至十几秒没有输出)。如果中间经过了 Nginx、负载均衡器、CDN 等中间件,它们可能会认为这个连接已经死了,主动把连接断掉。
解决方案:服务端定期发送一个空注释 : keepalive\n\n 或者 :\n\n,客户端会忽略它,但中间件看到有数据在传输,就不会超时断开连接。
这就是为什么你在调试大模型流式 API 时,偶尔会在 SSE 流里看到一个空行或者
:开头的行——它不是数据,是心跳。
3. SSE 字段速查表
| 字段 | 格式 | 作用 | 大模型 API 是否使用 |
|---|---|---|---|
data: | data: 内容 | 事件数据,核心字段 | 是,每个 chunk 都有 |
event: | event: 类型名 | 自定义事件类型,默认 message | 否,通常不使用 |
id: | id: 事件ID | 事件标识,支持断线重连 | 否,通常不使用 |
retry: | retry: 毫秒数 | 指定客户端重连间隔 | 否,通常不使用 |
: | : 注释内容 | 注释行(心跳保活) | 偶尔使用 |
可以看到,大模型 API 只用了 SSE 最核心的
data:字段,其他字段基本不用。但理解完整的 SSE 规范很重要——一方面帮你排查问题(比如突然收到一个:开头的行,你知道它是注释不是数据),另一方面在自建 SSE 服务时这些字段都用得上。
SSE vs WebSocket vs 长轮询
你可能会好奇:为什么大模型 API 都用 SSE?不是还有 WebSocket 吗?WebSocket 不是更高级吗?