调用大模型时,如果使用普通请求,用户往往要等到整段内容生成完毕,才能一次性看到结果。回答越长,这段“没有任何反馈”的等待就越明显。
流式输出解决的不是模型生成速度问题,而是结果交付方式问题:模型生成一部分,应用就接收一部分;服务端收到一部分,浏览器也可以立刻显示一部分。
不过,“大模型开启流式输出”和“浏览器看到打字机效果”并不是同一件事。一个完整的 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:
<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> { <span>ChatOpenAI</span> } <span>from</span> <span>"@langchain/openai"</span>;
<span>const</span> model = <span>new</span> <span>ChatOpenAI</span>({
<span>modelName</span>: process.<span>env</span>.<span>MODEL_NAME</span>,
<span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
<span>temperature</span>: <span>0</span>,
<span>configuration</span>: {
<span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
},
});
这里把 temperature 设为 0,是为了让演示结果尽量稳定。baseURL 放在 configuration 中,因此除了官方接口,也可以连接兼容 OpenAI 协议的模型服务。
二、invoke() 与 stream() 的区别
普通调用通常使用 invoke():
<span>const</span> response = <span>await</span> model.<span>invoke</span>(<span>"详细介绍 RAG"</span>);
<span>console</span>.<span>log</span>(response.<span>content</span>);
await 会一直等待,直到完整响应可用。代码简单,但无法在生成期间更新界面。
流式调用则使用 stream():
<span>const</span> stream = <span>await</span> model.<span>stream</span>(<span>"详细介绍 RAG"</span>);
此时得到的不是最终文本,而是一个可以异步迭代的流。可以用 for await...of 持续读取模型返回的数据块:
<span>const</span> prompt = <span>"详细介绍 RAG"</span>;
<span>try</span> {
<span>const</span> stream = <span>await</span> model.<span>stream</span>(prompt);
<span>let</span> fullContent = <span>""</span>;
<span>let</span> chunkCount = <span>0</span>;
<span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
chunkCount++;
<span>const</span> content = chunk.<span>content</span>;
fullContent += content;
<span>// 不自动换行,形成连续输出效果</span>
process.<span>stdout</span>.<span>write</span>(content);
}
<span>console</span>.<span>log</span>(<span>`\n\n共接收 <span>${chunkCount}</span> 个数据块`</span>);
<span>console</span>.<span>log</span>(<span>`完整内容:<span>${fullContent}</span>`</span>);
} <span>catch</span> (err) {
<span>console</span>.<span>error</span>(<span>"错误信息:"</span>, 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 模块:
<span>const</span> http = <span>require</span>(<span>"http"</span>);
<span>const</span> fs = <span>require</span>(<span>"fs"</span>);
<span>const</span> path = <span>require</span>(<span>"path"</span>);
<span>const</span> server = http.<span>createServer</span>(<span>(<span>req, res</span>) =></span> {
<span>if</span> (req.<span>url</span> === <span>"/"</span>) {
<span>const</span> indexPath = path.<span>join</span>(__dirname, <span>"index.html"</span>);
<span>const</span> indexStream = fs.<span>createReadStream</span>(indexPath);
indexStream.<span>once</span>(<span>"open"</span>, <span>() =></span> {
res.<span>writeHead</span>(<span>200</span>, {
<span>"content-type"</span>: <span>"text/html; charset=utf-8"</span>,
});
indexStream.<span>pipe</span>(res);
});
indexStream.<span>once</span>(<span>"error"</span>, <span>(<span>err</span>) =></span> {
<span>if</span> (!res.<span>headersSent</span>) {
res.<span>writeHead</span>(<span>500</span>, {
<span>"content-type"</span>: <span>"text/plain; charset=utf-8"</span>,
});
res.<span>end</span>(<span>"Failed to load index.html"</span>);
<span>return</span>;
}
res.<span>destroy</span>(err);
});
}
});
fs.createReadStream() 不会先把整个 HTML 文件一次性读入内存,而是创建文件读取流。indexStream.pipe(res) 再把文件流接到 HTTP 响应上:文件读出一块,响应就写出一块。
index.html -> 文件读取流 -> HTTP Response
这里还有一个容易忽略的错误处理细节:等文件真正触发 open 事件后才发送 200 响应头。如果文件打开失败,并且响应头还没有发出,就可以正确返回 500;若响应已经开始,只能销毁当前响应连接。
这段文件流不是 SSE,但它直观展示了 Node.js Stream 的“管道”思想。后面的大模型分片同样可以边到达、边写入响应。
五、实现 SSE 接口
新增 /stream 路由,先用一个字符数组模拟持续生成的数据:
} <span>else</span> <span>if</span> (req.<span>url</span> === <span>"/stream"</span>) {
res.<span>writeHead</span>(<span>200</span>, {
<span>"content-type"</span>: <span>"text/event-stream"</span>,
<span>"cache-control"</span>: <span>"no-cache"</span>,
<span>"connection"</span>: <span>"keep-alive"</span>,
});
<span>const</span> words = [<span>"H"</span>, <span>"e"</span>, <span>"l"</span>, <span>"l"</span>, <span>"o"</span>, <span>","</span>, <span>"W"</span>, <span>"o"</span>, <span>"r"</span>, <span>"l"</span>, <span>"d"</span>];
<span>let</span> index = <span>0</span>;
<span>const</span> timer = <span>setInterval</span>(<span>() =></span> {
<span>if</span> (index >= words.<span>length</span>) {
<span>clearInterval</span>(timer);
res.<span>write</span>(<span>"event: done\ndata: end\n\n"</span>);
res.<span>end</span>();
<span>return</span>;
}
res.<span>write</span>(<span>`data: <span>${words[index]}</span>\n\n`</span>);
index++;
}, <span>500</span>);
}
三个响应头分别承担不同职责:
content-type: text/event-stream:告诉浏览器响应遵循 SSE 格式;cache-control: no-cache:避免中间缓存影响消息的实时到达;connection: keep-alive:表明当前连接需要保持。
SSE 不只是“把字符串写进响应”这么简单,它有自己的消息格式:
data: H
对应的代码是:
res.<span>write</span>(<span>"data: H\n\n"</span>);
第一个换行结束 data 行,第二个换行结束整条 SSE 消息。只有形成完整消息后,浏览器才会触发相应事件。
当字符全部发送完成后,服务端又发送了一条命名事件:
event: done
data: end
event: done 表示事件名称为 done。它与没有 event 字段的普通消息不同,前端需要使用 addEventListener("done", ...) 监听。
最后调用 res.end(),表示本次 HTTP 响应已经结束。clearInterval(timer) 也不能省略,否则定时器仍会继续运行。
完整服务最后监听 3010 端口:
server.<span>listen</span>(<span>3010</span>, <span>() =></span> {
<span>console</span>.<span>log</span>(<span>"server is running on port 3010"</span>);
});
六、浏览器使用 EventSource 接收消息
页面只需要一个结果容器:
<span><<span>h1</span>></span>SSE DEMO<span></<span>h1</span>></span>
<span><<span>div</span> <span>id</span>=<span>"result"</span>></span><span></<span>div</span>></span>
浏览器原生提供了 EventSource,可以直接连接 SSE 地址:
<span><<span>script</span>></span><span>
<span>const</span> resultEle = <span>document</span>.<span>getElementById</span>(<span>"result"</span>);
<span>const</span> eventSource = <span>new</span> <span>EventSource</span>(<span>"http://localhost:3010/stream"</span>);
eventSource.<span>onmessage</span> = <span>(<span>event</span>) =></span> {
<span>console</span>.<span>log</span>(event.<span>data</span>);
resultEle.<span>innerText</span> += event.<span>data</span>;
};
eventSource.<span>addEventListener</span>(<span>"done"</span>, <span>() =></span> {
eventSource.<span>close</span>();
});
</span><span></<span>script</span>></span>
这里有两种监听方式:
- 服务端只发送
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 包围:
<span>try</span> {
<span>const</span> stream = <span>await</span> model.<span>stream</span>(prompt);
<span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
process.<span>stdout</span>.<span>write</span>(chunk.<span>content</span>);
}
} <span>catch</span> (err) {
<span>console</span>.<span>error</span>(<span>"错误信息:"</span>, 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() 得到下游业务可以直接使用的对象。
把“模型流”与“SSE 流”分层讲透,是本文最大价值。适合为大模型 Web 应用做流式渲染的后端与全栈开发者,照着代码即可打通端到端链路。