🔥 前端也能玩转 AI 流式输出!从二进制流到打字机效果,一篇讲透

🔥 前端也能玩转 AI 流式输出!从二进制流到打字机效果,一篇讲透

摘要:ChatGPT 那种"一个字一个字蹦出来"的效果到底是怎么实现的?本文用一个 Vue 3 小项目,从最底层的二进制流开始,一步步带你搞懂 AI 流式输出的全部原理。初学者友好,全程大白话 + 生活比喻,看完你也能自己写一个!
TL;DRfetch 请求 AI 接口(stream: true)→ response.body.getReader() 拿到读取器 → while 循环 reader.read() 逐块读二进制 → TextDecoder({ stream: true }) 解码 → 解析 SSE 格式提取 token → content.value += token 逐字追加 → Vue 响应式自动更新页面。核心就一个 while 循环。


📌 前言

最近在学 AI 应用开发,发现不管是 ChatGPT、Claude 还是各种 AI 聊天框,它们都有一个共同特点:回答不是一次性全给你的,而是一个字一个字"蹦"出来的

这个效果叫流式输出(Streaming Output)

一开始觉得很高深,后来自己动手写了一个 demo 才发现------原理其实不复杂,就像拧开水龙头接水一样简单。

今天就用这个 demo,从最基础的二进制流开始,一步步讲清楚:

  1. 什么是二进制流?(水是怎么流动的)
  2. 什么是 ReadableStream?(水管是什么)
  3. 什么是 SSE 协议?(水里加了什么调料)
  4. 怎么实现打字机效果?(怎么把水接到杯子里)

🎯 本文适合谁

  • ✅ 了解 Vue 3 基础(ref、响应式)的前端初学者
  • ✅ 想接入 AI API 但不知道流式输出怎么处理的同学
  • ✅ 听过 Stream、SSE 但不知道是啥的同学
  • ❌ 已经很熟悉 ReadableStream 的大佬(可以跳过前两节)

📚 第一站:二进制流------水是怎么流动的

在讲流式输出之前,我们先搞懂一个最基础的概念:二进制流

为什么数据是"流"?

你在浏览器里看到的文字、图片、视频,在底层都是0和1组成的二进制数据 。这些数据不是一次性全部到达的,而是像水流一样,一小块一小块地流过来

🌊 生活比喻:想象你在用吸管喝奶茶。奶茶(数据)不是一口全到你嘴里的,而是一小口一小口通过吸管(网络)流过来的。

TextEncoder 和 TextDecoder

浏览器提供了两个很实用的 API,帮我们在字符串二进制数据之间转换:

javascript 复制代码
// 🔤 编码器:把字符串 → 二进制数组(Uint8Array)
const encoder = new TextEncoder();
const bytes = encoder.encode('你好');
console.log(bytes);
// Uint8Array [228, 189, 160, 229, 165, 189]
// 每个数字都在 0-255 之间
// 这些数字是二进制字节的"十进制表示"------本质还是二进制,只是用十进制数字展示方便人看
// 比如 228 = 11100100(二进制),它就是"你"这个字的第一个字节

// 🔓 解码器:把二进制数组 → 字符串
const decoder = new TextDecoder('utf-8');
const str = decoder.decode(bytes);
console.log(str); // "你好"

Uint8Array 是什么?

Uint8Array 是 JavaScript 里的一种类型化数组 ,专门用来存放 0~255 之间的整数

🧊 生活比喻 :普通数组就像一个万能收纳盒,什么都能放(数字、字符串、对象...)。而 Uint8Array 是一个专门放鸡蛋的蛋托,每个格子只能放 0~255 大小的"蛋"。

为什么是 0~255?因为 8 位二进制(1 字节)能表示的最大数就是 11111111 = 255。这就是"Uint8Array"名字的由来。

为什么要了解这个?

因为后面我们从 AI 服务器接收到的数据,就是这种 Uint8Array 二进制流。如果不认识它,后面的内容就看不懂了。


