Skip to content

流式渲染:SSE / WebSocket 在 Chat UI 里的实现

做 AI 应用绕不开"打字机式"输出。看起来只是把字一个个吐出来,但真要做得不抖、不漏、不卡、可中断、可重连,前端要解决的细节比想象多。

TL;DR

  • HTTP 流式接口(SSE 风格)选 fetch + ReadableStream,不要用 EventSource,原因下文展开。
  • 真正双向(用户语音输入、Agent 状态推送)才上 WebSocket。
  • 必须AbortController,否则用户点"停止"时请求还在烧 token。

一、选型:SSE vs WebSocket vs HTTP 长轮询

维度SSE (HTTP 流式)WebSocket长轮询
方向服务端 → 客户端 单向双向客户端轮询
协议成本复用 HTTP,无需升级握手 + 升级纯 HTTP
防火墙友好部分企业网关阻断
主流大模型 API✅ OpenAI / Anthropic / DeepSeek 都用较少极少
多路复用HTTP/2 下天然支持需要自己封

结论:90% 的 LLM Chat UI 用 SSE 就够了。

二、为什么不要用 EventSource

EventSource 是浏览器内置的 SSE 客户端,但它有三个致命限制让它不适合 Chat 场景:

  1. 不能用 POST——LLM 接口几乎都要带 body(messages、tools、temperature),EventSource 只支持 GET。
  2. 不能自定义 Header——没法加 Authorization: Bearer xxx
  3. 不能 abort——只能 close(),但已经发出去的请求服务端依然在跑。

正确姿势是 fetch + ReadableStream 自己解析 SSE 格式

ts
async function* streamChat(messages: Message[], signal: AbortSignal) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`,
    },
    body: JSON.stringify({ messages, stream: true }),
    signal,
  });

  if (!res.ok || !res.body) {
    throw new Error(`stream failed: ${res.status}`);
  }

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = '';

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

    buffer += value;
    // SSE 格式以 \n\n 分隔每个事件
    const events = buffer.split('\n\n');
    buffer = events.pop() ?? '';

    for (const event of events) {
      const dataLine = event.split('\n').find((l) => l.startsWith('data: '));
      if (!dataLine) continue;

      const payload = dataLine.slice(6);
      if (payload === '[DONE]') return;

      try {
        const json = JSON.parse(payload);
        const delta = json.choices?.[0]?.delta?.content;
        if (delta) yield delta;
      } catch {
        // 非 JSON 片段,跳过
      }
    }
  }
}

三、Vue 3 里的最小可运行版本

vue
<script setup lang="ts">
import { ref } from 'vue';

const messages = ref<{ role: string; content: string }[]>([]);
const input = ref('');
const isStreaming = ref(false);
let abortCtl: AbortController | null = null;

async function send() {
  if (!input.value.trim() || isStreaming.value) return;

  messages.value.push({ role: 'user', content: input.value });
  const userInput = input.value;
  input.value = '';

  // 占位 assistant 消息
  const assistant = { role: 'assistant', content: '' };
  messages.value.push(assistant);

  isStreaming.value = true;
  abortCtl = new AbortController();

  try {
    for await (const chunk of streamChat(messages.value, abortCtl.signal)) {
      assistant.content += chunk;
    }
  } catch (err) {
    if ((err as Error).name !== 'AbortError') {
      assistant.content += '\n\n[出错了:' + (err as Error).message + ']';
    }
  } finally {
    isStreaming.value = false;
    abortCtl = null;
  }
}

function stop() {
  abortCtl?.abort();
}
</script>

<template>
  <div class="chat">
    <div v-for="(m, i) in messages" :key="i" :class="`msg msg--${m.role}`">
      {{ m.content }}
    </div>
    <div class="composer">
      <input v-model="input" @keydown.enter="send" :disabled="isStreaming" />
      <button v-if="!isStreaming" @click="send">发送</button>
      <button v-else @click="stop">停止</button>
    </div>
  </div>
</template>

四、踩坑清单

1. 中文乱码

TextDecoder 默认不知道一个 chunk 是不是包含半个 UTF-8 字符。必须用 TextDecoderStreamnew TextDecoder('utf-8', { stream: true })——否则中文会偶尔出 �。

2. Nginx 缓冲

很多人本地跑得好好的,部署上线就成"几秒钟蹦一段"。十有八九是 Nginx 把响应缓冲了:

nginx
location /api/chat {
    proxy_pass http://upstream;
    proxy_buffering off;          # 关键
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;
}

3. 用户点"停止",但 token 还在烧

不调 abort() 的话,浏览器虽然不再读 chunk,但 底层 TCP 连接还开着,服务端依然在生成。一定要:

  • 前端给"停止"按钮绑 abortCtl.abort()
  • 服务端监听 req.on('close') 主动 cancel() 掉上游 LLM 调用。

4. 切换页面/路由后还在写

onBeforeUnmount 兜底:

ts
onBeforeUnmount(() => abortCtl?.abort());

否则 SPA 切路由后内存泄漏 + 写到已卸载组件的 ref。

5. Markdown 流式渲染会闪烁

每来一个 chunk 就重新解析整段 Markdown 是 O(n²),下一篇 Markdown 流式渲染 + 代码高亮的踩坑 专门写这个。

五、什么时候才需要上 WebSocket

单向 LLM 输出:不需要。 双向:以下三种场景才值得:

  1. 用户语音输入实时转写,需要客户端持续推音频。
  2. Agent 状态推送——服务端要不断告诉前端"现在在调用什么工具/在等什么"。
  3. 多人协作 Chat——别人发消息你也要看到。

下一节会展开 Markdown 流式渲染与代码高亮,那部分坑更多。


本站总访问量 --heart 本站访客数 -- 人次