孔乙己的茴字有多少种写法?|AI 对话实时通信的三种实现

WebSocket

握手机制(八股)

WebSocket 是一种应用层通信协议 ,用于在浏览器与服务器之间建立持久的、全双工的数据通道。

这里容易和名字里的 Socket 混淆:底层网络传输确实依赖 TCP socket,但 WebSocket 本身并不是"在传输层建立的 socket",而是运行在 TCP 之上的应用层协议。它规定了浏览器和服务端如何握手、如何编码和收发消息,以及如何关闭连接。

WebSocket 建连时会先发起一次看起来像普通 HTTP 的请求;这一步不是"假装 HTTP",而是为了复用 HTTP 的入口能力,例如 80/443 端口、反向代理、鉴权和 TLS。客户端在请求头中声明希望将连接升级为 WebSocket,以 Manus 为例:

如果服务端支持,便返回 101 Switching Protocols。此后,这条 TCP 连接不再按照 HTTP 的"一问一答"模型传输,而是切换为 WebSocket 帧协议,客户端和服务端都可以随时主动发送消息。

SSE

SSE 是 "chatbot" 选型最常用的方案,你如果让 AI 给你写一个 "chatbot",往往它会给来上一套经典的 Next.js + Tailwind + ai 的方案,因为 npm 里的 ai 和 Next.js 都是 Vercel 出品

手搓 SSE

虽然 SSE 本身有大量的封装 SDK 、开源库或者 npm 包,但面试还是会考一下你手搓滴,下面是一个示例

feature: SSE 封装:心跳 + 自动重连 + 消息缓存 + 卸载销毁

EventSource 版本

参考:reconnecting-eventsource

javascript 复制代码
/**
 * SSE 封装:心跳 + 自动重连 + 消息缓存 + 卸载销毁
 *
 * 客户端断开两种语义:
 * 1. 关页 / 卸载 → stop:清理资源,不再重连
 * 2. 网络断 → onerror / offline + 心跳超时:意外断开,自动重连
 *
 * @param {object} options
 * @param {(msg: object) => void} [options.onMessage] - 用来调试
 */
export function createSSEHook(options) {
  const { onMessage: _onMessage } = options;

  let eventSource = null;
  let stopped = false;
  let lastEventId = null;
  let lastActivityAt = Date.now();

  const messages = [];
  const messageId = new Set();

  const closeStream = () => {
    eventSource?.close();
    eventSource = null;
  };

  const connect = () => {
    if (stopped) return;

    closeStream();
    let url = `${location.protocol}//${location.hostname}:3002/ai-stream`;

    if (lastEventId) {
      url += "?lastEventId=" + encodeURIComponent(lastEventId);
    }

    const eventSource = new EventSource(url);

    eventSource.addEventListener("message", (event) => {
      if (event instanceof MessageEvent) {
        lastEventId = event.lastEventId;
      }

      try {
        lastActivityAt = Date.now();
        const data = JSON.parse(event.data);
        if (data.type === "heart") {
          lastActivityAt = Date.now();
        } else if (!messageId.has(data.id)) {
          messageId.add(data.id);
          messages.push(data);
        }
      } catch {}
    });

    eventSource.onopen = () => {
      lastActivityAt = Date.now();
    };

    eventSource.onerror = () => {
      if (stopped) return;
      if (eventSource.readyState === EventSource.CLOSED) {
        // 初始连接时,Nginx/网关没挂,但是服务器重启这种 case 不会触发 EventSource
        // 自动重连(即 readyState === EventSource.CONNECTING),因此需要实现重连
        closeStream();
        connect();
      }
    };
  };

  /** 场景 2:半开连接(TCP 还在但流已死)→ 心跳探测失败后重连 */
  const timer = setInterval(() => {
    if (stopped || eventSource?.readyState !== EventSource.OPEN) return;
    if (Date.now() - lastActivityAt > 3000) {
      connect();
    }
  }, 1000);

  /** 场景 1:关页 / 组件卸载 --- 彻底销毁 */
  const stop = () => {
    stopped = true;
    clearInterval(timer);
    closeStream();
    window.removeEventListener("pagehide", stop);
  };

  // pagehide 比 beforeunload 更稳(移动端/ bfcache)
  window.addEventListener("pagehide", stop);

  connect();

  return {
    messages,
    stop,
  };
}

