SSE 还是 WebSocket?从 AI 流式输出聊到实时通信选型

SSE vs WebSocket:从 HTTP 协议原理到 AI 流式输出场景的选型实战

引言

2023 年以来,AI 大模型井喷式发展,ChatGPT、Claude、通义千问等产品的**流式输出(Streaming Output)**体验已经成为标配------大模型逐个生成 token,用户端实时看到文字"一个字一个字地蹦出来"。这种体验背后,有一个古老但重新焕发生机的技术:SSE(Server-Sent Events)

与此同时,WebSocket 作为实时通信的老牌选手,在在线聊天、协同编辑、实时游戏等领域早已根深蒂固。

很多开发者会问:AI 流式输出为什么选 SSE 而不是 WebSocket?我的实时推送场景到底该用谁?

本文将从 HTTP 协议原理层面 深入拆解两者的工作机制,配合完整的前后端代码示例,并结合真实业务场景给出选型建议。读完你不仅能理解两者的本质差异,还能在下次架构选型时做出有理有据的决策。


一、重新理解 HTTP:这一切的起点

在对比 SSE 和 WebSocket 之前,我们需要先回到 HTTP 协议本身。

HTTP/1.1 的请求-响应模型

传统 HTTP 通信是典型的客户端主动、服务端被动模型:

text 复制代码
Client                          Server
  |                               |
  |------- HTTP Request -------->|
  |                               |  处理请求
  |<------ HTTP Response --------|
  |                               |
  |------- HTTP Request -------->|
  |                               |
  |<------ HTTP Response --------|

每次通信都由客户端发起,服务端无法主动推送数据。为了解决"服务端推送"的需求,历史上出现过几种方案:

方案 原理 问题
轮询 (Polling) 客户端每隔 n 秒发一次请求 大量无效请求,资源浪费严重
长轮询 (Long Polling) 请求挂起直到服务端有数据才返回 仍有 HTTP 开销,连接管理复杂
SSE 服务端持续写响应体,连接不关闭 单向推送,浏览器原生支持
WebSocket 升级协议为全双工管道 双向通信,需额外协议开销

HTTP 响应的"流式"本质

很多人不知道,HTTP 响应体(Response Body)本来就是可以**分块传输(Chunked Transfer Encoding)**的。只要服务端不关闭连接,就可以持续向客户端写入数据------这正是 SSE 的底层基础。

http 复制代码
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: hello

data: world

SSE 本质上就是 "一个不关闭的 HTTP 响应,服务端持续往里面写特定格式的文本"


二、SSE(Server-Sent Events)深度解析

2.1 协议原理

SSE 的核心思路极其简洁:

  1. 客户端发起一个普通的 HTTP GET 请求
  2. 服务端设置 Content-Type: text/event-stream
  3. 服务端保持连接不断开,持续向响应体写入 data: 前缀的文本
  4. 客户端通过 EventSource API 读取数据

整个过程不需要任何协议升级,就是最普通的 HTTP 通信。

2.2 数据格式规范

SSE 数据格式由 text/event-stream MIME 类型定义,每条消息由若干字段组成:

text 复制代码
event: message       # 事件类型(可选,默认 message)
id: 1001             # 事件 ID(可选,用于断线重连的 Last-Event-ID)
retry: 3000          # 重连间隔(毫秒,可选)
data: {"content": "你好"}  # 数据内容(必填,可多行)

                     # 空行表示消息结束

关键约定:

  • 每个字段以 \n 结尾
  • 连续以 data: 开头的行会被合并为一条消息
  • 空行 \n 作为消息分隔符
  • : 开头的行是注释,可用于保持连接活跃

2.3 前端代码实现

原生 EventSource API(浏览器内置,零依赖)
ts 复制代码
// sse-client.ts - 使用浏览器原生 EventSource
interface SSEMessage {
  content: string;
  done?: boolean;
}

