AI 流式输出都用 SSE,什么时候必须换 WebSocket?

上个月,我们的 AI 客服助手全量上线,第三天运维就找上门:问答接口的 QPS 莫名翻了一倍,客服还收到一堆投诉,说回答半天不吐字,用户以为卡死,点一次发一次,重试越点越多。

查了一圈,根因不在大模型,而在传输层。Nginx 默认把流式响应缓冲起来攒一波再吐,首字硬生生拖到 8 秒;前端用的还是最省事的裸 EventSource,断了不报错、慢了不提示,用户只能靠狂点续命。重试流量反过来又压垮网关,恶性循环。

这就是流式场景最真实的场景:模型没问题,管子先爆了。今天就把这根管子讲透,什么时候 SSE 够用,什么时候必须换 WebSocket,以及各自的坑怎么填。


裸 EventSource 和 WebSocket

出问题后,组里出现两种声音。一种说 EventSource 一行代码就能跑,别折腾;另一种说干脆全量切 WebSocket,一劳永逸。这两条路我都踩过,都有坑,只是结果不同。

裸 EventSource 的坑在哪?

EventSource 的 API 是只读的:只支持 GET,不支持自定义请求头。token 只能拼在 URL 上,网关日志、CDN 日志、浏览器历史全是明文,安全评审直接打回。断线重连看似自动,实则裸奔:没有服务端配合回放 Last-Event-ID,重连后要么丢消息,要么从头发完整流,用户会看到回答闪一下重打一遍。更隐蔽的是超时,Nginx 默认 proxy_buffering on,流式响应会被攒批,首字时间完全不可控。

WebSocket 的坑呢?

WebSocket 是长连接,网关要为每个连接维持状态,连接数直接变成成本。我们的客服页是低频使用的页面,用户问完就关,为这种低频、单向、下行主导的流量维持全双工连接,实例数要翻几倍。移动端更惨,切后台、锁屏、进电梯,连接一断就得整套重连、重鉴权、重订阅,心跳和退避策略全要自己写。

结论很清晰了👉:下行主导、低频、低延迟要求不极致,用 SSE;双向实时、高频上行、需要服务端主动推且客户端也要频繁说话,才上 WebSocket 。比如多轮问答、摘要生成是 SSE 的主场;协作编辑、语音对讲、实时游戏状态同步,才是 WebSocket 的主场。


生产级的 SSE 客户端长什么样

先看大多数人写的版本,十五分钟能跑,三个月后必返工:

typescript 复制代码
// 这个能跑,但迟早返工
export function streamAnswer(url: string, onToken: (t: string) => void) {
  const es = new EventSource(url); // token 只能拼 URL,GET 裸奔
  es.onmessage = (e) => onToken(e.data);
  es.onerror = () => {
    es.close(); // 断了就关,没有重连,没有续接
  };
}

生产级版本必须同时解决四件事:POST + token 请求头鉴权、可取消、断线按 Last-Event-ID 续接、退避重试。直接上代码:

typescript 复制代码
// 真正的 fetch + ReadableStream 的生产级 SSE 客户端
interface SSEMessage {
  id: string | null;
  event: string;
  data: string;
}

interface StreamOptions {
  headers?: Record<string, string>;
  lastEventId?: string | null;
  maxRetries?: number;
  baseDelayMs?: number;
  signal?: AbortSignal;
  onMessage?: (msg: SSEMessage) => void;
  onDone?: () => void;
}

function parseSSEBlock(block: string): SSEMessage | null {
  let id: string | null = null;
  let event = 'message';
  const dataLines: string[] = [];
  for (const line of block.split('\n')) {
    if (line.startsWith('id:')) id = line.slice(3).trim();
    else if (line.startsWith('event:')) event = line.slice(6).trim();
    else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
    else if (line.startsWith(':')) continue; // 服务端心跳注释行
  }
  if (dataLines.length === 0 && id === null) return null;
  return { id, event, data: dataLines.join('\n') };
}

function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);
    const onAbort = () => {
      clearTimeout(timer);
      reject(new DOMException('aborted', 'AbortError'));
    };
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

