从 SSE 到 LLM 流式输出:搞懂前端实时通信的两种姿势

从 SSE 到 LLM 流式输出:搞懂前端实时通信的两种姿势

ChatGPT 的打字机效果是怎么做的?为什么 AI 回答能一个字一个字蹦出来?答案就是流式输出。但前端实时通信有两条技术路线------SSE(Server-Sent Events)和 LLM Stream API,它们原理相似却场景不同。本文从一个原生 Node.js SSE Demo 出发,到 LangChain 的 LLM 流式调用,彻底拆解流式输出的全链路。为什么 HTTP 能"边传边显示"?EventSource 为什么只能 GET?chunk 到底是什么?建议收藏后跟着代码实操。


一、为什么需要流式输出?

1.1 传统 HTTP 的痛点

arduino 复制代码
传统 HTTP 请求-响应模型:

  浏览器                          服务器
    │                               │
    │──── HTTP 请求 ───────────────→│
    │                               │ (服务器处理中...)
    │                               │ (5 秒后...)
    │                               │ (10 秒后...)
    │                               │ (终于组装完完整响应)
    │←──── 完整 HTTP 响应 ──────────│
    │                               │

  问题:
  ① 用户体验差
  → 等待 10 秒才看到任何内容
  → 用户以为页面卡死了
  → "白屏"焦虑

  ② 服务器压力大
  → 必须等所有数据准备好才能响应
  → 大量数据积压在服务器内存中
  → 并发时容易 OOM

  ③ AI 场景特别需要
  → LLM 生成一个完整回答可能需要 10-30 秒
  → 如果等完整回答再返回 → 用户等 30 秒
  → 如果边生成边返回 → 用户 1 秒就看到第一个字
  → 这就是 ChatGPT 打字机效果的原理

1.2 流式输出的本质

ini 复制代码
流式输出 = 把"一次性返回"拆成"分块返回"

  传统方式(一次性):
  服务器:[准备完整数据...10秒...准备好了] → 一次性发给浏览器

  流式方式(分块):
  服务器:[chunk 1] → [chunk 2] → [chunk 3] → ... → [结束]
  浏览器:逐个接收,实时显示

  ┌──────────────────────────────────────────────────────────┐
│  流式输出的核心概念                                       │
│                                                          │
│  chunk(数据块)                                          │
│  → 流式传输的基本单位                                     │
│  → 就像水管里的水,一段一段流过来                          │
│  → 每个 chunk 是部分数据                                  │
│  → 拼接所有 chunk = 完整数据                              │
│                                                          │
│  stream(流)                                             │
│  → chunk 的有序序列                                       │
│  → 水管本身,chunk 是水管里的水                           │
│  → 有始有终,有打开有关闭                                  │
│                                                          │
│  消费者模式                                               │
│  → 前端逐个接收 chunk                                     │
│  → 每收到一个 chunk 就处理(追加显示)                    │
│  → 流结束后做收尾工作                                     │
└──────────────────────────────────────────────────────────┘

1.3 两种流式输出场景

arduino 复制代码
┌──────────────────────────────────────────────────────────────┐
│              两种流式输出技术路线                              │
│                                                              │
│  ① SSE(Server-Sent Events)                                │
│  → 服务器主动推送                                            │
│  → 浏览器用 EventSource API 接收                             │
│  → 基于 HTTP 长连接                                         │
│  → 本文第一部分:原生 Node.js SSE Demo                       │
│  → 场景:实时通知、股票行情、ChatGPT 打字机                 │
│                                                              │
│  ② LLM Stream API                                           │
│  → LLM 生成的文本流式返回                                    │
│  → LangChain 的 model.stream()                              │
│  → 基于 AsyncIterator                                       │
│  → 本文第二部分:LangChain 流式调用                          │
│  → 场景:AI 对话、AI 写作、AI 代码生成                      │
│                                                              │
│  两者关系:                                                  │
│  → LLM Stream 是数据源(AI 生成 chunk)                     │
│  → SSE 是传输方式(通过 HTTP 推给浏览器)                   │
│  → 实际应用中通常组合使用                                    │
│  → 后端用 LLM stream 获取 AI 输出                           │
│  → 再通过 SSE 推给前端                                       │
└──────────────────────────────────────────────────────────────┘