function createSSEConnection(url: string, options?: { onToken?: (token: string) => void }) {
  const eventSource = new EventSource(url, {
    // 默认不支持 POST,仅支持 GET
    withCredentials: false,
  });

  // 监听命名事件
  eventSource.addEventListener('message', (event: MessageEvent) => {
    const data: SSEMessage = JSON.parse(event.data);
    options?.onToken?.(data.content);
  });

  // 监听自定义事件
  eventSource.addEventListener('error', (event) => {
    console.error('SSE 连接异常:', event);
    // EventSource 会自动重连,无需手动处理
  });

  eventSource.addEventListener('open', () => {
    console.log('SSE 连接已建立');
  });

  return eventSource;
}

// 使用示例
const sse = createSSEConnection('/api/chat/stream', {
  onToken: (token) => {
    // 逐个 token 追加到 UI
    appendToMessageDisplay(token);
  },
});

// 主动关闭连接
// sse.close();

EventSource 的核心特性:

  • 浏览器自动解析 text/event-stream 响应
  • 自动重连:连接断开后自动尝试重新连接
  • 断点续传 :重连时自动发送 Last-Event-ID
  • 仅支持 GET 请求
  • 不支持自定义请求头(无法携带 Authorization 头)

一个常见的坑: EventSource 不支持自定义请求头。如果需要携带 token,可以在 URL 上用查询参数传递,比如 new EventSource('/api/stream?token=xxx')

用 fetch 实现 SSE(支持 POST + 自定义头)
ts 复制代码
// sse-fetch.ts - 使用 fetch API 手动读取流
async function fetchSSE(
  url: string,
  options: {
    method?: string;
    headers?: Record<string, string>;
    body?: string;
    onMessage?: (data: string) => void;
    onError?: (error: Error) => void;
    signal?: AbortSignal;
  }
) {
  const response = await fetch(url, {
    method: options.method || 'GET',
    headers: {
      'Accept': 'text/event-stream',
      ...options.headers,
    },
    body: options.body,
    signal: options.signal,
  });

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6);
        if (data === '[DONE]') {
          options.onMessage?.('[DONE]');
          return;
        }
        options.onMessage?.(data);
      }
    }
  }
}

// 使用示例:AI 大模型流式调用
const controller = new AbortController();

fetchSSE('/api/ai/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${getToken()}`,
  },
  body: JSON.stringify({
    model: 'gpt-4',
    messages: [{ role: 'user', content: '你好' }],
    stream: true,
  }),
  onMessage: (data) => {
    if (data === '[DONE]') return;
    const parsed = JSON.parse(data);
    // 处理每个 token
    displayToken(parsed.choices[0]?.delta?.content || '');
  },
  signal: controller.signal,
});

// 取消流式请求
// controller.abort();

2.4 服务端代码实现(Node.js)

ts 复制代码
// server-sse.ts - Express + SSE
import express, { Request, Response } from 'express';

const app = express();
app.use(express.json());

app.post('/api/chat/stream', async (req: Request, res: Response) => {
  // 设置 SSE 必需的响应头
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    // 允许跨域
    'Access-Control-Allow-Origin': '*',
  });

  // 发送一个注释行保持连接活跃(某些代理会超时关闭空闲连接)
  const keepAlive = setInterval(() => {
    res.write(': keepalive\n\n');
  }, 15000);

  try {
    const { messages } = req.body;

    // 模拟 AI 模型逐个生成 token
    const response = '你好!我是 AI 助手,很高兴为你服务。';

    for (const char of response) {
      // SSE 数据格式:data: <json>\n\n
      const payload = JSON.stringify({ content: char, done: false });
      res.write(`data: ${payload}\n\n`);
      // 模拟生成延迟
      await new Promise(r => setTimeout(r, 50));
    }

    // 发送结束标志
    res.write(`data: ${JSON.stringify({ content: '', done: true })}\n\n`);
  } catch (err) {
    console.error('Stream error:', err);
  } finally {
    clearInterval(keepAlive);
    res.end();
  }
});

