从“等待整段答案”到边生成边展示:大模型流式输出与 SSE 实战(上)

0 阅读8分钟

从“等待整段答案”到边生成边展示:大模型流式输出与 SSE 实战(上)

调用大模型时,如果使用普通请求,用户往往要等到整段内容生成完毕,才能一次性看到结果。回答越长,这段“没有任何反馈”的等待就越明显。

流式输出解决的不是模型生成速度问题,而是结果交付方式问题:模型生成一部分,应用就接收一部分;服务端收到一部分,浏览器也可以立刻显示一部分。

不过,“大模型开启流式输出”和“浏览器看到打字机效果”并不是同一件事。一个完整的 Web 应用通常有两段流:

大模型服务  --模型响应流-->  Node.js 服务  --SSE-->  浏览器

本文先实现第一段,再用原生 Node.js 和浏览器 EventSource 打通第二段。

一、准备模型客户端

示例使用 ESM,因此脚本使用 .mjs 后缀,可以直接使用 import 和顶层 await。所需依赖如下:

本文对应的依赖组合是 @langchain/core 1.2.12@langchain/openai 1.5.13zod 4.6.5dotenv 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);
}

这段代码同时完成了三件事:

  1. process.stdout.write(content) 立即展示当前分片;
  2. fullContent += content 保存完整结果;
  3. chunkCount 记录一共接收了多少个分片。

console.log() 默认会换行,不适合连续打印每个小片段;process.stdout.write() 不会自动添加换行,更适合表现流式文本。

还要注意,chunk 表示一次收到的数据块。业务代码不应假定它一定对应一个汉字、一个单词或者一个完整句子。可靠的做法是:每次收到什么就处理什么,需要完整文本时再自行累加。

三、模型流还没有抵达浏览器

上面的代码只能让 Node.js 进程在终端中边接收边打印。若应用还有浏览器前端,Node.js 服务需要继续把收到的数据向浏览器发送。

HTTP 最常见的使用方式是:

请求 -> 完整响应 -> 断开连接

流式场景需要让响应保持一段时间,并且允许服务端连续写入数据:

请求 -> 响应块 -> 响应块 -> 响应块 -> 结束

SSE(Server-Sent Events,服务器发送事件)正适合这种服务器单向推送场景。浏览器建立连接后,服务端可以不断发送消息,直到主动结束连接。

它不只可以承载大模型输出,也可以用于实时通知、任务进度和日志推送。大模型只是 SSE 的一种应用场景。

四、先用 Node.js 返回页面

下面不引入 Web 框架,直接使用 Node.js 内置的 httpfspath 模块:

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 追加到 divinnerText 中,于是页面依次显示 Hel……形成逐字出现的效果。

接收到 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-streamdata: ...\n\n 向浏览器持续推送消息;
  • EventSource 负责在浏览器中接收默认事件和命名事件;
  • 实际 Web 应用需要把“模型到服务端”和“服务端到浏览器”两段流真正串起来。

流解决的是“什么时候交付数据”。下一篇继续解决另一个问题:交付的数据应该是什么形状,以及如何用 Output Parser、Zod、Tool Calling 和 withStructuredOutput() 得到下游业务可以直接使用的对象。