🍕 AI 的嘴巴装了水管(上):从"等它说完"到"边说边听"的流式输出指南

写在前面:跟 AI 聊天时你有没有想过一个问题------为什么 ChatGPT 的回答是一个字一个字蹦出来的,而不是等半天突然蹦出一大段?这就是流式输出(Streaming) 。今天的课程从 HTTP 协议的底层讲起,到 SSE(Server Sent Events)机制,再到 Node.js 原生实现和 LangChain 的 stream API,把"AI 怎么边想边说"这件事讲透了。readme 用了一个精准的比喻------"水管,一头接着 LLM server,一头客户端,不断有 token 流向客户端"。今天我们就顺着这根水管,从源头走到水龙头。以下所有代码均来自课堂真实文件。


一、同步输出:等水壶烧开

在讲流式之前,先看"传统"的同步输出是什么体验。

normal.mjs 展示了标准的同步调用:

javascript 复制代码
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 }
});

const prompt = `请介绍一下爱因斯坦的信息。请以 JSON 格式返回...`;

const response = await model.invoke(prompt);  // 同步调用
console.log(response.content);

model.invoke(prompt) --- 发请求,等 LLM 把整段回答生成完,一次性返回。就像烧水壶烧水------你按下开关,等几分钟,"叮"一声,水开了。

问题是什么?等的那几分钟里,用户看到的白屏。 LLM 可能要生成几百上千字,全部生成完才返回------用户盯着 loading 转圈,不知道发生了什么。

readme 用一句话概括了同步和流式的区别:

"invoke 同步输出。stream 流式输出。"

一个 invoke,一个 stream------API 层面就差一个词,但底层协议完全不同。


二、HTTP 协议:从"一次快递"到"持续水管"

readme 从协议层面解释了流式的本质:

"stream 服务器端本质:llm server,http 协议------基于请求响应的简单协议。响应?response------同步、流式?pipe。"

同步 HTTP:一次快递

传统 HTTP 是什么模式?

复制代码
浏览器发请求 → 服务器处理 → 服务器返回完整响应 → 连接断开

一次请求,一次响应,连接断开。像快递------下单,等包裹,签收,结束。readme 列了同步响应的 Content-Type:

arduino 复制代码
Content-Type: text/plain
Content-Type: text/html

整包数据一次性返回,浏览器收到完整内容后渲染。

流式 HTTP:持续水管

流式响应不一样------服务器不等数据处理完,有一丁点数据就先发出去,再有了再发,直到全部发完。

readme 的水管比喻:

"水管,一头接着 llm server,一头客户端,不断有 token 流向客户端------buffer。"

水管接上了,水龙头拧开了,水一点一点流过来。客户端这边拿个桶接着(buffer),来一滴接一滴。

这就是 SSE(Server Sent Events)


三、SSE:服务器主动推送的"水管协议"

readme 对 SSE 的定义:

"Server Sent Events。服务器单向不停地往浏览器推送消息,发送多次,不会断开链接。浏览器会建立一条长连接,服务器一点一点(chunk 发数据),也就是流式输出。"

关键词:单向、长连接、chunk。

特性 同步 HTTP SSE
连接 请求-响应-断开 长连接,不断开
方向 双向(一问一答) 单向(服务器→客户端)
数据 一次性返回 一块一块推
体验 白屏等待 逐步显示

readme 还列了 SSE 的三个响应头:

css 复制代码
Content-Type: text/event-stream;
Cache-Control: no-cache;
Connection: keep-alive;
响应头 含义 为什么需要
text/event-stream 告诉浏览器"这是 SSE 流" 浏览器按 SSE 协议解析
no-cache 禁止缓存 每一块数据都是实时的,缓存没意义
keep-alive 保持连接 不让 HTTP 自动断开

readme 还提了一句:

"SSE 不只有 LLM 返回,股票..."