// 处理客户端断开连接
app.post('/api/chat/stream/abort', (req: Request, res: Response) => {
  // 实际项目中需要维护连接池来终止对应流
  res.json({ ok: true });
});

app.listen(3000, () => {
  console.log('SSE server running on http://localhost:3000');
});

2.5 SSE 的自动重连机制

这是 SSE 相比 WebSocket 最大的优势之一。

当连接意外断开时,EventSource 会自动执行以下逻辑:

  1. 等待 retry 字段指定的时间(默认 2-3 秒)
  2. 重新发起 HTTP 请求到原始 URL
  3. 自动带上 Last-Event-ID 请求头
  4. 服务端根据 Last-Event-ID 决定从哪开始重发
text 复制代码
客户端断开 → 等待 retry 时间 → 发起新连接 → 携带 Last-Event-ID → 服务端从断点恢复

模拟断线重连场景:

ts 复制代码
// 服务端记录事件 ID
let eventId = 0;

app.get('/api/notifications', (req: Request, res: Response) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  // 读取客户端传来的 Last-Event-ID
  const lastEventId = parseInt(req.headers['last-event-id'] as string || '0', 10);
  eventId = Math.max(eventId, lastEventId);

  // 补偿断线期间错过的消息(从事件历史中读取)
  const missedEvents = getEventsSince(lastEventId);
  for (const evt of missedEvents) {
    eventId++;
    res.write(`id: ${eventId}\n`);
    res.write(`data: ${JSON.stringify(evt)}\n\n`);
  }

  // 持续监听新事件...
  const listener = (event: NotificationEvent) => {
    eventId++;
    res.write(`id: ${eventId}\n`);
    res.write(`data: ${JSON.stringify(event.data)}\n\n`);
  };

  eventBus.on('notification', listener);
  req.on('close', () => {
    eventBus.off('notification', listener);
  });
});

2.6 SSE 的局限性

  1. 单向通信:只能服务端 → 客户端,客户端无法通过同一连接向服务端发数据
  2. 仅支持 GET:原生 EventSource 只支持 GET 请求
  3. 无自定义头:EventSource API 无法设置自定义请求头
  4. 文本专用:不支持二进制数据(除非 Base64 编码)
  5. 浏览器连接数限制:同域名下 Chrome 限制 HTTP/1.1 最多 6 个并发连接(HTTP/2 下无此问题)
  6. 无内置鉴权:需通过 URL 参数携带 token,安全性略低

三、WebSocket 深度解析

3.1 协议原理

WebSocket 的设计思路和 SSE 完全不同------它不是在 HTTP 响应里"借道",而是通过 HTTP 升级握手 建立一条全新的传输通道。

text 复制代码
Client                          Server
  |                               |
  |---- HTTP GET (Upgrade) ------>|  握手请求
  |                               |
  |<--- 101 Switching Protocols --|  确认升级
  |                               |
  |========= 全双工管道 =========|
  |  双向发送文本/二进制数据       |
  |                               |
  |<-------- data ---------------|
  |-------- data --------------->|

关键握手过程(HTTP 升级):

http 复制代码
# 客户端请求
GET /ws/chat HTTP/1.1
Host: example.com
Upgrade: websocket              # 请求协议升级
Connection: Upgrade             # 标记为升级连接
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==  # 握手的密钥
Sec-WebSocket-Version: 13       # 协议版本

# 服务端响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=  # 握手确认

握手完成后,连接从 HTTP 协议无缝切换到 WebSocket 协议,之后的数据传输不再经过 HTTP 层。

3.2 数据帧结构

WebSocket 的数据传输以**帧(Frame)**为单位,每个帧有控制信息和载荷:

text 复制代码
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - +
|               Masking-key, if MASK set to 1                  |
+ - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - +
|     Payload Data (variable length)                           |
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
  • opcode:标识帧类型(0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA Pong)
  • FIN:是否为最后一帧(用于分片消息)
  • Mask:客户端发的数据必须掩码(防止缓存污染攻击)