二、SSE:Server-Sent Events

2.1 SSE 原理

kotlin 复制代码
SSE = Server-Sent Events(服务器发送事件)

  核心原理:
  → 基于 HTTP 协议
  → 服务器保持连接不断开
  → 服务器持续推送数据给浏览器
  → 浏览器通过 EventSource 接收

  ┌──────────────────────────────────────────────────────────┐
│  SSE 工作流程                                             │
│                                                          │
│  浏览器                          服务器                   │
│    │                               │                      │
│    │── GET /stream ───────────────→│                      │
│    │                               │                      │
│    │←── data: 你\n\n ─────────────│  ← 第 1 秒           │
│    │   (浏览器显示"你")          │                      │
│    │                               │                      │
│    │←── data: 好\n\n ─────────────│  ← 第 2 秒           │
│    │   (浏览器显示"你好")        │                      │
│    │                               │                      │
│    │←── data: ,\n\n ──────────────│  ← 第 3 秒           │
│    │   (浏览器显示"你好,")       │                      │
│    │                               │                      │
│    │   ... 持续推送 ...            │                      │
│    │                               │                      │
│    │←── 连接关闭 ─────────────────│  ← 数据发完           │
│    │                               │                      │
│  关键点:                                                 │
│  ① HTTP 连接保持不断开                                    │
│  ② 服务器可以随时推送数据                                 │
│  ③ 每个 data: 块就是一个 chunk                           │
│  ④ 浏览器通过 onmessage 事件逐个接收                      │
└──────────────────────────────────────────────────────────┘

2.2 SSE 的三个响应头

arduino 复制代码
SSE 必须设置三个关键响应头:

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',  // ① 声明 SSE 协议
    'Cache-Control': 'no-cache',           // ② 禁止缓存
    'Connection': 'keep-alive',            // ③ 保持连接
  });

  ┌──────────────────────────────────────────────────────────┐
│  ① Content-Type: text/event-stream                       │
│  → 告诉浏览器"这是 SSE 流"                               │
│  → 浏览器切换到流式接收模式                               │
│  → 不是普通的 text/html 或 application/json              │
│                                                          │
│  ② Cache-Control: no-cache                                │
│  → 禁止浏览器缓存流数据                                   │
│  → 每个chunk 都是实时的,不能用旧缓存                     │
│  → SSE 的数据是"一次性"的,过时不候                      │
│                                                          │
│  ③ Connection: keep-alive                                  │
│  → 保持 TCP 连接不断开                                    │
│  → HTTP 默认是短连接(请求-响应-关闭)                    │
│  → SSE 需要长连接(请求-持续推送-最终关闭)              │
│  → keep-alive 让连接"活着"                               │
└──────────────────────────────────────────────────────────┘

2.3 SSE 数据格式

kotlin 复制代码
SSE 的数据格式非常简单:

  data: 你好\n\n

  规则:
  ① 每条消息以 "data: " 开头
  ② 消息内容跟在后面
  ③ 以 "\n\n"(两个换行)结尾
  ④ 两个换行表示"这条消息发送完毕"

  ┌──────────────────────────────────────────────────────┐
│  SSE 消息格式                                         │
│                                                      │
│  data: 你\n\n                                        │
│  → 浏览器收到 onmessage 事件                          │
│  → event.data = "你"                                 │
│                                                      │
│  data: {"name":"张三","age":25}\n\n                  │
│  → 也可以发 JSON                                     │
│  → event.data = '{"name":"张三","age":25}'           │
│  → 前端 JSON.parse(event.data) 解析                  │
│                                                      │
│  data: 第一行\n                                       │
│  data: 第二行\n\n                                     │
│  → 多行数据用多个 data:                               │
│  → event.data = "第一行\n第二行"                     │
└──────────────────────────────────────────────────────┘

  为什么用 \n\n 而不是其他分隔符?
  → HTTP 是基于文本的协议
  → \n\n 是 HTTP 消息分隔的自然方式
  → 类似 HTTP 头部和 body 的分隔也是 \n\n
  → 简单、可靠、跨平台

