从“等待整段答案”到边生成边展示:大模型流式输出与 SSE 实战(上)
调用大模型时,如果使用普通请求,用户往往要等到整段内容生成完毕,才能一次性看到结果。回答越长,这段“没有任何反馈”的等待就越明显。
流式输出解决的不是模型生成速度问题,而是结果交付方式问题:模型生成一部分,应用就接收一部分;服务端收到一部分,浏览器也可以立刻显示一部分。
不过,“大模型开启流式输出”和“浏览器看到打字机效果”并不是同一件事。一个完整的 Web 应用通常有两段流:
大模型服务 --模型响应流--> Node.js 服务 --SSE--> 浏览器
本文先实现第一段,再用原生 Node.js 和浏览器 EventSource 打通第二段。
一、准备模型客户端
示例使用 ESM,因此脚本使用 .mjs 后缀,可以直接使用 import 和顶层 await。所需依赖如下:
本文对应的依赖组合是 @langchain/core 1.2.12、@langchain/openai 1.5.13、zod 4.6.5 和 dotenv 18.0.1。其中 @langchain/openai 1.5.13 要求 Node.js 22 或更高版本,复现前可以先用 node --version 检查运行环境。
pnpm add @langchain/core @langchain/openai dotenv zod
环境变量只需要保存连接模型服务所需的信息:
MODEL_NAME=你的模型名称
OPENAI_API_KEY=你的密钥
OPENAI_BASE_URL=兼容 OpenAI 协议的服务地址
通过 dotenv/config,程序启动时会自动加载 .env:
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,
},
});
这里把 temperature 设为 0,是为了让演示结果尽量稳定。baseURL 放在 configuration 中,因此除了官方接口,也可以连接兼容 OpenAI 协议的模型服务。
二、invoke() 与 stream() 的区别
普通调用通常使用 invoke():
const response = await model.invoke("详细介绍 RAG");
console.log(response.content);
await 会一直等待,直到完整响应可用。代码简单,但无法在生成期间更新界面。
流式调用则使用 stream():
const stream = await model.stream("详细介绍 RAG");
此时得到的不是最终文本,而是一个可以异步迭代的流。可以用 for await...of 持续读取模型返回的数据块:
const prompt = "详细介绍 RAG";
try {
const stream = await model.stream(prompt);
let fullContent = "";
let chunkCount = 0;
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}`);
} catch (err) {
console.error("错误信息:", err);
}
这段代码同时完成了三件事:
process.stdout.write(content)立即展示当前分片;fullContent += content保存完整结果;chunkCount记录一共接收了多少个分片。
console.log() 默认会换行,不适合连续打印每个小片段;process.stdout.write() 不会自动添加换行,更适合表现流式文本。
还要注意,chunk 表示一次收到的数据块。业务代码不应假定它一定对应一个汉字、一个单词或者一个完整句子。可靠的做法是:每次收到什么就处理什么,需要完整文本时再自行累加。
三、模型流还没有抵达浏览器
上面的代码只能让 Node.js 进程在终端中边接收边打印。若应用还有浏览器前端,Node.js 服务需要继续把收到的数据向浏览器发送。
HTTP 最常见的使用方式是:
请求 -> 完整响应 -> 断开连接
流式场景需要让响应保持一段时间,并且允许服务端连续写入数据:
请求 -> 响应块 -> 响应块 -> 响应块 -> 结束
SSE(Server-Sent Events,服务器发送事件)正适合这种服务器单向推送场景。浏览器建立连接后,服务端可以不断发送消息,直到主动结束连接。
它不只可以承载大模型输出,也可以用于实时通知、任务进度和日志推送。大模型只是 SSE 的一种应用场景。
四、先用 Node.js 返回页面
下面不引入 Web 框架,直接使用 Node.js 内置的 http、fs 和 path 模块:
const http = require("http");
const fs = require("fs");
const path = require("path");
const server = http.createServer((req, res) => {
if (req.url === "/") {
const indexPath = path.join(__dirname, "index.html");
const indexStream = fs.createReadStream(indexPath);
indexStream.once("open", () => {
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
});
indexStream.pipe(res);
});
indexStream.once("error", (err) => {
if (!res.headersSent) {
res.writeHead(500, {
"content-type": "text/plain; charset=utf-8",
});
res.end("Failed to load index.html");
return;
}
res.destroy(err);
});
}
});
fs.createReadStream() 不会先把整个 HTML 文件一次性读入内存,而是创建文件读取流。indexStream.pipe(res) 再把文件流接到 HTTP 响应上:文件读出一块,响应就写出一块。
index.html -> 文件读取流 -> HTTP Response
这里还有一个容易忽略的错误处理细节:等文件真正触发 open 事件后才发送 200 响应头。如果文件打开失败,并且响应头还没有发出,就可以正确返回 500;若响应已经开始,只能销毁当前响应连接。
这段文件流不是 SSE,但它直观展示了 Node.js Stream 的“管道”思想。后面的大模型分片同样可以边到达、边写入响应。
五、实现 SSE 接口
新增 /stream 路由,先用一个字符数组模拟持续生成的数据:
} else if (req.url === "/stream") {
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
"connection": "keep-alive",
});
const words = ["H", "e", "l", "l", "o", ",", "W", "o", "r", "l", "d"];
let index = 0;
const timer = setInterval(() => {
if (index >= words.length) {
clearInterval(timer);
res.write("event: done\ndata: end\n\n");
res.end();
return;
}
res.write(`data: ${words[index]}\n\n`);
index++;
}, 500);
}
三个响应头分别承担不同职责:
content-type: text/event-stream:告诉浏览器响应遵循 SSE 格式;cache-control: no-cache:避免中间缓存影响消息的实时到达;connection: keep-alive:表明当前连接需要保持。
SSE 不只是“把字符串写进响应”这么简单,它有自己的消息格式:
data: H
对应的代码是:
res.write("data: H\n\n");
第一个换行结束 data 行,第二个换行结束整条 SSE 消息。只有形成完整消息后,浏览器才会触发相应事件。
当字符全部发送完成后,服务端又发送了一条命名事件:
event: done
data: end
event: done 表示事件名称为 done。它与没有 event 字段的普通消息不同,前端需要使用 addEventListener("done", ...) 监听。
最后调用 res.end(),表示本次 HTTP 响应已经结束。clearInterval(timer) 也不能省略,否则定时器仍会继续运行。
完整服务最后监听 3010 端口:
server.listen(3010, () => {
console.log("server is running on port 3010");
});
六、浏览器使用 EventSource 接收消息
页面只需要一个结果容器:
<h1>SSE DEMO</h1>
<div id="result"></div>
浏览器原生提供了 EventSource,可以直接连接 SSE 地址:
<script>
const resultEle = document.getElementById("result");
const eventSource = new EventSource("http://localhost:3010/stream");
eventSource.onmessage = (event) => {
console.log(event.data);
resultEle.innerText += event.data;
};
eventSource.addEventListener("done", () => {
eventSource.close();
});
</script>
这里有两种监听方式:
- 服务端只发送
data: ...时,触发默认的message事件,由onmessage处理; - 服务端发送
event: done时,触发名为done的自定义事件。
每次普通消息到达后,都把 event.data 追加到 div 的 innerText 中,于是页面依次显示 H、e、l……形成逐字出现的效果。
接收到 done 后主动调用 eventSource.close(),明确告诉浏览器不再维持或重建这条连接。
七、三种“流”不要混为一谈
到这里一共出现了三个容易混淆的概念:
| 概念 | 数据从哪里到哪里 | 代码中的关键 API |
|---|---|---|
| 模型响应流 | 大模型服务到 Node.js 应用 | model.stream()、for await...of |
| Node.js 文件流 | HTML 文件到 HTTP 响应 | fs.createReadStream()、pipe() |
| SSE 消息流 | Node.js 服务到浏览器 | res.write()、EventSource |
它们都体现“数据不用全部准备好再处理”,但协议和数据结构并不相同。
尤其是在真实的大模型 Web 应用中,Node.js 服务处在中间位置:
1. model.stream(prompt) 持续产生模型响应块
2. Node.js 读取每个响应块
3. Node.js 按 SSE 格式写入 res
4. EventSource 收到消息并更新页面
终端里的 process.stdout.write() 只是展示分片;浏览器无法直接看到它。要实现网页上的流式回答,仍需把同一个分片写进面向浏览器的响应。
八、错误边界也要分层处理
模型调用和 HTTP 传输分别有自己的错误边界。
模型调用适合使用 try...catch 包围:
try {
const stream = await model.stream(prompt);
for await (const chunk of stream) {
process.stdout.write(chunk.content);
}
} catch (err) {
console.error("错误信息:", err);
}
文件响应则监听读取流的 error 事件,并根据响应头是否已经发送,选择返回 500 或直接销毁连接。
SSE 示例还用自定义 done 事件定义了正常结束路径。这样前端不需要根据最后一个普通文本片段猜测回答是否完成。
九、小结
流式输出的核心不是某个特殊的打印函数,而是一条连续的数据链路:
model.stream()让 Node.js 能够逐块读取模型响应;for await...of消费异步数据流;process.stdout.write()适合在终端中连续显示文本;- SSE 使用
text/event-stream和data: ...\n\n向浏览器持续推送消息; EventSource负责在浏览器中接收默认事件和命名事件;- 实际 Web 应用需要把“模型到服务端”和“服务端到浏览器”两段流真正串起来。
流解决的是“什么时候交付数据”。下一篇继续解决另一个问题:交付的数据应该是什么形状,以及如何用 Output Parser、Zod、Tool Calling 和 withStructuredOutput() 得到下游业务可以直接使用的对象。