📚 第二站:ReadableStream------认识这根"水管"

什么是 ReadableStream?

ReadableStream 是浏览器原生提供的流式读取 API ,它代表一个可读的数据流

🚰 生活比喻:ReadableStream 就是一根水管。水管的一头连着服务器(水厂),另一头连着你的浏览器(水龙头)。数据(水)从服务器流过来,你可以在浏览器这边一点一点地接。

怎么用?

当你用 fetch 请求一个返回流式数据的接口时,response.body 就是一个 ReadableStream 对象:

javascript 复制代码
const response = await fetch(url, options);

// response.body 就是那根"水管"
console.log(response.body); // ReadableStream { locked: false, ... }

// 拿到一个"读取器"------就像在水管口装了一个水龙头
const reader = response.body.getReader();

reader.read()------拧开水龙头接水

reader.read() 每调用一次,就从水管里**"嘬"一口水**,返回一个 Promise:

javascript 复制代码
// 每次 read() 返回 { done: boolean, value: Uint8Array }
const { done, value } = await reader.read();

// done = false  → 还有水,继续接
// done = true   → 水接完了,水管空了
// value = 本次接到的水(二进制数据)

💡 关键理解await reader.read() 会"等着"------如果服务器还没发数据过来,它就暂停在这里 ,直到有新数据到达才 resolve。这就是"流"的精髓:有数据就处理,没数据就等着


📚 第三站:SSE 协议------水里加了什么"调料"

什么是 SSE?

SSE(Server-Sent Events)是一种服务器向浏览器推送数据的协议。AI 接口的流式输出,用的就是这种协议。

🍵 生活比喻:SSE 就像泡茶。服务器把茶叶(数据)放在滤网里,热水(网络连接)流过茶叶,每一口水都带着茶味流到你的杯子里。而且每一口的浓度可能不一样------这就是为什么 AI 的回答是一个词一个词出来的。

SSE 的数据格式长什么样?

服务器发过来的数据不是纯文本,而是有固定格式的:

css 复制代码
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"好"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"呀"}}]}

data: [DONE]

每一条数据的规则:

  1. 每条消息以 data: 开头(注意 data: 后面有个空格)
  2. 每条消息之间用换行符 \n 分隔
  3. 最后一条是 data: [DONE],表示流结束了

🔑 为什么要了解这个? 因为后面解析数据的时候,我们需要按换行符分割 ,然后过滤出以 data: 开头的行,再解析里面的 JSON。


📚 第四站:Vue 3 实战------逐行拆解执行流程

理论讲完了,现在开始写代码!这一站是全文最重要的部分,我会把每一行代码的执行过程、每一轮循环内存里发生了什么,全部拆给你看。

项目初始化

bash 复制代码
npm create vite@latest stream-demo -- --template vue
cd stream-demo
npm install

💡 这个项目没有引入任何额外依赖,完全用原生的 fetch + ReadableStream 实现流式输出。


一、响应式数据准备

vue 复制代码
<script setup>
import { ref } from 'vue'

const question = ref('讲一个中国龙的故事')  // 用户输入框
const content = ref('')                       // AI 回答显示区
const stream = ref(true)                      // 流式/普通模式开关
</script>

三个 ref 就是全部的状态。重点理解 content :它是页面显示和流式数据的桥梁。后面所有 token 都会往它身上追加,它一变,页面就自动更新------这就是 Vue 响应式的威力。


二、调用 AI 接口