SSE 不是 AI 专属------股票行情推送、实时通知、日志流,都用 SSE。只要服务器需要"持续往浏览器推数据",SSE 就是首选方案。


四、Node.js 原生实现:手搓一根水管

server.js 用 Node.js 原生 http 模块手搓了一个 SSE 服务器------没有 Express,没有框架,就纯 Node。

服务器全貌

javascript 复制代码
const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
    if (req.url === '/') {
        // 返回静态 HTML 页面
        const readStream = fs.createReadStream('sse-demo/index.html');
        res.writeHead(200, { 'Content-Type': 'text/html' });
        readStream.pipe(res);
    } else if (req.url === '/stream') {
        // SSE 流式输出
        res.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'Cache-control': 'no-cache',
            'Connection': 'keep-alive',
        });
        let words = ["你", "好", ", ", "欢", "迎", "了", "解", "sse"];
        let index = 0;
        const timer = setInterval(() => {
            if (index >= words.length) {
                clearInterval(timer);
                res.end();
                return;
            }
            res.write(`data: ${words[index]}\n\n`);
            index++;
        }, 1000);
    }
});

server.listen(3000, () => {
    console.log('server is running on port 3000');
});

两个路由,两种响应模式------完美对比了同步和流式。

路由一:/ --- 同步返回静态文件

javascript 复制代码
if (req.url === '/') {
    const readStream = fs.createReadStream('sse-demo/index.html');
    res.writeHead(200, { 'Content-Type': 'text/html' });
    readStream.pipe(res);
}

浏览器访问 http://localhost:3000/,服务器读取 HTML 文件,通过 pipe 一次性返回。readme 注释说:

"stream fs 流 pipe 一下。"

fs.createReadStream 创建一个文件读取流,.pipe(res) 把文件流直接接到 HTTP 响应上------文件读一块写一块,读完就完。这是 Node.js 流式处理的经典模式。

路由二:/stream --- SSE 流式输出

javascript 复制代码
else if (req.url === '/stream') {
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-control': 'no-cache',
        'Connection': 'keep-alive',
    });
    let words = ["你", "好", ", ", "欢", "迎", "了", "解", "sse"];
    let index = 0;
    const timer = setInterval(() => {
        if (index >= words.length) {
            clearInterval(timer);
            res.end();
            return;
        }
        res.write(`data: ${words[index]}\n\n`);
        index++;
    }, 1000);
}

逐行拆解:

  1. 三个 SSE 响应头------告诉浏览器"这是 SSE 流,别缓存,别断开"
  2. words 数组------模拟 LLM 的 token 流,一个字一个字
  3. setInterval------每 1 秒推一个字,模拟 LLM 生成 token 的延迟
  4. res.write('data: xxx\n\n')------SSE 的数据格式

SSE 的数据格式是核心:

kotlin 复制代码
data: 你\n\n
data: 好\n\n
data: , \n\n

每条消息以 data: 开头,以 \n\n 结尾。浏览器收到 \n\n 就知道一条完整的 chunk 到了,触发 onmessage 事件。

  1. res.end()------所有字推完了,关闭连接

两段代码的注释也值得一提

javascript 复制代码
// commonjs , import esm
const http = require('http');  // CommonJS

readme 第一行注释就标注了------这个文件用的是 CommonJS(require),而其他 .mjs 文件用的是 ESM(import)。.mjs 扩展名就是 ESM 的标志。两种模块系统在同一个项目里共存,Node.js 生态的过渡期写照。


五、浏览器端:EventSource --- 接水管的水龙头

index.html 是前端代码------怎么接收 SSE 流。

javascript 复制代码
const eventSource = new EventSource('http://localhost:3000/stream');

eventSource.onmessage = (e) => {
    console.log(e.data);
    result.innerText += e.data;
}

三行代码,搞定 SSE 客户端。

EventSource:浏览器的 SSE 专用 API

readme 说的:

