🌟 引言:为什么需要流式输出?
在调用大语言模型(如 DeepSeek、OpenAI 等)时,如果等待完整响应再一次性渲染,用户会经历漫长的"白屏"等待。而流式输出(Streaming) 能让模型生成的内容逐字/逐词返回,极大提升交互体验------就像你在和真人聊天一样。
本文将手把手带你用 Vue 3 + fetch + Server-Sent Events (SSE) 实现一个支持流式输出的简易 Chat 应用,并深入解析关键细节。
🛠️ 一、项目初始化
我们使用 Vite 快速搭建 Vue 3 项目:
kotlin
bash
编辑
npm init vite@latest llm-stream-demo -- --template vue
cd llm-stream-demo
npm install
选择:
- 框架:Vue
- 变体:JavaScript
项目结构如下:
css
text
编辑
src/
├── App.vue # 根组件
├── main.js
└── ...
💡 Vite 优势:启动快、热更新快、原生支持 ES 模块,非常适合现代前端开发。
🧩 二、核心逻辑:如何处理流式响应?
1. API 接口规范(以 DeepSeek 为例)
DeepSeek 的 /chat/completions 接口支持 stream: true 参数,返回格式为 SSE(Server-Sent Events) :
css
text
编辑
data: {"choices": [{"delta": {"content": "你"}}]}
data: {"choices": [{"delta": {"content": "好"}}]}
data: [DONE]
每行以 data: 开头,最后以 [DONE] 结束。
2. 使用 fetch + ReadableStream 解析流
关键在于正确读取 response.body 这个 HTML5 ReadableStream。下面我们将重点剖析其中最核心的一行代码。
🔍 三、前置基础:response.body?.getReader() 全面解析
在进入 reader.read() 之前,必须先理解这行代码:
ini
js
编辑
const reader = response.body?.getReader();
它是整个流式读取流程的起点 。下面我们从 核心语法拆解 → 逐部分深度解析 → 完整示例 → 注意事项 → 适用场景 五个维度讲透。
一、核心语法拆解
| 语法片段 | 作用说明 |
|---|---|
response |
fetch() 返回的 Response 对象(HTTP 响应封装) |
response.body |
Response 的 body 属性,类型为 ReadableStream(可读字节流) |
?. |
可选链操作符,防止 response.body 为 null/undefined 时报错 |
getReader() |
ReadableStream 的方法,返回 ReadableStreamDefaultReader(流读取器) |
const reader |
声明常量存储读取器实例,用于手动读取流中的数据块 |
二、逐部分深度解析
1. response:Fetch API 的响应对象
response 是调用 fetch(url) 后返回的 Promise 解析结果:
ini
js
编辑
fetch('https://example.com/large-data')
.then(response => {
// 这里的 response 就是 Response 对象
const reader = response.body?.getReader();
});
它包含 HTTP 响应的元信息(状态码、头信息)和响应体(body)。
2. response.body:可读字节流(ReadableStream)
-
核心特性 :代表流式数据,而非一次性加载的完整内容,适用于大文件、实时日志、视频流等场景。
-
与
response.json()/.text()的区别:json()/text()会将整个响应体加载到内存 → 适合小数据;body支持逐块读取 → 避免内存溢出(如处理 1GB 文件)。
-
可能的值:
- 正常响应:返回
ReadableStream实例; - 无响应体(如
204 No Content)或跨域错误:返回null。
- 正常响应:返回
3. ?.:可选链操作符
-
作用 :若
response.body为null或undefined,则response.body?.getReader()直接返回undefined,不会执行后续方法。 -
避免错误 :防止抛出
Cannot read properties of null (reading 'getReader')。 -
等价 ES5 写法:
inijs 编辑 const reader = response.body ? response.body.getReader() : undefined;
4. getReader():获取流读取器
调用后返回一个 ReadableStreamDefaultReader 实例(简称 reader),其核心方法包括:
| 方法 | 作用 |
|---|---|
reader.read() |
异步读取下一个数据块,返回 Promise<{ done: boolean, value: Uint8Array }> |
reader.releaseLock() |
释放读取器对流的独占锁,允许其他读取器使用该流 |
⚠️ 注意 :调用
getReader()后,流被"锁定",只能由该读取器读取,直到调用releaseLock()。
三、完整使用示例(逐块读取流)
javascript
js
编辑
async function readStreamData(url) {
const response = await fetch(url);
// 获取读取器(安全处理 body 为 null 的情况)
const reader = response.body?.getReader();
if (!reader) {
console.log('响应无数据流');
return;
}
const chunks = []; // 存储所有数据块
try {
while (true) {
const { done, value } = await reader.read();
if (done) break; // ✅ 流读取完毕
chunks.push(value); // value 是 Uint8Array
}
// 合并所有块为完整 Uint8Array
const fullLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const fullData = new Uint8Array(fullLength);
let offset = 0;
for (const chunk of chunks) {
fullData.set(chunk, offset);
offset += chunk.length;
}
// 转为字符串(根据实际编码调整)
const text = new TextDecoder().decode(fullData);
console.log('完整数据:', text);
} catch (err) {
console.error('读取流失败:', err);
} finally {
reader.releaseLock(); // ✅ 释放锁,避免内存泄漏
}
}
// 调用
readStreamData('https://example.com/large-file.txt');
四、关键注意事项
- 独占锁 :
getReader()会锁定流,其他读取器无法使用,必须调用releaseLock(); - 数据类型 :
value是Uint8Array,需用TextDecoder转字符串,或按需解析为 JSON/二进制; - 错误处理 :网络中断等异常会 reject
read()的 Promise,需try/catch; - 兼容性:浏览器全支持(IE 除外),Node.js 16+ 支持(18+ 更稳定);
- 取消读取 :可调用
reader.cancel()主动终止流并释放资源。
五、适用场景
- 大文件下载/上传(避免内存爆炸);
- 实时数据推送(如 SSE、日志流);
- 边下载边解析(JSON/CSV 分块处理);
- 音视频流的前端处理。
✅ 总结 :
const reader = response.body?.getReader();是安全获取流读取器的关键一步,为处理海量或实时数据奠定基础。
🔥 四、深度解析:reader.read() 与 done 状态机制
在流式响应处理中,以下这行代码是整个机制的引擎:
bash
js
编辑
const { value, done: doneReading } = await reader?.read();
它看似简单,却融合了解构赋值、异步流读取、可选链等多个关键概念。下面我们从 逐行拆解 + 核心概念 + 实际场景 + 易错点 四个维度彻底讲透。
✅ 上下文回顾
典型流式读取循环如下:
ini
js
编辑
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
while (!done) {
const { value, done: doneReading } = await reader?.read(); // ← 核心行
done = doneReading;
if (value) {
const chunk = decoder.decode(value);
content.value += chunk;
}
}
📖 逐行拆解核心代码
const { value, done: doneReading } = await reader?.read();
| 部分 | 作用 | 详细说明 |
|---|---|---|
reader?.read() |
异步读取流的「下一个数据块」 | - reader 是通过 response.body.getReader() 创建的流读取器; - ?. 是可选链,防止 reader 为 undefined 时报错(比如请求失败); - read() 每次只读一块,不会自动继续,需手动循环调用。 |
await |
等待异步读取完成 | read() 返回 Promise<{ value, done }>, 必须 await 才能拿到实际数据。 |
{ value, done: doneReading } |
解构并重命名 | - value: 当前块的二进制数据(Uint8Array),流结束时为 undefined; - done: 布尔值,true 表示流已结束; - 重命名为 doneReading 是为了避免与外层 let done 变量冲突(否则循环无法终止!)。 |
done = doneReading;
- 外层
let done = false是控制while循环的全局状态; - 每次读取后,将当前块的
done状态同步给全局变量; - 一旦某次返回
done: true,循环终止,流读取完成。
🔑 补充深度:done 状态的本质与使用逻辑
你代码中的 done = doneReading,其实是在对 read() 返回的 done 字段做别名赋值 。这个 done 是 ReadableStream 读取完成的核心标识,理解它对正确实现流式逻辑至关重要。
一、done(即 doneReading)的核心含义
done 值 |
状态说明 | value 对应值 |
|---|---|---|
false |
流未读取完成,本次 read() 成功获取到了一段数据块 |
Uint8Array(非空字节数组) |
true |
流已完全读取完毕,没有更多数据 | undefined |
✅ 简单说:
done: true→ "流读完了,没数据了"done: false→ "还有数据,这次拿到了一小段"
二、done: true 的触发时机
该状态由流的底层机制自动决定,不是前端手动设置的,常见触发场景包括:
- 正常完成:服务器发送完全部响应体,且前端已读取完所有数据块;
- 主动终止 :调用
reader.cancel()取消读取,后续read()返回done: true; - 响应无体 :HTTP 状态码为
204 No Content或1xx信息响应,response.body为空; - 流被关闭:服务器或客户端主动断开连接(如 TCP 正常关闭)。
三、done 状态的典型使用逻辑
标准流读取循环通常这样写:
javascript
js
编辑
while (true) {
const { done: doneReading, value } = await reader.read();
// ✅ 关键判断:流是否读完?
if (doneReading) {
console.log('流读取完成');
break; // 终止循环
}
// 处理当前数据块
console.log('收到数据:', decoder.decode(value));
}
你代码中的 done = doneReading 正是为了实现这一逻辑------将局部 done 状态同步到循环控制变量,从而安全退出。
四、关键注意点
done: true是最终状态 :一旦出现,后续所有read()调用都会返回{ done: true, value: undefined },无需再读;done: true≠ 错误 :它是正常结束信号 ,错误会通过 Promise reject 抛出(需try/catch捕获);- 必须终止循环 :若忽略
done: true,会导致无限循环(虽然不报错,但浪费 CPU)。
五、对比理解:错误 vs 正常结束
| 场景 | done 状态 |
处理方式 |
|---|---|---|
| 流正常读取完毕 | true |
退出循环,合并数据 |
| 读取到有效数据块 | false |
处理 value,继续循环 |
| 网络中断 / 流损坏 | ---(无返回) | read() Promise 被 reject,需 catch 捕获 |
✅ 总结 :
done = doneReading中的doneReading就是read()返回的原始done字段,它是判断流是否终结的唯一可靠依据 。true表示"读完了",false表示"还有数据"。这是流式编程的基石。
🌐 核心概念:为什么流要这样读?
- 普通响应 :服务器拼好全部内容 → 一次性返回 → 前端
response.json()拿到完整数据。 - 流式响应 :服务器边生成边返回(如大模型 token-by-token 输出)→ 数据分块传输 → 前端必须逐块读取。
ReadableStream 的设计正是为了:
- 避免一次性加载大文本(节省内存);
- 支持实时渲染(提升用户体验);
- 通过
done明确告知"是否还有下一块"。
🎯 实际场景举例
假设你问:"1+1 等于几?",流式读取过程如下:
| 读取次数 | read() 返回值 |
doneReading |
全局 done |
操作 |
|---|---|---|---|---|
| 第1次 | { value: Uint8Array("1+1"), done: false } |
false |
false |
解码为 "1+1",拼接到页面 |
| 第2次 | { value: Uint8Array("等于2"), done: false } |
false |
false |
拼接为 "1+1等于2" |
| 第3次 | { value: undefined, done: true } |
true |
true |
循环结束 |
⚠️ 易错点与注意事项
- 必须用
while循环
read()是单次操作,只调一次只能拿到第一块! value是二进制
直接使用会看到Uint8Array,必须用TextDecoder.decode()转字符串。- 可选链
?.很重要
若response.body为空(如 404 错误),reader为undefined,不加?.会直接报错。 - 避免变量名冲突
如果写成const { value, done } = ...,会覆盖外层done,导致循环永不退出! - 流只能读一次
调用getReader()后,流被"锁定",读完即销毁,无法重复读取。
💬 简化版"人话"逻辑
arduino
plaintext
编辑
准备一个读取器
流是否结束 = false
只要没结束,就一直读:
等待读取下一块
拿到数据块 和 "是否最后一块"
更新"流是否结束"状态
如果有数据,转成文字,拼到页面上
✅ 记住两个关键点:
reader.read()每次读一块,返回{ value, done };done = doneReading是让循环知道"什么时候该停"。
🧠 五、关于 buffer 的真相:它真的多余吗?
在很多简化示例(包括本文前面的代码)中,你会看到类似这样的写法:
ini
js
编辑
let buffer = '';
const chunk = buffer + decoder.decode(value, { stream: true });
buffer = '';
content.value += chunk;
乍一看,buffer 全程为空,拼接和清空操作"走了流程但没产生实际效果"------在这个特定场景下,确实如此 。但这并不意味着 buffer 是多余的,而是你的示例处于"理想情况":每次 decoder.decode() 都恰好返回完整的业务字符串,没有残段。
我们分两层说清楚:
1. 在你的简化示例中:buffer 确实 "没用"
回顾逻辑:
ini
js
编辑
let buffer = '';
const decoder = new TextDecoder('utf-8');
// 第一次:buffer 为空
const chunkValue1 = buffer + decoder.decode(Buffer.from('你好世'), { stream: true });
buffer = ''; // 清空 → 仍为空
// 第二次:buffer 仍为空
const chunkValue2 = buffer + decoder.decode(Buffer.from('界'), { stream: true });
buffer = ''; // 清空 → 无意义
这里 buffer 既没有被赋值为非空值,也没有为拼接提供任何有效数据,纯粹是"标准写法的惯性"------就像给空杯子擦桌子,动作做了,但杯子本来就干净,没产生实际价值。
✅ 结论:在纯文本连续输出(如 LLM 流式回复)且无需按规则切分的场景下,
buffer可省略。
2. 但在真实业务场景中:buffer 是必不可少的
buffer 的核心价值,是处理 "业务层存在未完成的字符串残段" 的情况。例如:
- 按
|、\n、}等分隔符解析流数据; - 按固定长度截取协议帧;
- 解析 JSON 流时遇到半截对象。
🌰 举个真实例子:按 | 分隔接收数据
目标完整数据 :你好|世界|123
实际传输被拆成两段:
- 第一段:
Buffer.from('你好|世') - 第二段:
Buffer.from('界|123')
如果没有 buffer 缓存残段,第一次会错误地认为 世 是一个完整字段,导致数据错乱。
正确处理(此时 buffer 发挥关键作用) :
ini
js
编辑
let buffer = '';
const decoder = new TextDecoder('utf-8');
// 第一次接收
const chunk1 = buffer + decoder.decode(Buffer.from('你好|世'), { stream: true });
// → chunk1 = '你好|世'
const parts1 = chunk1.split('|');
const complete1 = parts1.slice(0, -1); // ['你好'] ← 可安全使用
buffer = parts1[parts1.length - 1]; // buffer = '世' ← 缓存残段!
// 第二次接收
const chunk2 = buffer + decoder.decode(Buffer.from('界|123'), { stream: true });
// → chunk2 = '世' + '界|123' = '世界|123'
const parts2 = chunk2.split('|');
const complete2 = parts2.slice(0, -1); // ['世界', '123']
buffer = parts2[parts2.length - 1]; // buffer = '' ← 清空
// 最终完整字段:['你好', '世界', '123']
✅ 关键点 :
buffer缓存了第一次拆分后无法构成完整业务单元的残段世,第二次拼接后才形成世界------避免数据断裂。
总结:buffer 的设计哲学
| 场景 | buffer 作用 |
是否必要 |
|---|---|---|
| 简化 LLM 流式输出(连续文本) | 无实质性作用,仅"标准写法惯性" | ❌ 可省略 |
| 按分隔符/长度/协议解析流数据 | 缓存业务层残段,保证数据完整性 | ✅ 必不可少 |
💡 最佳实践建议 :
即使当前场景不需要
buffer,保留其声明和清空逻辑是一种"鲁棒性设计"------为未来可能的协议变更或数据格式扩展预留空间。属于"提前留好扩展接口"的工程思维。
🖼️ 六、完整组件实现(App.vue)
xml
vue
编辑
<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref('')
const askLLM = async () => {
if (!question.value) return
content.value = stream.value ? '思考中...' : ''
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream: stream.value,
messages: [{ role: 'user', content: question.value }]
})
})
if (!response.ok) {
content.value = '请求失败,请检查 API Key 或网络'
return
}
if (stream.value) {
content.value = ''
const reader = response.body?.getReader() // 获取流读取器(可能为 undefined)
const decoder = new TextDecoder() // 用于二进制 → 字符串
let done = false // 全局流结束标记
while (!done) {
// 🔥【核心行】逐块读取流数据
// - reader?.read():安全调用 read(),防止 reader 为 undefined
// - await:等待异步读取完成
// - 解构并重命名 done → doneReading,避免与外层变量冲突
const { value, done: doneReading } = await reader?.read()
// 更新全局流状态:决定 while 循环是否继续
// doneReading 即 read() 返回的 done 字段,true 表示流已读完
done = doneReading
if (value) {
// 将二进制块解码为字符串,并拼接到响应内容
const chunk = decoder.decode(value)
content.value += chunk
}
}
} else {
const data = await response.json()
content.value = data.choices?.[0]?.message?.content || '无内容'
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input v-model="question" placeholder="请输入问题" />
<button @click="askLLM">提交</button>
</div>
<div class="output">
<label>
<input type="checkbox" v-model="stream" /> 启用流式输出
</label>
<div class="content">{{ content }}</div>
</div>
</div>
</template>
<style scoped>
.container {
padding: 20px;
max-width: 600px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
input, button {
padding: 6px 10px;
margin: 4px;
border: 1px solid #ccc;
border-radius: 4px;
}
.output {
margin-top: 20px;
}
.content {
margin-top: 10px;
padding: 12px;
background: #f9f9f9;
border-radius: 6px;
min-height: 100px;
white-space: pre-wrap;
}
</style>
✅ 环境变量安全提示 :
VITE_DEEPSEEK_API_KEY需在.env.local中定义(不要提交到 Git):
initext 编辑 VITE_DEEPSEEK_API_KEY=your_api_key_here
📌 七、注意事项与最佳实践
-
错误处理必须完善
- 检查
response.ok - 捕获
fetch网络错误 - 处理 JSON 解析异常
- 检查
-
避免内存泄漏
- 在组件卸载时取消未完成的流(可结合
AbortController) - 虽然 Vue 组件销毁后
reader会被 GC,但显式调用reader.releaseLock()更规范
- 在组件卸载时取消未完成的流(可结合
-
用户体验优化
- 显示"思考中..."加载态
- 自动滚动到底部(可配合
nextTick+scrollIntoView)
-
安全性
- API Key 绝不能硬编码在前端
- 生产环境应通过后端代理转发请求(避免暴露密钥)
🧠 八、拓展思考
如果不用 DeepSeek,换成 OpenAI 呢?
接口几乎一致!只需改:
endpoint→https://api.openai.com/v1/chat/completionsmodel→gpt-3.5-turbo或gpt-4- Header 中的 Bearer Token
✅ 说明:主流 LLM 的流式接口设计高度统一,迁移成本极低。
能否支持 Markdown 渲染?
可以!在 content.value += delta 后,用 marked.js 或 vue-markdown-plus 实时渲染:
javascript
js
编辑
import { marked } from 'marked'
// ...
const html = marked(content.value)
// 绑定到 v-html(注意 XSS 风险!)
⚠️ 警告 :
v-html有 XSS 风险,务必对内容进行过滤或使用可信来源。
✅ 总结要点
| 要点 | 说明 |
|---|---|
response.body?.getReader() |
安全获取流读取器,是流式处理的起点 |
reader.read() |
每次读取一个数据块,返回 { value, done } |
done 状态 |
true 表示流读取完成,是循环终止的唯一依据 |
| 数据类型 | value 是 Uint8Array,需用 TextDecoder 转字符串 |
buffer 的作用 |
在连续文本场景中可省略;在协议解析场景中必不可少,用于缓存业务残段 |
| 错误 vs 结束 | done: true 是正常结束,错误需 catch 捕获 |
| 响应式更新 | Vue 3 的 ref 自动触发 DOM 更新 |
| 安全第一 | API Key 不应暴露在前端,建议走代理 |