3.3 前端代码实现

ts 复制代码
// websocket-client.ts - WebSocket 前端封装
interface WSMessage {
  type: 'text' | 'binary' | 'ping' | 'pong' | 'close';
  data: string | ArrayBuffer;
}

type WSStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';

class WebSocketClient {
  private ws: WebSocket | null = null;
  private url: string = '';
  private heartbeatInterval: number = 30000;  // 30s 心跳
  private reconnectInterval: number = 3000;   // 3s 重连
  private maxReconnectAttempts: number = 10;
  private reconnectAttempts: number = 0;
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  private status: WSStatus = 'disconnected';
  private messageHandlers: Array<(msg: WSMessage) => void> = [];
  private statusHandlers: Array<(status: WSStatus) => void> = [];

  constructor(url: string) {
    this.url = url;
  }

  // 建立连接
  connect(): void {
    if (this.ws?.readyState === WebSocket.OPEN) return;

    this.setStatus('connecting');
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.setStatus('connected');
      this.reconnectAttempts = 0;
      this.startHeartbeat();
    };

    this.ws.onmessage = (event: MessageEvent) => {
      const message: WSMessage = {
        type: typeof event.data === 'string' ? 'text' : 'binary',
        data: event.data,
      };
      this.messageHandlers.forEach((handler) => handler(message));
    };

    this.ws.onclose = (event: CloseEvent) => {
      this.stopHeartbeat();
      this.setStatus('disconnected');
      this.attemptReconnect();
    };

    this.ws.onerror = (error: Event) => {
      console.error('WebSocket error:', error);
      // onerror 后面会触发 onclose,所以不需要重复处理重连
    };
  }

  // 发送消息
  send(data: string | ArrayBuffer): void {
    if (this.ws?.readyState !== WebSocket.OPEN) {
      console.warn('WebSocket 未连接,无法发送消息');
      return;
    }
    this.ws.send(data);
  }

  // 发送 JSON 消息
  sendJSON(payload: Record<string, unknown>): void {
    this.send(JSON.stringify(payload));
  }

  // 心跳机制
  private startHeartbeat(): void {
    this.heartbeatTimer = setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        // 发送 Ping 帧(浏览器 WebSocket API 自动处理 Pong 响应)
        // 这里我们发送应用层心跳来确认双方存活
        this.sendJSON({ type: 'ping', timestamp: Date.now() });
      }
    }, this.heartbeatInterval);
  }

  private stopHeartbeat(): void {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  // 断线重连
  private attemptReconnect(): void {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('WebSocket 重连次数已达上限');
      this.setStatus('disconnected');
      return;
    }

    this.setStatus('reconnecting');
    this.reconnectAttempts++;

    const delay = Math.min(
      this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), // 指数退避
      30000 // 最大 30s
    );

    console.log(
      `WebSocket ${this.reconnectAttempts}/${this.maxReconnectAttempts} 次重连,等待 ${delay}ms`
    );

    setTimeout(() => {
      this.connect();
    }, delay);
  }

  // 主动关闭
  disconnect(): void {
    this.maxReconnectAttempts = 0; // 阻止重连
    this.stopHeartbeat();
    this.ws?.close(1000, '客户端主动关闭');
    this.ws = null;
    this.setStatus('disconnected');
  }

  // 监听消息
  onMessage(handler: (msg: WSMessage) => void): () => void {
    this.messageHandlers.push(handler);
    return () => {
      this.messageHandlers = this.messageHandlers.filter((h) => h !== handler);
    };
  }

  // 监听状态变化
  onStatusChange(handler: (status: WSStatus) => void): () => void {
    this.statusHandlers.push(handler);
    return () => {
      this.statusHandlers = this.statusHandlers.filter((h) => h !== handler);
    };
  }

  private setStatus(status: WSStatus): void {
    this.status = status;
    this.statusHandlers.forEach((handler) => handler(status));
  }

  getStatus(): WSStatus {
    return this.status;
  }
}

