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

文章来源声明: 原文作者:半个落月; 来源站点:掘金; 原文链接:https://juejin.cn/post/7688212417865007131; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把“模型流”与“SSE 流”分层讲透,是本文最大价值。适合为大模型 Web 应用做流式渲染的后端与全栈开发者,照着代码即可打通端到端链路。

从“等待整段答案”到边生成边展示:大模型流式输出与 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

<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);
}

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

  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 模块:

<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 追加到 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 包围:

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

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