大模型流式输出实战:SSE 与 WebSocket

一、引言

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: noNginx 特有的响应头,用于禁用 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 的优势与局限

优势

  1. 实现简单:基于 HTTP,无需额外的协议升级,服务端代码简洁
  2. 自动重连:浏览器原生支持,断线后自动恢复连接
  3. HTTP 友好:可以穿透所有支持 HTTP 的代理和防火墙
  4. CORS 支持:原生支持跨域请求
  5. 事件系统:支持多事件类型,适合推送多种类型的通知

局限

  1. 单向通信:仅服务端 → 客户端,无法客户端 → 服务端发送数据
  2. 连接数限制:HTTP/1.1 下每个域名最多 6 个并发连接
  3. 文本传输:仅支持 UTF-8 文本,不支持二进制数据
  4. 无内置心跳 :需要服务端发送注释(: 开头的行)维持连接活跃

图:前端 → 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 的优势与局限

优势

  1. 全双工通信:客户端和服务端可以随时互相发送数据
  2. 低延迟:帧传输开销小,适合实时交互场景
  3. 二进制支持:可以传输二进制数据(音频、视频等)
  4. 单连接多路复用:一个 WebSocket 连接可以处理多个会话
  5. 自定义协议:可以在应用层定义任意消息格式

局限

  1. 实现复杂度高:需要处理握手、帧编解码、心跳等
  2. 代理穿透问题:部分企业代理不支持 WebSocket 升级
  3. 无原生重连:需要手动实现重连逻辑
  4. 负载均衡要求: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 为什么需要指数退避?

在网络不稳定或服务端故障时,如果使用固定间隔重连(例如每秒重试一次),会产生两个问题:

  1. 雪崩效应(Thundering Herd):当服务端恢复时,大量客户端同时重连,瞬间流量峰值可能导致服务端再次崩溃
  2. 资源浪费:在服务端尚未恢复时,高频重连只会增加客户端和服务端的负担

指数退避(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   │
                    └─────────────┘

关键设计要点:

  1. 负载均衡:WebSocket 连接需要 sticky session(粘性会话),确保同一连接始终路由到同一后端实例
  2. Redis 共享状态:多实例间通过 Redis 共享会话状态和消息队列
  3. 连接池:复用上游 LLM API 连接,减少握手开销
  4. 水平扩展:根据连接数自动伸缩(HPA)

八、总结与展望

8.1 核心要点回顾

要点 说明
流式输出是刚需 大模型推理延迟高,流式可大幅改善用户体验
SSE 适合多数场景 实现简单、自动重连、HTTP 友好,是问答场景的首选
WebSocket 适合复杂交互 全双工通信适合需要客户端→服务端反馈的场景
重连机制不可少 网络不稳定是常态,必须做好断线重连和状态恢复
安全与监控要跟上 认证、限流、可观测性是生产环境的基石

8.2 技术趋势

  1. HTTP/3 (QUIC):基于 UDP 的新一代传输协议,连接建立延迟更低,网络切换时连接不中断,为流式传输提供更好的基础
  2. WebTransport:基于 QUIC 的新型传输协议,低延迟、可靠/不可靠传输兼备,值得关注
  3. LLM API 标准化:OpenAI、Anthropic、Google 等厂商的流式 API 格式趋于统一,降低了集成成本
  4. 边缘计算:Cloudflare Workers、Vercel Edge 等边缘平台原生支持 SSE,可以将流式代理部署在离用户更近的位置

8.3 选型决策树

bash 复制代码
需要流式输出?
├── 仅服务端推送文本 → SSE ✅
│   ├── 需要自动重连? → SSE(内置支持)✅
│   └── 需要穿透企业代理? → SSE(HTTP 友好)✅
│
└── 需要双向通信? → WebSocket ✅
    ├── 需要传输二进制? → WebSocket(唯一选择)✅
    ├── 需要低延迟交互? → WebSocket ✅
    └── 需要多路复用? → WebSocket ✅

选择合适的协议,搭配合适的重连策略和前端渲染方案,就能构建出流畅、可靠的大模型流式交互体验。

相关推荐
weixin_530152601 小时前
【干货】SFP连接器选型指南:数据速率、光导配置与散热设计 | VOOHU 沃虎电子
网络协议·信息与通信
介一安全1 小时前
【案例分析】网盘高危漏洞深度剖析:存储型XSS与CSRF的组合攻击
网络·xss·csrf
呉師傅1 小时前
将CD音频抓轨转换成MP3的两种方法【图文解释】
运维·服务器·网络·windows·电脑·音视频
Soonyang Zhang1 小时前
nccl分析(二)——RDMA带外建链过程
网络·nccl·集合通信
一路往蓝-Anbo1 小时前
第一章:嵌入式TDD-环境搭建
网络·stm32·单片机·嵌入式硬件·tdd
蚊子码农1 小时前
每日一题--TR-069协议基础了解
网络协议
H Journey2 小时前
TCP断开连接四次挥手
网络·tcp/ip·四次挥手
闲人编程2 小时前
Agent的安全边界:如何防止AI失控(对齐问题)
网络·python·ai·agent·权限·智能体·cai
江上清风山间明月2 小时前
RPC failed; curl 65 OpenSSL SSL_read: OpenSSL/3.1.2错误解决方法
网络协议·rpc·ssl·failed