写在前面
最近在开发 AI 聊天应用时,发现很多同学对"流式输出"的理解停留在"调用 API,数据一点点回来"的层面。但当真正动手实现时,却会被 ReadableStream、Uint8Array、TextDecoder、data: [DONE] 这些概念搞得晕头转向。
本文会从一个完整的 Vue3 组件出发,逐行解析每个细节,让你不仅知道"怎么用",更理解"为什么这么用"。
一、先看完整代码(带着问题往下读)
vue
ini
<script setup>
import { ref } from 'vue'
const question = ref('将一个中国龙的故事');
const content = ref('');
const stream = ref(true);
const update = async () => {
if (!question.value) return;
content.value = '思考中....';
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value
})
});
if (stream.value) {
content.value = '';
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
while (!done) {
const { done: doneReading, value } = await reader?.read();
done = doneReading;
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);
if (incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta;
}
} catch (error) {
buffer = `data: ${incoming}`;
}
}
}
} else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
}
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
</script>
这段代码看起来不长,但每一行背后都藏着重要的知识点。接下来,我们以"水流"为比喻,逐步拆解。
二、整体架构:从"水龙头"到"水杯"
text
bash
┌─────────────────────────────────────────────────────────────┐
│ 用户点击发送 │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 1. 发送请求:fetch 带上 stream: true │
│ → 告诉服务器:"我要流式响应,别一次性全给我" │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. 服务器响应:返回 ReadableStream 对象 │
│ → 就像打开了一个"水龙头" │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. 获取读取器:response.body.getReader() │
│ → 拿到"水管子",准备接水 │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. 循环读取:while (!done) { reader.read() } │
│ → 一滴一滴(或一勺一勺)接水 │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 5. 解析数据:TextDecoder 解码 + JSON.parse │
│ → 把"水"变成能喝的内容 │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 6. 渲染页面:content.value += delta │
│ → 一口一口喝掉,实时展示 │
└─────────────────────────────────────────────────────────────┘
三、逐层深入:每个细节都不放过
3.1 为什么用 ref?Vue3 响应式原理
javascript
ini
const question = ref('将一个中国龙的故事');
const content = ref('');
const stream = ref(true);
核心概念:响应式数据
ref 是 Vue3 Composition API 的核心,它返回一个 RefImpl 对象:
javascript
kotlin
// 简化版 RefImpl 实现
class RefImpl {
constructor(value) {
this._value = value;
this.dep = new Set(); // 依赖收集
}
get value() {
track(this.dep); // 收集依赖(模板中的使用位置)
return this._value;
}
set value(newValue) {
this._value = newValue;
trigger(this.dep); // 触发更新(重新渲染模板)
}
}
为什么用 ref 而不是普通变量?
javascript
ini
// ❌ 普通变量:改变后页面不会更新
let content = '';
content = '你好'; // 页面毫无反应
// ✅ ref:改变后页面自动刷新
const content = ref('');
content.value = '你好'; // 页面立刻显示"你好"
在流式输出中,每次收到新的 delta,我们都要执行 content.value += delta,这会触发 Vue 的响应式系统,实现"逐字打印"的效果。
3.2 stream: true 到底做了什么?
javascript
css
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value // ← 就是这个参数!
})
这是关键!
当 stream: true 时,服务器会:
- 不等待完整生成:LLM 每生成一个 token(词元),就立即发送
- 使用 SSE 协议 :Server-Sent Events,数据格式为
data: {json}\n\n - 响应头变化 :
Content-Type: text/event-stream
对比一下:
| 模式 | 响应方式 | 首字节时间 | 用户体验 |
|---|---|---|---|
stream: false |
等待全部生成完,一次性返回 | 可能 5-10 秒 | 白屏等待,焦虑 |
stream: true |
边生成边返回 | 200-500ms | 逐字显示,流畅 |
核心差异:
javascript
javascript
// stream: false - 等待全部
const data = await response.json(); // 阻塞等待 5 秒
console.log(data.choices[0].message.content); // 一次性全部显示
// stream: true - 流式返回
const reader = response.body.getReader(); // 立刻返回
while (true) {
const { value } = await reader.read(); // 每次拿到一小块
// 逐字显示...
}
3.3 response.body 是什么?为什么是二进制流?
javascript
vbscript
console.log(response.body);
// 输出:ReadableStream { locked: false, ... }
深入理解 ReadableStream
response.body 是一个 ReadableStream 对象,它代表了 HTTP 响应体这个"数据源"。
想象一下:
- 传统响应 (
response.json()):就像快递员把整个包裹放在你家门口,你一次性拆开 - 流式响应 (
response.body):就像快递员打开包裹,一件一件递给你
为什么是二进制?
HTTP 协议传输的数据本质上是字节流(Binary Stream)。ReadableStream 读取到的数据是 Uint8Array(二进制数组),而不是字符串。
javascript
perl
// 实际 value 的内容
console.log(value);
// Uint8Array(47) [
// 100, 97, 116, 97, 58, 32, 123, 34, 99, 104, 111, 105, 99, 101, 115, 34,
// 58, 91, 123, 34, 100, 101, 108, 116, 97, 34, 58, 123, 34, 99, 111, 110,
// 116, 101, 110, 116, 34, 58, 34, 228, 189, 160, 229, 165, 189, 34, 125, 125, 93, 125
// ]
// 这些数字是 "data: {"choices":[{"delta":{"content":"你好"}}]}" 的 UTF-8 编码
Uint8Array 是什么?
- 一个数组,每个元素是 0-255 的整数
- 表示二进制数据(字节)
100对应字符'd',97对应'a',以此类推
3.4 getReader() 和 read() 的原理
javascript
ini
const reader = response.body?.getReader();
const { done: doneReading, value } = await reader?.read();
getReader() 的作用
- 创建一个"读取器"(Reader),用于从
ReadableStream中读取数据 - 一个流只能有一个活跃的 reader(
locked状态) - 类似"打开水龙头,拿到水管"
read() 的返回值
typescript
typescript
{
done: boolean, // true 表示流已结束,没有更多数据
value: Uint8Array | undefined // 读取到的数据块
}
read() 的工作机制
text
yaml
┌────────────────────────────────────────────────────────┐
│ 1. 调用 reader.read() │
│ → 检查内部缓冲区是否有数据 │
├────────────────────────────────────────────────────────┤
│ 2. 如果有数据: │
│ → 立即返回 { done: false, value: 数据块 } │
├────────────────────────────────────────────────────────┤
│ 3. 如果没有数据,但流还在进行: │
│ → 等待(await),直到有数据到达或流结束 │
├────────────────────────────────────────────────────────┤
│ 4. 如果流已结束: │
│ → 返回 { done: true, value: undefined } │
└────────────────────────────────────────────────────────┘
为什么用 while (!done)?
因为每次 read() 只返回一个数据块(chunk),我们需要循环读取直到 done: true。
3.5 TextDecoder:从二进制到文本
javascript
ini
const decoder = new TextDecoder();
const chunkValue = buffer + decoder.decode(value);
为什么需要解码?
因为 value 是 Uint8Array(二进制),而我们需要的是字符串(文本)。
text
css
Uint8Array [100, 97, 116, 97, 58, 32, 123, ...]
↓ TextDecoder.decode()
"data: {"choices":[{"delta":{"content":"你好"}}]}"
TextDecoder 的原理
javascript
ini
// 简化版实现
class TextDecoder {
decode(bytes) {
let result = '';
let i = 0;
while (i < bytes.length) {
// 根据 UTF-8 编码规则解析
if (bytes[i] < 0x80) {
// 1 字节字符(ASCII)
result += String.fromCharCode(bytes[i]);
i++;
} else if (bytes[i] < 0xE0) {
// 2 字节字符(如部分拉丁文)
const code = ((bytes[i] & 0x1F) << 6) | (bytes[i+1] & 0x3F);
result += String.fromCharCode(code);
i += 2;
} else if (bytes[i] < 0xF0) {
// 3 字节字符(中文、日文等)
const code = ((bytes[i] & 0x0F) << 12) |
((bytes[i+1] & 0x3F) << 6) |
(bytes[i+2] & 0x3F);
result += String.fromCharCode(code);
i += 3;
}
}
return result;
}
}
为什么要用 { stream: true } 参数?
javascript
php
decoder.decode(value, { stream: true });
stream: true表示"可能有后续数据",解码器会保留未完成的字符- 例如:中文字符"你"的 UTF-8 编码是 3 个字节
[228, 189, 160] - 如果第一个数据块只包含
[228, 189],解码器会保留这两个字节 - 等第二个数据块送来
[160]时,完整解码为"你"
3.6 buffer:处理不完整的 JSON
javascript
ini
let buffer = '';
const chunkValue = buffer + decoder.decode(value);
buffer = '';
// ... 处理 lines ...
catch (error) {
buffer = `data: ${incoming}`;
}
为什么需要 buffer?
SSE 数据以 \n 分隔,但网络传输时:
text
scss
第1次 read(): "data: {"choices":[{"delta":{"cont"
第2次 read(): "ent":"你好"}}]}\n\ndata: [DONE]"
第1次读取的数据不完整({"cont 被截断),无法 JSON.parse。
buffer 的工作机制
text
rust
┌─────────────────────────────────────────────────────────┐
│ 第1次读取:buffer = "" │
│ chunkValue = "" + "data: {"choices":[{"delta":{"cont" │
│ 按行分割 → 没有完整的行(没有 \n) │
│ 全部进 catch → buffer = 'data: {"choices":[{"delta":{"cont' │
├─────────────────────────────────────────────────────────┤
│ 第2次读取:buffer 有值 │
│ chunkValue = 'data: {"choices":[{"delta":{"cont' + │
│ 'ent":"你好"}}]}\n\ndata: [DONE]' │
│ 现在有完整行: │
│ → "data: {"choices":[{"delta":{"content":"你好"}}]}" │
│ → "data: [DONE]" │
│ 可以正确解析了! │
└─────────────────────────────────────────────────────────┘
注意 catch 中的处理
javascript
ini
catch (error) {
buffer = `data: ${incoming}`;
}
当 JSON.parse 失败时,我们把不完整的 JSON 存到 buffer 中,等待下一次 read() 补齐。
为什么要加 data: 前缀?
因为下一次循环时,chunkValue = buffer + decoder.decode(value) 直接拼接,我们需要保持 data: 前缀存在,这样 split('\n') 和 startsWith('data:') 才能正确处理。
3.7 data: [DONE] 的作用
javascript
ini
if (incoming === '[DONE]') {
done = true;
break;
}
[DONE] 是协议规定的终止信号
SSE 规范并没有硬性规定结束标志,但 OpenAI 和 DeepSeek 等 API 遵循了一个事实标准:
text
css
data: {"id":"1","choices":[{"delta":{"content":"你"}}]}
data: {"id":"2","choices":[{"delta":{"content":"好"}}]}
data: [DONE]
为什么需要 [DONE]?
- 明确结束 :
reader.read()的done可能因为网络问题延迟,但[DONE]是业务层面的明确信号 - 双重保障 :即使
done: true提前到来,[DONE]也能确保我们正确处理了最后的数据 - 兼容性 :某些流实现可能在
done: true前就发送了所有数据
两个 done 的区别
| 变量 | 来源 | 含义 | 类型 |
|---|---|---|---|
doneReading |
reader.read() 返回 |
流是否已经结束(无更多数据) | 网络层 |
done(业务变量) |
我们自己定义 | 是否应该停止循环 | 业务层 |
javascript
ini
// 两个 done 配合使用
const { done: doneReading, value } = await reader?.read();
done = doneReading; // 网络层告诉业务层
// 业务层还可以通过 [DONE] 提前结束
if (incoming === '[DONE]') {
done = true; // 业务层主动结束
break;
}
3.8 JSON 解析与 content 拼接
javascript
ini
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta;
}
SSE 数据格式
服务器返回的每一行都是:
text
css
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}
解析路径
javascript
kotlin
data.choices[0].delta.content
// ↓ ↓ ↓
// 数组 第一个 增量对象 内容
delta 的含义
delta= 变化量、增量- 每次只返回新增的内容(一个 token),而不是完整文本
- 前端需要不断累加:
content.value += delta
为什么是 += 而不是 =?
javascript
ini
// 第1次:收到 "你"
content.value = '' + '你' // "你"
// 第2次:收到 "好"
content.value = '你' + '好' // "你好"
// 第3次:收到 "吗"
content.value = '你好' + '吗' // "你好吗"
四、深度解析:split('\n') 与 filter 的精妙设计
这是整个流式处理中最容易被忽视,却最考验功底的部分。
javascript
arduino
const lines = chunkValue.split('\n')
.filter((line) => line.startsWith('data:'));
4.1 为什么要用 split('\n') 而不是 split('\n\n')?
SSE 协议规范
SSE 协议的标准格式:
text
css
data: {"content":"你"}
data: {"content":"好"}
data: [DONE]
- 每条消息以
data:开头 - 每条消息以 两个换行符
\n\n结束(即一个空行)
如果用 split('\n\n') 会怎样?
javascript
css
const chunkValue = 'data: {"content":"你"}\n\ndata: {"content":"好"}\n\ndata: [DONE]\n\n';
const messages = chunkValue.split('\n\n');
// 结果:[
// 'data: {"content":"你"}',
// 'data: {"content":"好"}',
// 'data: [DONE]',
// '' // 末尾的空字符串
// ]
看起来可以,但流式场景下会出问题:
javascript
rust
// 第1次 read() 可能只拿到一半:
const chunkValue = 'data: {"content":"你"}\n\ndata: {"con';
const messages = chunkValue.split('\n\n');
// 结果:[
// 'data: {"content":"你"}',
// 'data: {"con' // ← 不完整!无法解析!
// ]
结论 :用 split('\n\n') 在流式场景下会出错,因为数据块可能在任意位置被截断。
为什么用 split('\n') 更安全?
javascript
perl
const chunkValue = 'data: {"content":"你"}\n\ndata: {"con';
const lines = chunkValue.split('\n');
// 结果:[
// 'data: {"content":"你"}',
// '', // 空行(原来的 \n\n)
// 'data: {"con' // 不完整的行
// ]
即使数据被截断,我们仍然能:
- 识别出完整的
'data: {"content":"你"}'并立即解析 - 把不完整的
'data: {"con'存到buffer中等待补齐 - 空行
''被后续的filter过滤掉
核心思想 :split('\n') 保证了每行数据的独立性,即使数据被截断,也能最大程度地恢复有效数据。
4.2 filter((line) => line.startsWith('data:')) 的 4 层防护
第一层:过滤空行
javascript
css
const chunkValue = 'data: {"content":"你"}\n\ndata: {"content":"好"}\n\n';
const lines = chunkValue.split('\n');
// ['data: {"content":"你"}', '', 'data: {"content":"好"}', '']
const filtered = lines.filter(line => line.startsWith('data:'));
// ['data: {"content":"你"}', 'data: {"content":"好"}']
没有 filter,空行会进入循环导致:
javascript
scss
const incoming = line.slice(6); // 空行 slice(6) → ''
JSON.parse(''); // ❌ SyntaxError: Unexpected end of JSON input
第二层:过滤注释行
SSE 协议支持注释行(以 : 开头):
text
css
: 这是一个注释
data: {"content":"你"}
: 另一个注释
data: {"content":"好"}
filter 自动过滤掉这些注释,避免解析错误。
第三层:过滤其他控制行
SSE 协议还有 event:、id:、retry: 等字段:
text
vbnet
event: user
data: {"content":"你"}
id: 123
data: {"content":"好"}
retry: 10000
data: [DONE]
如果不过滤,line.slice(6) 会切出 user、123、10000 等无效内容,导致 JSON.parse 失败。
第四层:防御 data: 前缀变体
某些实现可能使用:
text
css
data: {"content":"你"} // 标准格式
data: {"content":"你"} // 多个空格(已过滤)
data: {"content":"你"} // 注意空格数量
startsWith('data:') 要求严格匹配 ,避免了 'data: ' 这种带多个空格的变体带来的问题。
4.3 为什么不用 trim() 或 includes()?
如果用 trim()
javascript
ini
// ❌ 错误示范
const lines = chunkValue.split('\n')
.map(line => line.trim())
.filter(line => line.startsWith('data:'));
问题:
trim()会删除行首尾空格,但 SSE 要求data:后面有一个空格- 如果数据本身包含前导空格(如 Markdown 代码块),
trim()会破坏内容
javascript
rust
// 原始数据
'data: {"content":" const a = 1; "}'
// trim() 后
'data: {"content":"const a = 1;"}' // ❌ 丢失了缩进空格
如果用 includes('data:')
javascript
ini
// ❌ 错误示范
const lines = chunkValue.split('\n')
.filter(line => line.includes('data:'));
问题:
includes太宽松,会匹配到其他包含data:的字符串- 例如:
"error: invalid data: field"也会被错误匹配
正确做法:startsWith('data:')
javascript
arduino
// ✅ 正确
.filter(line => line.startsWith('data:'))
优势:
- 严格匹配协议前缀
- 避免误匹配
- 性能更好(不需要扫描整个字符串)
4.4 实战:完整的行处理流程
场景 1:标准数据块
javascript
css
// 输入
const chunkValue = 'data: {"content":"你"}\n\ndata: {"content":"好"}\n\ndata: [DONE]\n\n';
// split('\n')
const rawLines = [
'data: {"content":"你"}',
'',
'data: {"content":"好"}',
'',
'data: [DONE]',
''
];
// filter(line => line.startsWith('data:'))
const validLines = [
'data: {"content":"你"}',
'data: {"content":"好"}',
'data: [DONE]'
];
// 遍历处理
for (const line of validLines) {
const incoming = line.slice(6); // 去掉 'data: '
// incoming: '{"content":"你"}' → JSON.parse ✅
// incoming: '{"content":"好"}' → JSON.parse ✅
// incoming: '[DONE]' → 结束信号 ✅
}
场景 2:数据被截断
javascript
ini
// 输入(第1次 read)
const chunkValue = 'data: {"content":"你"}\n\ndata: {"con';
const rawLines = [
'data: {"content":"你"}',
'',
'data: {"con'
];
const validLines = [
'data: {"content":"你"}',
'data: {"con' // 注意:它确实以 'data:' 开头!
];
for (const line of validLines) {
// 第1条:正常解析 ✅
const incoming1 = '{"content":"你"}';
JSON.parse(incoming1); // ✅
// 第2条:不完整
const incoming2 = '{"con';
try {
JSON.parse(incoming2); // ❌ SyntaxError
} catch (error) {
buffer = `data: ${incoming2}`; // 存起来等下一次
}
}
场景 3:多个空行
javascript
rust
// 输入
const chunkValue = 'data: {"content":"你"}\n\n\n\ndata: {"content":"好"}\n\n';
const rawLines = [
'data: {"content":"你"}',
'',
'',
'',
'data: {"content":"好"}',
''
];
const validLines = [
'data: {"content":"你"}',
'data: {"content":"好"}'
];
// 多个空行被优雅地过滤掉了 ✅
4.5 边界情况与防御性编程
Windows 换行符(\r\n)
javascript
css
// 某些服务器可能返回 Windows 风格的换行
const chunkValue = 'data: {"content":"你"}\r\n\r\ndata: {"content":"好"}\r\n\r\n';
// split('\n') 会留下 \r
const lines = chunkValue.split('\n');
// ['data: {"content":"你"}\r', '\r', 'data: {"content":"好"}\r', '\r']
// filter(line => line.startsWith('data:'))
// ['data: {"content":"你"}\r', 'data: {"content":"好"}\r']
// 仍然能工作,因为 \r 在末尾不影响 startsWith
// 但 slice(6) 后变成 '{"content":"你"}\r'
// JSON.parse('{"content":"你"}\r') ✅ 能正常工作(JSON 允许尾部空白)
建议:更健壮的写法
javascript
ini
// 统一处理换行符
const lines = chunkValue.replace(/\r\n/g, '\n').split('\n')
.filter(line => line.startsWith('data:'));
数据中包含换行符
javascript
css
// 如果 content 本身包含换行符
const data = {
content: '第一行\n第二行\n第三行'
};
// SSE 格式:
// data: {"content":"第一行\n第二行\n第三行"}
// 处理时:
const chunkValue = 'data: {"content":"第一行\n第二行\n第三行"}\n\n';
const lines = chunkValue.split('\n');
// ['data: {"content":"第一行', '第二行', '第三行"}', '']
// filter(line => line.startsWith('data:'))
// ['data: {"content":"第一行'] // ❌ 丢失了 '第二行' 和 '第三行"}'
解决方案:使用更智能的 SSE 解析器
javascript
ini
// 更健壮的解析(生产级实现)
const parseSSEChunk = (chunkValue) => {
const lines = [];
let current = '';
const parts = chunkValue.split('\n');
for (const part of parts) {
if (part.startsWith('data:')) {
if (current) {
lines.push(current);
current = '';
}
current = part;
} else if (part !== '' || current) {
// 空行可能是数据的一部分(如 Markdown 空行)
// 但 SSE 的空行是分隔符,需要区分
// 这里简化处理:非 data: 开头的行都作为当前行的一部分
current += '\n' + part;
}
}
if (current) lines.push(current);
return lines.filter(line => line.startsWith('data:'));
};
五、完整数据流追踪
让我们追踪一次完整的请求-响应过程:
text
css
用户输入:将一个中国龙的故事
↓
点击发送 → update() 执行
↓
fetch POST https://api.deepseek.com/chat/completions
headers: { Authorization: 'Bearer sk-xxx' }
body: { model: 'deepseek-v4-flash', messages: [...], stream: true }
↓
服务器开始生成,返回 ReadableStream
↓
┌─────────────────────────────────────────────────────────┐
│ 第1次 read() │
│ value: Uint8Array(89) [100,97,116,97,58,32,123,...] │
│ decoder.decode() → "data: {"choices":[{"delta":{"con" │
│ buffer = 'data: {"choices":[{"delta":{"con"' │
│ (JSON 不完整,存入 buffer) │
├─────────────────────────────────────────────────────────┤
│ 第2次 read() │
│ value: Uint8Array(60) [116,101,110,116,34,58,34,...] │
│ chunkValue = buffer + decoder.decode(value) │
│ → "data: {"choices":[{"delta":{"content":"从"}}]}" │
│ JSON.parse → { choices: [{ delta: { content: '从' } }] } │
│ content.value = '' + '从' = '从' │
│ 页面显示:从 │
├─────────────────────────────────────────────────────────┤
│ 第3次 read() │
│ value: Uint8Array(...) [228,184,128,...] │
│ JSON.parse → { choices: [{ delta: { content: '前' } }] } │
│ content.value = '从' + '前' = '从前' │
│ 页面显示:从前 │
├─────────────────────────────────────────────────────────┤
│ ... 重复多次,每次拼接一个 token ... │
├─────────────────────────────────────────────────────────┤
│ 第N次 read() │
│ value: Uint8Array(15) [100,97,116,97,58,32,91,68,...] │
│ decoder.decode() → "data: [DONE]" │
│ incoming === '[DONE]' → done = true; break │
│ 循环结束,输出完成 │
└─────────────────────────────────────────────────────────┘
六、非流式模式对比
javascript
ini
} else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
当 stream: false 时:
text
css
发送请求
↓
等待... (可能 5-10 秒)
↓
收到完整响应 (一次性)
↓
{
"choices": [{
"message": {
"content": "从前有一条中国龙..."
}
}]
}
↓
content.value = "从前有一条中国龙..."
↓
页面一次性显示全部内容
优缺点对比
| 特性 | 流式 (stream: true) | 非流式 (stream: false) |
|---|---|---|
| 首字节时间 | 200-500ms | 5-10s |
| 用户体验 | 逐字显示,流畅 | 白屏等待,焦虑 |
| 实现复杂度 | 复杂(需要解析流) | 简单(直接 JSON.parse) |
| 网络占用 | 持续小包传输 | 一次性大包传输 |
| 错误处理 | 部分数据可能丢失 | 要么成功要么失败 |
七、常见问题与优化
7.1 为什么不用 response.text()?
javascript
ini
// ❌ 这样会等待全部接收完成,失去流式意义
const text = await response.text();
// ✅ 应该用 response.body.getReader()
const reader = response.body.getReader();
7.2 如何处理网络中断?
javascript
csharp
try {
while (!done) {
const { done: doneReading, value } = await reader?.read();
// ...
}
} catch (error) {
// 网络中断或解析错误
content.value = '❌ 连接中断,请重试';
}
7.3 如何优化性能?
javascript
ini
// 使用 requestAnimationFrame 批量更新
let pendingContent = '';
let lastUpdate = performance.now();
while (!done) {
// ...
pendingContent += delta;
// 每 16ms 刷新一次(60fps)
if (performance.now() - lastUpdate > 16) {
content.value += pendingContent;
pendingContent = '';
lastUpdate = performance.now();
}
}
7.4 如何支持取消?
javascript
ini
let abortController = new AbortController();
const update = async () => {
abortController = new AbortController();
const response = await fetch(endpoint, {
signal: abortController.signal, // 传入取消信号
// ...
});
// ...
}
const cancel = () => {
abortController.abort(); // 取消请求
content.value = '⏹️ 已停止生成';
7.5 如何处理超大 chunk?
javascript
ini
// 如果一次 read() 返回了 1MB 数据
// split('\n') 会创建大量数组元素
// 更高效的做法:使用 for 循环逐个处理
const processChunkEfficient = (chunkValue) => {
let start = 0;
let end = chunkValue.indexOf('\n');
const validLines = [];
while (end !== -1) {
const line = chunkValue.slice(start, end);
if (line.startsWith('data:')) {
validLines.push(line);
}
start = end + 1;
end = chunkValue.indexOf('\n', start);
}
return validLines;
};