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 并没有告诉你
- 哪几个字节是一条业务消息?
- 消息在哪里结束?
- 这条消息是什么类型?
- 断线以后如何知道上次收到哪里?
- 多久重连?
例如服务器直接持续发送:
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);
}
}
这里实际上有两个完全不同的残缺问题。
- 字节层残缺: 一个 UTF-8 字符被拆在两个网络 chunk 中
js
TextDecoder(..., {
stream: true
});
- 协议层残缺: 一个SSE事件被拆在两个网络chunk中
text
buffer