2.4 完整 SSE 服务端实现

javascript 复制代码
// server.js --- 原生 Node.js 实现 SSE
const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
  // 路由一:返回 HTML 页面
  if (req.url === '/') {
    const readStream = fs.createReadStream('./sse-demo/index.html');
    readStream.on('error', (err) => {
      res.writeHead(500, { 'Content-Type': 'text/plain' });
      res.end('Internal Server Error');
    });
    res.writeHead(200, { 'Content-Type': 'text/html' });
    readStream.pipe(res);
  }
  // 路由二:SSE 流式接口
  else if (req.url === '/stream') {
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    });

    const words = ['你', '好', ', ', '欢', '迎', '了', '解', 'sse'];
    let index = 0;

    // 每秒推送一个字
    const timer = setInterval(() => {
      if (index >= words.length) {
        clearInterval(timer);
        res.end(); // 所有数据发完,关闭连接
        return;
      }
      // SSE 格式:data: 内容\n\n
      res.write(`data: ${words[index]}\n\n`);
      index++;
    }, 1000);
  }
});

server.listen(3000, () => {
  console.log('server is running on port 3000');
});
vbnet 复制代码
代码解析:

  路由一:GET /
  → 返回 HTML 页面
  → fs.createReadStream 创建可读流
  → readStream.pipe(res) 管道直接写入响应
  → 这本身也是一种流式传输(文件流 → HTTP 响应流)

  路由二:GET /stream
  → SSE 流式接口
  → 设置三个 SSE 响应头
  → setInterval 每秒推送一个字
  → res.write() 发送 SSE 格式数据
  → 数据发完调用 res.end() 关闭连接

  为什么用 setInterval?
  → 模拟服务器"持续产生数据"的过程
  → 实际场景中可以是:
    → LLM 生成 token → 推送
    → 数据库查询结果分批 → 推送
    → 实时事件发生 → 推送

2.5 前端 EventSource 接收

html 复制代码
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSE Demo</title>
</head>
<body>
    <h1>Stream SSE Demo</h1>
    <div id="result"></div>
    <script>
        const result = document.getElementById('result');

        // 创建 EventSource 连接 SSE 端点
        const eventSource = new EventSource('http://localhost:3000/stream');

        // 每个 chunk 到达时触发
        eventSource.onmessage = (e) => {
            console.log(e.data);
            result.innerText += e.data;
        }
    </script>
</body>
</html>
sql 复制代码
EventSource API 解析:

  new EventSource(url)
  → 浏览器原生 API,无需安装任何库
  → 自动建立 HTTP 长连接
  → 自动处理重连(连接断开会自动重连)

  eventSource.onmessage
  → 每收到一个 data: 消息时触发
  → e.data 就是 data: 后面的内容
  → 逐个 chunk 处理

  为什么用 innerText += 而不是 innerHTML?
  → 安全:防止 XSS 注入
  → 如果 AI 输出 <script> 标签,innerText 会原样显示
  → innerHTML 会执行它 → XSS 漏洞

  EventSource 的局限:
  ① 只支持 GET 请求
  ② 不能自定义请求头(不能带 Authorization)
  ③ 不能发 POST body
  ④ 如果需要 POST + 自定义头 → 用 fetch + ReadableStream

2.6 SSE vs WebSocket

