在当今的大模型应用开发中,“流式传输”几乎是提升用户体验的必修课。如果不使用流式,用户必须死死盯着 Loading 图标等待大模型把几百上千个字全部生成完毕,体验极差。
为了彻底搞懂流式请求的底层原理,我决定抛开现成的框架和高度封装的 SDK,使用纯粹的 HTML/原生 JS + Node.js (Express),从零开始搭建了一套 AI 对话系统。以下是整个演进和学习过程的技术复盘。
1. 架构与选型:化繁为简
为了专注核心技术——“推流”与“接流”,系统被设计为最精简的三层结构:
- 前端:负责处理用户输入、发送 POST 请求,并“像打字机一样”把接到的碎片文本拼接在 DOM 节点上。
- 中间代理 (Node.js):负责接收前端的诉求,向真实大模型发起请求,同时建立流式通道透传片段给前端。
- 大模型 API:接入符合业界标准的通用大模型服务端接口。
2. 避坑指南:为什么不用原生的 EventSource ?
WARNING最开始查阅资料时,发现很多教程推荐使用原生提供的
new EventSource()来实现 Server-Sent Events 流式接收。 但在实际技术调研时,发现一个问题:EventSource只支持 GET 请求。 大模型对话需要把包含大量字符的history(历史上下文数组) 传给后端,GET 请求的 URL 长度限制会成为致命短板。
最终方案: 采用 Fetch API + POST 请求 + 流式拦截。
3. 后端如何“造流”?
为了不被繁杂的网络环境和限流报错打断思路,最好的练习方式是先手搓一个本地 Mock 推流接口。 普通的 HTTP 接口通常是把全部数据塞进变量最后一并发走。而流式接口的核心在于:
- 先告诉浏览器“我要持续发片段了”。
- 用
res.write()不断推送细小的数据。 - 最后用
res.end()收尾。
Node.js 发流示例:
app.post('/api/mock/chat/stream', (req, res) => { res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive');
const chars = "这是一段用来测试流式打字机效果的 Mock 文本".split(''); let currentIndex = 0;
const timer = setInterval(() => { if (currentIndex < chars.length) { res.write(chars[currentIndex++]); } else { clearInterval(timer); res.end(); // 告诉前端结束了 } }, 50);});4. 前端读取与深层解析:解码与数据的“三连操作”
前端拿到了流,普通 fetch 会挂起直到 res.end(),我们需要通过 response.body.getReader() 打开“渐进式水管”,而此时我们拿到的实际上是包含底层字节编码的 Uint8Array(无符号整数数组)。此时我们引入了一个关键对象——TextDecoder 解码器。
让我们细致看看我们用于接收流的内部循环做了什么:
const reader = response.body.getReader();const decoder = new TextDecoder('utf-8');
while (true) { const { done, value } = await reader.read(); if (done) break;
const chunkText = decoder.decode(value, { stream: true }); aiMessageDiv.textContent += chunkText;}解析一:TextDecoder 与 stream: true 的玄机在此处,每次调用
TextDecoder的decode方法对value进行解码。此value即来自响应体的数据片段,比如底层数组若是[100, 97, 116, 97]四个数字合起来对应字符就是 ‘data’。所以decode是在把无符号整数数组映射为字符串。特别需要注意
stream: true参数,这对于流式传输极为重要,因为它专门用来处理多字节字符的残留问题。比如中文字符在 UTF-8 编码规则下需要用到三个数字(例如表示某些汉字需要228, 189, 160)。由于流的数据是被硬生生一块块截断传输的,这段字节可能在两次接收之间“拦腰斩断”。如果不完整,TextDecoder会直接解出乱码;而加上stream: true参数后,它会在内部将不完整的字节缓存起来,累积到下次凑齐完整数据后再进行解码,从而完美避免了中文乱码灾难。
解析二:如何面对真实 API 的数据格式?
目前的代码里后端发什么我们就直接拼什么。但在真实 API 和标准 SSE 约定里,包含多行带有 data: 前缀的 JSON 字符串(例如 data: {"content":"你好"}),并且用空行分割,最后一行固定为 data: [DONE]。
这时候在循环接到 chunkText 之后,我们就需要紧接着进行数据清洗的操作:
split('\n\n'):为什么按换行符分割呢?因为每次循环拿到的value一定包含一个或多个完整的 JSON 串区段。将多行响应片段分离出来备用。filter&map:遍历判断是否是结束标识,将空行过滤,随后通过map去掉文本头部的data:前缀字符。- 反序列化解析:最后转化为真正的 JS 对象(
JSON.parse),提取出对象中的content字段去驱动 UI 显示。这里如果是在后端 Node.js 控制台打印流水的话,切记要使用process.stdout.write而不是console.log,以免反复自动换行破坏连续阅读体验。当这一切循环结束后,我们就拿到了本轮对话的完整回复,后续逻辑便如寻常。
5. 进阶探讨:从循环驱动到 Async Generator (彻底解耦)
你可能发现了,这一段代码其实大部分都在做数据转换和格式处理的事情,这在应用设计上会暴露出一个问题:数据处理逻辑(解码、split、格式清洗)和业务逻辑(更新到 DOM 气泡)深深地耦合在一起。 假设项目中有很多地方都需要进行这样的异步迭代(比如要复用流读取做分析、打点),如何避免重复代码?能否只在循环里保留核心业务代码,把相对通用的逻辑封装到专门的方法内部?这里我们要了解流背后的——迭代器(Iterator)和生成器(Generator)。
理解迭代的本质:同步 vs 异步迭代器
-
ES6 同步迭代 (
for...of):规范规定了实现了Symbol.iterator方法的对象都可以用for...of进行遍历。我们常用的原生数组由于内置了该对象便具备了这个特性。实际上如果拆开来看,在这个 iterator 对象上有三个方法,重点关注next(),每次调用都会同步返回下个值以及是否结束return { value, done }。for...of就是我们在底层帮我们简化驱动这种对象的语法。// 同步迭代示例const arr = [1, 2, 3];const iterator = arr[Symbol.iterator]();console.log(iterator.next()); // { value: 1, done: false }console.log(iterator.next()); // { value: 2, done: false }console.log(iterator.next()); // { value: 3, done: false }console.log(iterator.next()); // { value: undefined, done: true } -
ES9 异步迭代 (
for await...of):规范规定了实现Symbol.asyncIterator方法的对象适用。像 Fetch API 请求返回的ReadableStream恰恰便实现了这个接口!同样去驱动它,不同的是这里的next()方法返回的是一个需要await的 Promise(包裹着和同步迭代一样的{ value, done })。// 异步迭代简易示例(模拟)const asyncIterable = {[Symbol.asyncIterator]() {let i = 0;return {next: async () => {await new Promise(resolve => setTimeout(resolve, 100)); // 模拟异步延迟if (i < 3) return { value: ++i, done: false };return { value: undefined, done: true };}};}};// 使用 for await...of 消费(async () => {for await (const val of asyncIterable) {console.log(val); // 依次输出 1, 2, 3 (每隔100ms)}})();
引入异步生成器解耦
所以,最优雅的设计,是编写一个异步生成器函数 (Async Generator),利用 yield 将复杂的网络流转变为业务所需的“纯净流”:
/* 注意到 async function 后面多了一个星号 *,这表示它是一个异步生成器函数。 在函数内部使用 yield 关键字产出一个值,并暂停函数执行。*/async function* processStream(response) { const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8');
while (true) { const { done, value } = await reader.read(); if (done) break;
// 解析二进制 const chunkText = decoder.decode(value, { stream: true }); // (此处省略上方讲到的 split、map、filter 及去 data: 和 parse 的数据处理过程) const cleanContent = "...(提取好的业务字符串)...";
// 使用 yield 产出最终给到业务层食用的值 yield cleanContent; }}很多人学了语法但觉得”生成器好像没什么用”,实际上它其中一个极具价值的作用便在于流的转换!调用生成器函数会返回一个自动实现了可迭代接口的新对象。这样我们就把 fetch 丢出来的晦涩难懂的 readableStream 转换成了自定义的流形态,完美屏蔽了内部数据格式处理细节。
经过这身定制封装,在 UI 侧的业务代码就会变得清爽无比,只需要使用极为现代的 for await...of:
// 业务层视角:代码极致干净,只聚焦渲染处理自身关心的字符串for await (const pureText of processStream(response)) { aiMessageDiv.textContent += pureText;}6. 收获与总结
在本次探索中,不仅从最底层的机制打通了 AI 对话项目从无到有的流式实践,更通过深挖背后的网络与语言特性,我们发现:
- 流的断裂容错:一个小小的
{ stream: true },巧夺天工地避免了底层基于包截断带来的错位乱码陷阱。 - 三段式拆解:理解了真实场景下面对
data: xxx的split,filter,map拆解机制。 - 现代 JS 语法的优雅:从繁琐的
reader.read()发动机模型,通过async function*(生成器) 和for await...of的绝妙化学反应,优雅地完成了底层与业务的隔离。在 AI 开发中,往往前端不会直接去处理底层数据结构,我们要学会通过“流转换”为业务自定义的数据结构。
这是一次对 HTTP 流通信机制与 ES9 前沿迭代语法结合的深刻实践。感谢你看到这里,希望这篇文章能带给你启发!
部分信息可能已经过时