EventSource 建立的 SSE 对应的服务端一般会返回 chunk_id 如下图

在网络抖动或者服务端重启导致的 EventSource 自动重连会带上最后一个 id,这样服务端就能够根据 last-event-id 里去按需返回 redis 缓存里的 chunk,client 渲染的 chunk 就不会重复

重连时服务端日志如下

css 复制代码
$ npm run start -w sse-hook

> sse-hook@1.0.0 start
> node server.js

SSE demo: http://localhost:3001
Last-Event-ID: undefined
Last-Event-ID: heart-1793717750411
QA

Q: 我在浏览器怎么看不到 last-event-id?

A: Chrome 里的重连连接可能看不到浏览器携带的 last-event-id

最好是在服务端打印观察,比如

javascript 复制代码
app.get('/ai-stream', (req, res) => {
  console.log('Last-Event-ID:', req.headers['last-event-id']);
  ...
}

Q: 我该怎么模拟网络抖动?

A: 最好是在电脑做服务端,然后手机和电脑连同一个 Wi-Fi,打印电脑端的局域网 iP,然后手机访问对应的 iP + Port 去访问(比如 http://192.168.1.10:3001),再关闭 Wi-Fi,触发 EventSource 的自动重连,然后再开 Wi-Fi,观察服务端有没有接收到重连的请求

如果你有两台电脑,接收端的电脑(如果是 MacOS)记得在打开 系统设置 → 隐私与安全性 → 本地网络 ,允许实际用于访问的应用,比如 Chrome,不然访问不了你的局域网 iP + Port,开启示例如下图

ReadableStream 版本

相对于 EventSource,ReadableStream 的 SSE 实现更好拓展和自定义,可以使用 POST、携带请求体或自定义请求头,也可以在读取前检查响应状态,比如 HTTP 状态码和 Content-Type,决定是否解析 SSE,是否重连和对应的实现也可以自己根据业务决定,一个例子如下:(由于实现代码太长,只使用伪代码,完整代码可以查看参考文档里的 github 链接)

js 复制代码
function useSseChat() {
  const [messages, setMessages] = useState([]);
  const [status, setStatus] = useState("ready");
  const prevStatusRef = useRef(status);
  const canResumeRef = useRef(false);
  const sseController = useRef(null);

  useEffect(() => () => sseController.current?.abort(), []);

  const sendMessage = async (url = "/api/chat") => {
    canResumeRef.current = true;
    sseController.current?.abort();
    const controller = new AbortController();
    sseController.current = controller;
    setStatus("streaming");

    try {
      const res = await fetch(url, {
        signal: controller.signal,
      });
      await res.body
        .pipeThrough(new TextDecoderStream())
        .pipeThrough(new EventSourceParserStream())
        .pipeThrough(
          new TransformStream({
            transform(event) {
              const reply = JSON.parse(event.data);
              setMessages((curr) => {
                const i = curr.findLastIndex((m) => m.id === reply.id);
                if (i === -1) return [...curr, reply];
                else return curr.with(i, reply);
              });
            },
          }),
        )
        .pipeTo(new WritableStream());
    } catch {
    } finally {
      if (!controller.signal.aborted) setStatus("ready");
    }
  };

  useEffect(() => {
    const prevStatus = prevStatusRef.current;
    prevStatusRef.current = status;
    if (
      prevStatus !== "streaming" ||
      status !== "ready" ||
      !canResumeRef.current
    ) {
      return;
    }
    const controller = new AbortController();
    let timer,
      attemps = 0;

    const checkHeartbeat = async () => {
      if (attemps >= 10 || controller.signal.aborted) return;
      attemps++;

      try {
        const res = await fetch("/api/chat/heart", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ id }),
          signal: controller.signal,
        });
        const { canResume } = await res.json();
        if (controller.signal.aborted) return;
        if (canResume) return sendMessage(`/api/chat/${id}/heart`);
      } catch {}

      if (!controller.signal.aborted && attemps < 10) {
        timer = setTimeout(checkHeartbeat, 1000);
      }
    };

    checkHeartbeat();

    return () => {
      controller.abort();
      clearTimeout(timer);
    };
  }, [status]);

  return {
    messages,
    sendMessage,
    stop() {
      canResumeRef.current = false;
      sseController.current?.abort();
      setStatus("ready");
    },
  };
}