"EventSource 类,用于链接 SSE,给它 url。当服务器有新的数据 chunk 到达后,触发 onmessage 事件。"

new EventSource(url) --- 浏览器自动建立长连接,不需要你手动管 WebSocket 握手。

eventSource.onmessage --- 每当服务器推一条 data: xxx\n\n,这个回调就触发一次。e.data 就是 data: 后面的内容。

逐步显示

javascript 复制代码
result.innerText += e.data;

+= 是关键------每次收到新 chunk,追加到已有内容后面。用户看到的效果就是"你"、"好"、"、"、"欢"、"迎"......一个字一个字蹦出来。

这就是 ChatGPT 那种"打字机效果"的底层原理。

EventSource vs WebSocket

readme 提到 SSE 是"服务器单向"推送。对比一下:

特性 SSE (EventSource) WebSocket
方向 服务器 → 客户端(单向) 双向
协议 HTTP 独立协议
自动重连 需手动实现
复杂度 低(3行代码)
适用场景 服务器推数据(LLM、股票) 聊天室、游戏

LLM 流式输出只需要服务器→客户端的单向推送,SSE 足够了。WebSocket 是杀鸡用牛刀。


六、LangChain 的 stream API:LLM 专属水管

server.jssetInterval 模拟了流式输出。但真实场景中,LLM 的 token 流不是定时器------是 LLM 生成一个 token 就推一个。

stream-normal.mjs 展示了 LangChain 怎么做流式:

javascript 复制代码
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 },
});

const prompt = `详细介绍莫扎特的信息`;