arduino 复制代码
┌──────────────────────────────────────────────────────────────┐
│              SSE vs WebSocket 对比                            │
│                                                              │
│              SSE                    WebSocket                 │
│  ─────────────────────────────────────────────               │
│  通信方向    服务器→浏览器           双向                    │
│  协议        基于 HTTP              独立协议(ws://)         │
│  断线重连    自动重连              需手动实现               │
│  浏览器API   EventSource            WebSocket               │
│  HTTP方法    只能 GET               无限制                   │
│  自定义头    不支持                支持                     │
│  复杂度      简单                   中等                    │
│  适合        服务器推送             实时双向通信             │
│  典型应用    ChatGPT、通知、股票    在线游戏、聊天室、协作   │
│                                                              │
│  ChatGPT 用 SSE 而不是 WebSocket?                          │
│  → AI 回答是"服务器→浏览器"单向推送                         │
│  → 不需要双向通信                                           │
│  → SSE 更简单,基于 HTTP,穿透防火墙好                      │
│  → 自动重连,开发成本低                                     │
│  → 足够用了                                                 │
└──────────────────────────────────────────────────────────────┘

三、LLM 流式输出

3.1 为什么 LLM 需要流式?

arduino 复制代码
LLM 生成文本的本质:

  → LLM 是"逐 token 生成"的
  → 每个 token 是一个词或字
  → 生成 500 字可能需要 10-20 秒
  → 如果等 500 字全生成完再返回 → 用户等 20 秒
  → 如果逐 token 返回 → 用户 1 秒就看到第一个字

  token vs 字:
  → "你好" 可能是 2 个 token(中文)
  → "hello" 可能是 1 个 token(英文)
  → token 是 LLM 的最小生成单位

  ┌──────────────────────────────────────────────────────────┐
│  LLM 生成流程                                             │
│                                                          │
│  prompt: "详细介绍莫扎特的信息"                            │
│                                                          │
│  传统方式(invoke):                                     │
│  → model.invoke(prompt)                                 │
│  → 等模型生成完整回答(10-20 秒)                          │
│  → 返回完整字符串                                         │
│  → 用户等 20 秒才看到任何内容                             │
│                                                          │
│  流式方式(stream):                                     │
│  → model.stream(prompt)                                 │
│  → 模型边生成边返回 token                                 │
│  → 第 1 个 token 0.5 秒返回                              │
│  → 用户 0.5 秒就看到第一个字                             │
│  → 打字机效果                                             │
└──────────────────────────────────────────────────────────┘

3.2 LangChain 流式调用

javascript 复制代码
// stream-normal.mjs --- LangChain LLM 流式输出
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const prompt = '详细介绍莫扎特的信息。';

async function streamDemo() {
  console.log('流式输出演示:\n');

  try {
    // stream() 返回一个异步可迭代对象
    const stream = await model.stream(prompt);

    let fullContent = '';
    let chunkCount = 0;

    console.log('接收流式数据:');

    // for await...of 消费异步流
    for await (const chunk of stream) {
      chunkCount++;
      const content = chunk.content;
      fullContent += content;
      // 实时输出(不换行)
      process.stdout.write(content);
    }

    console.log(`\n\n共接收 ${chunkCount} 个数据块`);
    console.log(`完整内容长度:${fullContent.length} 字符`);
  } catch (err) {
    console.error('流式输出出错:', err);
  }
}

streamDemo();

3.3 核心概念解析

scss 复制代码
model.stream() vs model.invoke()

  ┌──────────────────────────────────────────────────────────┐
│  model.invoke(prompt)                                    │
│  → 同步等待完整响应                                       │
│  → 返回一个完整的 AIMessage                               │
│  → 适合短回答、不需要流式的场景                           │
│  → 缺点:等待时间长                                       │
│                                                          │
│  model.stream(prompt)                                    │
│  → 返回一个异步可迭代对象(AsyncIterable)               │
│  → 不是一次性返回结果,而是一个"流"                       │
│  → 需要 for await...of 来消费                            │
│  → 每个 chunk 是部分内容                                 │
│  → 适合长回答、打字机效果                                 │
│  → 优点:即时反馈                                         │
└──────────────────────────────────────────────────────────┘
csharp 复制代码
for await...of:异步迭代

  普通的 for...of:
  → 遍历同步可迭代对象(数组、字符串等)
  → const arr = [1, 2, 3]; for (const item of arr) { ... }

  for await...of:
  → 遍历异步可迭代对象
  → 每个 chunk 可能需要等待(模型生成需要时间)
  → await 确保前一个 chunk 处理完再取下一个

  ┌──────────────────────────────────────────────────────────┐
│  流式消费过程                                             │
│                                                          │
│  const stream = await model.stream(prompt);               │
│  //  stream 是一个"管子"                                  │
│                                                          │
│  for await (const chunk of stream) {                     │
│    // chunk 是管子里流过来的"水"                          │
│    // 每个 chunk 包含部分 AI 生成的文本                    │
│    process.stdout.write(chunk.content);                  │
│    // 实时输出,不等完整结果                              │
│  }                                                       │
│  // 流结束                                                │
└──────────────────────────────────────────────────────────┘

  stream 对象的本质:
  → 不是数据本身,而是"数据的管道"
  → 数据在管道中"流动"
  → 你从管道中逐个取出 chunk
  → 类似 Node.js 的 Readable Stream

3.4 process.stdout.write vs console.log

lua 复制代码
为什么用 process.stdout.write 而不是 console.log?

  console.log:
  → 每次调用会自动在末尾加 \n(换行)
  → 流式输出时每个 chunk 后都换行 → 格式混乱
  → "莫\n扎\n特\n" → 不连贯

  process.stdout.write:
  → 原始输出,不加任何额外字符
  → chunk 之间无间隔 → 连贯显示
  → "莫扎特" → 打字机效果
  → 类似浏览器的 innerText +=

  ┌──────────────────────────────────────────────────────┐
│  console.log(chunk.content)                           │
│  输出:                                               │
│  莫                                                   │
│  扎                                                   │
│  特                                                   │
│  是                                                   │
│  ...                                                  │
│  → 每个 chunk 换行,不像打字机                         │
│                                                      │
│  process.stdout.write(chunk.content)                 │
│  输出:                                               │
│  莫扎特是一位伟大的作曲家...                          │
│  → chunk 连贯拼接,真正的打字机效果                   │
└──────────────────────────────────────────────────────┘

四、LLM Stream + SSE:完整链路

4.1 ChatGPT 的打字机效果原理

kotlin 复制代码
ChatGPT 打字机效果的完整链路:

  浏览器                      后端服务器                 LLM API
    │                           │                        │
    │── POST /chat ────────────→│                        │
    │                           │── stream(prompt) ─────→│
    │                           │                        │
    │                           │←─ chunk 1 "莫" ────────│
    │←─ data: 莫\n\n ──────────│                        │
    │  (显示"莫")             │                        │
    │                           │←─ chunk 2 "扎" ────────│
    │←─ data: 扎\n\n ──────────│                        │
    │  (显示"莫扎")           │                        │
    │                           │←─ chunk 3 "特" ────────│
    │←─ data: 特\n\n ──────────│                        │
    │  (显示"莫扎特")         │                        │
    │                           │   ...                  │
    │                           │←─ stream 结束 ─────────│
    │←─ 连接关闭 ──────────────│                        │
    │                           │                        │

  两个流的衔接:
  ① 后端 → LLM:model.stream() 获取 LLM 流式输出
  ② 后端 → 前端:SSE 将 chunk 推送给浏览器

  后端是"中转站":
  → 从 LLM 接收 chunk
  → 转成 SSE 格式
  → 推给浏览器
  → 逐 chunk 中转,不等待完整响应

4.2 完整链路伪代码

javascript 复制代码
// 后端:LLM Stream + SSE 组合
import { ChatOpenAI } from '@langchain/openai';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
});