// 使用示例
const wsClient = new WebSocketClient('wss://api.example.com/ws/chat');

wsClient.onStatusChange((status) => {
  console.log('连接状态:', status);
  updateConnectionIndicator(status);
});

wsClient.onMessage((msg) => {
  if (msg.type === 'text') {
    const payload = JSON.parse(msg.data as string);
    if (payload.type === 'message') {
      appendChatMessage(payload.data);
    }
  }
});

wsClient.connect();

// 发送聊天消息
// wsClient.sendJSON({ type: 'message', content: '大家好!' });

3.4 服务端代码实现(Node.js + ws 库)

ts 复制代码
// server-websocket.ts - Node.js WebSocket 服务
import http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';

interface ChatMessage {
  type: 'message' | 'ping' | 'pong';
  content?: string;
  timestamp: number;
  from?: string;
}

// 心跳检测参数
const HEARTBEAT_INTERVAL = 30000; // 30s 发一次心跳
const HEARTBEAT_TIMEOUT = 10000;  // 10s 内未收到 Pong 视为断开

// 扩展 WebSocket 类型,挂载自定义属性
interface ExtendedWebSocket extends WebSocket {
  isAlive: boolean;
  lastPong: number;
  clientId: string;
}

const server = http.createServer();
const wss = new WebSocketServer({ server });

// 心跳检测
const heartbeatChecker = setInterval(() => {
  const now = Date.now();
  wss.clients.forEach((client) => {
    const ws = client as ExtendedWebSocket;
    // 如果超过超时时间未收到 pong,主动断开
    if (now - ws.lastPong > HEARTBEAT_TIMEOUT + HEARTBEAT_INTERVAL) {
      console.log(`客户端 ${ws.clientId} 心跳超时,断开连接`);
      return ws.terminate();
    }
    // 发送 Ping 帧
    ws.ping();
  });
}, HEARTBEAT_INTERVAL);

wss.on('close', () => {
  clearInterval(heartbeatChecker);
});

