AI 前端流式输出手写与扩展知识

AI 前端流式输出手写与扩展知识

js 复制代码
async function streamChat(messages) {
    //发送请求
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer xxx',
    },
    body: JSON.stringify({ messages }),
  });
	//读取器,获取readablestream数据管道
  const reader = res.body.getReader();
    //解码器: 将字节转化成字符
  const decoder = new TextDecoder();

  while (true) {
      //done:流传输状态标志  value: 字节数组uint8Array
    const { done, value } = await reader.read();
	//服务端中断 流式传输结束
    if (done) break;
	//解码字节数组,获取字符串 , {stream:true}:表示当前value是一个流式传输的数据
    const chunk = decoder.decode(value, { stream: true });
	//AI传输的数据用两个换行符分割,这里要把数据区分开
    chunk.split('\n\n').forEach(block => {
        //正则表达式获取data: 之后的正文
      const line = block.replace(/^data:\s*/, '');
		//[DONE]:流式消息传递结束
      if (line && line !== '[DONE]') {
        document.body.textContent += line;
      }
    });
  }
}

一、先看完整的数据流转过程

假设服务器想返回:

text 复制代码
你好

发送端首先需要按照某种字符编码,例如 UTF-8,将字符串编码成字节:

text 复制代码
"你好"
   ↓
UTF-8 编码
   ↓
一系列字节
   ↓
TCP/IP 封装
   ↓
网络传输

真正经过网线、光纤、Wi-Fi 等物理介质传输时,底层最终体现为电信号、光信号或者无线电波。

接收端则反过来:

text 复制代码
物理信号
   ↓
网卡
   ↓
TCP/IP 协议栈
   ↓
HTTP 响应数据
   ↓
浏览器
   ↓
ReadableStream
   ↓
reader.read()
   ↓
Uint8Array
   ↓
TextDecoder
   ↓
JavaScript 字符串

因此,在前端 JavaScript 这一层开始处理数据的时候,很多网络底层工作已经由操作系统和浏览器完成了。

我们面对的主要是:

text 复制代码
HTTP 响应字节
↓
ReadableStream
↓
Uint8Array
↓
字符串

二、fetch 返回的到底是什么

执行:

js 复制代码
const res = await fetch('/api/chat');

得到的 res 并不是后端返回的 JSON,也不是字符串。

它是一个:

js 复制代码
Response

也就是 HTTP 响应对象

里面包含:

js 复制代码
res.status;
res.headers;
res.body;

其中:

js 复制代码
res.body

通常是:

js 复制代码
ReadableStream<Uint8Array> //ReadableStream<T>:浏览器提供的可读字节流
//<Uint8Array>:表示这个流每次读出来的数据块 chunk 是 Uint8Array 类型的二进制字节数据

严格来说:

ts 复制代码
Response.body: ReadableStream | null

ReadableStream 详解