ReadableStream 版本多了 chunk 的处理

EventSource 内置了 SSE 解析,监听器可以直接从 event.data 取到事件载荷。如果用 fetch 读取 SSE,就要先把响应字节流解码成文本,再用 EventSourceParserStream 从连续的文本块中识别事件边界、提取 data。服务端把 JSON 放在 data: 里时,提取出的载荷就是 JSON 字符串。 - codex

EventSource 版本根据心跳处理 TCP 半开连接的场景在 ReadableStream 版本先忽略

边界 Case
  1. if (canResume) return sendMessage(/api/chat/${id}/heart):检测到可以重连后需要及时终止心跳,直接 return sendMessage...,避免下一轮心跳再次触发,导致连续断点续连
  2. res.body 需要等待:ReadableStream 的处理是链式调用,类似 Promise().then().then(),如果需要等待流式管道完成,需要 await + pipeTo
竟态 Case

由于自动重连、续流、停止三个操作都互有关联,因此三方的竟态处理需要处处防御

  1. status 驱动的重连的防御:停止 SSE 也就是 stop 的调用也驱动了 status: streaming -> ready,但这不是我们自动重连该监听的,因此我们需要一个标志位防御下,即 canResumeRef(状态驱动自动重连其实不是一个很好的设计,后续类似的状态变多更应该考虑事件驱动,比如 redux, useReducer 的 dispatch,能够追根溯源)

轮询

有些流式可能是轮询实现,比如 OpenAI 的一个例子, 1s 去查一次状态,即 get_run_status,理论上 get_run_status 可以实现返回 message

轮询实现 AI 流式对话的 case 实在太少,就不过多展开

不过可以说下什么时候用它

比如文生视频、文生图,这种耗时较长的工具调用可以用轮询,因为这期间没啥输出

SSE 和 WebSocket 有什么不同?

两者都能让 AI 的回答"一个字一个字地冒出来",但通信模型不同。

SSE 是服务端单向推送 。客户端先用 HTTP 发起请求,服务端保持响应不结束,并持续把生成结果推回客户端。用户发送提问、点击"停止生成"等操作,则另外通过普通 HTTP 请求提交。

复制代码
客户端 ── HTTP 请求 ──> 服务端
客户端 <── SSE 事件流 ── 服务端

WebSocket 则是全双工通信。连接建立后,客户端和服务端共享一条长期存在的通道;任何一方都能主动发消息。

复制代码
客户端 <──── WebSocket 双向通道 ────> 服务端
对比项 SSE WebSocket
通信方向 服务端 → 客户端 客户端 ↔ 服务端
基于什么 HTTP 持续响应 升级后的 WebSocket 协议
浏览器原生接口 EventSource WebSocket
适合 AI 文本流 很适合 也适合,但常有些"重"
适合实时双向场景 一般 更适合,如语音、协作、游戏

对于普通 AI 文本对话,SSE 往往足够且实现简单:用户发一次请求,服务端流式返回回答。只有当客户端需要频繁、实时地主动向服务端发送数据------例如实时语音、多人协作、持续状态同步------WebSocket 的双向能力才更有价值。

总结:

SSE 是"服务端持续说,客户端另开口";WebSocket 是"双方一直通着电话,谁都能随时说话"。

参考文档

  1. reconnecting-eventsource.ts - fanout/reconnectin...
  2. EventSource - MDN
  3. assistant-basic - openai-quickstart-python - github
  4. pandoralink/hand-tearing - github
相关推荐
Penge6663 小时前
JavaScript:BOM 详解
前端
Penge6663 小时前
Vite vs Webpack:前端构建工具原理与实战
前端
Penge6663 小时前
前端包管理工具:npm、Yarn、pnpm 快速入门
前端
Penge6664 小时前
Sass 了解
前端
Penge6664 小时前
Webpack 打包从 0 到 1 实战笔记
前端
Penge6664 小时前
Webpack 入门:是什么、为什么需要、怎么使用?
前端
Penge6664 小时前
Ant Design(Antd)了解
前端
wangruofeng5 小时前
同一行代码,百种渲染:Asuka、React Native 与 Flutter 跨平台渲染机制深度拆解
前端·github
Penge6665 小时前
JavaScript 核心语法速通
前端