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自定义事件名(不写则默认 messageaddEventListener('事件名')
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\nid / event 可选,结尾必须两个换行)
怎么停前端 es.close(),或服务端 stream.end()
断线重连浏览器自动重连,带 Last-Event-ID
什么时候用服务端单向推:AI 流式、通知、日志、行情
什么时候不用双向交互(聊天、协同)→ WebSocket

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

最后更新时间: