LLM“打字机效果”背后:用 Node.js 与 SSE 实现流式输出

从 Node.js 手写 SSE 服务到 LLM API 的 stream: true,本文拆解大模型“打字机效果”背后的工程实现。

当用户在 ChatGPT 或 DeepSeek 等 AI 产品中发出提问,回答通常不会等待完整生成后一次性返回,而是逐字、逐句地呈现。这种“打字机效果”的工程基础,是服务端在模型每生成一部分 token 后就立即推送给客户端,而不是等整段文本生成完毕。支撑这一过程的常见协议之一,是 SSE(Server-Sent Events)。

与传统 HTTP 请求“请求—响应—断开”的模式不同,SSE 允许服务器在客户端发起一次请求后保持连接,并持续向客户端推送消息。对于大模型应用场景而言,这种单向推送恰好契合需求:用户发起提问,模型侧不断产生 token,服务端再把 token 流实时传给浏览器。

SSE 与普通 HTTP 的差异

从工程实现看,普通 HTTP 响应通常在处理完成后一次性返回数据。若将其用于大模型输出,用户需要等待模型完成全部生成,才能看到完整结果;在响应较慢的情况下,页面可能长时间停留在“思考中”状态。

SSE 的核心在于响应头和消息格式。服务端返回时通常设置:

  • Content-Type: text/event-stream,告知浏览器返回的是事件流。
  • Cache-Control: no-cache,避免缓存影响实时性。
  • Connection: keep-alive,保持 TCP 长连接,便于后续持续推送。

消息体则采用固定格式,例如:

data: 你好


data: 世界


data: [DONE]

其中,data: 前缀用于标识消息体。SSE 协议还支持 event:id:retry: 等字段,浏览器接收到数据后会自动解析,并将内容放入事件对象的 e.data 中。每条消息通常以两个换行符分隔,客户端据此判断一次推送结束。

Node.js 原生实现 SSE

素材中给出的示例展示了如何使用 Node.js 原生 http 模块实现一个简单的 SSE 服务。服务包含两个路由:一个用于返回 HTML 页面,另一个用于推送事件流。

/ 路由中,服务器读取本地 index.html 文件,并通过 res.end(data) 一次性返回。这对应传统的同步响应模式。

/stream 路由中,服务器先写入 SSE 相关响应头,然后使用 setInterval 每隔 1000 毫秒推送一个词。数组中的内容包括 , sse。每次推送时,服务端调用:

res.write(`data: ${words[index]}

`)

当数组全部发送完成后,服务端清除定时器并调用 res.end() 关闭连接。整个过程相当于第 1 秒发送“你”,第 2 秒发送“好”,第 3 秒发送“, ”,直到第 8 秒发送“sse”,第 9 秒关闭连接。

前端页面则通过浏览器原生 EventSource 接收数据:

const eventSource = new EventSource('http://localhost:3000/stream');
eventSource.onmessage = (e) => { resultEle.innerText += e.data; };

每收到一条消息,页面就将内容追加到指定元素中,从而形成逐步显示的效果。

大模型 API 中的 stream: true

在实际 AI 应用中,是否开启流式输出通常由请求参数决定。素材中提到,请求体中的 stream: true 是关键开关。当该参数关闭时,服务端会等待模型生成完整内容,再一次性返回完整 JSON;开启后,模型每生成一个或几个 token,就会写入 HTTP 响应流。

同步模式下,响应通常包含完整 message

{"choices":[{"message":{"content":"莫扎特是奥地利作曲家..."}}]}

流式模式下,每次返回的是 delta 增量:

data: {"choices":[{"delta":{"content":"莫"}}]}
data: {"choices":[{"delta":{"content":"扎"}}]}
data: {"choices":[{"delta":{"content":"特"}}]}
data: {"choices":[{"delta":{"content":"是"}}]}
data: [DONE]

多个增量片段最终拼接为完整回答。[DONE] 通常用于标识流结束。

在服务端调用模型时,也可以使用异步迭代器处理流式返回。素材中展示了 LangChain 的写法:通过 ChatOpenAI 创建模型实例,再调用 model.stream('详细介绍莫扎特')。返回值是一个 AsyncIterator,每次 yield 一个 chunk,开发者可以通过 for await 遍历,并将 chunk.content 实时写入标准输出。

前端如何处理 token 流

素材描述的完整链路是:用户输入问题后,前端发起带有 stream: true 的请求;LLM 服务端每生成一个 token,就将一条 data: {json}

格式的消息写入响应流;全部生成完成后发送 [DONE] 并关闭连接。

前端接收侧则可通过 EventSourceReadableStream 处理。若使用 ReadableStream,常见流程包括:读取字节流、使用 TextDecoder 解码、进入缓冲区、按换行符切分、解析 JSON,再取出 delta.content 拼接到页面。

这一过程也意味着,实际工程中需要关注若干细节:

  • token 流并非总是一段完整文字,可能是一个字、一个词或若干字符。
  • 网络缓冲区可能导致一次接收到多条消息或不完整消息,因此需要按行切分并维护缓冲。
  • [DONE] 是判断生成结束的重要标识。
  • 前端渲染通常采用增量拼接,而不是等待完整结果后再显示。

SSE 的适用边界

虽然 SSE 在 LLM 流式输出中被广泛使用,但它并非专为大模型设计。素材提到,SSE 是一种通用的服务器向客户端单向推送协议,也适用于其他需要服务器持续下发数据的场景。

与 WebSocket 相比,SSE 的优势在于实现简单,基于 HTTP,适合服务器向客户端推送单一方向的数据。若业务需要双向通信,例如实时聊天中客户端和服务器频繁互发消息,则 WebSocket 更合适。

从工程角度看,LLM 流式输出可以概括为“SSE + 逐 token 推送”。stream: true 的背后,是服务端将 HTTP 响应变成持续写入的数据通道,模型生成的 token 依次写入其中,前端再逐条接收并渲染。这种方式并没有改变模型生成本身的逻辑,但显著改变了用户等待结果时的体验:从等待完整响应,变为尽早看到第一个字,并在持续输出中完成整段回答。

原创文章,作者:点点,如若转载,请注明出处:https://www.dian8dian.com/llm-da-zi-ji-xiao-guo-bei-hou-yong-node-js-yu-sse-shi-xian

Like (0)
点点的头像点点
Previous 8小时前
Next 6小时前

相关推荐