现在主流大模型 API 都支持SSE 流式输出 ,能实现打字机实时返回回答效果,体验远优于一次性等待完整结果返回。本文基于 Vue3 <script setup> 组合式 API,使用原生 Fetch + ReadableStream 不依赖第三方库,完整实现 DeepSeek 接口普通返回 / 流式切换双模式,附带逐行代码拆解与底层原理讲解。
一、整体实现效果
- 输入提问文本,点击提交请求大模型
- 勾选 Streaming 开启流式打字机输出,取消则一次性返回完整回答
- 请求过程显示「思考中...」加载状态
- 纯原生 JS 实现流解析,无 axios、sse.js 等额外依赖
- 完整封装接口请求、二进制流解码、分段文本拼接逻辑
二、分段代码详细解析
3.1 响应式数据定义
ini
const question = ref("讲一个中国龙的故事");
const content = ref("");
const stream = ref(true);
ref:Vue3 基础响应式 API,处理字符串、数字等基础类型;底层为RefImpl实例,通过.value读写原始值question:双向绑定输入框,存储用户提问content:AI 回答渲染容器,流式模式下持续追加文本stream:布尔开关,控制接口stream参数,切换两种返回模式
3.2 基础请求封装
javascript
const endpoint = "https://api.deepseek.com/chat/completions";
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
};
- DeepSeek 官方对话接口地址,鉴权使用 Bearer Token
import.meta.env.VITE_XXX:Vite 环境变量,将 API 密钥存入.env文件,避免硬编码泄露密钥
css
body: JSON.stringify({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: question.value }],
stream: stream.value,
})
接口入参规范:
model:指定调用模型messages:对话上下文数组,role: user代表用户提问stream:布尔值,true开启 SSE 流式分片返回,false同步返回完整结果
3.3 非流式逻辑(同步一次性返回)
ini
const data = await response.json();
content.value = data.choices[0].message.content;
关闭流式时,接口阻塞等待 AI 完整生成回答后返回 JSON,直接读取message.content完整文本赋值,缺点是长文本等待时间长,无实时反馈。
3.4 流式输出核心:ReadableStream 数据流处理
1. 获取二进制读取器与解码器
ini
const reader = response.body?.getReader();
const decoder = new TextDecoder();
response.body:Fetch 响应内置ReadableStream二进制流对象,服务端持续推送二进制数据分片getReader():创建流读取器,提供read()方法逐块拉取数据TextDecoder:浏览器内置 API,将二进制 Uint8Array 转 UTF-8 字符串,解决中文乱码问题
2. 循环读取分片 代码逐行深度拆解
ini
while (!done) {
const { value, done: doneReading } = await reader?.read();
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
buffer = "";
}
整体逻辑概述
while (!done) 是无限循环读取二进制数据流 ,只要服务端还在持续返回大模型 Token 分片,循环就不会停止;直到服务端推送结束标识 [DONE] 或流读取完毕,done 变为 true,跳出循环。 整个流程本质:一次拉取一小块二进制数据 → 拼接缓存 → 转字符串 → 解析文本 → 继续拉取下一块。
逐行拆解
1. while (!done)
-
done:自定义布尔标记,初始值false,代表「流是否读取完毕」 -
循环条件:只要流没有结束,就持续执行读取逻辑
-
终止条件:两种场景会让
done = truereader.read()返回doneReading = true:服务端所有数据推送完成,流自然关闭- 手动捕获到
data: [DONE]结束标记,主动赋值done = true跳出循环
2. const { value, done: doneReading } = await reader?.read();
① reader?.read() 可选链 ?.
response.body?.getReader() 有可能返回 undefined(老旧浏览器不支持 ReadableStream),使用可选链避免 read() is not a function 报错,做兼容性兜底。
② read() 是异步方法,必须加 await
reader.read() 会阻塞等待,直到浏览器收到服务端下发的一块二进制分片才会 resolve 返回结果; 如果当前没有新数据,代码会挂起等待,不会无限空跑循环,不会占用 CPU。
③ 返回值解构 { value, done: doneReading }
read() 执行后返回一个 Promise,成功后得到对象:
typescript
{
value: Uint8Array | undefined, // 本次拿到的二进制字节数组
done: boolean // 原生流结束标记,重命名为 doneReading 区分自定义done变量
}
-
value:本次从服务端拉取的二进制原始数据,存储在Uint8Array类型数组中,中文、英文、符号全部以二进制字节形式存在; -
done: doneReading:重命名变量,防止和外层自定义done冲突。true:服务端数据流彻底关闭,不会再有新分片;false:还有后续数据,继续循环读取。
3. done = doneReading;
把原生流的结束状态同步给外层循环标记 done。 当服务端传输完毕,doneReading 为 true,赋值后下一轮 while (!done) 条件不成立,循环终止。
4. const chunkValue = buffer + decoder.decode(value);
① decoder.decode(value)
TextDecoder 浏览器原生 API,专门解析 Uint8Array 二进制数组:
- 将二进制字节转为 UTF-8 标准字符串,完美解决中文多字节乱码问题;
- 单独解码单块
value可能出现截断残缺字符串(网络分片切割在一个汉字 / 一条 JSON 中间)。
② buffer + 解码后的字符串
buffer 是全局缓存变量,专门存放上一轮解析残缺、不完整无法解析的字符串片段 。 举例场景: 上一轮分片结尾只读到 {"delta":{"content":"你好,JSON 不完整,解析失败,存入 buffer; 本轮新分片开头是 世界"}},拼接后 {"delta":{"content":"你好世界"}},形成完整可解析 JSON。
5. buffer = "";
本轮缓存 + 新分片拼接完成 chunkValue 后,清空缓存; 后续解析 chunkValue 过程中如果再次遇到残缺 JSON,再重新把残缺片段写入 buffer,等待下一轮拼接。
完整运行流程示例
- 初始:
done = false,buffer = "",进入循环 await reader.read()等待服务端下发二进制块,得到value二进制、doneReading = falsedone = false,循环继续- 二进制转字符串,和空 buffer 拼接得到完整
chunkValue - 清空 buffer,解析
chunkValue内所有data:数据行 - 若解析过程有残缺 JSON,将残缺片段存入 buffer
- 回到循环开头,再次拉取下一块二进制分片,自动拼接上一轮残缺缓存
关键设计目的:buffer 分片容错
网络传输时,服务端的一行完整 SSE 数据可能被拆成 2~3 个二进制包分次下发:
- 包 1:
data: {"delta":{"content":"我今 - 包 2:
天去公园"}}\n只解析单包会 JSON.parse 报错,依靠buffer跨循环拼接分片,保证一定拿到完整 JSON 字符串再解析,是流式处理必不可少的容错逻辑。
3. 过滤 SSE 标准数据行
arduino
const lines = chunkValue
.split("\n")
.filter((line) => line.startsWith("data: "));
大模型 SSE 协议规范:每一条数据以data: 开头,换行分隔,过滤无关空行、注释行,只保留有效业务数据。
4. JSON 解析与增量文本拼接
ini
for (const line of lines) {
if (line === "data: [DONE]") {
done = true;
break;
}
const jsonStr = line.replace("data: ", "");
try {
const resJson = JSON.parse(jsonStr);
const deltaText = resJson.choices[0].delta.content || "";
content.value += deltaText;
} catch (err) {
buffer += line;
}
}
data: [DONE]:服务端流结束标识,终止读取循环delta.content:流式独有增量字段,每次只返回新增文字,持续追加到content实现打字机效果try/catch容错:JSON 解析失败代表分片不完整,存入 buffer 等待下一轮合并
三、完整可运行代码
xml
<script setup>
// vue3 composition 组合 api
// 业务逻辑聚合,区分Vue2选项式分散写法
import { ref } from "vue";
// 响应式数据
const question = ref("讲一个中国龙的故事"); // 用户提问
const content = ref(""); // AI返回内容
const stream = ref(true); // 是否开启流式输出开关
// 核心请求函数
const update = async () => {
// 空输入拦截
if (!question.value) return;
content.value = "思考中...";
// DeepSeek 接口地址
const endpoint = "https://api.deepseek.com/chat/completions";
// 请求头,携带鉴权密钥
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
};
// 发起POST请求
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: question.value }],
stream: stream.value, // 根据开关切换流式/一次性返回
}),
});
// 分支1:流式输出处理逻辑
if (stream.value) {
content.value = "";
// 获取响应二进制读取器
const reader = response.body?.getReader();
// 二进制转UTF-8文本解码器
const decoder = new TextDecoder();
let done = false;
let buffer = ""; // 分片缓存,防止单行数据截断
// 循环读取数据流,直到流结束
while (!done) {
// 读取一块二进制分片
const { value, done: doneReading } = await reader?.read();
done = doneReading;
// 拼接缓存+当前分片,转字符串
const chunkValue = buffer + decoder.decode(value);
buffer = "";
// 过滤SSE标准data: 前缀数据行
const lines = chunkValue
.split("\n")
.filter((line) => line.startsWith("data: "));
// 遍历每一行分片JSON
for (const line of lines) {
// 服务端结束标识,终止循环
if (line === "data: [DONE]") {
done = true;
break;
}
// 截取data: 后面的JSON字符串
const jsonStr = line.replace("data: ", "");
try {
const resJson = JSON.parse(jsonStr);
// 增量文本delta追加到页面内容
const deltaText = resJson.choices[0].delta.content || "";
content.value += deltaText;
} catch (err) {
// JSON解析失败存入缓存,下一轮拼接处理
buffer += line;
}
}
}
} else {
// 分支2:非流式,一次性获取完整JSON
const data = await response.json();
content.value = data.choices[0].message.content;
}
};
</script>
<template>
<div class="container">
<!-- 提问输入区域 -->
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<!-- 输出控制与展示区域 -->
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
</div>
<div>{{ content }}</div>
</div>
</div>
</template>
<style>
.container {
/* flex纵向布局,贴合文档流 */
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
button {
padding: 0 10px;
margin-left: 6px;
}
</style>
四、模板与样式说明
Template
- 输入框使用
v-model双向绑定question,按钮绑定点击事件触发请求 - 复选框
v-model绑定stream,一键切换流式开关 {{ content }}响应式渲染 AI 回答,数据流更新页面自动刷新
Style
- 使用 flex 纵向布局,适配页面全屏高度
- 输出区域设置最小高度,预留回答展示空间
- 基础尺寸、边距优化,保证基础交互观感
五、环境配置与踩坑指南
5.1 环境变量配置
项目根目录新建.env文件:
ini
VITE_DEEPSEEK_API_KEY=你的DeepSeek密钥
重启项目后import.meta.env可正常读取密钥。
5.2 常见问题解决
- 跨域报错 本地开发前端直接请求 DeepSeek 接口会触发 CORS,解决方案:
- Vite 配置本地代理转发接口请求
- 后端搭建中转接口,前端请求自身服务,由服务端调用大模型 API
- 中文乱码 必须使用
TextDecoder解析二进制分片,不可直接转字符串,二进制分片无法正确识别多字节中文。 - JSON 解析报错 网络分片截断单行 JSON,依靠
buffer缓存碎片,不要直接丢弃解析失败的行。 - 流读取无响应 判断
response.body是否存在,低版本浏览器不支持ReadableStream,可增加降级逻辑切换非流式模式。
六、核心技术原理总结
- SSE 流式通信:服务端长连接持续推送增量数据,相比轮询、WebSocket 更适合大模型文本输出场景
- ReadableStream:浏览器原生流式处理 API,无需一次性加载全部响应数据,分片处理节省内存
- Vue 响应式更新 :每次追加
delta增量文本,ref触发局部 DOM 更新,实现实时打字效果 - 组合式 API 优势:接口请求、流处理、状态控制全部逻辑聚合在一处,对比 Vue2 选项式代码更易维护、复用