Vue3 流式 AI 对话:深入理解两个 done 与 buffer 缓存机制

本文基于一段 Vue 3 调用 DeepSeek API 的流式对话代码,深入剖析两个最容易混淆的核心机制:两个 done 的区别与触发方式 ,以及 buffer 如何缓存半截 JSON 碎片


一、原始代码回顾

先把完整的流式处理核心代码贴出来,后面所有分析都围绕这段代码展开:

js 复制代码
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;          // ① 外层循环开关
let buffer = '';           // ② 缓存半截 JSON 碎片

while (!done) {
  const { value, done: doneReading } = await reader?.read();  // ③ 解构出 doneReading
  done = doneReading;      // ④ 把底层状态同步给外层 done

  const chunkValue = buffer + decoder.decode(value);  // ⑤ 拼接上一轮的缓存碎片
  buffer = '';             // ⑥ 清空缓存

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

  for (const line of lines) {
    const incoming = line.slice(6);   // 切掉 "data: " 前缀

    if (incoming === '[DONE]') {      // ⑦ 业务层结束标记
      done = true;
      break;
    }

    try {
      const data = JSON.parse(incoming);
      const delta = data.choices[0].delta.content;
      if (data && delta) {
        content.value += delta;
      }
    } catch (err) {
      buffer = `data: ${incoming}`;   // ⑧ 解析失败 → 存入缓存
    }
  }
}

上面标注了 ①~⑧ 八个关键点,下面逐一展开。


二、两个 done 的区别与触发机制

代码中出现了两个 "done",它们名字相似但来源、含义、触发时机完全不同。这是整个流式循环最核心的控制逻辑。

2.1 第一个 done:外层循环开关 let done = false(标注 ①)

js 复制代码
let done = false;   // 在 while 循环外部定义

while (!done) {     // 只要 done 不为 true,就一直循环
  // ...
}
属性 说明
定义位置 while 循环外部,是一个普通的 JavaScript 局部变量
初始值 false,表示"流还没结束,可以开始循环"
作用 控制 while 循环的启停,是整个循环的总开关
谁来修改它 有两个来源可以把它改成 true(见下文)
修改后的效果 !done 变为 falsewhile 循环条件不成立,循环终止

这个 done 本身不检测任何事情------它只是一个被动的布尔变量,等别人来改它

2.2 第二个 done:浏览器底层流状态 doneReading(标注 ③④)

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

这行代码做了两件事:

  1. 调用 reader.read() 从流中读取数据
  2. 解构重命名 语法,把返回对象里的 .done 字段改名为 doneReading

拆开写等价于:

js 复制代码
const result = await reader.read();  // result = { value: Uint8Array, done: false }
const doneReading = result.done;     // 把 result.done 赋值给 doneReading
const value = result.value;

为什么要改名? 因为 reader.read() 返回的对象自带一个 .done 字段,如果直接写 const { value, done } = ...,就会和外层 let done 重名冲突。所以用 done: doneReading 改名。

属性 说明
来源 reader.read() 的返回值,由浏览器内核自动维护
含义 标记底层 HTTP/TCP 管道是否已经永久关闭
返回值规则 流活跃有数据 → { value: Uint8Array, done: false };流彻底关闭 → { value: undefined, done: true }
谁决定的 W3C ReadableStream 标准规范,不由前端代码也不由后端代码控制

2.3 关键桥梁:done = doneReading(标注 ④)

js 复制代码
done = doneReading;

这行代码把浏览器底层状态同步给外层循环开关:

  • doneReading = falsedone = false!donetrue → 循环继续
  • doneReading = truedone = true!donefalse → 循环终止

如果删掉这行代码会怎样? 外层 done 永远是初始值 falsewhile(!done) 条件永远成立,页面陷入无限死循环

2.4 两条触发路径:[DONE]doneReading 分别什么时候生效?

done 变为 true两条完全不同的路径,它们触发的场景、时机、含义都不一样:


路径一:业务标记 [DONE] 修改 done(正常对话完成)

触发条件 :AI 文字全部生成完毕,后端主动推送一条 data: [DONE] 消息。