const stream = await model.stream(prompt);  // stream 替代 invoke

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}个chunk,共${fullContent.length}个token`);

invoke vs stream

readme 说的:

"invoke 同步输出。stream 流式输出。"

API 返回 体验 代码
model.invoke(prompt) 完整响应 等待→一次性显示 const res = await model.invoke(prompt)
model.stream(prompt) AsyncIterable 逐步显示 for await (const chunk of stream)

model.stream() 返回的是一个异步可迭代对象 (AsyncIterable)------不能直接 await 拿结果,要用 for await...of 循环逐块消费。

chunk:水管里的一滴水

readme 说的:

"stream 水流,管子,chunk 一个数据块。"

chunk 就是水管里的一滴水------LLM 每生成一小段文字就作为一个 chunk 推过来。一个 chunk 可能是一个字、一个词、甚至半个字(取决于 tokenizer)。

javascript 复制代码
for await (const chunk of stream) {
    const content = chunk.content;    // 取这一块的文本
    fullContent += content;           // 拼接完整内容
    process.stdout.write(content);    // 实时输出到终端
}

process.stdout.write(content) 而不是 console.log(content)------因为 console.log 会自动加换行符,流式输出不需要换行,要的是文字连续涌现的效果。

统计信息

javascript 复制代码
console.log(`\n\n共接收${chunkCount}个chunk,共${fullContent.length}个token`);

课堂代码统计了 chunk 数量和总 token 数------一个回答被拆成了多少块、总共多长。这能看到 LLM 流式输出的粒度。


七、从 server.js 到 stream-normal.mjs:两层流式

今天有两个流式实现------它们不是同一层:

arduino 复制代码
┌─────────────────────────────────────────────────┐
│              完整的 LLM 流式架构                   │
│                                                   │
│  LLM Server                                       │
│    ↓ token 流(SSE)                               │
│  Node.js 服务器                                    │
│    ↓ res.write('data: xxx\n\n')                   │
│  浏览器 EventSource                                │
│    ↓ onmessage 回调                                │
│  页面逐步渲染                                       │
│                                                   │
└─────────────────────────────────────────────────┘
层级 文件 角色
LLM → 服务器 stream-normal.mjs Node.js 作为客户端,接收 LLM 的流式输出
服务器 → 浏览器 server.js + index.html Node.js 作为服务端,SSE 推送给浏览器

stream-normal.mjs 是第一层------Node.js 调 LLM 的 stream API,接收 token 流。

server.js 是第二层------Node.js 作为 SSE 服务器,把数据推给浏览器。

真实项目里两层是连起来的------Node.js 服务器收到 LLM 的 chunk,立刻通过 SSE 推给浏览器:

javascript 复制代码
// 伪代码:两层流式串联
const stream = await model.stream(prompt);
for await (const chunk of stream) {
    res.write(`data: ${chunk.content}\n\n`);  // 收到一块,推一块
}
res.end();

LLM 生成一个 token → Node.js 收到 → 立刻通过 SSE 推给浏览器 → 浏览器追加显示。 全程没有"等完再发"------每一滴水流过来就立刻流出去,这就是真正的实时流式体验。


八、SSE 的数据格式详解

server.js 里这行代码是 SSE 的核心格式:

javascript 复制代码
res.write(`data: ${words[index]}\n\n`);

SSE 协议规定,每条消息的格式是:

kotlin 复制代码
data: 消息内容\n
\n
  • data: --- 字段名,表示这是数据内容
  • \n --- 字段结束
  • \n --- 空行,表示一条消息结束

浏览器收到空行(\n\n)就触发一次 onmessage

SSE 还支持其他字段:

makefile 复制代码
event: 自定义事件名
data: 消息内容
id: 消息ID
retry: 重连间隔(毫秒)

课堂代码只用了 data:------最常用、最基础。其他字段用于更复杂场景:event 可以区分不同类型的消息,id 用于断线重连时告诉服务器"我收到哪了",retry 控制重连间隔。


九、为什么不用 WebSocket?

readme 暗含了这个对比------SSE 是"服务器单向推送",WebSocket 是双向。

LLM 流式输出场景:

  • 用户发一次请求("介绍一下莫扎特")
  • 服务器持续推送回答(一个 token 一个 token)

这是典型的"一问多答"模式------用户只问一次,服务器回答多次。 SSE 完美匹配。

WebSocket 适合什么?聊天室------你发一条、我发一条、你再来一条。双向、高频、持续。LLM 流式输出不需要双向------用户不会在 LLM 生成回答的过程中插嘴。

而且 SSE 有一个 WebSocket 没有的优势------自动重连。网络断了,EventSource 会自动重连,不用写一行重连代码。WebSocket 断了你得自己处理。


PS:下次看到 ChatGPT 一个字一个字蹦回答,你就知道了------背后是一根 SSE 水管,LLM 每生成一个 token 就滴一滴水,Node.js 接到后立刻通过 res.write('data: xxx\n\n') 推给浏览器,EventSource 的 onmessage 接住后 innerText += 追加上去。水管全程不断,水一滴一滴流。下篇我们换个话题------给 AI 发一张结构化表格,让它老老实实填空。

相关推荐
lichenyang45344 分钟前
让 VK 小程序调用 HarmonyOS 原生能力:壳子 SDK 的实现思路
前端
梨想橙汁1 小时前
Vue3 组合式 API 深度解析:ref/reactive 响应式,计算属性与侦听器
前端·vue.js
暖焰核心1 小时前
继承全解——继承、默认成员函数、切片、隐藏与虚继承
java·前端·javascript
by组态1 小时前
Ricon组态系统API参考手册
前端·后端·物联网
前端逗比逗1 小时前
AI 前端落地实战:SSE 流式输出、断点续传、打字机渲染
前端·webassembly
晴天162 小时前
前端跨域方案解析:JSONP 的原理、实战与演进
前端·状态模式
web打印社区2 小时前
浏览器静默打印?先别装第三个库了
前端·vue.js·chrome·electron·pdf
艾伦野鸽ggg2 小时前
前端异步请求的状态竞争(请求竞态)问题
前端·javascript·axios
是立不是利2 小时前
前端交互基石:深入剖析JavaScript三级联动背后的设计哲学
开发语言·前端·javascript