1.LLM 大模型、流式输出 SSE / Websocket(前端重中之重!对话页面核心)

在大模型(LLM)应用中,‌流式输出‌是提升用户体验的核心技术,它能将首字延迟从数十秒缩短至毫秒级,实现“打字机”效果。针对 ‌SSE (Server-Sent Events)‌ 和 ‌WebSocket‌ 两种主流协议,以下是基于 2026 年工程实践的深度解析与选型建议。

a.核心协议对比与选型

SSE‌ 和 ‌WebSocket‌ 各有优劣,选择取决于具体的交互场景和部署环境:

  • SSE (Server-Sent Events)‌:基于 HTTP 协议的‌单向通信‌(服务器→客户端)。它是 LLM 文本生成场景的‌首选方案‌,因为大模型输出通常是单向的。其优势在于‌开发复杂度极低‌、天然支持‌自动重连‌,且能无缝穿透企业防火墙和 CDN,无需特殊配置 。
  • WebSocket‌:独立的‌全双工通信‌协议。适用于需要‌高频双向交互‌的场景,如实时语音对话(允许用户打断 AI)、多人协同编辑或复杂的 Agent 工具调用。虽然性能略优,但需要手动处理心跳、重连和状态同步,运维成本较高 。
  • 性能差异‌:在 HTTP/2 普及的当下,SSE 的延迟表现已非常接近 WebSocket。除非追求极致吞吐(如微服务内部调用)或必须双向通信,否则 SSE 的‌工程性价比更高‌ 。

b.关键工程实现细节

无论选择哪种协议,在生产环境中都需注意以下核心要点,以避免常见陷阱:

  1. 禁用反向代理缓冲‌:在使用 Nginx 等反向代理时,必须显式禁用缓冲(如设置 X-Accel-Buffering: no 或 proxy_buffering off),否则数据会被缓存直到生成完毕,导致流式效果失效,用户仍看到“一次性返回” 。
  2. 中断与资源释放‌:当用户停止生成时,后端必须立即感知并终止 LLM 推理任务,释放显存(KV Cache)。WebSocket 和 gRPC 可通过流控制实现,SSE 则需监听连接断开事件 。
  3. 心跳与超时管理‌:长连接需配置心跳机制以防超时断开。SSE 协议自带简易心跳,而 WebSocket 需手动实现 ping/pong 逻辑,gRPC 则有内置 keepalive 参数 。

c.核心协议选择:为什么首选 SSE?

在 90% 的 LLM 文本对话场景中,‌SSE (Server-Sent Events)‌ 是最佳选择。

  • LLM 的特性‌:单向通信(服务端推 Token -> 客户端展示)。
  • SSE 优势‌:基于 HTTP,天然穿透防火墙/CDN,浏览器原生支持自动重连,开发成本极低。
  • WebSocket 适用场景‌:仅当你需要‌实时语音双向对讲‌、‌高频中断控制‌或‌复杂 Agent 工具调用反馈时才考虑‌。

d.SSE 实战:从入门到生产级稳定

1. 基础 API 使用 (EventSource)

浏览器原生提供了 EventSource API,这是最标准的实现方式。

// 基本用法
const eventSource = new EventSource('/api/chat/stream');

eventSource.onmessage = (event) => {
  // event.data 是服务器推送的字符串
  console.log('收到数据:', event.data);
  
  // 注意:LLM 通常返回 JSON 字符串,需要解析
  try {
    const chunk = JSON.parse(event.data);
    if (chunk.content) {
      appendToUI(chunk.content);
    }
  } catch (e) {
    // 处理非 JSON 数据或结束标记 [DONE]
    if (event.data === '[DONE]') {
      eventSource.close();
    }
  }
};

eventSource.onerror = (err) => {
  console.error('SSE 连接错误', err);
  // 浏览器会自动重连,但你可以在这里做 UI 提示
};
2. 关键痛点与解决方案

痛点 A:中文乱码与 UTF-8 截断

  • 现象‌:EventSource 默认假设数据是 UTF-8 文本。如果后端发送的二进制流被错误切割,或者包含非 UTF-8 字符,可能会乱码。
  • 解决‌:确保后端设置 Content-Type: text/event-stream; charset=utf-8。前端通常无需额外处理,但若遇到乱码,需检查后端是否直接传输了二进制 Buffer 而未正确编码。

痛点 B:消息格式规范

  • 规范‌:SSE 要求每条消息以 \n\n 结尾。
  • 陷阱‌:如果后端忘记加双换行,前端会一直缓冲直到超时或连接关闭,导致“一次性吐出”而非流式。
  • 调试技巧‌:在浏览器 Network 面板查看 Response 流,确认是否有连续的 data: {...}\n\n

痛点 C:自动重连与状态恢复

  • 机制‌:SSE 断开后,浏览器会自动重连,并发送 Last-Event-ID 头。
  • 前端任务‌:如果你希望断线后从上次位置继续,后端需支持 id 字段。对于 LLM 对话,通常‌不建议‌依赖此机制恢复长对话上下文(因为后端可能已释放资源),而是应在 onerror 中提示用户“连接中断,请重试”。

