mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5
2537 字
13 分钟
从零手搓 AI 网页对话:原生流式传输技术实践总结

在当今的大模型应用开发中,“流式传输”几乎是提升用户体验的必修课。如果不使用流式,用户必须死死盯着 Loading 图标等待大模型把几百上千个字全部生成完毕,体验极差。

为了彻底搞懂流式请求的底层原理,我决定抛开现成的框架和高度封装的 SDK,使用纯粹的 HTML/原生 JS + Node.js (Express),从零开始搭建了一套 AI 对话系统。以下是整个演进和学习过程的技术复盘。

1. 架构与选型:化繁为简#

为了专注核心技术——“推流”与“接流”,系统被设计为最精简的三层结构:

  1. 前端:负责处理用户输入、发送 POST 请求,并“像打字机一样”把接到的碎片文本拼接在 DOM 节点上。
  2. 中间代理 (Node.js):负责接收前端的诉求,向真实大模型发起请求,同时建立流式通道透传片段给前端。
  3. 大模型 API:接入符合业界标准的通用大模型服务端接口。

2. 避坑指南:为什么不用原生的 EventSource ?#

WARNING

最开始查阅资料时,发现很多教程推荐使用原生提供的 new EventSource() 来实现 Server-Sent Events 流式接收。 但在实际技术调研时,发现一个问题:EventSource 只支持 GET 请求。 大模型对话需要把包含大量字符的 history (历史上下文数组) 传给后端,GET 请求的 URL 长度限制会成为致命短板。

最终方案: 采用 Fetch API + POST 请求 + 流式拦截

3. 后端如何“造流”?#

为了不被繁杂的网络环境和限流报错打断思路,最好的练习方式是先手搓一个本地 Mock 推流接口。 普通的 HTTP 接口通常是把全部数据塞进变量最后一并发走。而流式接口的核心在于:

  1. 先告诉浏览器“我要持续发片段了”。
  2. res.write() 不断推送细小的数据。
  3. 最后用 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 的玄机

在此处,每次调用 TextDecoderdecode 方法对 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 之后,我们就需要紧接着进行数据清洗的操作:

  1. split('\n\n'):为什么按换行符分割呢?因为每次循环拿到的 value 一定包含一个或多个完整的 JSON 串区段。将多行响应片段分离出来备用。
  2. filter & map:遍历判断是否是结束标识,将空行过滤,随后通过 map 去掉文本头部的 data: 前缀字符。
  3. 反序列化解析:最后转化为真正的 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 对话项目从无到有的流式实践,更通过深挖背后的网络与语言特性,我们发现:

  1. 流的断裂容错:一个小小的 { stream: true },巧夺天工地避免了底层基于包截断带来的错位乱码陷阱。
  2. 三段式拆解:理解了真实场景下面对 data: xxxsplit, filter, map 拆解机制。
  3. 现代 JS 语法的优雅:从繁琐的 reader.read() 发动机模型,通过 async function* (生成器) 和 for await...of 的绝妙化学反应,优雅地完成了底层与业务的隔离。在 AI 开发中,往往前端不会直接去处理底层数据结构,我们要学会通过“流转换”为业务自定义的数据结构。

这是一次对 HTTP 流通信机制与 ES9 前沿迭代语法结合的深刻实践。感谢你看到这里,希望这篇文章能带给你启发!

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从零手搓 AI 网页对话:原生流式传输技术实践总结
https://blog.moxiaoshuai.fun/2026-03-流式传输-流式传输/
作者
莫莫
发布于
2026-03-31
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
示例歌曲
示例艺人
封面
示例歌曲
示例艺人
0:00 / 0:00