ChatGPT 为什么用 SSE 不用 WebSocket?
前几天随手打开 F12,想看看 ChatGPT 的对话接口长什么样。本来以为会看到一个 WebSocket 连接——毕竟"一字一字往外蹦"这么实时,不是 WS 还能是什么?
结果翻了一圈,根本没有 WS。
接口是普通的 HTTP,但 Network 里多了一栏叫 EventStream 的东西。查了一下才知道,这是个叫 SSE 的老技术。豆包、Kimi、Claude 全部同款。
带着这份好奇,我决定一探究竟,看看它到底是个什么玩意儿。
1. SSE 是什么
Server-Sent Events:基于 HTTP 的服务端单向推送协议。客户端发起一次请求,服务端保持连接,持续往下推数据。
┌──────────┐ ┌──────────┐
│ Browser │ ──────────── ① 一次 HTTP 请求 ─────────────► │ Server │
│ │ │ │
│ │ ◄──── ② 持续 push (text/event-stream) ────── │ │
└──────────┘ └──────────┘
单向 · 自动重连说白了,SSE 就是一个永不结束的 HTTP 响应。
普通 HTTP 接口和 SSE 的区别:
普通接口: write(完整数据) → end() → 连接关闭 ← 一次性
SSE: write(消息1) → write(消息2) → ... 永不 end() ← 持续流式服务端不调用 end(),TCP 连接就一直开着。每次推数据,就往连接里追加一条消息,以 \n\n 结尾——是的,就是直接往响应体后面追加。浏览器边收边按 \n\n 切分,每收到一条就触发一次事件。
抓包看到的响应长这样(注意它是渐进出现的,不是一次性返回):
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
id: 1 ← 服务端在 t=0s 时 write 了这一段
event: progress
data: {"step":1}
id: 2 ← 服务端在 t=3s 时又 write 了这一段
data: hello
... 连接保持中,等待下一段 ...2. 方案对比与场景选型
2.1 三种方案对比
| 维度 | 定时轮询 | WebSocket | SSE |
|---|---|---|---|
| 通信方向 | 客户端拉 | 双向 | 服务端推 |
| 协议 | HTTP | ws/wss(独立协议) | HTTP |
| 实时性 | 差(取决于间隔) | 高 | 高 |
| 自动重连 | 不需要 | 需手写 | 原生支持 |
| 鉴权/网关兼容 | ✅ | ⚠️ 需额外配置 | ✅ |
| 实现复杂度 | 低 | 高 | 低 |
| 二进制支持 | ✅ | ✅ | ❌ |
| 浏览器连接数限制 | 无 | 无 | ⚠️ 同域 6 个(HTTP/1.1) |
决策树:
需要服务端主动推送?
│
┌────┴────┐
No Yes
│ │
普通请求 需要客户端也频繁推?
│
┌────┴────┐
Yes No
│ │
WebSocket SSE ✅回到标题的问题:ChatGPT 为什么用 SSE 不用 WebSocket?
AI 对话只有一个方向——服务端把 token 一条条推给浏览器,浏览器从不往服务端推内容。WebSocket 的双向能力在这里完全用不上,却要额外搞定 ws/wss 协议的网关配置、鉴权改造、连接管理。SSE 直接复用 HTTP,什么都不用动,几行代码就能跑起来。所以,需求是单向推送,上 WebSocket 就是杀鸡用牛刀。
2.2 典型场景
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| AI 流式输出(ChatGPT 类) | ✅ SSE | 服务端 token 流推送,天然单向 |
| 消息通知 / 站内信 | ✅ SSE | 实时性够,复用 HTTP 鉴权 |
| 实时日志 / 监控大屏 | ✅ SSE | 持续推数据,客户端只读 |
| 股价、赛事比分 | ✅ SSE | 高频推送,单向 |
| 在线聊天室 | ❌ WebSocket | 双向交互 |
| 协同编辑(飞书文档类) | ❌ WebSocket | 低延迟双向同步 |
| 实时游戏 / 音视频信令 | ❌ WebSocket | 二进制 + 低延迟 |
| 数据每分钟变一次 | ❌ 短轮询 | SSE 反而浪费长连接 |
3. 代码实战
3.1 前端
EventSource 是浏览器原生 API,专门用来接收 SSE 流,自带断线重连,无需任何依赖。
服务端每条消息可以包含三个字段:
| 字段 | 作用 | 前端如何拿到 |
|---|---|---|
id | 消息 ID,断线重连时浏览器会自动带上 | e.lastEventId |
event | 自定义事件名(不写则默认 message) | addEventListener('事件名') |
data | 消息内容(必填) | e.data |
const es = new EventSource('/api/stream');
// 监听默认事件(服务端没写 event 字段时)
es.onmessage = (e) => {
console.log('id:', e.lastEventId);
console.log('data:', e.data);
};
// 监听自定义事件
es.addEventListener('progress', (e) => {
const payload = JSON.parse(e.data);
console.log('进度:', payload);
});
es.addEventListener('done', (e) => {
console.log('结束:', e.data);
es.close(); // ⚠️ 收到结束信号后,主动关闭,否则会持续接收
});
// 错误处理(断线时浏览器会自动重连,无需手动处理)
es.onerror = () => console.log('连接异常,浏览器自动重连中...');关于何时断开:
| 触发方式 | 说明 |
|---|---|
es.close() | ✅ 推荐:收到业务结束信号后主动关 |
| 关闭/刷新页面 | 浏览器自动断开 |
| 网络断开 | 自动重连,不会真正停止 |
如果不调
close(),服务端的setInterval会一直推,前端会一直收,直到页面关闭。
3.2 后端:Koa 实现
⚠️ 格式要点:每条消息以 \n\n(两个换行)结束,缺一不可。
const Koa = require('koa');
const { PassThrough } = require('stream');
const app = new Koa();
app.use(async (ctx) => {
ctx.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const stream = new PassThrough();
ctx.body = stream;
let id = 0;
const timer = setInterval(() => {
id++;
// 推一条自定义事件
stream.write(`id: ${id}\n`);
stream.write(`event: progress\n`);
stream.write(`data: ${JSON.stringify({ step: id })}\n\n`);
if (id >= 5) {
stream.write(`event: done\ndata: ok\n\n`); // 通知前端结束
clearInterval(timer);
stream.end();
}
}, 1000);
// 前端断开时清理定时器,避免内存泄漏
ctx.req.on('close', () => clearInterval(timer));
});
app.listen(3000);3.3 断线重连机制
Client Server
│ │
│── GET /stream ────────────────────►│
│◄── id:1 data:A ────────────────────│
│◄── id:2 data:B ────────────────────│
│ │
│ ✗ 网络中断 ✗ │
│ │
│── GET /stream ────────────────────►│
│ Last-Event-ID: 2 │ ← 浏览器自动带上
│◄── id:3 data:C ────────────────────│ ← 服务端从 3 续推服务端只需读取 ctx.headers['last-event-id'] 即可实现断点续传,整个重连过程不需要前端写任何代码。
服务端实现断点续传:
app.use(async (ctx) => {
ctx.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const stream = new PassThrough();
ctx.body = stream;
// 读取断线前的最后一条消息 ID,没有则从 0 开始
const lastId = parseInt(ctx.headers['last-event-id'] || '0', 10);
let id = lastId;
const timer = setInterval(() => {
id++;
stream.write(`id: ${id}\n`);
stream.write(`data: ${JSON.stringify({ step: id })}\n\n`);
}, 1000);
ctx.req.on('close', () => clearInterval(timer));
});效果验证:
// 第一次连接,正常推送
id:1 data:{"step":1}
id:2 data:{"step":2}
id:3 data:{"step":3}
// 此时网络断开...
// 浏览器自动重连,请求头自动带上:
// Last-Event-ID: 3
// 服务端从 id=4 续推,不会重复
id:4 data:{"step":4}
id:5 data:{"step":5}关键点:服务端每条消息必须带
id字段,浏览器才会记录并在重连时携带Last-Event-ID。不写id的话,断线重连后会从头开始。
4. 优缺点
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 基于 HTTP,无需额外网关配置 | 单向,客户端无法推 |
浏览器原生 EventSource,自带重连 | 仅支持 UTF-8 文本,不支持二进制 |
| 实现简单,几行代码即可 | HTTP/1.1 下同域最多 6 个连接 |
| 支持自定义事件类型 + 事件 ID | IE/旧版 Edge 不支持(需 polyfill) |
5. 30 秒速查表
| 想知道 | 答案 |
|---|---|
| SSE 一句话 | 基于 HTTP 的服务端单向推送 |
| 前端 API | new EventSource(url) |
| 服务端关键 Header | Content-Type: text/event-stream |
| 消息格式 | 最简:data: xxx\n\n(id / event 可选,结尾必须两个换行) |
| 怎么停 | 前端 es.close(),或服务端 stream.end() |
| 断线重连 | 浏览器自动重连,带 Last-Event-ID |
| 什么时候用 | 服务端单向推:AI 流式、通知、日志、行情 |
| 什么时候不用 | 双向交互(聊天、协同)→ WebSocket |
一句话记住:单向推选 SSE,双向交互才上 WebSocket,轮询只在低频场景兜底。