// Express 路由
app.get('/chat', async (req, res) => {
  // 设置 SSE 响应头
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  // LLM 流式输出 → SSE 推送
  const stream = await model.stream('详细介绍莫扎特');

  for await (const chunk of stream) {
    // 每个 LLM chunk → SSE 格式推给前端
    res.write(`data: ${chunk.content}\n\n`);
  }

  // 流结束,关闭连接
  res.end();
});
javascript 复制代码
// 前端:EventSource 接收
const eventSource = new EventSource('/chat?prompt=莫扎特');

eventSource.onmessage = (e) => {
  // 每个 chunk 到达时追加显示
  document.getElementById('result').innerText += e.data;
};

eventSource.onerror = () => {
  // 连接关闭或出错
  eventSource.close();
};
scss 复制代码
完整链路总结:

  LLM API  ──stream()──→  后端  ──SSE──→  浏览器

  ① model.stream(prompt)
  → 返回 AsyncIterable
  → 逐个 chunk 产出

  ② for await (const chunk of stream)
  → 消费 LLM 流
  → 每个 chunk 即时处理

  ③ res.write(`data: ${chunk.content}\n\n`)
  → 转成 SSE 格式
  → 推给浏览器

  ④ eventSource.onmessage
  → 浏览器接收 chunk
  → 追加显示

  ⑤ res.end()
  → LLM 流结束
  → SSE 连接关闭