代码位置(标注 ⑦):

js 复制代码
if (incoming === '[DONE]') {
  done = true;   // 直接修改外层 done
  break;         // 跳出 for 循环
}

触发流程

bash 复制代码
后端推送 "data: [DONE]"
  → split('\n') 切割行
  → filter(line.startsWith('data: ')) 过滤通过
  → line.slice(6) 得到 incoming = "[DONE]"
  → 进入 if 分支
  → done = true(外层循环开关关闭)
  → break(跳出内层 for 循环)
  → 回到 while(!done),条件为 false
  → 循环终止,不会再次执行 reader.read()

关键点 :这条路径下,代码在 for 循环内部就已经 break 了,根本不会走到下一轮的 reader.read() ,也就是说 doneReading 这条路径全程不参与。


路径二:底层流状态 doneReading 修改 done(异常兜底)

触发条件 :后端没有发送 [DONE],但 HTTP/TCP 连接已经关闭。常见场景:

  • 用户断网 / WiFi 断开
  • 后端服务崩溃 / 进程被杀
  • 请求超时
  • 后端代码有 bug,漏发了 data: [DONE]

代码位置(标注 ③④):

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

触发流程

bash 复制代码
后端异常关闭 TCP 连接
  → 浏览器内核检测到连接断开
  → 下一次 reader.read() 返回 { value: undefined, done: true }
  → doneReading = true
  → done = true(同步给外层)
  → 本轮 chunkValue 为空或仅剩残留数据
  → 处理完后回到 while(!done),条件为 false
  → 循环终止

关键点 :这条路径发生在传输层 ,浏览器不关心 AI 有没有说完------它只检测 TCP 管道是不是还通着。管道断了就返回 done: true


2.5 两条路径的时序对比

用一个时间线来展示正常流程和异常流程的区别:

正常对话(路径一先生效):

lua 复制代码
时间 →
reader.read() → 读到AI文字分片 → 解析渲染 → reader.read() → 读到AI文字分片 → 解析渲染
    → reader.read() → 读到 "data: [DONE]" → done=true, break → 循环结束

全程 doneReading 始终为 false,[DONE] 直接终止循环

异常断网(路径二兜底):

ini 复制代码
时间 →
reader.read() → 读到AI文字分片 → 解析渲染 → reader.read() → 读到AI文字分片 → 解析渲染
    → ⚡网络断开 → reader.read() → doneReading=true → done=true → 循环结束

全程没有收到 [DONE],doneReading 强制终止循环

2.6 两套结束机制对照表

对比维度 [DONE] 路径 doneReading 路径
所属层级 AI 业务层(应用协议) HTTP/TCP 传输层(网络协议)
信号来源 后端代码主动推送字符串 data: [DONE] 浏览器内核根据 TCP 连接状态自动生成
代码位置 if (incoming === '[DONE]') 分支内 const { done: doneReading } = await reader.read()
触发时机 AI 输出最后一个 token 之后 TCP 连接关闭之后的下一次 reader.read()
是否完整输出 ✅ 是,AI 回答完整 ❌ 否,回答可能被截断
正常对话会触发吗 ✅ 会,且优先触发 ❌ 不会,被 [DONE] 抢先终止了
设计目的 正常流程的主动刹车 异常场景的安全兜底

2.7 一句话总结两个 done

doneReading 是浏览器告诉你"水管断了",[DONE] 是后端告诉你"话说完了"。正常情况话说完在先,水管断开在后;异常情况水管先断,话没说完也被迫终止。外层 done 只是一个开关------谁先把它拨到 true,循环就听谁的。


三、buffer 缓存机制:逐行追踪代码执行

3.1 先搞懂:buffer 为什么存在?

TCP 是无边界字节流协议。后端发送的完整 SSE 消息:

css 复制代码
data: {"choices":[{"delta":{"content":"你好"}}]}

在传输过程中可能被 TCP 协议栈任意切分,比如切成两块:

css 复制代码
第一块 TCP 分片 → data: {"choices":[{"delta":{"con
第二块 TCP 分片 → tent":"你好"}}]}

第一块的结尾是 {"con------这是一个不完整的 JSON 字符串 ,缺少闭合的 } 和引号。对它执行 JSON.parse() 会直接抛异常。

buffer 的作用就是:把本轮解析失败的半截 JSON 存起来,等下一轮和新数据拼接成完整 JSON 再解析。

3.2 代码中 buffer 出现的位置

buffer 在代码中只有三处操作:

位置 代码 操作
初始化 let buffer = '' 一开始是空字符串
while 循环开头 const chunkValue = buffer + decoder.decode(value) 然后 buffer = '' 消耗缓存:把旧碎片拼到新数据前面,然后清空
catch 块 buffer = \data: ${incoming}`` 写入缓存:把解析失败的半截 JSON 存起来

下面通过一个完整的例子,逐行追踪代码执行过程。

3.3 完整追踪:一次 TCP 半包截断的全过程

假设场景:后端生成了一句 "你好",对应的 SSE 消息是:

css 复制代码
data: {"choices":[{"delta":{"content":"你好"}}]}

TCP 把它切成了两块传输:

css 复制代码
第一块到达浏览器:data: {"choices":[{"delta":{"con
第二块到达浏览器:tent":"你好"}}]}

第一轮 while 循环(收到前半截碎片)

第一步:reader.read() 读到第一块数据

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

此时 reader.read() 返回:

  • value:二进制数据,解码后是文本 data: {"choices":[{"delta":{"con
  • doneReadingfalse(流还没关闭,后面还有数据)

done = false,循环继续。

第二步:拼接 buffer + 新数据

js 复制代码
const chunkValue = buffer + decoder.decode(value);
//          ↑        ↑
//       空字符串    "data: {\"choices\":[{\"delta\":{\"con"
// chunkValue = "data: {\"choices\":[{\"delta\":{\"con"

第一轮 buffer 是空的 "",所以 chunkValue 就是解码后的文本本身。

第三步:清空 buffer

js 复制代码
buffer = '';   // 旧碎片已经拼进去了,清空准备存新的

第四步:切割行并过滤

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

这一块数据只有一行,且以 data: 开头,过滤通过:

swift 复制代码
lines = ["data: {\"choices\":[{\"delta\":{\"con"]

第五步:切掉协议前缀

js 复制代码
const incoming = line.slice(6);
// "data: " 正好 6 个字符
// incoming = "{\"choices\":[{\"delta\":{\"con"

第六步:尝试 JSON.parse ------ 失败!

js 复制代码
try {
  const data = JSON.parse(incoming);
  //  ↑ 这里抛出异常!
  //  因为 "{\"choices\":[{\"delta\":{\"con" 不是合法 JSON
  //  缺少闭合的 }]}

incoming 的内容是 {"choices":[{"delta":{"con------:

  • "con 后面的引号没闭合
  • {"delta":{"con 缺了两层 }} 闭合

JSON.parse() 直接抛出 SyntaxError

第七步:进入 catch,存入 buffer(标注 ⑧)

js 复制代码
} catch (err) {
  buffer = `data: ${incoming}`;
  // buffer = "data: {\"choices\":[{\"delta\":{\"con"
}

这里有一个关键细节 ------为什么要重新拼接 data:

因为 incomingline.slice(6) 切掉 "data: " 之后的内容。如果直接存 incoming

swift 复制代码
buffer = "{\"choices\":[{\"delta\":{\"con"   ← 没有 "data: " 前缀!

下一轮拼接后 chunkValue 可能是:

swift 复制代码
"{\"choices\":[{\"delta\":{\"con" + "tent\":\"你好\"}}]}"

第一行不再以 data: 开头 → 被 .filter(line.startsWith('data: ')) 过滤丢弃 → 碎片永久丢失!

所以必须复原前缀:``buffer = `data: ${incoming}```。

第一轮循环结束时的状态:

swift 复制代码
buffer = "data: {\"choices\":[{\"delta\":{\"con"
done = false(还等着下一轮)
页面没有新文字渲染(因为 JSON 解析失败了)

第二轮 while 循环(收到后半截碎片)

第一步:reader.read() 读到第二块数据

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

此时 reader.read() 返回:

  • value:二进制数据,解码后是文本 tent":"你好"}}]}
  • doneReadingfalse(假设流还没关闭)

第二步:拼接 buffer + 新数据(标注 ⑤)

js 复制代码
const chunkValue = buffer + decoder.decode(value);
//          ↑                        ↑
//  "data: {\"choices\":[{\"delta\":{\"con"    "tent\":\"你好\"}}]}"
//
// chunkValue = "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}"
//              ↑ 完整的一行合法 SSE 消息!

上一轮的半截碎片和本轮的半截碎片拼在一起,恰好还原了完整的 SSE 消息!

第三步:清空 buffer

js 复制代码
buffer = '';   // 碎片已经用完了,清空

第四步:切割行并过滤

swift 复制代码
lines = ["data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}"]

第五步:切掉协议前缀

js 复制代码
const incoming = line.slice(6);
// incoming = "{\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}"
//             ↑ 完整的合法 JSON 字符串

第六步:JSON.parse 成功!

js 复制代码
const data = JSON.parse(incoming);   // ✅ 解析成功
const delta = data.choices[0].delta.content;  // "你好"
if (data && delta) {
  content.value += delta;   // 页面显示 "你好"
}

第二轮循环结束时的状态:

arduino 复制代码
buffer = ""(清空了,等待下一次可能的半包)
页面成功渲染了 "你好"

3.4 buffer 完整生命周期图示

ini 复制代码
第一轮循环:
  buffer="" ──→ chunkValue = "" + "data: ...{\"con" ──→ buffer清空 ──→ parse失败
                                                                          │
                                                               ┌──────────┘
                                                               ▼
                                              buffer = "data: ...{\"con"
                                                               │
第二轮循环:                                                     │
  buffer="data: ...{\"con" ──→ chunkValue = "data: ...{\"con" + "tent\":...}}" ──→ buffer清空
                                                                                      │
                                                                                      ▼
                                                                              parse成功 → 渲染文字

3.5 什么条件会进入 catch?

进入 catch 的条件有且只有一个:JSON.parse(incoming) 抛出异常

具体来说就是 incoming 不是合法的 JSON 字符串。最常见的三种情况:

情况 incoming 示例 原因
TCP 半包截断 {"choices":[{"delta":{"con JSON 结构不完整,缺少闭合括号和引号
空字符串 "" line.slice(6) 后得到空串,JSON.parse("") 报错
非 JSON 内容 后端异常输出了错误日志 内容根本不是 JSON 格式

其中 TCP 半包截断是最常见且必须处理的情况------它不是 bug,而是 TCP 协议的正常行为。

3.6 catch 里做了什么?怎么变成 buffer?

js 复制代码
} catch (err) {
  buffer = `data: ${incoming}`;
}

逐步拆解:

  1. 触发JSON.parse(incoming) 抛异常,程序流程跳入 catch
  2. 核心动作 :把解析失败的 incoming 重新拼上 "data: " 前缀,赋值给全局 buffer
  3. 为什么补前缀incoming 来自 line.slice(6)"data: " 已经被切掉了。如果不补回来,下一轮 .filter(line.startsWith('data: ')) 会把它过滤丢弃
  4. 效果 :这条半截消息被"暂存"到 buffer,等下一轮 reader.read() 拿到新数据后,在循环开头通过 buffer + decoder.decode(value) 拼接还原

一句话:catch 把本轮"啃不动的骨头"存到 buffer,下一轮和新肉一起炖。

3.7 一个隐藏 Bug

原代码中用的是覆盖赋值

js 复制代码
buffer = `data: ${incoming}`;   // 覆盖,不是追加

如果一个 chunk 里有两行都是半截 JSON(极端情况),第二次 catch 会覆盖第一次存的碎片,导致数据丢失。

更健壮的写法

js 复制代码
buffer += `data: ${incoming}\n`;   // 追加,不是覆盖

四、完整流程图:一次正常对话的全链路

把两个 done 的触发和 buffer 机制串起来,看一次完整的正常对话:

ini 复制代码
用户点击提交
    │
    ▼
fetch() 发起 POST 请求,stream: true
    │
    ▼
response.body.getReader() 获取读取器
    │
    ▼
┌──────────────────────────────────────────────┐
│  while (!done) {                             │
│                                              │
│    ① reader.read() → doneReading = false    │
│    ② done = false(循环继续)                │
│    ③ buffer + 新数据 → 拼接 chunkValue       │
│    ④ buffer = ''                             │
│    ⑤ split + filter → 得到 SSE 行            │
│    ⑥ line.slice(6) → incoming                │
│                                              │
│    ┌─ incoming === '[DONE]' ? ────────────┐  │
│    │  是 → done = true, break             │  │
│    │  否 → 继续                            │  │
│    └───────────────────────────────────────┘  │
│                                              │
│    ⑦ try JSON.parse(incoming)               │
│    ┌─ 成功 → 提取 delta → 渲染文字          │  │
│    │  失败 → catch → buffer = data: xxx     │  │
│    └───────────────────────────────────────┘  │
│                                              │
│    回到 while 开头                            │
│  }                                           │
└──────────────────────────────────────────────┘
    │
    ▼
循环终止(done = true),流式对话结束

五、总结

机制 核心问题 答案
两个 done 外层 donedoneReading 是什么关系? doneReading 来自浏览器底层 API,反映 TCP 管道状态;外层 done 是循环开关,通过 done = doneReading 同步。两者是两个独立变量,名字相似但来源完全不同
[DONE] 触发 什么时候走这条路径? AI 正常输出完成后,后端推送 data: [DONE],在 for 循环内直接 done = true; break,不会再执行下一轮 reader.read()
doneReading 触发 什么时候走这条路径? 网络断开 / 后端崩溃 / 漏发 [DONE],下一次 reader.read() 时浏览器检测到 TCP 连接关闭,返回 done: true,同步给外层 done
谁优先? 正常流程哪个先生效? [DONE] 优先------它在 for 循环内就 break 了,doneReading 全程不触发
buffer 进入 catch 什么情况进入 catch? JSON.parse(incoming) 报错时------99% 的情况是 TCP 把一条完整的 JSON 切成了两半,本轮的 incoming 只有前半截
catch 怎么变成 buffer catch 里做了什么? 把半截 JSON 碎片重新拼上 data: 前缀,赋值给外层 buffer,等下一轮循环开头和新的二进制分片拼接还原
为什么补前缀 为什么要 ``buffer = `data: ${incoming}```? 因为 incoming 已经被 line.slice(6) 切掉了前缀,不补回来的话下一轮 .filter(line.startsWith('data: ')) 会把它过滤丢弃

通过这两个机制,不到 40 行核心代码实现了一个既能正常逐字输出、又能优雅应对网络异常的流式 AI 对话系统。

相关推荐
qq_589666051 小时前
npm install npm install -g @vue/cli 分别是什么意思以及安装路径
前端·vue.js·npm
李剑一20 小时前
ant-design-vue 1.x 版本换肤功能开发
前端·vue.js·ant design
李明卫杭州21 小时前
Vue2 与 Vue3 自定义指令详解:钩子变化、迁移指南与实战示例
前端·javascript·vue.js
nicole bai1 天前
vue 自定义模块内容
前端·javascript·vue.js
遇乐的果园1 天前
前端学习笔记-vue列表处理
前端·vue.js·学习
ss2731 天前
Vue 完整版本发展史时间线(2013–2026)|版本迭代里程碑+核心特性对照表
前端·javascript·vue.js
英勇无比的消炎药1 天前
体积砍半、加载提速、请求不卡:TinyRobot 生产环境性能调优全攻略
vue.js
gis开发之家1 天前
《Vue3 从入门到大神32篇》Vue3 源码详解(二):从 GitHub Clone 到断点调试,手把手搭建源码阅读环境
vue.js·前端框架·github·vue3·vue3源码
徐小超1 天前
从0到1落地AI知识问答系统(一):AI结对协作实战技巧
vue.js·node.js·全栈