AI大模型中fetch 和 ReadableStream为啥一起出现

75 阅读7分钟

下面从 fetch 和 ReadableStream 分别讲起,再说明它们为什么经常一起出现。

一、fetch 是什么?

fetch 是浏览器内置的全局方法,用来发起 HTTP 请求。它基于 Promise,是现代前端替代 XMLHttpRequest 的标准网络请求接口。

基本语法
fetch(url, options)
  .then(response => {
    // response 是 Response 对象
  })
  .catch(error => {
    // 网络错误
  });
async/await 写法
async function getData() {
  const res = await fetch('/api/user');

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }

  const data = await res.json();
  console.log(data);
}

二、fetch 的返回值不是“最终数据”,而是 Response 对象

很多人第一次用 fetch 时,会误以为:

const res = await fetch('/api/data');

这一步已经拿到了接口返回的数据。

其实不是。fetch()返回的是一个Promise<Response>,也就是说,它先给你一个 响应对象,而不是直接给你 JSON、文本或文件内容。

这个 Response 对象里包含两类信息:

类型常见内容说明
响应元信息res.status、res.ok、res.headers状态码、是否成功、响应头
响应体res.body、res.json()、res.text()真正的业务数据

所以常见写法是:

const res = await fetch('/api/data');
const data = await res.json();

这里有两步:

  1. fetch():拿到响应对象
  2. res.json():把响应体解析成 JSON

三、Response 响应体有多种读取方式

Response 的响应体可以用不同方法读取,但注意:响应体通常只能读取一次。

await res.json();         // 解析成 JSON

await res.text();         // 读取成纯文本

await res.blob();         // 读取成二进制文件对象

await res.arrayBuffer();  // 读取成原始二进制缓冲

await res.formData();     // 读取成表单数据

这些方法本质上都是把响应体一次性消费掉。

比如:

const data = await res.json();

它适合普通接口:服务端把完整 JSON 返回,前端等完整响应到达后,再一次性解析。

但这不适合“边生成边返回”的场景,比如大模型流式输出。

四、普通 fetch 用法的问题:必须等完整响应

普通用法里,你调用:

const data = await res.json();

浏览器会等整个响应体接收完,再把它解析成 JSON。对于普通接口没问题。

但如果服务端是流式返回,比如大模型一边生成一边推送:

data: {"content":"你"}
data: {"content":"好"}
data: {"content":","}
data: {"content":"我是"}
...

你如果还用 res.json(),就不合适,因为:

  • 它不是一整段合法 JSON;
  • 数据是分批到达的;
  • 你需要“来一块,处理一块”,而不是等全部结束。

这时候就要用到 res.body。

五、res.body 是什么?

res.body 就是 Response的响应体流。它的类型通常是:ReadableStream

可以简单理解成:res.body 是一根从服务端通向浏览器的“数据水管”。服务端产生的数据会一块一块地流过来,前端可以一块一块地读取,而不需要等全部数据到达。

所以:

const res = await fetch('/api/stream');

console.log(res.body);
// ReadableStream

这里 res.body 不是一个完整字符串,也不是一个 JSON 对象,而是一个可以持续读取的数据流。

六、ReadableStream 是什么?

ReadableStream 是浏览器 Streams API 的一部分,表示一个**可读的数据流**。

它的作用是:让 JavaScript 可以按块读取数据,而不是必须等全部数据准备好。

它最典型的数据单位是:Uint8Array,也就是二进制字节块。

你可以把它理解成:

服务端数据
   ↓
分成很多小块 chunk
   ↓
前端每次 read() 拿到一块
   ↓
前端自己决定怎么解码、拼接、解析、渲染

所以 ReadableStream 的核心价值是:

  • 支持边接收边处理;
  • 降低首字节等待时间;
  • 避免一次性把大量数据塞进内存;
  • 可以中途取消;
  • 可以和其他流处理机制组合。

七、ReadableStream 的核心 API

ReadableStream 最常用的是这几个方法:

方法作用
getReader()创建一个 reader,开始读取流
reader.read()每次读取一个数据块
cancel()取消流
pipeThrough()把流经过一个转换流处理
pipeTo()把流写入一个 WritableStream
tee()把一个流拆成两个相同的分支

其中最关键的是:

response.body.getReader()

它的作用是:从响应体流上安装一个“水龙头”,然后你通过这个水龙头一块一块地取水。

八、 reader.read() 是怎么工作的?

reader.read() 返回一个 Promise,结果是:{ value, done }

含义如下:

字段含义
value当前读到的数据块,通常是 Uint8Array
done流是否已经结束