五、关键技术深度

5.1 HTTP 长连接

vbnet 复制代码
为什么 HTTP 能"边传边显示"?

  HTTP/1.1 的 Transfer-Encoding: chunked

  → HTTP 响应不需要预先知道 Content-Length
  → 可以用 chunked 编码,逐块传输
  → 每块前标注本块大小,0 大小块表示结束

  ┌──────────────────────────────────────────────────────────┐
│  HTTP 分块传输编码                                        │
│                                                          │
│  HTTP/1.1 200 OK                                        │
│  Transfer-Encoding: chunked                              │
│  Content-Type: text/event-stream                         │
│                                                          │
│  5\r\n                                                   │
│  data:\r\n                                               │
│  3\r\n                                                   │
│  你\r\n                                                  │
│  2\r\n                                                   │
│  \n\n                                                    │
│  0\r\n                                                   │
│  \r\n                                                    │
│  (0 表示传输结束)                                       │
└──────────────────────────────────────────────────────────┘

  SSE 就是利用了这个机制:
  → 不设 Content-Length(不知道总共多少数据)
  → 设置 Connection: keep-alive(保持连接)
  → 服务器持续 res.write()(逐块发送)
  → 最终 res.end()(关闭连接)

  这就是为什么 SSE 基于 HTTP 就能实现流式
  → 不需要 WebSocket 的协议升级
  → 普通 HTTP 就能做到

5.2 AsyncIterator 原理

javascript 复制代码
model.stream() 返回的是 AsyncIterable

  AsyncIterator 的本质:
  → 实现了 [Symbol.asyncIterator]() 方法
  → 每次 next() 返回一个 Promise
  → Promise resolve 后得到 { value: chunk, done: false }
  → 流结束时 { done: true }

  ┌──────────────────────────────────────────────────────────┐
│  for await...of 的内部机制                                │
│                                                          │
│  // 这段代码:                                            │
│  for await (const chunk of stream) {                     │
│    console.log(chunk);                                   │
│  }                                                       │
│                                                          │
│  // 等价于:                                              │
│  const iterator = stream[Symbol.asyncIterator]();        │
│  while (true) {                                          │
│    const { value, done } = await iterator.next();        │
│    if (done) break;                                      │
│    console.log(value);                                   │
│  }                                                       │
│                                                          │
│  → 每次 await iterator.next() 等待下一个 chunk            │
│  → LLM 生成一个 token → next() resolve                   │
│  → 循环继续,取下一个 chunk                               │
│  → LLM 生成完毕 → done: true → 循环结束                  │
└──────────────────────────────────────────────────────────┘

  这就是"流"的本质:
  → 不是一次性拿到所有数据
  → 而是数据"一点一点"地来
  → 每来一点就处理一点
  → 来完为止

5.3 res.write vs res.end