export async function streamSSE(url: string, body: unknown, options: StreamOptions = {}): Promise<void> {
  const { headers = {}, maxRetries = 5, baseDelayMs = 1000, signal, onMessage, onDone } = options;
  let lastEventId = options.lastEventId ?? null;
  let attempt = 0;

  while (true) {
    signal?.throwIfAborted();
    let res: Response;
    try {
      res = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', ...headers },
        body: JSON.stringify({ ...(body as Record<string, unknown>), ...(lastEventId ? { resume_from: lastEventId } : {}) }),
        signal,
      });
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return; // 用户主动取消,不算失败
      if (++attempt > maxRetries) throw err;
      await sleep(Math.min(baseDelayMs * 2 ** attempt + Math.random() * 500, 30000), signal);
      continue;
    }

    if (!res.ok || !res.body) {
      if (++attempt > maxRetries) throw new Error(`stream failed: HTTP ${res.status}`);
      await sleep(Math.min(baseDelayMs * 2 ** attempt + Math.random() * 500, 30000), signal);
      continue;
    }

    attempt = 0; // 连上一次,清零计数
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    try {
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        const blocks = buffer.split('\n\n');
        buffer = blocks.pop() ?? '';
        for (const block of blocks) {
          const msg = parseSSEBlock(block);
          if (!msg) continue;
          if (msg.id) lastEventId = msg.id; // 记住位点,断线从这里续
          if (msg.event === 'done') {
            onDone?.();
            await reader.cancel().catch(() => undefined);
            return;
          }
          onMessage?.(msg);
        }
      }
      onDone?.();
      return; // 服务端正常关流,结束
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        await reader.cancel().catch(() => undefined);
        return;
      }
      if (++attempt > maxRetries) throw err; // 读一半断了,带位点重试
      await sleep(Math.min(baseDelayMs * 2 ** attempt + Math.random() * 500, 30000), signal);
    } finally {
      reader.releaseLock();
    }
  }
}

用法上配合 AbortController 做取消,组件卸载或用户点停止时直接 abort,不再有幽灵请求:

typescript 复制代码
const controller = new AbortController();

streamSSE('/api/chat/stream', { messages }, {
  headers: { Authorization: `Bearer ${token}` }, // 头部鉴权,不拼 URL
  signal: controller.signal,
  onMessage: (msg) => appendToken(msg.data),
  onDone: () => setLoading(false),
}).catch((err) => {
  if (err instanceof DOMException && err.name === 'AbortError') return;
  toast('网络抖动,已自动重试仍失败,请再试一次');
  report(err); // 上报监控,带 lastEventId 便于复盘
});

// 用户点停止 / 组件卸载
controller.abort();

讲一讲遇到的一些坑

客户端写结实了,线上照样可能翻车。下面 👇 四个全是生产里真金白银换来的,每个都给兜底。

Nginx 吞流,首字永远慢

Nginx 默认 proxy_buffering on,会把上游的流攒成块再吐,流式直接退化成轮询。兜底要在 location 里显式关掉,并让上游声明不缓冲:

nginx 复制代码
location /api/chat/stream {
  proxy_pass http://upstream;
  proxy_buffering off;          # 关键:禁止攒批
  proxy_cache off;
  proxy_read_timeout 300s;      # 流式需要长超时
  proxy_http_version 1.1;
  chunked_transfer_encoding on;
}

服务端再补一个响应头 X-Accel-Buffering: no,双保险。验收标准只有一个:首字时间进监控,超过 3 秒直接告警,别等用户投诉🤷‍♂️。

浏览器同域连接上限,SSE 会断开其他请求

HTTP/1.1 下浏览器对同一域名只有 6 个并发连接,SSE 一旦连上就长期占一个。用户开三个问答标签页,再加载页面资源就开始排队,页面转圈的锅最后全算在前端头上。兜底策略:全局限制同页 SSE 并发数为 1,后开的流先 abort 前一个;切 HTTP/2 则多路复用天然缓解,但网关的 stream 并发上限同样要核对,别从浏览器瓶颈换成网关瓶颈👋。

断线重连导致消息重复

网络抖动是常态,重连后服务端从 Last-Event-ID 重放,客户端如果直接追加,用户会看到半截回答突然重来一遍。正确做法是按 ID 去重 + 按位点替换 :维护已收 ID 集合,重放的消息只用于补位不重复渲染;服务端必须保证 ID 单调递增,这是前后端要对齐的契约,不是前端单方面能解决的🫡。

