Skip to content

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
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 三种方案对比 ​

维度定时轮询WebSocketSSE
通信方向客户端拉双向服务端推
协议HTTPws/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
javascript
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(两个换行)结束,缺一不可。

javascript
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'] 即可实现断点续传,整个重连过程不需要前端写任何代码。

服务端实现断点续传:

javascript
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 个连接
支持自定义事件类型 + 事件 IDIE/旧版 Edge 不支持(需 polyfill)

5. 30 秒速查表 ​

想知道答案
SSE 一句话基于 HTTP 的服务端单向推送
前端 APInew EventSource(url)
服务端关键 HeaderContent-Type: text/event-stream
消息格式最简:data: xxx\n\n(id / event 可选,结尾必须两个换行)
怎么停前端 es.close(),或服务端 stream.end()
断线重连浏览器自动重连,带 Last-Event-ID
什么时候用服务端单向推:AI 流式、通知、日志、行情
什么时候不用双向交互(聊天、协同)→ WebSocket

一句话记住:单向推选 SSE,双向交互才上 WebSocket,轮询只在低频场景兜底。

最后更新时间: