Appearance
流式渲染: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 场景:
- 不能用 POST——LLM 接口几乎都要带 body(messages、tools、temperature),
EventSource只支持 GET。 - 不能自定义 Header——没法加
Authorization: Bearer xxx。 - 不能 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 字符。必须用 TextDecoderStream 或 new 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 输出:不需要。 双向:以下三种场景才值得:
- 用户语音输入实时转写,需要客户端持续推音频。
- Agent 状态推送——服务端要不断告诉前端"现在在调用什么工具/在等什么"。
- 多人协作 Chat——别人发消息你也要看到。
下一节会展开 Markdown 流式渲染与代码高亮,那部分坑更多。
