本文基于一段 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 变为 false,while 循环条件不成立,循环终止 |
这个 done 本身不检测任何事情------它只是一个被动的布尔变量,等别人来改它。
2.2 第二个 done:浏览器底层流状态 doneReading(标注 ③④)
js
const { value, done: doneReading } = await reader?.read();
done = doneReading;
这行代码做了两件事:
- 调用
reader.read()从流中读取数据 - 用解构重命名 语法,把返回对象里的
.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 = false→done = false→!done为true→ 循环继续doneReading = true→done = true→!done为false→ 循环终止
如果删掉这行代码会怎样? 外层 done 永远是初始值 false,while(!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":{"condoneReading:false(流还没关闭,后面还有数据)
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: ?
因为 incoming 是 line.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":"你好"}}]}doneReading:false(假设流还没关闭)
第二步:拼接 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}`;
}
逐步拆解:
- 触发 :
JSON.parse(incoming)抛异常,程序流程跳入catch块 - 核心动作 :把解析失败的
incoming重新拼上"data: "前缀,赋值给全局buffer - 为什么补前缀 :
incoming来自line.slice(6),"data: "已经被切掉了。如果不补回来,下一轮.filter(line.startsWith('data: '))会把它过滤丢弃 - 效果 :这条半截消息被"暂存"到
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 | 外层 done 和 doneReading 是什么关系? |
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 对话系统。