一、引言
1.1 为什么大模型需要流式传输?
在 Generative AI 应用开发中,大语言模型(LLM)的推理延迟是用户体验的核心瓶颈。大模型生成的内容往往较长,以 GPT-4 为例,生成一段 1000 字的回复通常需要 10-30 秒。如果采用传统的 HTTP 请求-响应模式,用户需要等待模型生成完全部内容后才能看到结果,这段时间内的"白屏等待"会严重降低用户体验。
更糟糕的是,当模型生成的内容超过网关(如 Nginx、API Gateway)的超时设置时,整个请求会被中断,导致用户白等几十秒后收到 504 Gateway Timeout 错误。这种情况在生产环境中非常常见,尤其是使用免费的 LLM API 服务时。
流式传输(Streaming)通过边生成边传输的方式,将首字到达时间(Time to First Token, TTFT)从数十秒缩短到 1-2 秒内。用户几乎在发送请求的瞬间就能看到文字开始逐字出现,这种体验与人类打字的速度相当,大幅降低了感知延迟。同时,由于连接始终保持活跃,网关超时问题也得到了有效缓解。
在实际开发中,主流的 LLM API 服务商(OpenAI、Anthropic、Google Gemini、DeepSeek、通义千问等)都已经原生支持流式输出。以 OpenAI 为例,只需在 Chat Completion API 请求中设置 stream: true,接口就会返回 Server-Sent Events 格式的数据流,每个 SSE 事件包含一个生成的 token。
1.2 流式 vs 非流式体验对比
| 维度 | 非流式(传统 HTTP) | 流式(SSE/WebSocket) |
|---|---|---|
| 首字延迟 | 10-30 秒 | 1-2 秒 |
| 内存占用 | 服务端需缓存完整响应 | 逐 token 发送,内存占用低 |
| 用户体验 | 长时间等待后一次性展示 | 实时打字机效果 |
| 超时风险 | 长请求易触发网关超时 | 连接持续活跃,超时风险低 |
| 中断处理 | 无法中途取消 | 可随时中断,节省算力 |
1.3 本文目标
本文将深入讲解两种主流的流式传输协议------Server-Sent Events (SSE) 和 WebSocket------在大模型应用中的实战应用,包括:
- 协议原理与后端/前端实现
- 代码级实战示例(Node.js、Python、React)
- 断开重连机制与生产级最佳实践
- 两种协议的选型决策框架
文章面向软件从业人员,从协议层到应用层,从开发环境到生产环境,提供一套完整的流式输出解决方案。无论你是前端工程师、后端开发,还是全栈开发者,都能从本文中找到可直接落地的代码和架构建议。
1.4 前置知识
阅读本文建议具备以下基础知识:
- 熟悉 HTTP 协议的基本概念(请求头、响应头、状态码等)
- 了解 JavaScript / Node.js / Python 的基本语法
- 对 React 组件开发有一定了解
- 有实际使用过 OpenAI 或类似 LLM API 的经验更佳
二、Server-Sent Events (SSE) 详解
2.1 SSE 协议基础
Server-Sent Events(SSE)是一种服务端推送技术,最初作为 HTML5 规范的一部分引入,目前是 WHATWG HTML Living Standard 中的独立章节。它基于 HTTP 长连接,允许服务器向客户端持续推送事件流。
2.1.1 核心特性
- 单向通信:仅支持服务端 → 客户端的单向数据推送,这对于大模型流式输出场景来说完全足够
- 基于 HTTP:使用标准的 HTTP/HTTPS 协议,无需额外的协议升级,所有现有的 HTTP 基础设施(负载均衡、CDN、代理、防火墙)都可以无缝支持
- 自动重连:浏览器原生支持断线自动重连,开发者无需手动实现重连逻辑
- 事件机制:支持自定义事件类型和事件 ID,可以在同一条连接上推送多种类型的消息
SSE 本质上是一个 HTTP 长连接,服务端通过保持连接不关闭,持续向客户端写入数据。
2.1.2 协议格式
SSE 使用 text/event-stream 作为 Content-Type,数据格式遵循 W3C 规范:
: 这是一条注释
event: message
id: 42
retry: 3000
data: {"token": "你好"}
data: {"token": ","}
data: {"token": "世界"}
| 字段 | 说明 | 必填 |
|---|---|---|
: 开头的行 |
注释,会被客户端忽略 | 否 |
event |
事件类型名称,默认 "message" | 否 |
id |
事件 ID,用于重连时 Last-Event-ID 头 | 否 |
retry |
重连时间间隔(毫秒) | 否 |
data |
事件数据,可跨多行 | 是 |
2.1.3 HTTP 层面的关键头
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
其中 X-Accel-Buffering: no 是 Nginx 特有的响应头,用于禁用 Nginx 的响应缓冲,确保数据实时推送给客户端。如果你使用其他反向代理(如 HAProxy、Envoy、AWS ALB),需要查阅各自的缓冲配置方式。
2.2 SSE 后端实现
2.2.1 Node.js / Express 实现
以下是使用 Express 对接 OpenAI Chat Completion API 的流式实现:
// npm install express openai
const express = require('express');
const OpenAI = require('openai');
const app = express();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
app.get('/api/chat-stream', async (req, res) => {
const { prompt } = req.query;
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
// 关闭 Express 的响应缓冲
res.flushHeaders();
try {
const stream = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
let tokenIndex = 0;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
const data = JSON.stringify({
id: tokenIndex++,
token: content,
done: false,
});
res.write(`data: ${data}\n\n`);
}
}
// 发送结束标记
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
} catch (error) {
console.error('Stream error:', error);
try {
if (!res.writableEnded) {
res.write(`event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
}
} catch (writeError) {
// 客户端已断开,忽略写入错误
}
}
});
app.listen(3000, () => console.log('SSE server running on port 3000'));
2.2.2 Python / FastAPI 实现
# pip install fastapi uvicorn openai
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import openai
import json
import asyncio
app = FastAPI()
client = openai.AsyncOpenAI(api_key="your-api-key")
async def generate_stream(prompt: str):
"""异步生成器:逐 token 推送 SSE 事件"""
stream = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
stream=True,
)
token_index = 0
async for chunk in stream:
content = chunk.choices[0].delta.content or ""
if content:
data = json.dumps({
"id": token_index,
"token": content,
"done": False,
})
yield f"data: {data}\n\n"
token_index += 1
# 发送结束标记
yield f"data: {json.dumps({'done': True})}\n\n"
@app.get("/api/chat-stream")
async def chat_stream(prompt: str):
return StreamingResponse(
generate_stream(prompt),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
2.3 SSE 前端消费
2.3.1 EventSource API 基础用法
浏览器原生支持的 EventSource API 是消费 SSE 流的标准方式:
// 创建 EventSource 连接
const eventSource = new EventSource(
`/api/chat-stream?prompt=${encodeURIComponent(userInput)}`
);
let accumulatedText = '';
// 监听 message 事件
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.done) {
// 流结束
eventSource.close();
console.log('流式输出完成');
return;
}
// 追加新 token 到累积文本
accumulatedText += data.token;
// 实时更新 DOM
document.getElementById('output').textContent = accumulatedText;
});
// 监听错误
eventSource.addEventListener('error', (event) => {
// 关闭连接以防止浏览器自动重连导致内容重复
eventSource.close();
console.error('SSE 连接异常');
// 如需支持手动重连,可在此处调用重新创建 EventSource 的逻辑
});
// 监听自定义事件(如错误事件)
eventSource.addEventListener('error-event', (event) => {
const data = JSON.parse(event.data);
console.error('服务端错误:', data.error);
eventSource.close();
});
2.3.2 Markdown 实时渲染
大模型输出通常包含 Markdown 格式(代码块、列表、标题等),需要实时渲染:
import { marked } from 'marked';
import DOMPurify from 'dompurify';
let accumulatedMarkdown = '';
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.done) {
eventSource.close();
return;
}
accumulatedMarkdown += data.token;
// 将 Markdown 转换为 HTML 并清理 XSS
// 注意:marked.parse() 在 marked v4.x 中返回 string(同步),
// 在 marked v5+ 中默认返回 Promise(异步)。
const html = marked.parse(accumulatedMarkdown);
const cleanHtml = DOMPurify.sanitize(html);
document.getElementById('output').innerHTML = cleanHtml;
// 自动滚动到最新内容
const outputEl = document.getElementById('output');
outputEl.scrollTop = outputEl.scrollHeight;
});
2.3.3 打字机效果实现
let tokenBuffer = [];
let rendering = false;
function renderNextToken() {
if (tokenBuffer.length === 0) {
rendering = false;
return;
}
rendering = true;
const token = tokenBuffer.shift();
accumulatedText += token;
const html = marked.parse(accumulatedText);
document.getElementById('output').innerHTML = DOMPurify.sanitize(html);
// 逐字渲染,模拟打字机效果(约每秒 15-20 字)
setTimeout(renderNextToken, 50 + Math.random() * 30);
}
// ⚠️ 生产环境注意:上述打字机效果在文本较长时会有性能问题,
// 因为每次渲染都对累积的全部文本执行 marked.parse()。
// 生产环境建议使用增量 Markdown 渲染(如 markdown-it),
// 或在打字机效果阶段只渲染纯文本,流结束后再执行一次完整 Markdown 渲染。
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.done) {
// 清空缓冲区,加速渲染剩余内容
while (tokenBuffer.length > 0) {
const token = tokenBuffer.shift();
accumulatedText += token;
}
const html = marked.parse(accumulatedText);
document.getElementById('output').innerHTML = DOMPurify.sanitize(html);
eventSource.close();
return;
}
tokenBuffer.push(data.token);
if (!rendering) {
renderNextToken();
}
});
// 注意:marked.parse() 在 marked v4.x 中返回 string(同步),
// 在 marked v5+ 中默认返回 Promise(异步)。
// 本示例基于 marked v4.x。如使用 v5+,需改为 await marked.parse() 或配置 {async: false}。
2.4 SSE 的优势与局限
优势
- 实现简单:基于 HTTP,无需额外的协议升级,服务端代码简洁
- 自动重连:浏览器原生支持,断线后自动恢复连接
- HTTP 友好:可以穿透所有支持 HTTP 的代理和防火墙
- CORS 支持:原生支持跨域请求
- 事件系统:支持多事件类型,适合推送多种类型的通知
局限
- 单向通信:仅服务端 → 客户端,无法客户端 → 服务端发送数据
- 连接数限制:HTTP/1.1 下每个域名最多 6 个并发连接
- 文本传输:仅支持 UTF-8 文本,不支持二进制数据
- 无内置心跳 :需要服务端发送注释(
:开头的行)维持连接活跃
图:前端 → SSE/WebSocket → 大模型 API 的三层架构数据流向
三、WebSocket 详解
3.1 WebSocket 协议基础
WebSocket 是 RFC 6455 定义的全双工通信协议,通过一次 HTTP 握手升级为 WebSocket 连接后,客户端和服务端可以随时互相发送数据。
3.1.1 握手过程
客户端 → 服务端(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=
3.1.2 数据帧格式
WebSocket 使用帧(Frame)作为数据传输单位,每个帧包含:
- FIN(1 bit):是否为消息的最后一帧,支持大消息分片传输
- Opcode(4 bits):帧类型(0x1=文本,0x2=二进制,0x8=关闭,0x9=Ping,0xA=Pong)
- MASK(1 bit):客户端发送的帧必须掩码编码,这是 RFC 6455 的安全要求,防止缓存投毒攻击
- Payload length(7/23/71 bits):数据长度,支持可变长度编码
- Payload data:实际数据
WebSocket 帧的最小开销仅为 2 字节(小消息),相比 HTTP 每次请求的完整头部(通常数百字节),传输效率大幅提升。这对于高频的流式 token 推送尤为重要------每个 token 通常只有几个字节,如果使用 HTTP 短连接传输,头部开销会远大于数据本身。
3.1.3 ws:// 与 wss://
| 协议 | 传输层 | 端口 | 安全性 |
|---|---|---|---|
ws:// |
TCP | 80 | 明文传输,不加密 |
wss:// |
TLS + TCP | 443 | 加密传输,与 HTTPS 同等安全 |
生产环境必须使用 wss://,防止数据被中间人截获或篡改。
3.2 WebSocket 后端实现
3.2.1 Node.js / ws 库实现
// npm install ws openai
const WebSocket = require('ws');
const OpenAI = require('openai');
const wss = new WebSocket.Server({ port: 8080 });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
wss.on('connection', (ws) => {
console.log('WebSocket 客户端已连接');
// 心跳检测:每 30 秒发送 ping
const heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 30000);
ws.on('message', async (message) => {
try {
const parsed = JSON.parse(message);
// 处理应用层心跳请求
if (parsed.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
const { prompt, sessionId } = parsed;
console.log(`收到会话 ${sessionId} 的请求`);
const stream = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
let tokenIndex = 0;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
ws.send(JSON.stringify({
type: 'token',
data: {
id: tokenIndex++,
token: content,
sessionId,
},
}));
}
}
// 发送完成标记
ws.send(JSON.stringify({
type: 'done',
data: { sessionId },
}));
} catch (error) {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'error',
data: { error: error.message },
}));
}
} catch (writeError) {
// 客户端已断开,忽略
}
}
});
ws.on('close', () => {
clearInterval(heartbeat);
console.log('WebSocket 客户端已断开');
});
ws.on('pong', () => {
// 收到 pong,连接正常
});
});
console.log('WebSocket server running on ws://localhost:8080');
3.2.2 Python / websockets 库实现
# pip install websockets openai
import asyncio
import json
import websockets
import openai
client = openai.AsyncOpenAI(api_key="your-api-key")
async def handle_client(websocket):
"""处理单个 WebSocket 客户端连接"""
print(f"客户端已连接: {websocket.remote_address}")
try:
async for message in websocket:
data = json.loads(message)
prompt = data.get("prompt", "")
session_id = data.get("sessionId", "")
print(f"收到会话 {session_id} 的请求")
stream = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
stream=True,
)
token_index = 0
async for chunk in stream:
content = chunk.choices[0].delta.content or ""
if content:
response = json.dumps({
"type": "token",
"data": {
"id": token_index,
"token": content,
"sessionId": session_id,
},
})
await websocket.send(response)
token_index += 1
# 发送完成标记
await websocket.send(json.dumps({
"type": "done",
"data": {"sessionId": session_id},
}))
except websockets.exceptions.ConnectionClosed:
print("客户端连接已断开")
async def main():
async with websockets.serve(handle_client, "localhost", 8765):
print("WebSocket server running on ws://localhost:8765")
await asyncio.Future() # 永久运行
if __name__ == "__main__":
asyncio.run(main())
3.3 WebSocket 前端消费
3.3.1 基础 WebSocket 客户端
// 创建 WebSocket 连接
const ws = new WebSocket('wss://your-server.com/ws/chat');
let accumulatedText = '';
// 连接打开
ws.onopen = () => {
console.log('WebSocket 连接已建立');
// 发送请求
ws.send(JSON.stringify({
prompt: '你好,请介绍一下自己',
sessionId: Date.now().toString(),
}));
};
// 接收消息
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'token':
accumulatedText += message.data.token;
document.getElementById('output').textContent = accumulatedText;
break;
case 'done':
console.log('流式输出完成');
break;
case 'error':
console.error('服务端错误:', message.data.error);
break;
default:
console.warn('未知消息类型:', message.type);
}
};
// 连接关闭
ws.onclose = (event) => {
console.log(`WebSocket 连接已关闭: code=${event.code}, reason=${event.reason}`);
};
// 错误处理
ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
3.3.2 消息协议设计
在 WebSocket 中传输大模型输出时,建议定义清晰的消息协议:
// TypeScript 类型定义
interface WebSocketMessage {
type: 'token' | 'done' | 'error' | 'ping' | 'pong';
data: TokenData | DoneData | ErrorData;
}
interface TokenData {
id: number; // token 序号
token: string; // token 内容
sessionId: string; // 会话 ID
}
interface DoneData {
sessionId: string;
usage?: { // token 用量统计
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
interface ErrorData {
error: string;
code?: number;
}
3.4 WebSocket 的优势与局限
优势
- 全双工通信:客户端和服务端可以随时互相发送数据
- 低延迟:帧传输开销小,适合实时交互场景
- 二进制支持:可以传输二进制数据(音频、视频等)
- 单连接多路复用:一个 WebSocket 连接可以处理多个会话
- 自定义协议:可以在应用层定义任意消息格式
局限
- 实现复杂度高:需要处理握手、帧编解码、心跳等
- 代理穿透问题:部分企业代理不支持 WebSocket 升级
- 无原生重连:需要手动实现重连逻辑
- 负载均衡要求:WebSocket 连接需要 sticky session
四、SSE 与 WebSocket 深度对比
图:SSE 单向推送与 WebSocket 全双工通信的视觉对比
4.1 技术维度对比
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务端→客户端) | 全双工 |
| 协议 | HTTP/HTTPS | WebSocket (ws/wss) |
| 浏览器支持 | EventSource API(IE 不支持) | WebSocket API(现代浏览器全支持) |
| 自动重连 | ✅ 浏览器原生支持 | ❌ 需手动实现 |
| 二进制数据 | ❌ 仅 UTF-8 文本 | ✅ 支持 |
| 代理穿透 | ✅ HTTP 天然穿透 | ⚠️ 部分代理可能拦截 |
| 实现复杂度 | 低 | 中到高 |
| 连接开销 | HTTP Header 每次重连 | 一次握手后低开销 |
| 适用场景 | 推送通知、流式文本输出 | 实时聊天、游戏、协同编辑 |
| 大模型适配 | ✅ 非常适合(单向文本流) | ✅ 适合(尤其需要双向交互时) |
4.2 大模型场景下的选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 纯问答对话 | SSE | 实现简单,自动重连,完全满足需求 |
| 多轮对话 + 工具调用 | WebSocket | 客户端需要发送工具执行结果回服务端 |
| 语音/视频流式传输 | WebSocket | 支持二进制数据传输 |
| 简单通知推送 | SSE | 轻量级,HTTP 友好 |
| 协同编辑 + AI 辅助 | WebSocket | 需要客户端→服务端实时交互 |
4.3 混合架构方案
在复杂应用中,可以同时使用两种协议:
bash
前端
├── SSE 连接 → 接收 AI 模型流式输出(单向文本)
└── WebSocket 连接 → 实时状态同步、工具调用结果返回
这种方案结合了 SSE 的简单性和 WebSocket 的灵活性。
五、断开重连机制
5.1 SSE 自动重连机制
5.1.1 retry 字段控制
服务端可以通过 retry 字段指定客户端重连间隔:
retry: 3000
data: {"token": "hello"}
这告诉客户端在连接断开后等待 3 秒再重连。
5.1.2 Last-Event-ID 机制
浏览器在重连时会自动发送 Last-Event-ID 请求头,服务端可以据此恢复断点:
// 服务端读取 Last-Event-ID
app.get('/api/chat-stream', async (req, res) => {
const lastEventId = req.headers['last-event-id'];
const lastTokenIndex = lastEventId ? parseInt(lastEventId, 10) : 0;
// 从断点继续流式输出...
});
5.1.3 幂等性设计
由于重连可能导致事件重复接收,服务端发送的每个事件都应该是幂等的:
// 使用递增 ID 保证幂等
let eventId = 0;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
res.write(`id: ${eventId++}\n`);
res.write(`data: ${JSON.stringify({ token: content })}\n\n`);
}
}
客户端通过检查事件 ID,可以跳过已处理的事件。
5.2 WebSocket 手动重连策略
5.2.1 指数退避重连
5.2.1.1 为什么需要指数退避?
在网络不稳定或服务端故障时,如果使用固定间隔重连(例如每秒重试一次),会产生两个问题:
- 雪崩效应(Thundering Herd):当服务端恢复时,大量客户端同时重连,瞬间流量峰值可能导致服务端再次崩溃
- 资源浪费:在服务端尚未恢复时,高频重连只会增加客户端和服务端的负担
指数退避(Exponential Backoff)通过逐步增加重连间隔来解决这两个问题。首次重连等待 1 秒,第二次 2 秒,第三次 4 秒,以此类推。同时加入随机抖动(Jitter),进一步分散重连时间点。
5.2.1.2 指数退避代码实现
class WebSocketClient {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.baseDelay = 1000; // 1 秒
this.maxDelay = 30000; // 30 秒
this.messageQueue = [];
this.isConnected = false;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket 连接已建立');
this.reconnectAttempts = 0;
this.isConnected = true;
// 发送排队中的消息
while (this.messageQueue.length > 0) {
this.ws.send(this.messageQueue.shift());
}
};
this.ws.onmessage = (event) => {
this.handleMessage(JSON.parse(event.data));
};
this.ws.onclose = (event) => {
this.isConnected = false;
console.log(`连接关闭,尝试重连...`);
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
}
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('达到最大重连次数,放弃重连');
return;
}
// 指数退避:delay = min(baseDelay * 2^attempt, maxDelay)
const delay = Math.min(
this.baseDelay * Math.pow(2, this.reconnectAttempts),
this.maxDelay
);
// 添加随机抖动,避免所有客户端同时重连(thundering herd)
const jitter = delay * 0.2 * Math.random();
const totalDelay = delay + jitter;
this.reconnectAttempts++;
console.log(`将在 ${Math.round(totalDelay)}ms 后重连 (第 ${this.reconnectAttempts} 次)`);
setTimeout(() => this.connect(), totalDelay);
}
send(data) {
const message = JSON.stringify(data);
if (this.isConnected && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(message);
} else {
this.messageQueue.push(message);
}
}
handleMessage(message) {
// 业务逻辑处理
console.log('收到消息:', message);
}
}
// 使用示例
const client = new WebSocketClient('wss://your-server.com/ws/chat');
client.connect();
// 发送消息(断线时自动排队)
client.send({ prompt: '你好', sessionId: '123' });
5.2.2 心跳检测
5.2.2.1 心跳机制的作用
WebSocket 连接建立后,如果长时间没有数据交互,中间的代理服务器(如 Nginx、云厂商的负载均衡器)可能会因超时主动断开连接。心跳检测通过定期发送小数据包来保持连接活跃,同时可以及时发现"半开连接"(连接已断开但两端都不知道)。
注意:浏览器原生 WebSocket API 不暴露协议级 ping/pong 帧的控制权。以下心跳方案使用应用层消息实现,需要服务端配合处理(见 3.2.1 节中的应用层心跳处理)。
5.2.2.2 心跳检测代码实现
// 客户端心跳
class WebSocketClient {
constructor(url) {
// ...
this.heartbeatInterval = null;
this.heartbeatTimeout = null;
this.pingPongTimeout = 5000; // 5 秒无 pong 认为断线
}
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
// 设置 pong 超时检测
this.heartbeatTimeout = setTimeout(() => {
console.warn('心跳超时,主动断开重连');
this.ws.close();
}, this.pingPongTimeout);
}
}, 15000); // 每 15 秒发送一次心跳
}
handleMessage(message) {
if (message.type === 'pong') {
// 收到 pong,清除超时检测
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
}
return;
}
// 其他消息处理...
}
connect() {
// ...
this.ws.onopen = () => {
// ...
this.startHeartbeat();
};
this.ws.onclose = () => {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
}
// 重连逻辑...
};
}
}
5.3 前端重连 UI 设计
// React 重连状态组件
function ConnectionStatus({ status, onRetry }) {
const config = {
connected: { icon: '🟢', text: '已连接', color: '#22c55e' },
connecting: { icon: '🟡', text: '正在重连...', color: '#eab308' },
disconnected: { icon: '🔴', text: '连接已断开', color: '#ef4444' },
failed: { icon: '⚠️', text: '重连失败', color: '#f97316' },
};
const { icon, text, color } = config[status] || config.disconnected;
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '8px',
background: `${color}15`,
border: `1px solid ${color}30`,
fontSize: '14px',
}}>
<span>{icon}</span>
<span style={{ color }}>{text}</span>
{status === 'failed' && (
<button onClick={onRetry} style={{
padding: '4px 12px',
borderRadius: '4px',
border: `1px solid ${color}`,
background: 'transparent',
color,
cursor: 'pointer',
}}>
手动重试
</button>
)}
</div>
);
}
六、前端流式渲染实战
6.1 React 流式组件
import { useState, useRef, useEffect, useCallback } from 'react';
// 自定义 Hook:流式聊天
function useStreamingChat() {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef(null);
const sendMessage = useCallback(async (prompt) => {
// 中止之前的请求(竞态处理)
if (abortRef.current) {
abortRef.current.abort();
}
// 添加用户消息
const userMessage = { role: 'user', content: prompt };
setMessages(prev => [...prev, userMessage]);
// 创建 AI 消息占位
const assistantIndex = messages.length + 1;
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
setIsStreaming(true);
abortRef.current = new AbortController();
try {
const response = await fetch(
`/api/chat-stream?prompt=${encodeURIComponent(prompt)}`,
{ signal: abortRef.current.signal }
);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulated = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// ⚠️ 这是简化版 SSE 解析器,仅处理 data: 行。
// 生产环境建议使用成熟的 SSE 解析库,如 eventsource-parser 或 fetch-event-source。
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留不完整的行
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (data.done) break;
accumulated += data.token;
// 实时更新最后一条消息
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = {
...updated[assistantIndex],
content: accumulated,
};
return updated;
});
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求已中止');
} else {
console.error('流式请求失败:', error);
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = {
...updated[assistantIndex],
content: '⚠️ 请求失败,请重试',
error: true,
};
return updated;
});
}
} finally {
setIsStreaming(false);
}
}, [messages.length]);
// 组件卸载时中止请求
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, []);
return { messages, sendMessage, isStreaming };
}
// 聊天组件
function ChatComponent() {
const [input, setInput] = useState('');
const { messages, sendMessage, isStreaming } = useStreamingChat();
const messagesEndRef = useRef(null);
// 自动滚动到最新消息
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSubmit = (e) => {
e.preventDefault();
if (input.trim() && !isStreaming) {
sendMessage(input.trim());
setInput('');
}
};
return (
<div className="chat-container">
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
<div className="content">
{msg.role === 'assistant' ? (
<MarkdownRenderer content={msg.content} />
) : (
<p>{msg.content}</p>
)}
</div>
</div>
))}
{isStreaming && (
<div className="typing-indicator">
<span className="cursor" />
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入消息..."
disabled={isStreaming}
/>
<button type="submit" disabled={isStreaming || !input.trim()}>
发送
</button>
</form>
</div>
);
}
6.2 光标动画
/* CSS 打字机光标动画 */
.cursor {
display: inline-block;
width: 2px;
height: 1.2em;
background-color: currentColor;
margin-left: 2px;
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
6.3 性能优化
6.3.1 节流渲染(Throttle)
当 token 到达频率很高时(大模型通常每秒生成 20-50 个 token),直接更新 DOM 会造成性能问题。每个 token 到达都触发一次 React 的 re-render 和 DOM 更新,在低端设备上会导致明显的卡顿。使用节流(Throttle)可以降低渲染频率,将 DOM 更新控制在每秒 20-30 次以内:
function useThrottledRender(interval = 50) {
const [renderContent, setRenderContent] = useState('');
const bufferRef = useRef('');
const timerRef = useRef(null);
const update = useCallback((newContent) => {
bufferRef.current = newContent;
if (!timerRef.current) {
timerRef.current = setTimeout(() => {
setRenderContent(bufferRef.current);
timerRef.current = null;
}, interval);
}
}, [interval]);
// 组件卸载时刷新缓冲区
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
setRenderContent(bufferRef.current);
}
};
}, []);
return { renderContent, update };
}
6.3.2 虚拟滚动
对于超长对话,使用虚拟滚动只渲染可视区域内的内容:
import { FixedSizeList } from 'react-window';
function VirtualizedChat({ messages }) {
const Row = ({ index, style }) => (
<div style={style} className="message-row">
<Message message={messages[index]} />
</div>
);
return (
<FixedSizeList
height={600}
itemCount={messages.length}
itemSize={100}
width="100%"
>
{Row}
</FixedSizeList>
);
}
七、生产环境最佳实践
7.1 错误处理与降级策略
// 多层降级策略
async function fetchWithFallback(prompt) {
// 第一层:SSE 流式
try {
return await fetchSSE(prompt);
} catch (sseError) {
console.warn('SSE 失败,降级到轮询:', sseError);
// 第二层:短轮询
try {
return await fetchWithPolling(prompt);
} catch (pollError) {
console.warn('轮询失败,降级到同步请求:', pollError);
// 第三层:传统同步请求
return await fetchSync(prompt);
}
}
}
7.2 安全考量
7.2.1 CORS 配置
// Express CORS 配置
const cors = require('cors');
app.use(cors({
origin: ['https://your-domain.com'],
methods: ['GET', 'POST'],
credentials: true,
}));
// 注意:cors() 中间件已经为所有请求(包括 SSE)设置了正确的 CORS 头。
// 如果需要对 SSE 端点做特殊处理,请使用路由级别的中间件而非全局中间件。
7.2.2 认证与授权
// WebSocket 认证:在握手阶段验证 token
// ⚠️ 安全警告:将 token 放在 URL 查询参数中会导致 token 泄露到日志和浏览器历史。
// 生产环境建议:
// 1. 使用 Sec-WebSocket-Protocol 头传递 token:new WebSocket(url, [token])
// 2. 或在连接建立后的第一条消息中进行认证
// 3. 或使用基于 Cookie 的认证(WebSocket 握手自动携带 Cookie)
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token');
if (!token || !verifyToken(token)) {
ws.close(4001, 'Unauthorized');
return;
}
// 认证成功,继续处理
});
7.2.3 速率限制
const rateLimit = require('express-rate-limit');
const sseLimiter = rateLimit({
windowMs: 60 * 1000, // 1 分钟
max: 20, // 最多 20 次请求
message: { error: '请求过于频繁,请稍后再试' },
});
app.get('/api/chat-stream', sseLimiter, chatStreamHandler);
7.3 监控与可观测性
生产环境中,流式输出的可观测性至关重要。你需要关注以下关键指标:
- TTFT(Time to First Token):首字到达时间,直接影响用户体验
- TPOT(Time Per Output Token):每个 token 的生成间隔,反映模型推理速度
- 连接数:当前活跃的 SSE/WebSocket 连接数量
- 错误率:流式连接中断、重连失败的比率
- Token 用量:每个请求消耗的 prompt token 和 completion token
建议将这些指标上报到 Prometheus、Datadog 或 Grafana 等监控平台,并设置告警阈值。
// OpenTelemetry 集成
const { trace, metrics } = require('@opentelemetry/api');
async function chatStreamHandler(req, res) {
const tracer = trace.getTracer('chat-service');
await tracer.startActiveSpan('chat.stream', async (span) => {
span.setAttribute('model', 'gpt-4');
span.setAttribute('user.id', req.user.id);
const start = Date.now();
let tokenCount = 0;
try {
// ... 流式输出逻辑
// 每发送一个 token,tokenCount++
} finally {
const duration = Date.now() - start;
span.setAttribute('duration_ms', duration);
// 记录指标
const counter = metrics.getMeter('chat').createCounter('stream.tokens');
counter.add(tokenCount, { model: 'gpt-4' });
span.end();
}
});
}
7.4 大规模部署架构
当你的应用用户量增长到数千甚至数万并发连接时,单机架构无法满足需求。以下是经过验证的大规模部署方案:
bash
┌─────────────┐
│ CDN/Nginx │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ App 1 │ │ App 2 │ │ App 3 │
│ (Node.js) │ │(Node.js)│ │(Node.js) │
└─────┬─────┘ └───┬────┘ └────┬─────┘
│ │ │
└────────────┼────────────┘
│
┌──────▼──────┐
│ Redis │ ← 会话状态、消息队列
└──────┬──────┘
│
┌──────▼──────┐
│ OpenAI API │
│ / LLM API │
└─────────────┘
关键设计要点:
- 负载均衡:WebSocket 连接需要 sticky session(粘性会话),确保同一连接始终路由到同一后端实例
- Redis 共享状态:多实例间通过 Redis 共享会话状态和消息队列
- 连接池:复用上游 LLM API 连接,减少握手开销
- 水平扩展:根据连接数自动伸缩(HPA)
八、总结与展望
8.1 核心要点回顾
| 要点 | 说明 |
|---|---|
| 流式输出是刚需 | 大模型推理延迟高,流式可大幅改善用户体验 |
| SSE 适合多数场景 | 实现简单、自动重连、HTTP 友好,是问答场景的首选 |
| WebSocket 适合复杂交互 | 全双工通信适合需要客户端→服务端反馈的场景 |
| 重连机制不可少 | 网络不稳定是常态,必须做好断线重连和状态恢复 |
| 安全与监控要跟上 | 认证、限流、可观测性是生产环境的基石 |
8.2 技术趋势
- HTTP/3 (QUIC):基于 UDP 的新一代传输协议,连接建立延迟更低,网络切换时连接不中断,为流式传输提供更好的基础
- WebTransport:基于 QUIC 的新型传输协议,低延迟、可靠/不可靠传输兼备,值得关注
- LLM API 标准化:OpenAI、Anthropic、Google 等厂商的流式 API 格式趋于统一,降低了集成成本
- 边缘计算:Cloudflare Workers、Vercel Edge 等边缘平台原生支持 SSE,可以将流式代理部署在离用户更近的位置
8.3 选型决策树
bash
需要流式输出?
├── 仅服务端推送文本 → SSE ✅
│ ├── 需要自动重连? → SSE(内置支持)✅
│ └── 需要穿透企业代理? → SSE(HTTP 友好)✅
│
└── 需要双向通信? → WebSocket ✅
├── 需要传输二进制? → WebSocket(唯一选择)✅
├── 需要低延迟交互? → WebSocket ✅
└── 需要多路复用? → WebSocket ✅
选择合适的协议,搭配合适的重连策略和前端渲染方案,就能构建出流畅、可靠的大模型流式交互体验。