SSE 流式输出原理
Server-Sent Events 的工作原理、格式,以及与 AI 流式输出的关系
什么是 SSE?
SSE(Server-Sent Events)是一种服务器向客户端推送数据的技术。
核心特点:服务器可以主动把数据「推」给客户端,不需要客户端反复问「有新消息吗?」。
为什么 AI API 要用 SSE?
AI 生成回复时,是一字一字「想」出来的。如果等全部想完再一次性返回,用户可能要等好几秒。
用 SSE 可以做流式输出:AI 每生成一个字,就立刻推给客户端,用户立刻就能看到。
服务器:你 ← 好 ← 世 ← 界
(每生成一个字就推一次)
对比:
- 不流式:等 AI 全部想完 → 一次性返回「你好世界」(等待时间长)
- 流式:AI 每想一个字就推送 → 用户立刻看到「你」「你好」「你好世」「你好世界」(体验好)
SSE 的格式
SSE 基于 HTTP,服务器返回的是纯文本流,每一条消息以 data: 开头。
data: 你好
data: 世界
data: [DONE]
规则:
- 每条消息以
data:开头,后面跟一个空格 - 消息之间用两个换行符(
\n\n)分隔 - 最后一条通常是
data: [DONE],表示「传输结束」
AI API 中的两种 SSE 封装
不同的 AI 服务商对 SSE 的封装方式不同:
OpenAI 方式:纯 data: 行
每条消息只有一个 data: 前缀,后面直接是 JSON 数据。
data: {"choices": [{"delta": {"content": "你"}}]}
data: {"choices": [{"delta": {"content": "好"}}]}
data: {"choices": [{"delta": {"content": "世"}}]}
data: {"choices": [{"delta": {"content": "界"}}]}
data: [DONE]
特点:简单直接,只有一种事件类型。
Anthropic 方式:命名事件
每条消息分两行:event:(事件类型)+ data:(数据内容)。
event: message_start
data: {"type": "message", "role": "assistant", ...}
event: content_block_start
data: {"type": "text", "text": ""}
event: content_block_delta
data: {"type": "content_block_delta", "delta": {"text": "你"}}
event: content_block_delta
data: {"type": "content_block_delta", "delta": {"text": "好"}}
event: message_stop
data: {"type": "message_stop"}
特点:事件类型丰富,能精确控制每个阶段的处理逻辑。
Anthropic 事件类型速查
| 事件类型 | 含义 | 类比 |
|---|---|---|
message_start | 开始生成一条新消息 | 📋 打开一张新纸 |
content_block_start | 开始生成一个内容块 | 📝 准备写一段 |
content_block_delta | 内容增量(每次推送的文字) | ✍️ 每写一个字推一次 |
content_block_stop | 内容块生成完毕 | ✅ 写完一段 |
message_delta | 消息级别的增量(如停止原因) | 📊 补充元数据 |
message_stop | 整条消息生成完毕 | 🏁 写完 |
OpenAI 的增量结构
OpenAI 的 data: 行中,增量内容在 choices[0].delta 里:
{
"choices": [
{
"delta": {
"content": "你" ← 这是本次推送的文字
}
}
]
}
通俗比喻
把 AI 流式输出想象成老师现场板书:
- 不流式:老师先想好整句话,然后一次性写出来。你只能等。
- 流式(SSE):老师想一个字写一个字,你同步看到。黑板就是「客户端」,老师的粉笔就是「数据流」。
流式解析的关键逻辑
OpenAI 解析逻辑
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
const json = JSON.parse(data);
const text = json.choices[0]?.delta?.content || '';
// 把 text 追加到界面上
}
}
}
Anthropic 解析逻辑
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('event: ')) {
const eventType = line.slice(7);
// 记住当前事件类型
} else if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
switch (eventType) {
case 'content_block_delta':
// data.delta.text 是本次增量
appendToUI(data.delta.text);
break;
case 'message_stop':
// 消息结束,可以做收尾处理
break;
}
}
}
}
与 WebSocket 的区别
| 特性 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务器 → 客户端(单向) | 双向(客户端也能发) |
| 协议 | 基于 HTTP | 独立协议(ws://) |
| 重连 | 自动重连 | 需要手动处理 |
| 适用场景 | AI 流式输出、实时通知 | 聊天、游戏、协作编辑 |
| 复杂度 | 低 | 高 |
为什么 AI 用 SSE 而不是 WebSocket?
- AI 回复主要是服务器推数据给客户端
- 客户端很少需要往服务器发中间数据
- SSE 更简单,HTTP 基础设施(代理、CDN、防火墙)天然支持
常见问题
| 问题 | 说明 |
|---|---|
| SSE 和 Webhook 的区别 | Webhook 是「服务器 A 通知服务器 B」,SSE 是「服务器通知浏览器」 |
| SSE 能传二进制吗? | 可以,但 AI 文本场景不需要 |
| 断线了怎么办? | SSE 有自动重连机制,客户端重新连接后请求最后一条消息的 ID |
| 能同时开多个 SSE 连接吗? | 可以,但浏览器对同一域名有并发限制(通常 6 个) |
实战技巧
- 解析时跳过空行:SSE 消息之间有空行,要先过滤
- 处理
[DONE]:收到这个标志就停止读取 - 错误处理:网络断开时
reader.read()会抛错,需要 try-catch - 取消请求:用户点击「停止生成」时,调用
reader.cancel()
返回 REST API 基础概念 了解 HTTP 请求/响应的整体流程 返回 JSON 数据结构基础 了解 SSE 传输的 JSON 数据如何解析
互链
链接来自概念卡正文里的双链,编译时能解到本期词条集的才成为站内链接。
- JSON 数据结构基础正文引用
- REST API 基础概念正文引用
反链(4)
- JSON 数据结构基础正文引用
- LogQL相关概念
- MDN Web Docs相关概念
- Token相关概念
出现在哪些课时里(0)
判据与「课时正文里能不能点出来」是同一个:那一节确实提到了它。点课时走当前页跳转, 它是导航,不是词条链接。
还没有课时讲到这一条。它是词条跑在课程前面的那一部分,不是错链。
关联图
只画这一条与它直接相连的词条,边与上面两区同源。可拖拽、可缩放,点节点是新标签。
以列表方式看这 6 个节点
出处
路径是本地知识库(Obsidian 库)里的位置,正文与概念卡逐字对应。
- 概念卡原文SSE 流式输出原理
03_RESOURCES/概念/sse-流式输出原理.md - 所属知识地图栏目概念页索引 · API 与后端基础
03_RESOURCES/概念/Index.md
编译入库:2026年11月2日。词条由知识库编译而来, 修正要回到概念卡改,再重新编译。