痛点 D:携带认证信息

  • 限制‌:原生 EventSource ‌不支持‌自定义 Header(如 Authorization: Bearer token)。
  • 解决方案‌:
    1. URL 参数传参‌:new EventSource('/api/stream?token=xyz')(注意安全性,Token 会出现在日志中)。
    2. Cookie‌:将 Token 放在 HttpOnly Cookie 中,浏览器自动携带。
    3. Polyfill/Fetch 方案‌:放弃原生 EventSource,使用 fetch + ReadableStream 手动实现(见下文高级部分)。

e.高级方案:Fetch + ReadableStream(推荐用于复杂场景)

当你需要‌自定义 Header‌、‌更好的错误处理‌或‌取消请求‌时,原生 EventSource 不够用。现代前端应掌握基于 fetch 的手动流式解析。

async function streamChat(prompt, signal) {
  const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer your-token' // 支持自定义 Header
    },
    body: JSON.stringify({ prompt }),
    signal: signal // 支持 AbortController 取消请求
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';

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

    // 解码字节流
    buffer += decoder.decode(value, { stream: true });
    
    // 按行分割处理 SSE 格式
    const lines = buffer.split('\n');
    buffer = lines.pop(); // 保留最后一行不完整的部分

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const jsonStr = line.slice(6);
        if (jsonStr === '[DONE]') return;
        
        try {
          const chunk = JSON.parse(jsonStr);
          handleChunk(chunk); // 更新 UI
        } catch (e) {
          console.warn('解析失败', line);
        }
      }
    }
  }
}

// 取消请求示例
const controller = new AbortController();
streamChat('Hello', controller.signal);
// 用户点击停止按钮时:
// controller.abort();

掌握要点:

  1. TextDecoder‌:处理流式字节到字符串的转换,注意 { stream: true } 选项以处理多字节字符(如中文)被截断的情况。
  2. Buffer 管理‌:网络包可能截断 SSE 的行,必须维护一个缓冲区,只处理完整的行。
  3. AbortController‌:LLM 生成时间长,用户常需“停止生成”,必须能立即中断网络请求并释放后端资源。

f.WebSocket 实战(仅在必要时使用)

如果项目强制要求 WebSocket,你需要掌握:

  1. 心跳保活 (Ping/Pong)‌:

    • LLM 生成可能耗时数十秒,中间无数据传输,代理服务器(如 Nginx)可能因超时而切断连接。
    • 前端任务‌:每隔 30s 发送 { type: 'ping' },或监听后端的心跳。
  2. 手动重连策略‌:

    • WebSocket 断开后不会自动重连。
    • 实现‌:使用指数退避算法(Exponential Backoff)进行重连,避免雪崩效应。
  3. 消息队列与顺序保证‌:

    • 网络抖动可能导致消息乱序。虽然 TCP 保证顺序,但应用层可能需要处理“停止”指令优先于“后续 Token”的逻辑。

g、 UI/UX 渲染优化:让“打字机”更丝滑

拿到数据只是第一步,如何展示才是用户体验的关键。

1. 防抖与批量渲染
  • 问题‌:LLM 可能每秒推送 10-50 个 Token,频繁操作 DOM 会导致页面卡顿。
  • 解决‌:
    • React/Vue‌:不要每个 Token 都触发一次 setState。可以使用 requestAnimationFrame 或简单的节流(Throttle),每 50-100ms 更新一次 UI。
    • 累积字符串‌:在内存中累积完整文本,定期刷新视图。
2. Markdown 实时渲染
  • 挑战‌:流式输出的 Markdown 是不完整的(如 **bold 只有开头),直接渲染会导致闪烁或样式错误。
  • 解决方案‌:
    • 使用支持‌增量解析‌的 Markdown 库(如 react-markdown 配合 remark-gfm)。
    • 技巧‌:在渲染前,对未闭合的标签进行临时补全(如自动添加缺失的 ** 或 }),或在 CSS 中隐藏未完成的块。
3. 光标与滚动
  • 自动滚动‌:确保容器在有新内容时自动滚动到底部,但‌如果用户手动向上滚动查看历史,应暂停自动滚动‌。
  • 光标效果‌:在最后一个字符后添加一个闪烁的光标动画,增强“正在生成”的心理暗示。
4. 处理特殊内容
  • 代码块‌:流式输出代码时,语法高亮库可能在代码未完成时报错。建议仅在代码块闭合(检测到 ```)后才进行完整高亮,过程中使用纯文本显示。
  • 思维链 (Reasoning)‌:如果模型返回 <think>...</think> 内容,前端需识别并折叠/展开显示,避免干扰主回答。

h.异常处理与兜底

  1. 超时处理‌:设置前端超时(如 60s)。如果首字延迟过高,提示用户“网络繁忙”。
  2. 不完整 JSON 处理‌:网络中断可能导致最后一个 JSON 片段截断。前端解析时需 try-catch,忽略最后一条无效数据。
  3. 敏感词过滤‌:如果后端返回的内容包含被拦截标记,前端需友好提示“内容不符合规范”,而不是直接报错崩溃。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