javascript 复制代码
const update = async () => {
  if (!question.value) return

  content.value = '思考中...'  // 先给用户一个反馈

  const endpoint = `${import.meta.env.VITE_MIMO_API_BASE_URL}/chat/completions`
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_MIMO_API_KEY}`,
    'Content-Type': 'application/json'
  }

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: import.meta.env.VITE_MIMO_MODEL,
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value  // 👈 关键参数!
    })
  })

🔑 stream: true 这个参数告诉 AI 服务器:"我要流式返回,你生成一个 token 就发一个给我,别等全部生成完。"


三、流式模式:逐轮拆解执行流程

这是整个 demo 的灵魂。我不打算给你贴一段代码然后"读者自明"------我要带你模拟执行每一轮循环,让你看到内存里数据是怎么变化的。

先看完整代码,后面逐行拆
javascript 复制代码
if (stream.value) {
  content.value = ''

  // ===== 阶段 A:准备工具 =====
  const reader = response.body.getReader()
  const decoder = new TextDecoder('utf-8')  // 指定 UTF-8 编码

  let done = false
  let buffer = ''

  // ===== 阶段 B:循环读取 =====
  while (!done) {
    const { done: doneReading, value } = await reader.read()
    done = doneReading

    const chunkValue = buffer + decoder.decode(value, { stream: true })
    buffer = ''

    const lines = chunkValue
      .split('\n')
      .filter(line => line.startsWith('data: '))

    // ===== 阶段 C:逐行解析 =====
    for (const line of lines) {
      const data = line.replace('data: ', '')

      if (data === '[DONE]') {
        done = true
        break
      }

      try {
        const parsed = JSON.parse(data)
        const token = parsed.choices[0].delta.content
        if (token) {
          content.value += token
        }
      } catch (e) {
        buffer = line
      }
    }
  }
}

阶段 A:准备工具(只执行一次)
javascript 复制代码
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let done = false
let buffer = ''

逐行解释:

代码 做了什么 生活比喻
response.body 拿到服务器返回的 ReadableStream 对象 拿到一根连着水厂的水管
.getReader() 从水管上"安装"一个读取器(Reader) 在水管口装一个水龙头
new TextDecoder('utf-8') 创建 UTF-8 编码的翻译器 准备一个翻译官
done = false 开关变量,控制 while 循环 水流是否结束的信号灯
buffer = '' 缓冲区,暂存不完整的数据 接水的临时容器

⚠️ getReader() 调用后,这个 ReadableStream 就被"锁定"了 。你不能再对同一个 Stream 调用第二次 getReader(),就像一根水管只能装一个水龙头。这是浏览器的安全机制。

此刻内存状态:

ini 复制代码
reader  = ReadableStreamReader(已锁定流,等待数据)
decoder = TextDecoder(UTF-8,待命)
done    = false
buffer  = ""
content = "思考中..."(页面显示"思考中...")

阶段 B:while 循环------逐轮模拟执行

这是最关键的部分。我假设 AI 服务器陆续发来了 3 个数据包(chunk),我们来看每一轮循环发生了什么。


🔵 第 1 轮循环:while (!done)!false = true,进入!

Step 1:等待数据到达

javascript 复制代码
const { done: doneReading, value } = await reader.read()

reader.read() 返回一个 Promise。此时服务器还没发数据,代码就"暂停"在这里(不是阻塞,是让出 JS 主线程,等网络数据到了再继续)。

过了大约 200ms,服务器发来了第一批数据。await 拿到结果:

ini 复制代码
doneReading = false(还有更多数据)
value = Uint8Array [100, 97, 116, 97, 58, 32, 123, ...]
        (这就是 "data: {" 的二进制形式)

Step 2:解码二进制 → 字符串

javascript 复制代码
const chunkValue = buffer + decoder.decode(value, { stream: true })
// buffer 是 ""(空的),所以 chunkValue = decoder.decode(value, { stream: true })

⚠️ { stream: true } 是必须的! 它告诉翻译官:"这是一段流,后面还有数据,如果最后一个字符的字节被截断了,先别翻译它,留着等下一轮拼起来再翻译。"如果不传这个参数,中文等多字节字符在 chunk 边界被截断时会产生乱码( 替换字符)。

decoder.decode(value, { stream: true }) 把二进制翻译成字符串:

css 复制代码
chunkValue = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"从"}}]}\n
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"前"}}]}\n'

💡 这个 chunk 里包含了两个 SSE 消息!服务器可能一次发了多个 token,因为 LLM 的计算速度很快,多个 token 打包成一个 chunk 发送,效率更高。

Step 3:按换行符分割 + 过滤

javascript 复制代码
const lines = chunkValue
  .split('\n')
  .filter(line => line.startsWith('data: '))

split('\n') 把字符串按换行符切成数组:

rust 复制代码
split 后: [
  'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"从"}}]}',
  'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"前"}}]}',
  ''   ← 最后一个换行符产生了一个空字符串
]

filter(line => line.startsWith('data: ')) 过滤掉空行和非数据行:

rust 复制代码
lines = [
  'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"从"}}]}',
  'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"前"}}]}'
]

Step 4:进入 for 循环,逐行解析

第 1 行:

javascript 复制代码
const data = line.replace('data: ', '')
// data = '{"id":"chatcmpl-xxx","choices":[{"delta":{"content":"从"}}]}'

if (data === '[DONE]') // 不是 [DONE],跳过

const parsed = JSON.parse(data)
// parsed = { id: "chatcmpl-xxx", choices: [{ delta: { content: "从" } }] }

const token = parsed.choices[0].delta.content
// token = "从"

if (token) {  // 👈 这个判断很重要!
  content.value += token
  // content: "" + "从" = "从"
  // ⚡ Vue 响应式触发!页面上 {{ content }} 立刻更新为 "从"
}

💡 为什么要 if (token) 判断? 因为 AI 接口有时候会发送不含 content 的消息(比如 finish_reason: "stop" 的结束信号),此时 parsed.choices[0].delta.contentundefined。不加判断就会拼接一个 "undefined" 字符串到页面上------翻车现场。

第 2 行:

javascript 复制代码
// 同样的流程
token = "前"
content.value += "前"
// content: "从" + "前" = "从前"
// ⚡ 页面更新为 "从前"

第 1 轮结束时内存状态:

ini 复制代码
done    = false(流还没结束)
buffer  = ""(没有残余数据)
content = "从前"(页面显示"从前")

🟢 第 2 轮循环:while (!done)!false = true,继续!

Step 1:再次等待数据

javascript 复制代码
const { done: doneReading, value } = await reader.read()

又暂停了,等服务器发下一个 chunk...

过了 300ms,数据到了。但这次来了一个特殊情况 ------服务器发的数据被网络截断了!一个 JSON 对象只来了一半:

ini 复制代码
doneReading = false
value = Uint8Array [100, 97, 116, 97, 58, 32, 123, 34, 105, 100, ...]

解码后:

css 复制代码
chunkValue = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"在"}}]}\n
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"'    ← 被截断了!

Step 2-3:解码 + 分割 + 过滤

rust 复制代码
lines = [
  'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"在"}}]}',
  'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"'
]

Step 4:逐行解析

第 1 行: 正常解析,token = "在"

javascript 复制代码
content.value += "在"
// content = "从前" + "在" = "从前在"
// ⚡ 页面更新为 "从前在"

第 2 行: JSON 被截断,JSON.parse() 报错!

javascript 复制代码
try {
  const parsed = JSON.parse('data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"')
  // ❌ SyntaxError: Unexpected end of JSON input
} catch (e) {
  // 进入 catch 分支
  buffer = line  // 把这行不完整的数据存进 buffer!
}

🎯 buffer 的精髓就在这里! 不完整的数据不丢弃,存进 buffer,下一轮和新到的数据拼在一起处理。

第 2 轮结束时内存状态:

ini 复制代码
done    = false
buffer  = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"'  ← 不完整的数据!
content = "从前在"

🟡 第 3 轮循环:buffer 拼接------残余数据的处理

Step 1:等待数据

服务器发来了被截断的那部分数据的剩余部分

ini 复制代码
doneReading = false
value = Uint8Array [...]

解码后:

arduino 复制代码
decoder.decode(value) = '山"}]}\n'

Step 2:buffer 拼接------关键一步!

javascript 复制代码
const chunkValue = buffer + decoder.decode(value, { stream: true })
// buffer  = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"'
// 新数据  = '山"}]}\n'
// 拼接后  = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"山"}}]}\n'

💡 看到了吗!上一轮被截断的 JSON,和这一轮新到的数据拼在一起,恢复成了一个完整的 JSON!

Step 3-4:正常解析

makefile 复制代码
lines = ['data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"山"}}]}']
javascript 复制代码
token = "山"
content.value += "山"
// content = "从前在" + "山" = "从前在山"
// ⚡ 页面更新为 "从前在山"

第 3 轮结束时内存状态:

ini 复制代码
done    = false
buffer  = ""(清空了,因为数据都处理完了)
content = "从前在山"

🔴 第 4 轮循环:收到 data: [DONE],流结束!

Step 1:等待数据

ini 复制代码
doneReading = true  ← 服务器说:我发完了!
value = Uint8Array [100, 97, 116, 97, 58, 32, 91, 68, 79, 78, 69, 93]

解码后:

arduino 复制代码
decoder.decode(value) = 'data: [DONE]\n'

Step 2-3:解码 + 分割

ini 复制代码
lines = ['data: [DONE]']

Step 4:解析

javascript 复制代码
const data = line.replace('data: ', '')
// data = '[DONE]'

if (data === '[DONE]') {
  done = true  // ✅ 标记流结束
  break        // 跳出 for 循环
}

回到 while 判断: while (!done)!true = false退出循环!

最终内存状态:

ini 复制代码
done    = true
buffer  = ""
content = "从前在山"(页面最终显示"从前在山")

📊 完整执行流程图

把上面 4 轮循环画成一张图:

javascript 复制代码
时间线 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→

服务器:  [chunk1: "从前"]  [chunk2: "在" + 截断]  [剩余: "山"]  [DONE]
             ↓                  ↓                    ↓           ↓
浏览器:    第1轮               第2轮                第3轮        第4轮

第1轮:  reader.read() → Uint8Array
        decoder.decode() → 'data: {..."从"}...\ndata: {..."前"}...\n'
        split + filter → ["data:{...}", "data:{...}"]
        JSON.parse → "从" → content = "从前" ✅
        JSON.parse → "前" → content = "从前" ✅

第2轮:  reader.read() → Uint8Array
        decoder.decode() → 'data: {..."在"}...\ndata: {"...(截断)'
        split + filter → ["data:{...}", "data:{"]
        JSON.parse → "在" → content = "从前在" ✅
        JSON.parse → 报错!→ buffer = 'data:{"...(截断)' ⚠️

第3轮:  reader.read() → Uint8Array
        decoder.decode() → '山"}]}\n'
        buffer + 新数据 → 'data: {..."山"}...}\n'  ← 拼接修复!
        JSON.parse → "山" → content = "从前在山" ✅

第4轮:  reader.read() → done = true
        decoder.decode() → 'data: [DONE]\n'
        data === '[DONE]' → done = true → 退出循环 🏁

页面最终显示: "从前在山"

💡 关于 { stream: true } 的补充

你可能注意到上面所有 decoder.decode(value, { stream: true }) 都传了 { stream: true }。这不是可选的------在流式场景下它是必须的

原因:中文字符在 UTF-8 中占 3 个字节。如果 chunk 恰好在中文字符的中间被截断(比如只来了 2 个字节),decoder.decode() 默认会把这 2 个字节当成"无法识别的字符",替换成 (U+FFFD 替换字符)。传入 { stream: true } 后,翻译官会说:"这个字符还没完,先留着,等下一轮新数据来了再一起翻译。"

javascript 复制代码
// ❌ 不传 stream: true → 中文截断时产生乱码 "�"
decoder.decode(value)

// ✅ 传了 stream: true → 截断的字节会保留到下一轮
decoder.decode(value, { stream: true })
🔄 关于 [DONE]done 的关系

第 4 轮循环中,你可能注意到 done 被设为 true 有两个来源:

  1. reader.read() 返回的 doneReading = true(网络层说:流关闭了)
  2. 解析到 data: [DONE](业务层说:AI 生成完了)

在实际场景中,这两个信号不一定同时到达

  • 可能 doneReading = false 但数据里包含 [DONE]------大部分情况是这样
  • 可能 doneReading = true 且数据里包含 [DONE]------最后一包恰好是结束标记
  • 可能 doneReading = true 但没有 [DONE]------网络异常断开

当前代码用 while (!done) 同时监听两个信号,无论哪种情况都能正确退出

⚠️ buffer 的一个边界场景

当前的 buffer 机制有一个极端情况 没覆盖到:如果整个 chunk 恰好是某个 JSON 消息的前半部分(没有换行符 \n),split('\n') 不会切出它,filter 也会把它过滤掉------数据就丢了

当前 demo 中这种情况概率极低(AI 接口一般会在每条消息末尾加 \n),但如果你要做生产级的流式解析,可以这样改进:

javascript 复制代码
// 在 while 循环末尾,检查是否有未被 filter 捕获的残余数据
const lines = chunkValue
  .split('\n')
  .filter(line => line.startsWith('data: '))

// 如果 chunkValue 不以 '\n' 结尾,最后一段可能是不完整的消息
if (!chunkValue.endsWith('\n')) {
  // 从 chunkValue 中找到最后一个 'data: ' 的位置
  const lastDataIndex = chunkValue.lastIndexOf('data: ')
  if (lastDataIndex !== -1) {
    buffer = chunkValue.slice(lastDataIndex)  // 存进 buffer
  }
}

初学阶段不需要掌握这个,知道有这么个边界就行。等你真正做生产项目时再处理。


5. 页面模板
vue 复制代码
<template>
  <div class="container">
    <!-- 输入区域 -->
    <div>
      <label>输入:</label>
      <input class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>

    <!-- 输出区域 -->
    <div class="output">
      <!-- 流式/普通模式切换 -->
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
      </div>
      <!-- AI 回答显示 -->
      <div>{{ content }}</div>
    </div>
  </div>
</template>

四、完整数据流图解

把整个过程串起来,从用户点击按钮到页面显示文字,数据经历了这些阶段:

javascript 复制代码
用户点击"提交"
    │
    ▼
┌─────────────────────────────────────────────┐
│  ① fetch 发送 POST 请求                      │
│     body: { messages: [...], stream: true }  │
└─────────────────┬───────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────────┐
│  ② AI 服务器开始生成回答                      │
│     每生成一个 token,就通过 SSE 推送一条消息   │
│     消息格式: data: {"choices":[{"delta":...}]}
└─────────────────┬───────────────────────────┘
                  │
                  ▼  (数据以 Uint8Array 二进制流的形式,通过网络到达浏览器)
┌─────────────────────────────────────────────┐
│  ③ response.body (ReadableStream) 接收数据    │
│     数据在浏览器的"水管"里等待被读取           │
└─────────────────┬───────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────────┐
│  ④ while 循环:reader.read()                  │
│     ┌───────────────────────────────────┐   │
│     │ 嘬一口 → Uint8Array               │   │
│     │ TextDecoder 解码 → 字符串          │   │
│     │ 和 buffer 拼接                     │   │
│     │ split('\n') + filter('data: ')    │   │
│     │ for 循环逐行:                      │   │
│     │   JSON.parse → 提取 token          │   │
│     │   content.value += token           │   │
│     │     ↓                              │   │
│     │   Vue 响应式触发 → DOM 局部更新     │   │
│     │     ↓                              │   │
│     │   页面显示新文字 ✨                 │   │
│     └───────────────────────────────────┘   │
│     重复,直到 done = true                    │
└─────────────────────────────────────────────┘

🐛 踩坑记录

坑 1:done 变量名冲突

javascript 复制代码
// ❌ 错误写法
const { done, value } = await reader.read()
done = done  // 自己赋值给自己???

// ✅ 正确写法:用解构重命名
const { done: doneReading, value } = await reader.read()
done = doneReading

while (!done) 里的 donereader.read() 返回的 done 撞名了!用解构重命名就能解决。

坑 2:不完整的 JSON 数据包

有时候服务器发来的数据被截断 了------一个 JSON 对象可能被分成两个包发送。直接 JSON.parse 会报错。

javascript 复制代码
try {
  const parsed = JSON.parse(data)
  // 正常处理...
} catch (e) {
  // 解析失败,把这行数据存到 buffer,下一轮和新数据拼起来再处理
  buffer = line
}

buffer 缓冲区 + try/catch 就能优雅地处理这个问题。

坑 3:API Key 安全问题

千万不要把 API Key 写死在代码里!.env.local 文件管理环境变量:

bash 复制代码
# .env.local(不要提交到 git)
VITE_MIMO_API_BASE_URL=https://api.xxx.com/v1
VITE_MIMO_API_KEY=sk-xxxxxxxxxxxxxxxx
VITE_MIMO_MODEL=xxx

Vite 项目中,以 VITE_ 开头的环境变量才会暴露给前端代码。


💡 重点总结

  1. 二进制流(Uint8Array) 是网络数据传输的基本形式,TextEncoder/TextDecoder 是字符串和二进制之间的桥梁

  2. ReadableStream 是浏览器原生的流式读取 API,response.body.getReader() 获取读取器,reader.read() 逐块读取

  3. SSE(Server-Sent Events) 是 AI 接口流式输出的标准协议,数据以 data: 前缀 + JSON 格式发送,data: [DONE] 表示结束

  4. Vue 响应式 + 流式读取 = 打字机效果 :每次追加一个 token 到 ref 变量,页面自动局部更新

  5. 核心就三行代码

    javascript 复制代码
    const reader = response.body.getReader()           // 拿到读取器
    const { value } = await reader.read()               // 读一块数据
    content.value += decoder.decode(value, { stream: true })  // 解码并追加

🔗 参考资料


💬 交流讨论

流式输出其实是一个很小但很核心的知识点,搞懂了之后你会发现:

  • 文件上传的进度条(ReadableStream)
  • 视频播放的缓冲(MediaSource)
  • 大文件下载(ReadableStream + WritableStream)

它们的底层原理都是相通的------流式处理

你在项目中用过流式输出吗?遇到了什么坑?欢迎评论区交流!


觉得有用?点个赞 👍 收藏 ⭐ 关注 👆,下篇准备写「大文件分片上传」,同样是流式处理的实战应用!

相关推荐
腾渊信息科技公司1 小时前
工业数据运维痛点根治方案:基于AI Agent的产线自动化台账系统落地
运维·人工智能·自动化·个人开发·ai编程
西安老张(AIGC&ComfyUI)1 小时前
第030章:ComfyUI视频制作LTX-2.3模型文生视频工作流详解(三)
人工智能·aigc·comfyui
胡萝卜术1 小时前
深度重构:Agent Skills——从 Prompt 工程到能力工程
javascript·架构·github
苦猿的大模型日记1 小时前
Day25 | 模型量化横评 GPTQ vs AWQ vs GGUF vs INT8——同一个 Qwen3-8B 压四遍,谁还活着
人工智能
benchmark_cc2 小时前
如何用 Python + QuantDash 快速构建高胜率“配对交易(Pairs Trading)”策略?
开发语言·人工智能·python·pandas·量化交易·quantdash
深海鱼肝油ya2 小时前
小说自动生成系统(二)
人工智能·大模型·agent·智能体·自动化编程·小说生成系统
通问AI2 小时前
Apple Intelligence 国行备案深度技术解析:阿里千问如何被集成到苹果端侧AI架构
人工智能·架构
视***间2 小时前
算力赋能零售与创意新生态:视程空间Pandora,解锁线下场景智能化无限可能
人工智能·边缘计算·智慧零售·ai算力·视程空间·创意开发
冬奇Lab2 小时前
MCP 系列(08):企业治理——Registry、路由与可观测性
人工智能·llm·mcp