从 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 实时处理。
如果这篇文章对你有帮助,欢迎点赞 和收藏!