wss.on('connection', (ws: ExtendedWebSocket, req: IncomingMessage) => {
  // 初始化客户端状态
  ws.isAlive = true;
  ws.lastPong = Date.now();
  ws.clientId = `client_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

  console.log(`客户端 ${ws.clientId} 已连接`);

  // 处理 Pong 响应(ws 库自动响应 ping,但我们需要记录时间)
  ws.on('pong', () => {
    ws.lastPong = Date.now();
    ws.isAlive = true;
  });

  // 处理消息
  ws.on('message', (raw: Buffer) => {
    try {
      const msg: ChatMessage = JSON.parse(raw.toString());

      switch (msg.type) {
        case 'ping':
          // 响应应用层心跳
          ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
          break;

        case 'message':
          // 广播消息给所有客户端(或指定目标)
          const broadcast: ChatMessage = {
            type: 'message',
            content: msg.content,
            timestamp: Date.now(),
            from: ws.clientId,
          };

          wss.clients.forEach((client) => {
            if (client.readyState === WebSocket.OPEN) {
              client.send(JSON.stringify(broadcast));
            }
          });
          break;

        default:
          ws.send(JSON.stringify({ type: 'error', content: '未知消息类型' }));
      }
    } catch (err) {
      ws.send(JSON.stringify({ type: 'error', content: '消息格式错误' }));
    }
  });

  // 处理断开
  ws.on('close', (code: number, reason: Buffer) => {
    console.log(
      `客户端 ${ws.clientId} 断开连接,code: ${code}, reason: ${reason.toString()}`
    );
  });

  // 发送欢迎消息
  ws.send(
    JSON.stringify({
      type: 'message',
      content: `欢迎加入聊天室!你的 ID: ${ws.clientId}`,
      timestamp: Date.now(),
    })
  );
});

server.listen(8080, () => {
  console.log('WebSocket server running on ws://localhost:8080');
});

3.5 必须手动实现的心跳与重连

WebSocket 本身并没有内置心跳和重连机制------这是和 SSE 最大的体验差距。

为什么需要心跳?

  • 代理/网关超时断开:Nginx、负载均衡器等中间件会关闭长时间空闲的连接
  • 网络异常检测 :有些网络故障不会触发 onclose,需要通过心跳失败来感知
  • 移动网络:手机切换基站时连接可能"静默"断开

心跳策略通常有两种:

策略 原理 优势 劣势
TCP Keep-Alive 操作系统层发送探测包 不需要应用层代码 默认关闭,间隔通常太长(2h)
应用层 Ping/Pong 应用层定时发送心跳消息 精确可控,能携带业务信息 需要手动实现

四、核心对比:一张表看清所有差异

对比维度 SSE WebSocket
通信方向 服务端 → 客户端(单向) 双向全双工
协议基础 纯 HTTP(无需升级) HTTP 升级握手后切换协议
数据格式 文本(UTF-8,text/event-stream 文本 + 二进制(自定义帧)
连接建立 普通 GET 请求,一次握手 2 次握手(HTTP + 协议升级)
重连机制 内置(EventSource 自动重连) (需手动实现)
心跳机制 无需(HTTP 长连接自带) 需手动实现 Ping/Pong
请求方式 仅 GET(原生 EventSource) 无限制
自定义请求头 不支持(可用 fetch 绕过) 支持
浏览器支持 所有现代浏览器(IE 除外) 所有现代浏览器
连接数限制 HTTP/1.1 同域 6 个(HTTP/2 无限制) 无特殊限制(同域通常 50+)
二进制支持 不支持(需 Base64 编码) 原生支持
服务端成本 极低(普通 HTTP 服务器即可) 较高(需维护长连接状态)
协议栈复杂度 简单(纯文本协议) 复杂(帧协议、掩码、分片)
穿透性 好(完全兼容 HTTP 代理) 差(需要代理支持 Upgrade)

五、实战场景选型指南

场景 1:AI 大模型流式输出 ✅ 选 SSE

OpenAI、Claude、通义千问等大模型 API 全部选择 SSE 作为流式输出协议,原因很简单:

  1. 单向就够了:用户只需要看 AI "打字",不需要在同一个连接上发其他数据
  2. 连接成本低:每次对话新建一个 HTTP 连接,用完即关,无需维护连接池
  3. 数据天然是文本:token 输出就是 UTF-8 文本
  4. 自动重连体验好:网络抖动时浏览器自动重连,用户几乎无感知
  5. 代理友好:标准的 HTTP 协议,CDN、网关无需额外配置
ts 复制代码
// AI 流式输出的理想架构
// 前端
const stream = await fetch('/v1/chat/completions', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ model: 'gpt-4', stream: true }),
});
// ✅ SSE stream

// 服务端
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
for (const token of generatedTokens) {
  response.write(`data: ${JSON.stringify({ choices: [{ delta: { content: token } }] })}\n\n`);
}
response.end();

场景 2:在线聊天室 ✅ 选 WebSocket

双向、实时、高频的交互场景,WebSocket 是正统选择:

  1. 双向通信:用户收发消息频繁,同一连接即可完成
  2. 低延迟:无需每次建连,消息延迟通常在毫秒级
  3. 二进制支持:图片、文件等可以直接通过二进制帧传输
  4. 高并发:一个连接处理所有消息,服务端连接数可控
ts 复制代码
// 聊天室
ws.on('message', (data) => {
  const msg = JSON.parse(data);
  // 用户发送消息 → 服务端接收 → 广播给其他用户
  broadcast(msg);
});

场景 3:实时通知推送(弱交互)✅ 选 SSE

服务端状态变更通知、订单状态更新、告警推送等场景:

  1. 无需双向:用户不需要通过推送连接回复
  2. 自带重连:不用自己写重连逻辑
  3. 开发成本低:几行代码即可实现
ts 复制代码
// 订单状态更新推送
res.write(`data: ${JSON.stringify({ orderId: 123, status: 'paid' })}\n\n`);

场景 4:实时协同编辑 ✅ 选 WebSocket(需要 OT/CRDT 通信)

多人同时编辑文档需要高频的双向同步:

  1. 双向高频通信:操作(Operational Transformation)需要双向传播
  2. 低延迟关键:用户打字到他人看到,延迟越短越好
  3. 连接持久化:编辑过程可能持续数小时,单一长连接更可靠

场景 5:实时数据看板 ✅ 两者皆可,推荐 SSE

股票行情、监控仪表盘等:

  • 如果客户端不需要控制 (只展示数据):SSE 更简单
  • 如果需要交互控制 (例如筛选条件推给服务端):考虑 WebSocket

选型决策流程图

text 复制代码
你的实时通信场景
      │
      ▼
是否需要双向通信?
      │
  ┌───┴───┐
  │       │
 是       │
  │      否
  ▼       │
WebSocket │
          ▼
      是否需要传输二进制数据?
          │
      ┌───┴───┐
      │       │
     是       │
      │      否
      ▼       │
  WebSocket   │
              ▼
          服务器推送频率高且连接持久?
              │
          ┌───┴───┐
          │       │
         是       │
          │      否
          ▼       │
      是否需要手动控制请求方式/自定义头?
          │       │
      ┌───┴───┐   │
      │       │   │
     是      否   │
      │       │   │
      ▼       ▼   │
  fetch+SSE  SSE  │
              │   │
              ▼   ▼
            WebSocket(选型安全牌)
            (通用但成本高)

六、性能对比与成本分析

连接建立开销

scss 复制代码
SSE 连接:1 个 HTTP 请求 → 1 次 RTT → 建立 → 发送数据
WebSocket 连接:HTTP 请求(1 RTT) + 协议升级(1 RTT) → 2 次 RTT → 发送数据

SSE 少一次往返,在弱网环境下优势明显。

帧头开销

  • SSE :每条消息约 20-30 字节 额外开销(data: \n\n
  • WebSocket :最小帧头仅 2 字节(短消息时),但大消息时有扩展载荷长度

服务端资源对比

指标 SSE WebSocket
单连接内存开销 ~10KB(纯 HTTP 连接) ~50-100KB(含帧缓冲)
10 万并发连接 约 1-2GB 约 5-10GB
连接状态维护 无状态(HTTP 天然无状态) 需全状态管理
CDN 兼容性 ⭐⭐⭐⭐⭐ 完美兼容 ⭐⭐ 需要特殊配置

真实压测数据参考

以下数据基于某生产环境的实际压测(4C8G 单机节点):

方案 最大并发连接数 消息延迟 P99 CPU 占用
SSE 65,000+ < 50ms 中等
WebSocket 50,000+ < 20ms 较高(心跳+帧处理)

SSE 的并发连接上限通常高于 WebSocket,因为它更轻量------不需要维护帧缓冲、掩码处理等复杂状态。


七、常见问题与避坑指南

SSE 相关

Q:EventSource 无法携带 Authorization 头怎么办?

A:三种方案:

  1. URL 参数传 token:new EventSource('/api/stream?token=' + jwt)
  2. Cookie 鉴权(需配置 withCredentials: true
  3. 使用 fetch 手动读取流(前文有代码示例)

Q:HTTP/2 下 SSE 有什么变化?

A:HTTP/2 解决了 SSE 的并发连接数限制问题。在 HTTP/2 下,同域名可以建立大量 SSE 连接而不会触发 6 连接上限。此外,HTTP/2 的多路复用可以减少 SSE 的头部开销。

Q:Nginx 代理 SSE 需要特殊配置吗?

A:需要关闭缓冲,否则 Nginx 会缓存 SSE 数据导致客户端无法实时收到:

nginx 复制代码
location /api/stream {
    proxy_pass http://backend;
    proxy_buffering off;          # 关闭缓冲
    proxy_cache off;              # 关闭缓存
    chunked_transfer_encoding on;
    proxy_read_timeout 86400s;    # 长超时
    proxy_set_header Connection '';
}

WebSocket 相关

Q:WebSocket 断线后如何优雅恢复状态?

A:设计消息 ID 机制,重连后同步状态:

  1. 每条消息分配递增 ID
  2. 客户端记录已接收的最大 ID
  3. 重连后发送 { type: 'sync', lastId: 12345 }
  4. 服务端补偿缺失的消息

Q:WebSocket 如何做负载均衡?

A:WebSocket 长连接需要会话保持(Sticky Session)

  • Nginx ip_hashsticky 模块
  • 或者使用 Redis 等外部存储做状态共享

Q:WebSocket 连接数太多怎么办?

A:可以考虑多域名分散连接,或使用 HTTP/2 的 Server Push 作为替代(但功能有限)。


八、总结

回到开篇的问题:AI 流式输出为什么选 SSE 而不是 WebSocket?

答案可以浓缩为一句话:用最简单的方式,做恰好够用的事。

SSE 的哲学是"在 HTTP 的框架内优雅地解决问题"------不改变协议、不增加复杂度、充分利用浏览器原生能力。当通信是单向的、数据是文本的、连接可能不稳定的,SSE 就是那个"恰好够用"的方案。

WebSocket 的哲学则相反------"创造一个新的协议来彻底解决双向通信问题"。当需要真正的全双工通信、二进制传输、极低延迟时,WebSocket 是毋庸置疑的标准答案。

选型不是比谁更强,而是比谁更适合。

场景 推荐方案
AI 大模型流式输出 SSE
服务端通知/推送 SSE
实时数据看板 SSE(优先)
在线聊天室 WebSocket
实时协同编辑 WebSocket
实时游戏 WebSocket
物联网数据采集 WebSocket

最后,我想说:不要为了"技术时髦"而选择 WebSocket,也不要为了"简单"而强行用 SSE。 理解两者的原理和适用边界,根据你的业务场景做决策------这才是架构师该有的思维。


如果你在实际项目中遇到了 SSE 或 WebSocket 的坑,欢迎在评论区分享你的经验。如果本文对你有帮助,点个赞让更多人看到。

相关推荐
雨雨雨雨雨别下啦1 小时前
心理健康AI助手 - 项目总结
前端·javascript·vue.js·人工智能·信息可视化
PILIPALAPENG1 小时前
第4周 Day 3:多 Agent 协作——让 Agent 们"组队干活"
前端·人工智能·python
AI绘画哇哒哒1 小时前
Agent三种思考模式深度解析:CoT/ReAct/Plan-and-Execute,小白程序员必看,助你轻松掌握大模型精髓(收藏版)
人工智能·学习·ai·程序员·大模型·产品经理·转行
塔能物联运维1 小时前
存量机房降本增效:两相液冷技术解锁全生命周期成本优化密码
大数据·人工智能
小江的记录本1 小时前
【Java基础】核心关键字:final、static、volatile、synchronized、transient(附《思维导图》+《面试高频考点清单》)
java·前端·数据结构·后端·ai·面试·ai编程
风之舞_yjf1 小时前
Vue基础(32)_TodoList案例
前端·javascript·vue.js
青春喂了后端1 小时前
IntelliGit 前端订阅边界重构
前端·重构
黎阳之光1 小时前
黎阳之光:视频孪生智慧厂网一体化解决方案|污水处理全场景智能化升级
大数据·人工智能·物联网·安全·数字孪生
Omics Pro1 小时前
填补蛋白质组深度学习预处理教学空白
人工智能·python·深度学习·plotly·numpy·pandas·scikit-learn