ruby 复制代码
Node.js HTTP 响应的两种写入方式:

  res.write(data)
  → 向响应中写入一块数据
  → 不关闭连接
  → 可以继续 write
  → 数据被"推"到 TCP 缓冲区
  → 浏览器实时收到

  res.end([data])
  → 标记响应结束
  → 可以最后再写一块数据
  → 关闭 HTTP 连接
  → 浏览器收到连接关闭信号

  ┌──────────────────────────────────────────────────────┐
│  SSE 中的使用                                         │
│                                                      │
│  res.write(`data: 你\n\n`);  // 推送数据,不断开     │
│  res.write(`data: 好\n\n`);  // 继续推送             │
│  res.write(`data: !\n\n`);   // 继续推送             │
│  res.end();                  // 全部发完,关闭连接   │
│                                                      │
│  如果只 write 不 end?                               │
│  → 浏览器会一直等待                                  │
│  → 连接不会关闭                                      │
│  → EventSource 会一直保持连接                        │
│  → 这就是"长连接"                                    │
└──────────────────────────────────────────────────────┘

六、SSE 进阶用法

6.1 自定义事件

javascript 复制代码
SSE 支持自定义事件类型:

  服务端:
  res.write(`event: update\ndata: {"price": 100}\n\n`);
  res.write(`event: notice\ndata: 系统维护中\n\n`);

  前端:
  eventSource.addEventListener('update', (e) => {
    console.log('价格更新:', e.data);
  });
  eventSource.addEventListener('notice', (e) => {
    console.log('通知:', e.data);
  });

  默认 message 事件 vs 自定义事件:
  → event: xxx → 用 addEventListener('xxx', ...) 接收
  → 不写 event → 用 onmessage 接收

6.2 带重试间隔

r 复制代码
SSE 支持指定重连等待时间:

  res.write(`retry: 5000\n\n`);  // 断线后 5 秒重连
  res.write(`data: 你好\n\n`);

  → retry: 毫秒数
  → 浏览器断线后等待指定毫秒再重连
  → 默认 3 秒

6.3 SSE 的 CORS 问题