示例:

const reader = response.body.getReader();

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

  if (done) {
    break;
  }

  console.log(value); // Uint8Array
}

这里有一个很关键的点:

await reader.read()

它不是死循环一直占用 CPU,而是:

  • 有数据块到达时,Promise 会 resolve;
  • 没有数据时,它会暂停等待;
  • 数据结束后,done 会变成 true。

所以它是由“数据到达”驱动的,而不是由 CPU 一直轮询驱动的。

九、为什么流式场景里 fetch 和 ReadableStream 经常一起出现?

因为大模型流式接口通常是这样的链路:

fetch 发起 POST 请求
   ↓
服务端返回 text/event-stream 或分块响应
   ↓
response.body 是一个 ReadableStream
   ↓
getReader() 获取 reader
   ↓
reader.read() 逐块读取 Uint8Array
   ↓
TextDecoder 把二进制转成文本
   ↓
前端解析 SSE 事件或 JSON 增量
   ↓
页面逐步渲染

所以它们分工很明确:

API职责
fetch()负责发起 HTTP 请求,拿到 Response
response.body提供响应体的可读流
ReadableStream负责让前端按块读取数据
reader.read()每次取出一个数据块
TextDecoder把二进制字节转成文本

也就是说:

fetch 负责“请求”,ReadableStream 负责“流式读取响应体”。

它们不是同一个东西。

十、 一个最小示例

const response = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    message: '你好'
  })
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

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

  if (done) {
    break;
  }

  const text = decoder.decode(value, { stream: true });
  console.log(text);
}

这段代码的意思是:

  1. 用 fetch 发起请求;
  2. 从 response.body 拿到可读流;
  3. 用 getReader() 创建读取器;
  4. 循环调用 reader.read() 逐块读取;
  5. 用 TextDecoder 把二进制块转成文本。

这就是流式读取的基本模型。

有一个疑问:这段代码是一个循环,条件为true,接口不断的推送数据一块一块的,但如果第一块的数据推送过来了,代码一轮循环执行完毕,然后第二块的代码还没推送到,这轮循环会再把第一块的数据再读取一遍吗,很疑惑,因为我之前开发的循环有具体数字的加减来标记,知道什么时候结束循环,但这个无法通过代码看出来。

回答:

不会重复读取。 这个循环不会“倒回去”重读,关键在于reader.read() 是一个有状态的、带内部指针的读取器,它内部维护了一个“读到哪了”的游标,每次调用只会返回下一块新数据。

核心机制:内部游标与“消费即丢弃”

ReadableStream 内部有一个队列(内部队列),数据从网络到达后,会按顺序排进这个队列里。

reader.read() 每次调用时,做的事情是:

  1. 从队列头部取出下一块数据(不是从头取)
  2. 把这块数据返回给你
  3. 内部指针自动往后移一位

数据一旦被read()取走,就从队列里移除了,不会再出现第二次。 这就像排队买票——你买完票往前走,后面的人只能买下一张,不可能再买到你已经买走的那张。

第二块数据还没到时会发生什么?

这是你最关心的点。当第一块处理完,循环进入第二轮,调用 await reader.read() 时:

  1. 如果第二块数据已经到了(已经在内部队列里)→ 立刻返回,不等待
  2. 如果第二块数据还没到 → await reader.read() 这个 Promise 会挂起等待,代码暂停在这一行,不会往下执行,也不会回到循环顶部重新读

它不会空转,不会重复读,就是安静地等着。 等数据到了,Promise 自动 resolve,代码继续往下走。

十一、和普通 res.json() 的区别

普通接口:

const res = await fetch('/api/user');
const data = await res.json();

特点:

  • 等完整响应;
  • 一次性解析;
  • 适合普通 CRUD 接口。

流式接口:

const res = await fetch('/api/stream');
const reader = res.body.getReader();

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

  // 处理 value
}

特点:

  • 不等完整响应;
  • 来一块处理一块;
  • 适合大模型流式输出、大文件下载、实时日志、边下边处理 等场景。

十二、一句话总结

fetch是用来发起 HTTP 请求并拿到 Response的;ReadableStream 是用来把响应体当作数据流,按块持续读取的。

普通接口里,你通常只关心:

await res.json()

但在流式场景里,你不再一次性消费响应体,而是通过:

res.body.getReader()

逐块读取数据。

所以大模型流式返回常用 fetch + ReadableStream,不是因为fetch 本身能流式解析 JSON,而是因为 fetch的response.body 暴露了一个可读流,让前端可以边接收、边解码、边解析、边渲染。