网关空闲超时切断 和 移动端切后台直接失联

多数网关默认 60 秒无数据就掐连接,大模型思考超过 60 秒不吐字,连接就被静默掐掉。兜底是服务端每 15 秒发一条注释心跳(:\n\n),既保活又不污染业务流;客户端加一层兜底计时器,30 秒无任何字节就主动重连。同时统计要洗干净:重连次数、首字时间、完整率单独上报,别把重连算进接口成功率里,否则监控一片祥和,线上全是断流。


什么时候必须换 WebSocket

最后给一张决策表,贴在组里能少吵很多架。出现下面任意一条,SSE 不要硬撑,直接上 WebSocket!

WebSocket 也不是换过去就完事,心跳、重连退避、token 过期重鉴权,一行都不能少:

typescript 复制代码
// WebSocket 保活与重连骨架:心跳 + 指数退避,token 过期单独处理
interface WSOptions {
  heartbeatMs?: number;
  maxRetries?: number;
  getToken: () => Promise<string>;
  onMessage: (data: string) => void;
}

export function connectWS(url: string, options: WSOptions): () => void {
  const { heartbeatMs = 25000, maxRetries = 8, getToken, onMessage } = options;
  let ws: WebSocket | null = null;
  let retries = 0;
  let timer: ReturnType<typeof setInterval> | null = null;
  let closed = false;

  const open = async () => {
    const token = await getToken();
    ws = new WebSocket(`${url}?token=${encodeURIComponent(token)}`);
    ws.onopen = () => { retries = 0; };
    ws.onmessage = (e) => {
      if (typeof e.data !== 'string') return;
      if (e.data === 'pong') return; // 心跳回包,直接丢弃
      onMessage(e.data);
    };
    ws.onclose = () => {
      if (timer) clearInterval(timer);
      if (closed || ++retries > maxRetries) return;
      setTimeout(open, Math.min(1000 * 2 ** retries, 30000)); // 退避重连
    };
  };

  timer = setInterval(() => {
    if (ws?.readyState === WebSocket.OPEN) ws.send('ping');
  }, heartbeatMs);
  void open();

  return () => { closed = true; if (timer) clearInterval(timer); ws?.close(); };
}

注意 token 过期时不要无脑重连,先刷新 token 再建连,否则重连风暴里全是 401,网关日志会被打爆。


流式传输的技术选型,从来都是成本多少的问题。SSE 便宜、简单、和 HTTP 基础设施处得来,配上可取消、可续接的客户端,能覆盖九成 AI 问答场景;WebSocket 留给真正双向的场景。但是别让低频页面替长连接买单!

好了,今天就分享到这里吧🙌


喜欢我的文章,也欢迎关注我的微信公众号:【前端技术官】。

主要分享:前端架构 · AI 编程 · 职场认知 · 开发者成长

微信扫码关注 👆

不定期更新,不刷屏,聊点真正有用的干货。

相关推荐
奇牙coding1 小时前
Codex CLI 升级后多文件编辑静默失效:config.yaml 枚举变更排查与修复指南
java·前端·javascript·ai
小羊没烦恼!1 小时前
Ajax与JSON的一些总结
android·java·前端·数据库·mysql·算法·okhttp
程序员老赵1 小时前
Docker 部署 Filestash:轻松搭建浏览器文件管理平台
前端·docker·开源
夏天要喝冰可乐1 小时前
给 23 万字修仙小说造个阅读站:浏览器内打包 EPUB 分页阅读 + 三主题实战
前端·产品·全栈
茶底世界之下1 小时前
林间步道要不要加暗角?用 Awaking 把强度收在20%
前端·ios·图片资源
执子念的飞鱼1 小时前
纯前端预览 275 种文件格式是怎么做到的
前端·vue.js·react.js
行者全栈架构师1 小时前
【鸿蒙心迹】互动卡片全链路——把核心功能放到桌面(FormKit 实战)(HarmonyOS 7.x)
前端·算法·架构
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - HTML5 表单基础以及增强
前端·html·html5
海带紫菜菠萝汤2 小时前
PostgreSQL 19 延期一个月:AI 把找 bug 变便宜了,修 bug 还是人工价
人工智能·ai·程序员·架构·大模型