javascript 复制代码
EventSource 的跨域限制:

  → EventSource 支持 CORS
  → 服务器需要设置 Access-Control-Allow-Origin
  → 但不能自定义请求头(如 Authorization)

  如果需要带 Token:
  ① 用 Cookie 传递(EventSource 会自动带 Cookie)
  ② 用 URL 参数传递(?token=xxx,不安全)
  ③ 放弃 EventSource,用 fetch + ReadableStream

  fetch + ReadableStream 方案(支持 POST + 自定义头):
  const response = await fetch('/chat', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}` },
    body: JSON.stringify({ prompt: '你好' }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const text = decoder.decode(value);
    // 解析 SSE 格式数据
  }

七、知识速查

7.1 SSE API 速查

API 作用 说明
new EventSource(url) 创建 SSE 连接 浏览器原生 API
eventSource.onmessage 默认消息事件 接收 data: 消息
eventSource.addEventListener 自定义事件 接收 event: 消息
eventSource.close() 关闭连接 手动断开
res.write('data: xxx\n\n') 发送 SSE 数据 服务端推送
res.end() 关闭 SSE 连接 结束流

7.2 LLM Stream API 速查

API 作用 说明
model.invoke(prompt) 同步调用 等完整响应
model.stream(prompt) 流式调用 返回 AsyncIterable
for await (chunk of stream) 消费流 逐 chunk 处理
chunk.content chunk 内容 文本字符串
process.stdout.write() 原始输出 不加换行

7.3 响应头速查

javascript 复制代码
// SSE 必须的三个响应头
res.writeHead(200, {
  'Content-Type': 'text/event-stream',  // 声明 SSE
  'Cache-Control': 'no-cache',           // 禁止缓存
  'Connection': 'keep-alive',            // 保持连接
});

八、总结

8.1 知识体系图

ini 复制代码
前端流式输出
│
├── 为什么需要流式
│   ├── 传统 HTTP 痛点:白屏等待
│   ├── LLM 场景:20 秒 → 0.5 秒看到第一个字
│   └── 流式本质:一次性 → 分块(chunk)传输
│
├── SSE(Server-Sent Events)
│   ├── 原理:HTTP 长连接 + 服务器持续推送
│   ├── 三个响应头
│   │   ├── Content-Type: text/event-stream
│   │   ├── Cache-Control: no-cache
│   │   └── Connection: keep-alive
│   ├── 数据格式:data: 内容\n\n
│   ├── 前端 API:EventSource + onmessage
│   ├── 局限:只能 GET、不能自定义头
│   ├── vs WebSocket:单向 vs 双向
│   └── 进阶:自定义事件、retry、fetch+ReadableStream
│
├── LLM 流式输出
│   ├── LLM 逐 token 生成
│   ├── model.invoke() vs model.stream()
│   ├── stream 返回 AsyncIterable
│   ├── for await...of 消费异步流
│   ├── chunk = 部分内容
│   ├── process.stdout.write() 连贯输出
│   └── for await 内部机制(Symbol.asyncIterator)
│
├── 完整链路(LLM Stream + SSE)
│   ├── 后端:model.stream() 获取 LLM 流
│   ├── 后端:for await 消费 chunk
│   ├── 后端:res.write(SSE 格式) 推给前端
│   ├── 前端:EventSource.onmessage 接收
│   ├── 前端:innerText += 追加显示
│   └── 后端:res.end() 关闭连接
│
├── 关键技术深度
│   ├── HTTP chunked 传输编码(边传边显示原理)
│   ├── AsyncIterator 原理(next() + done)
│   ├── res.write vs res.end
│   └── HTTP 长连接(keep-alive)
│
└── 核心认知
    ├── chunk = 流的基本单位
    ├── stream = chunk 的管道
    ├── SSE = 服务器→浏览器的单向流
    ├── LLM stream = AI 生成的流
    └── 两者组合 = ChatGPT 打字机效果

8.2 一句话总结

流式输出的本质是把"一次性返回完整数据"拆成"逐块返回 chunk"。SSE 是服务器到浏览器的单向推送机制------通过 HTTP 长连接和 data: xxx\n\n 格式,服务器可以持续推送 chunk 给浏览器。LLM 流式输出是 AI 的逐 token 生成------model.stream() 返回 AsyncIterable,用 for await...of 逐 chunk 消费。ChatGPT 的打字机效果就是两者的组合:后端用 model.stream() 从 LLM 获取流式输出,再通过 SSE 格式 res.write() 推给前端,前端 EventSource.onmessage 逐个接收并追加显示。核心概念只有一个------chunk 是流的基本单位,stream 是 chunk 的管道,流式输出就是从管道里逐个取 chunk 实时处理。


如果这篇文章对你有帮助,欢迎点赞收藏

相关推荐
10年前端老司机1 小时前
别卷CRUD了!前端用Next.js+LangChain.js,低成本冲进AI高薪赛道
前端·langchain·next.js
wangfpp1 小时前
原生NodeJS维护Agent Memory实践
后端·agent·全栈
二进制漫游记1 小时前
ECharts 从入门到实战|前端数据可视化完整教程
前端·信息可视化·echarts
weixin_440730501 小时前
pytest结合allure生成html测试报告(step、story、severity、screenshot)
前端·html·pytest·allure报告
星月日1 小时前
前端上手后端起手式
前端·后端
掘金挖土1 小时前
前端手摸手跑路之 AI 应用开发(一)
前端·后端
咖啡无伴侣2 小时前
3. 从零搭建企业级 Monorepo 工程化模板:集成 Husky 9 + lint-staged
前端·架构
Lyy2 小时前
DevOps平台 — 第九篇:配置中心的设计与实现
后端·devops
MetaLite2 小时前
Java通用枚举驱动下拉框-元数据接口与前端契约
java·前端·状态模式