最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
AI 流式输出指南(上):从等待完整回答到边生成边接收
时间:2026-09-11 20:48:01 编辑:袖梨 来源:一聚教程网
调用大语言模型时,如果必须等整段内容生成完再返回,页面就会长时间停留在加载状态。要让回答像聊天产品一样逐步出现,需要打通模型输出、HTTP 传输与浏览器渲染之间的流式链路。下面将从同步模式入手,依次说明 SSE 协议、Node.js 服务端实现以及 LangChain 的流式接口。
写在前面:跟 AI 聊天时你有没有想过一个问题——为什么 ChatGPT 的回答是一个字一个字蹦出来的,而不是等半天突然蹦出一大段?这就是流式输出(Streaming)。今天的课程从 HTTP 协议的底层讲起,到 SSE(Server Sent Events)机制,再到 Node.js 原生实现和 LangChain 的 stream API,把"AI 怎么边想边说"这件事讲透了。readme 用了一个精准的比喻——"水管,一头接着 LLM server,一头客户端,不断有 token 流向客户端"。今天我们就顺着这根水管,从源头走到水龙头。以下所有代码均来自课堂真实文件。
一、同步输出:等水壶烧开
在讲流式之前,先看"传统"的同步输出是什么体验。
normal.mjs 展示了标准的同步调用:
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 = `请介绍一下爱因斯坦的信息。请以 JSON 格式返回...`;
const response = await model.invoke(prompt); // 同步调用
console.log(response.content);
model.invoke(prompt) — 发请求,等 LLM 把整段回答生成完,一次性返回。就像烧水壶烧水——你按下开关,等几分钟,"叮"一声,水开了。
问题是什么?等的那几分钟里,用户看到的白屏。 LLM 可能要生成几百上千字,全部生成完才返回——用户盯着 loading 转圈,不知道发生了什么。
readme 用一句话概括了同步和流式的区别:
"invoke 同步输出。stream 流式输出。"
一个 invoke,一个 stream——API 层面就差一个词,但底层协议完全不同。
二、HTTP 协议:从"一次快递"到"持续水管"
readme 从协议层面解释了流式的本质:
"stream 服务器端本质:llm server,http 协议——基于请求响应的简单协议。响应?response——同步、流式?pipe。"
同步 HTTP:一次快递
传统 HTTP 是什么模式?
浏览器发请求 → 服务器处理 → 服务器返回完整响应 → 连接断开
一次请求,一次响应,连接断开。像快递——下单,等包裹,签收,结束。readme 列了同步响应的 Content-Type:
Content-Type: text/plain
Content-Type: text/html
整包数据一次性返回,浏览器收到完整内容后渲染。
流式 HTTP:持续水管
流式响应不一样——服务器不等数据处理完,有一丁点数据就先发出去,再有了再发,直到全部发完。
readme 的水管比喻:
"水管,一头接着 llm server,一头客户端,不断有 token 流向客户端——buffer。"
水管接上了,水龙头拧开了,水一点一点流过来。客户端这边拿个桶接着(buffer),来一滴接一滴。
这就是 SSE(Server Sent Events)。
三、SSE:服务器主动推送的"水管协议"
readme 对 SSE 的定义:
"Server Sent Events。服务器单向不停地往浏览器推送消息,发送多次,不会断开链接。浏览器会建立一条长连接,服务器一点一点(chunk 发数据),也就是流式输出。"
关键词:单向、长连接、chunk。
| 特性 | 同步 HTTP | SSE |
|---|---|---|
| 连接 | 请求-响应-断开 | 长连接,不断开 |
| 方向 | 双向(一问一答) | 单向(服务器→客户端) |
| 数据 | 一次性返回 | 一块一块推 |
| 体验 | 白屏等待 | 逐步显示 |
readme 还列了 SSE 的三个响应头:
Content-Type: text/event-stream;
Cache-Control: no-cache;
Connection: keep-alive;
| 响应头 | 含义 | 为什么需要 |
|---|---|---|
text/event-stream | 告诉浏览器"这是 SSE 流" | 浏览器按 SSE 协议解析 |
no-cache | 禁止缓存 | 每一块数据都是实时的,缓存没意义 |
keep-alive | 保持连接 | 不让 HTTP 自动断开 |
readme 还提了一句:
"SSE 不只有 LLM 返回,..."
SSE 不是 AI 专属——行情推送、实时通知、日志流,都用 SSE。只要服务器需要"持续往浏览器推数据",SSE 就是首选方案。
四、Node.js 原生实现:手搓一根水管
server.js 用 Node.js 原生 http 模块手搓了一个 SSE 服务器——没有 Express,没有框架,就纯 Node。
服务器全貌
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
if (req.url === '/') {
// 返回静态 HTML 页面
const readStream = fs.createReadStream('sse-demo/index.html');
res.writeHead(200, { 'Content-Type': 'text/html' });
readStream.pipe(res);
} else if (req.url === '/stream') {
// SSE 流式输出
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-control': 'no-cache',
'Connection': 'keep-alive',
});
let words = ["你", "好", ", ", "欢", "迎", "了", "解", "sse"];
let index = 0;
const timer = setInterval(() => {
if (index >= words.length) {
clearInterval(timer);
res.end();
return;
}
res.write(`data: ${words[index]}nn`);
index++;
}, 1000);
}
});
server.listen(3000, () => {
console.log('server is running on port 3000');
});
两个路由,两种响应模式——完美对比了同步和流式。
路由一:/ — 同步返回静态文件
if (req.url === '/') {
const readStream = fs.createReadStream('sse-demo/index.html');
res.writeHead(200, { 'Content-Type': 'text/html' });
readStream.pipe(res);
}
浏览器访问 http://localhost:3000/,服务器读取 HTML 文件,通过 pipe 一次性返回。readme 注释说:
"stream fs 流 pipe 一下。"
fs.createReadStream 创建一个文件读取流,.pipe(res) 把文件流直接接到 HTTP 响应上——文件读一块写一块,读完就完。这是 Node.js 流式处理的经典模式。
路由二:/stream — SSE 流式输出
else if (req.url === '/stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-control': 'no-cache',
'Connection': 'keep-alive',
});
let words = ["你", "好", ", ", "欢", "迎", "了", "解", "sse"];
let index = 0;
const timer = setInterval(() => {
if (index >= words.length) {
clearInterval(timer);
res.end();
return;
}
res.write(`data: ${words[index]}nn`);
index++;
}, 1000);
}
逐行拆解:
- 三个 SSE 响应头——告诉浏览器"这是 SSE 流,别缓存,别断开"
words数组——模拟 LLM 的 token 流,一个字一个字setInterval——每 1 秒推一个字,模拟 LLM 生成 token 的延迟res.write('data: xxxnn')——SSE 的数据格式
SSE 的数据格式是核心:
data: 你nn
data: 好nn
data: , nn
每条消息以 data: 开头,以 nn 结尾。浏览器收到 nn 就知道一条完整的 chunk 到了,触发 onmessage 事件。
res.end()——所有字推完了,关闭连接
两段代码的注释也值得一提
// commonjs , import esm
const http = require('http'); // CommonJS
readme 第一行注释就标注了——这个文件用的是 CommonJS(require),而其他 .mjs 文件用的是 ESM(import)。.mjs 扩展名就是 ESM 的标志。两种模块系统在同一个项目里共存,Node.js 生态的过渡期写照。
五、浏览器端:EventSource — 接水管的水龙头
index.html 是前端代码——怎么接收 SSE 流。
const eventSource = new EventSource('http://localhost:3000/stream');
eventSource.onmessage = (e) => {
console.log(e.data);
result.innerText += e.data;
}
三行代码,搞定 SSE 客户端。
EventSource:浏览器的 SSE 专用 API
readme 说的:
"EventSource 类,用于链接 SSE,给它 url。当服务器有新的数据 chunk 到达后,触发 onmessage 事件。"
new EventSource(url) — 浏览器自动建立长连接,不需要你手动管 WebSocket 握手。
eventSource.onmessage — 每当服务器推一条 data: xxxnn,这个回调就触发一次。e.data 就是 data: 后面的内容。
逐步显示
result.innerText += e.data;
+= 是关键——每次收到新 chunk,追加到已有内容后面。用户看到的效果就是"你"、"好"、"、"、"欢"、"迎"……一个字一个字蹦出来。
这就是 ChatGPT 那种"打字机效果"的底层原理。
EventSource vs WebSocket
readme 提到 SSE 是"服务器单向"推送。对比一下:
| 特性 | SSE (EventSource) | WebSocket |
|---|---|---|
| 方向 | 服务器 → 客户端(单向) | 双向 |
| 协议 | HTTP | 独立协议 |
| 自动重连 | 是 | 需手动实现 |
| 复杂度 | 低(3行代码) | 高 |
| 适用场景 | 服务器推数据(LLM、) | 聊天室、游戏 |
LLM 流式输出只需要服务器→客户端的单向推送,SSE 足够了。WebSocket 是杀鸡用牛刀。
六、LangChain 的 stream API:LLM 专属水管
server.js 用 setInterval 模拟了流式输出。但真实场景中,LLM 的 token 流不是定时器——是 LLM 生成一个 token 就推一个。
stream-normal.mjs 展示了 LangChain 怎么做流式:
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 = `详细介绍莫扎特的信息`;
const stream = await model.stream(prompt); // stream 替代 invoke
let fullContent = '';
let chunkCount = 0;
for await (const chunk of stream) {
chunkCount++;
const content = chunk.content;
fullContent += content;
process.stdout.write(content); // 实时显示
}
console.log(`nn共接收${chunkCount}个chunk,共${fullContent.length}个token`);
invoke vs stream
readme 说的:
"invoke 同步输出。stream 流式输出。"
| API | 返回 | 体验 | 代码 |
|---|---|---|---|
model.invoke(prompt) | 完整响应 | 等待→一次性显示 | const res = await model.invoke(prompt) |
model.stream(prompt) | AsyncIterable | 逐步显示 | for await (const chunk of stream) |
model.stream() 返回的是一个异步可迭代对象(AsyncIterable)——不能直接 await 拿结果,要用 for await...of 循环逐块消费。
chunk:水管里的一滴水
readme 说的:
"stream 水流,管子,chunk 一个数据块。"
chunk 就是水管里的一滴水——LLM 每生成一小段文字就作为一个 chunk 推过来。一个 chunk 可能是一个字、一个词、甚至半个字(取决于 tokenizer)。
for await (const chunk of stream) {
const content = chunk.content; // 取这一块的文本
fullContent += content; // 拼接完整内容
process.stdout.write(content); // 实时输出到终端
}
process.stdout.write(content) 而不是 console.log(content)——因为 console.log 会自动加换行符,流式输出不需要换行,要的是文字连续涌现的效果。
统计信息
console.log(`nn共接收${chunkCount}个chunk,共${fullContent.length}个token`);
课堂代码统计了 chunk 数量和总 token 数——一个回答被拆成了多少块、总共多长。这能看到 LLM 流式输出的粒度。
七、从 server.js 到 stream-normal.mjs:两层流式
今天有两个流式实现——它们不是同一层:
┌─────────────────────────────────────────────────┐
│ 完整的 LLM 流式架构 │
│ │
│ LLM Server │
│ ↓ token 流(SSE) │
│ Node.js 服务器 │
│ ↓ res.write('data: xxxnn') │
│ 浏览器 EventSource │
│ ↓ onmessage 回调 │
│ 页面逐步渲染 │
│ │
└─────────────────────────────────────────────────┘
| 层级 | 文件 | 角色 |
|---|---|---|
| LLM → 服务器 | stream-normal.mjs | Node.js 作为客户端,接收 LLM 的流式输出 |
| 服务器 → 浏览器 | server.js + index.html | Node.js 作为服务端,SSE 推送给浏览器 |
stream-normal.mjs 是第一层——Node.js 调 LLM 的 stream API,接收 token 流。
server.js 是第二层——Node.js 作为 SSE 服务器,把数据推给浏览器。
真实项目里两层是连起来的——Node.js 服务器收到 LLM 的 chunk,立刻通过 SSE 推给浏览器:
// 伪代码:两层流式串联
const stream = await model.stream(prompt);
for await (const chunk of stream) {
res.write(`data: ${chunk.content}nn`); // 收到一块,推一块
}
res.end();
LLM 生成一个 token → Node.js 收到 → 立刻通过 SSE 推给浏览器 → 浏览器追加显示。 全程没有"等完再发"——每一滴水流过来就立刻流出去,这就是真正的实时流式体验。
八、SSE 的数据格式详解
server.js 里这行代码是 SSE 的核心格式:
res.write(`data: ${words[index]}nn`);
SSE 协议规定,每条消息的格式是:
data: 消息内容n
n
data:— 字段名,表示这是数据内容n— 字段结束n— 空行,表示一条消息结束
浏览器收到空行(nn)就触发一次 onmessage。
SSE 还支持其他字段:
event: 自定义事件名
data: 消息内容
id: 消息ID
retry: 重连间隔(毫秒)
课堂代码只用了 data:——最常用、最基础。其他字段用于更复杂场景:event 可以区分不同类型的消息,id 用于断线重连时告诉服务器"我收到哪了",retry 控制重连间隔。
九、为什么不用 WebSocket?
readme 暗含了这个对比——SSE 是"服务器单向推送",WebSocket 是双向。
LLM 流式输出场景:
- 用户发一次请求("介绍一下莫扎特")
- 服务器持续推送回答(一个 token 一个 token)
这是典型的"一问多答"模式——用户只问一次,服务器回答多次。 SSE 完美匹配。
WebSocket 适合什么?聊天室——你发一条、我发一条、你再来一条。双向、高频、持续。LLM 流式输出不需要双向——用户不会在 LLM 生成回答的过程中插嘴。
而且 SSE 有一个 WebSocket 没有的优势——自动重连。网络断了,EventSource 会自动重连,不用写一行重连代码。WebSocket 断了你得自己处理。
PS:下次看到 ChatGPT 一个字一个字蹦回答,你就知道了——背后是一根 SSE 水管,LLM 每生成一个 token 就滴一滴水,Node.js 接到后立刻通过 res.write('data: xxxnn') 推给浏览器,EventSource 的 onmessage 接住后 innerText += 追加上去。水管全程不断,水一滴一滴流。下篇我们换个话题——给 AI 发一张结构化表格,让它老老实实填空。