游乐游手机版
首页/前端开发/文章详情

Vue3流式AI对话两个done与buffer缓存机制解析

时间:2026-07-19 19:46
基于Vue3调用DeepSeekAPI的流式对话代码,剖析了两个关键机制:外层循环变量done与底层流状态doneReading的区别与触发,前者由业务层[DONE]标记或后者由传输层关闭触发;buffer缓存用于拼接因TCP分包导致的半截JSON碎片,确保解析完整。

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

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


一、原始代码回顾

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

 复制代码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(标注 ①)

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

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

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

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

这行代码做了两件事:

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

拆开写等价于:

 复制代码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(标注 ④)

 复制代码done = doneReading;

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

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

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

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

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


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

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

代码位置(标注 ⑦):

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

触发流程

 复制代码后端推送 "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]

代码位置(标注 ③④):

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

触发流程

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

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


2.5 两条路径的时序对比

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

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

 复制代码时间 →
reader.read() → 读到AI文字分片 → 解析渲染 → reader.read() → 读到AI文字分片 → 解析渲染
    → reader.read() → 读到 "data: [DONE]" → done=true, break → 循环结束全程 doneReading 始终为 false,[DONE] 直接终止循环

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

 复制代码时间 →
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

简单来说,两个 done 就像两把锁:一把是业务逻辑的锁([DONE]),另一把是传输层状态的锁(doneReading)。正常情况,业务锁先到先得;一旦出问题,传输层锁自动兜底,保证程序不会死循环。


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

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

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

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

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

 复制代码第一块 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 消息是:

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

TCP 把它切成了两块传输:

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

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

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

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

此时 reader.read() 返回:

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

done = false,循环继续。

第二步:拼接 buffer + 新数据

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

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

第三步:清空 buffer

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

第四步:切割行并过滤

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

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

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

第五步:切掉协议前缀

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

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

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

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

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

JSON.parse() 直接抛出 SyntaxError

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

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

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

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

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

下一轮拼接后 chunkValue 可能是:

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

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

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

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

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

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

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

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

此时 reader.read() 返回:

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

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

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

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

第三步:清空 buffer

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

第四步:切割行并过滤

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

第五步:切掉协议前缀

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

第六步:JSON.parse 成功!

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

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

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

3.4 buffer 完整生命周期图示

 复制代码第一轮循环:
  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?

 复制代码} 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

原代码中用的是覆盖赋值

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

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

更健壮的写法

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

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

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

 复制代码用户点击提交
    │
    ▼
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 对话系统。

来源:https://juejin.cn/post/7663376623647031348
上一篇Flex Tree配置使用技巧教学指南 下一篇HTML模板在组件样式复用基类中的实现方法
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
如何用纯CSS实现移动端双列Flex容器子元素交替排列
前端开发 · 2026-07-20

如何用纯CSS实现移动端双列Flex容器子元素交替排列

在移动端响应式布局中,使用`display:contents`消除列容器布局边界,使子元素直接成为Flex项目,再配合`order`属性即可实现跨列交错排列,无需JavaScript干预,从而简化代码、提升性能与自适应能力。

根据视口可见性动态控制固定按钮的显示与隐藏
前端开发 · 2026-07-20

根据视口可见性动态控制固定按钮的显示与隐藏

利用getBoundingClientRect()检测目标按钮是否进入视口,滚动时实时控制另一固定按钮的显隐,确保两者永不同时可见。采用严格边界判断,配合requestAnimationFrame节流,用visibility:hidden隐藏以保留布局空间,适用于电商购物车等场景。

每个折叠区域独立控制展开收起的方法
前端开发 · 2026-07-20

每个折叠区域独立控制展开收起的方法

在React中实现多段可折叠内容独立展开收起,核心是为每个区域维护独立状态而非共享布尔值。推荐在map内使用useState,或自定义Hook基于唯一ID管理状态,避免依赖标题作为标识,确保key稳定唯一。

移动端菜单点击导航栏外部自动关闭实现方法
前端开发 · 2026-07-20

移动端菜单点击导航栏外部自动关闭实现方法

利用useRef和useEffect监听全局点击事件,通过contains方法判断点击目标是否在菜单容器外,并排除菜单按钮自身触发,实现移动端导航栏点击外部区域时自动关闭菜单,有效防止误关闭,从而显著提升用户交互体验。

JavaScript计数器数字拼接而非累加问题的修复方法
前端开发 · 2026-07-20

JavaScript计数器数字拼接而非累加问题的修复方法

JavaScript中,使用textContent获取计数器值时返回字符串,故直接使用+=运算符会导致数字拼接而非数值累加。需显式将字符串转为数字,推荐使用Number()或一元加号,以确保正确递增。这是常见陷阱,在循环中尤其需要注意类型转换。