ReadableStream` 本身不是一块数据,而是一条可以不断读取数据的流。

普通接口可能会等服务器准备好完整结果:

text 复制代码
服务器:

生成完整结果
↓
返回

而流式响应可以:

text 复制代码
生成一点
↓
发送一点

生成一点
↓
发送一点

生成一点
↓
发送一点

例如 AI 返回:

text 复制代码
你
好
,
我
是
ChatGPT

服务器可以在内容生成过程中不断发送,而不是等完整答案生成完成。

浏览器把不断到达的响应体暴露成:

js 复制代码
res.body

也就是 ReadableStream

为什么 ReadableStream 可以一直保持打开

因为真正决定响应有没有结束的是服务端

例如 Node.js 服务端:

js 复制代码
app.get('/stream', (req, res) => {
  // 先发送第一块
  res.write('你好');

  setTimeout(() => {
    // 继续发送
    res.write('世界');
  }, 1000);

  setTimeout(() => {
    // 到这里才真正结束 HTTP 响应
    res.end();
  }, 2000);
});

在:

js 复制代码
res.write(...)

之后,只要服务器还没有执行:

js 复制代码
res.end()

HTTP 响应就还没有结束。

服务器暂时没数据 ≠ 服务器发送结束

所以:

text 复制代码
HTTP Response
     ↓
还没有结束
     ↓
ReadableStream 保持打开
     ↓
后面可能继续有数据

getReader() 是什么

有了一条:

js 复制代码
res.body

可读流以后,需要一个东西从里面读取数据

于是:

js 复制代码
const reader = res.body.getReader();

获取这条 ReadableStream读取器

可以理解成:

  • ReadableStream = 数据管道
  • reader = 从管道里拿数据的工具

因此:

js 复制代码
const reader = res.body.getReader();

之后,reader拥有了读取这条流的能力

reader.read() 是什么

真正从流里取数据的是:

js 复制代码
const { done, value } = await reader.read();

reader.read() 返回一个 Promise。

Promise 最终得到:

js 复制代码
{
  done: false,
  value: Uint8Array(...)
}

其中有两个非常重要的属性:

  • value → 当前读取到的数据
  • done → 流是否已经结束

例如:

js 复制代码
const { done, value } = await reader.read();

第一次:

js 复制代码
{
  done: false,
  value: Uint8Array(...)
}

第二次:

js 复制代码
{
  done: false,
  value: Uint8Array(...)
}

第三次:

js 复制代码
{
  done: false,
  value: Uint8Array(...)
}

最后:

js 复制代码
{
  done: true,
  value: undefined
}

于是通常写成:

js 复制代码
while (true) {
  // 每次读取流中的下一块数据
  const { done, value } = await reader.read();

  // 流已经关闭
  if (done) break;
}

Uint8Array 是什么

reader.read() 得到的:

js 复制代码
value

通常是:

js 复制代码
Uint8Array

例如:

js 复制代码
Uint8Array(5) [
  72,
  101,
  108,
  108,
  111
]
//`Uint8Array` 中保存的是一个个 8 位数值,至于人把它写成十进制、二进制还是十六进制,只是表示方式不同。

Uint8Array 可以理解成:JavaScript 中的字节数组

名字可以拆开:

text 复制代码
U
= Unsigned
= 无符号

Int
= Integer
= 整数

8
= 8 bit

也就是说,每一个元素都是一个:

text 复制代码
8 bit
=
1 byte

由于是无符号 8 位整数,所以范围是:

text 复制代码
0 ~ 255

因为:

text 复制代码
00000000 = 0
11111111 = 255

TextDecoder 是什么

创建:

js 复制代码
const decoder = new TextDecoder();

默认使用 UTF-8。

也可以明确写成:

js 复制代码
const decoder = new TextDecoder('utf-8');

它的作用就是:

text 复制代码
Uint8Array
↓
TextDecoder
↓
JavaScript 字符串

例如:

js 复制代码
const decoder = new TextDecoder();

const bytes = new Uint8Array([
  72,
  101,
  108,
  108,
  111
]);

const text = decoder.decode(bytes);

console.log(text);
// Hello

按照 UTF-8 编码规则,把字节转换成字符串。

为什么需要 stream: true

流式代码一般会这样:

js 复制代码
const chunk = decoder.decode(value, {
  stream: true
});

而不是简单:

js 复制代码
decoder.decode(value);

原因在于网络分块和字符边界没有必然关系

例如 UTF-8 中,一个中文字符通常占 3 个字节。

假设 "你" 的 UTF-8 字节是:

text 复制代码
11100100 10111101 10100000

网络完全可能这样拆:

text 复制代码
第一次 reader.read():

11100100 10111101

第二次:

text 复制代码
10100000

也就是说,一个字符可能被拆在两个 chunk

此时:

js 复制代码
decoder.decode(value, {
  stream: true
});

会告诉 TextDecoder:这只是整个字节流中的一部分,后面还有数据。如果最后出现一个没有完整收到的字符,不要立刻判错,先保存起来。

于是第一次收到:

text 复制代码
11100100 10111101

TextDecoder 发现还差一个字节,就先缓存。

下一次收到:

text 复制代码
10100000

再组合:

text 复制代码
11100100 10111101 10100000

最终得到:

text 复制代码

同时 : UTF-8 大致规定:

text 复制代码
0xxxxxxx
→ 单字节字符

110xxxxx
→ 两字节字符的第一个字节

1110xxxx
→ 三字节字符的第一个字节

11110xxx
→ 四字节字符的第一个字节

10xxxxxx
→ 多字节字符的后续字节

例如:

text 复制代码
11100100

以:

text 复制代码
1110

开头。

解码器因此知道这是一个三字节字符

于是后面必须继续出现两个:

text 复制代码
10xxxxxx

形式的字节。

所以:

text 复制代码
11100100
↓
需要三个字节

10111101
↓
第二个合法后续字节

10100000
↓
第三个合法后续字节

三个字节完整
↓
解码

三. reader 和 decoder 是怎么配合的

现在可以把两者放在一起理解:

js 复制代码
const { done, value } = await reader.read();

if (done) break;

const chunk = decoder.decode(value, {
  stream: true
});

职责分别是:

text 复制代码
reader
负责:
从 HTTP 响应流中拿下一块字节

decoder
负责:
把这块字节转换成字符串

也就是:

text 复制代码
ReadableStream
      ↓
reader.read()
      ↓
Uint8Array
      ↓
decoder.decode()
      ↓
字符串 chunk

然后进入下一轮:

text 复制代码
reader.read()
      ↓
下一块 Uint8Array
      ↓
decoder.decode()
      ↓
下一段字符串

一直重复。

补充说明 : res.json() 到底做了什么

平时我们经常写:

js 复制代码
const res = await fetch('/api/user');

const data = await res.json();

实际上可以粗略理解成浏览器内部帮我们做了

js 复制代码
// 伪代码

const reader = res.body.getReader();

const chunks = [];

while (true) {
  const { done, value } = await reader.read();

  if (done) break;

  chunks.push(value);
}

// 合并所有字节
// ↓
// 解码成完整字符串
// ↓
// JSON.parse()
// ↓
// 返回 JavaScript 对象

所以:

js 复制代码
res.json()

可以理解为浏览器帮你把整个响应流消费完,再解析成 JSON。

类似还有:

js 复制代码
res.text();
res.blob();
res.arrayBuffer();

它们本质上也都是在消费 Response.body,只是最终转换形式不同。

因此:

text 复制代码
res.body.getReader()
↓
自己逐块消费响应
--------------------------------------------------------------
res.json()
↓
浏览器帮你消费完整响应,再解析 JSON

四 . HTTP 本身支持流式传输

HTTP 响应体本身就可以持续发送数据。

关键在于服务端不要一次性结束响应,而是不断写入

js 复制代码
res.write('第一块');
res.write('第二块');
res.write('第三块');

res.end();

于是前端就可以不断:

js 复制代码
reader.read();

五. 既然 HTTP 已经支持流式,为什么还需要 SSE

HTTP 流解决的是:数据可以不断地传。 但是 HTTP 并没有告诉你

  1. 哪几个字节是一条业务消息?
  2. 消息在哪里结束?
  3. 这条消息是什么类型?
  4. 断线以后如何知道上次收到哪里?
  5. 多久重连?

例如服务器直接持续发送:

text 复制代码
你好世界完成

HTTP 可以负责把这些字节持续传到客户端。

但业务层不知道:

text 复制代码
"你好"是一条消息?

"世界"是一条消息?

还是"你好世界"是一条消息?

于是开发者需要再设计一套:流里的消息协议。

你完全可以自己规定:

text 复制代码
你好|世界|完成

规定:

text 复制代码
|
=
消息分隔符

也可以规定:

text 复制代码
{"text":"你好"}
{"text":"世界"}
{"done":true}

这实际上就是自定义流协议

而 SSE 做的就是:帮你提前定义好一套标准格式

SSE 到底是什么

SSE 全称:Server-Sent Events. 它不是新的网络传输协议,而是建立在 HTTP 流式响应之上 的一种事件流格式规范

可以理解成:HTTP Streaming负责:不断运输数据 ; SSE负责 : 规定这些数据怎么包装成一条条事件

一个完整的 SSE 事件可能是:

text 复制代码
event: message
id: 123
retry: 3000
data: {"text":"你好"}

注意最后存在一个空行。

SSE 常见字段有:

text 复制代码
data:
→ 事件的数据

event:
→ 事件类型

id:
→ 事件 ID

retry:
→ 断线后的重连时间

空行
→ 当前事件结束

最常见的 SSE:

text 复制代码
data: 你好

data: 世界

这里就是两条事件。

SSE 规定:一个空行表示当前事件结束

例如:

text 复制代码
data: 你好

data: 世界

实际上是:

text 复制代码
data: 你好\n\n
data: 世界\n\n

所以前端简化代码里经常看到:

js 复制代码
chunk.split('\n\n');

event、id、retry 又是什么

event

可以指定事件类型:

text 复制代码
event: token
data: 你好

event: finish
data: done

原生 EventSource 可以:

js 复制代码
source.addEventListener('token', event => {
  console.log(event.data);
});

source.addEventListener('finish', event => {
  console.log(event.data);
});

id

例如:

text 复制代码
id: 100
data: 第一条

id: 101
data: 第二条

主要可以辅助断线重连

retry

例如:

text 复制代码
retry: 5000
data: hello

表示客户端重新连接时可以采用指定的重试间隔。

六. Content-Type: text/event-stream 有什么作用

SSE 服务端通常设置:

http 复制代码
Content-Type: text/event-stream

它的作用只是声明:当前响应体采用 SSE 的事件流格式。

这和:

http 复制代码
Content-Type: application/json

一个道理。

application/json 表示:我接下来返回的是 JSON。

但服务端仍然必须真的发送:

json 复制代码
{"name":"Tom"}

不会因为设置了:

http 复制代码
Content-Type: application/json

服务器就自动把任意字符串转换成 JSON。

同样:

http 复制代码
Content-Type: text/event-stream

只是说:我接下来返回的是 SSE。

服务端仍然需要自己发送:

js 复制代码
res.write('data: 你好\n\n');

而不是:

js 复制代码
res.write('你好');

然后指望浏览器自动帮你补:

text 复制代码
data:

所以一句话:text/event-stream身份声明,不是格式转换器。

七. EventSource 又是什么

如果是标准 SSE,并且业务适合 GET 请求,可以直接:

js 复制代码
const source = new EventSource('/api/stream');

source.onmessage = event => {
  console.log(event.data);
};

浏览器会帮你处理很多 SSE 细节。

于是你不需要自己显式写:

js 复制代码
getReader();
TextDecoder();

因为浏览器已经在底层帮你完成。

但是 AI 对话经常需要:

http 复制代码
POST /api/chat

同时发送:

json 复制代码
{
  "messages": [...]
}

而原生 EventSource 的使用方式主要围绕 GET 建立连接。

因此 AI 聊天场景经常采用:fetch POST+ReadableStream+手动解析 SSE

这就是文章开头代码的来源。

示例还有一个小问题

js 复制代码
const chunk = decoder.decode(value, {
  stream: true
});

chunk.split('\n\n').forEach(...);

但这里存在一个问题:reader.read() 返回的 chunk 和 SSE 事件边界没有关系。

例如服务器发送:

text 复制代码
data: hello\n\n

网络完全可能拆成:

text 复制代码
第一次:

data: hel

第二次:

text 复制代码
lo\n\n

所以不能认为:

text 复制代码
一次 reader.read()
=
一条完整 SSE 消息

正确的思想应该是维护一个 buffer

js 复制代码
let buffer = '';

while (true) {
  const { done, value } = await reader.read();

  if (done) break;

  // 把新字符串追加到上次没有处理完的数据后面
  buffer += decoder.decode(value, {
    stream: true
  });

  // 按 SSE 事件边界拆分
  const blocks = buffer.split('\n\n');

  // 最后一块可能还没有接收完整
  buffer = blocks.pop() ?? '';

  for (const block of blocks) {
    // 这里只处理已经完整收到的事件
    console.log(block);
  }
}

这里实际上有两个完全不同的残缺问题。

  1. 字节层残缺: 一个 UTF-8 字符被拆在两个网络 chunk 中
js 复制代码
TextDecoder(..., {
  stream: true
});
  1. 协议层残缺: 一个SSE事件被拆在两个网络chunk中
text 复制代码
buffer
相关推荐
程序猿DD1 小时前
OctaFuse Gateway 2.6.0:完善 Vertex AI 鉴权、图片计费与日常调试
llm·agent
szp20051 小时前
为了在浏览器里跑多线程 ONNX 推理,我把自己的支付浮层弄挂了
前端·webassembly
kisshyshy2 小时前
从 useRef 到 Web Worker:理解 React 可变对象与浏览器多线程
前端·javascript·react.js
fatcoder2 小时前
玩转Nginx 04 — 反向代理:给 nginx 接上后端
前端·后端·nginx
阿里云大数据AI技术2 小时前
阿里云PAI发布通用蒸馏框架EasyDistill2.0,提供主流大模型蒸馏场景最佳实践
人工智能·agent
码匠许师傅2 小时前
【C++ 面试真题】19. 聊聊 C++ 的迭代器
开发语言·c++·面试
Data_Journal2 小时前
什么是 CAPTCHA,它是如何工作的?
java·大数据·服务器·前端·数据库
计算机魔术师3 小时前
我看了这个更新,把原来的检索方案推翻了
前端
zhanghaha13143 小时前
HTML系列教程:3_HTML 基础标签 — 标题、段落、超链接、图像
前端·html
黄敬峰3 小时前
TypeScript 工具类型一次讲透:Pick、Omit、Partial、Record 逐个击破
面试