不用第三方SDK!Vue3原生Fetch实现DeepSeek流式输出,90%前端都会踩的分片解析坑一次性解决
不用第三方SDK!Vue3原生Fetch实现DeepSeek流式输出,90%前端都会踩的分片解析坑一次性解决并不只看表面做法,关键还要理解相关条件、限制和后续影响。
前言
做AI聊天页面你一定遇过两种糟心体验:
- 关闭流式:点击提交黑屏等待3-10秒,一次性弹出全文,用户等待感极强
- 手写SSE流式:网络分包截断JSON,疯狂报
JSON.parse解析失败,文字丢失乱码
网上很多示例只给极简demo,没有处理分片容错,上线必崩。这篇内容以Vite+Vue3+原生Fetch完整实现DeepSeek对话,同时支持流式打字机/一次性返回双模式,自带buffer分片容错逻辑,看完你能学到:
- SSE流式输出底层二进制流传输原理
- ReadableStream、TextDecoder浏览器原生API完整用法
- buffer缓冲区解决TCP分包截断JSON的核心方案
- 流式/非流式接口两套分支代码完整实现
- 开发高频踩坑清单+修复方案,直接规避线上bug
- 可直接复制运行的完整单文件组件
一、先搞懂:什么是LLM流式SSE输出
1.1 传统一次性请求(stream=false)
后端等AI完整生成全部文本,组装成完整JSON一次性返回。前端调用response.json()直接解析,优点代码简单,缺点等待时间长,交互割裂。
1.2 SSE流式请求(stream=true)
大模型每生成一段Token,就封装成data: JSON格式通过二进制流实时推送到前端:
- 传输载体:
response.body二进制可读流(Uint8Array字节数组) - 分隔规则:每条数据用换行
n分割,结尾单独发送data: [DONE]标识流结束 - 传输痛点:TCP网络分包会把一条完整JSON拆成两半,直接解析报错,必须用buffer缓存残缺片段
1.3 核心API介绍
response.body.getReader():创建流读取器,逐块拉取二进制数据TextDecoder():二进制Uint8Array转UTF-8字符串,解决中文乱码- buffer缓冲区:存储上一轮未解析完成的残缺
data:报文,下一轮拼接完整再解析
二、项目前置环境配置
2.1 依赖无需额外安装
本方案纯浏览器原生API,不需要openai/langchain等第三方SDK,Vite Vue3项目开箱即用。
2.2 环境变量配置(关键,防止密钥硬编码泄露)
项目根目录新建.env文件,填入DeepSeek密钥:
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx
Vite通过import.meta.env.VITE_XXX读取环境变量,打包后不会明文暴露密钥。
三、完整可运行代码 App.vue
<script setup>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();// 二进制转UTF8文本解码器const decoder = new TextDecoder();let done = false; // 流读取完成标记let buffer = ''; // 残缺分片缓存(解决JSON截断报错核心)// 循环持续拉取二进制分片while (!done) {// 异步读取一块二进制数据const { value, done: doneReading } = await reader?.read();done = doneReading;// 拼接上一轮残留残缺片段 + 当前新解码文本const chunkValue = buffer + decoder.decode(value);buffer = ""; // 缓存已合并,清空等待下一轮残缺数据// 按换行分割文本,过滤仅保留data:开头的SSE有效行const lines = chunkValue.split('n').filter((line) => line.startsWith('data: '))// 逐行解析每条SSE报文for (const line of lines) {// 切掉前缀 data: 6个字符,获取纯JSON/结束标识const incoming = line.slice(6);// 检测到结束标识,终止全部循环if (incoming === '[DONE]') {done = true;break;}try {// 解析JSON字符串const data = JSON.parse(incoming);// 流式专属增量文本deltaconst delta = data.choices[0].delta.content;// 存在增量文字则追加到页面,实现打字机效果if (data && delta) {content.value += delta;}} catch (err) {// JSON解析失败=分片不完整,存入buffer下一轮拼接buffer = `data: ${incoming}`;}}}}// ========== 分支2:非流式一次性返回 ==========else {const data = await response.json();// 非流式使用message完整文本,而非delta增量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><!-- 流式开关 + AI回答展示区 --><div class="output"><div><label>Streaming流式输出</label><input type="checkbox" v-model="stream" /></div><div>{{ content }}</div></div></div></template><style scoped>.container {display: flex;flex-direction: column;align-items: flex-start;justify-content: flex-start;height: 100vh;font-size: 0.85rem;padding: 20px;}.input {width: 300px;padding: 4px 8px;}.output {margin-top: 12px;min-height: 300px;width: 100%;text-align: left;line-height: 1.6;}button {padding: 4px 12px;margin-left: 8px;cursor: pointer;}</style>
四、核心流式逻辑逐行深度拆解
4.1 基础变量初始化
if (stream.value) {content.value = "";const reader = response.body?.getReader();const decoder = newTextDecoder();let done = false;let buffer = '';
reader:流专属读取器,串行读取二进制数据,保证顺序不乱decoder:全局解码器,循环内复用,避免中文跨分片乱码done:外层while循环开关,控制数据流是否全部接收完毕buffer:全文最关键容错变量,专门存储被TCP分包截断的半条data:报文
4.2 while循环:持续拉取二进制分片
while (!done) {const { value, done: doneReading } = await reader?.read();done = doneReading;const chunkValue = buffer + decoder.decode(value);buffer = "";const lines = chunkValue.split('n').filter((line) => line.startsWith('data: '))}
reader.read():异步阻塞读取,有新分片立刻返回,无数据持续等待chunkValue = buffer + 新文本:核心容错操作,把上一轮残缺片段和本次新数据拼接,保证报文完整split('n'):SSE协议每条数据换行分隔,切割后过滤无效空行、心跳包,只保留data:有效数据
4.3 for循环:解析单条SSE报文
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 (data && delta) content.value += delta;} catch (err) {buffer = `data: ${incoming}`;}}
line.slice(6):剔除data:固定前缀,提取纯JSON字符串[DONE]:服务端流结束标志,终止所有循环delta.content:流式接口专属增量字段,每次仅返回本次生成的少量文字,Vue响应式追加实现逐字打字效果catch容错逻辑:JSON解析报错代表当前行是残缺报文,存入buffer,下一轮循环拼接新分片后再解析,杜绝文字丢失
4.4 非流式分支简单说明
else {const data = await response.json();content.value = data.choices[0].message.content;}
关闭流式时,后端等待AI全部生成完毕,一次性返回完整JSON,使用message.content完整文本,无需处理二进制流、分片、buffer,代码极简,但用户等待体验差。
五、高频开发踩坑清单(必看,上线避坑)
坑1:TCP分包截断JSON,疯狂报parse错误
现象:控制台频繁抛出JSON语法错误,AI回答文字残缺、丢失原因:网络传输会把一条data: JSON切成两块,单块无法完整解析解决方案:代码中buffer缓冲区,拼接残缺片段后再解析
坑2:中文跨分片解码出现乱码
现象:部分中文显示问号、乱码字符优化方案:decoder.decode(value, { stream: true }),解码器自动缓存跨分片字节,完整解析中文
坑3:忘记清空buffer,重复叠加文本
现象:AI回答重复、内容翻倍修复:拼接chunkValue后立刻执行buffer = ""清空缓存
坑4:混淆流式/非流式字段 delta / message
现象:关闭流式返回undefined,开启流式无文字输出区分:
- stream=true →
data.choices[0].delta.content - stream=false →
data.choices[0].message.content
坑5:连续点击提交,多请求文字叠加错乱
优化补充:增加loading锁,请求期间禁用提交按钮,防止并发请求
坑6:API Key硬编码写在代码内
风险:前端打包后源码泄露密钥,产生高额扣费规范:统一放入.env环境变量,通过import.meta.env读取
六、流式与非流式方案对比
| 对比维度 | stream=true 流式SSE | stream=false 一次性返回 |
|---|---|---|
| 传输方式 | 二进制分片持续推送 | 完整JSON单次返回 |
| 解析逻辑 | ReadableStream+buffer容错 | 直接response.json() |
| 输出字段 | delta.content(增量小段) | message.content(全文) |
| 用户体验 | 边生成边展示,低等待感知 | 等待全部生成后一次性渲染 |
| 代码复杂度 | 高,需处理分片、异常截断 | 极低,两行代码完成 |
| 适用场景 | 正式AI对话产品 | 内部简单工具、本地Demo |
七、项目扩展优化方向
- 增加加载锁:新增
loading响应式变量,请求中禁用按钮,防止重复点击 - 异常捕获:外层增加try/catch,处理网络失败、401密钥错误、接口限流
- Markdown渲染:流式输出纯文本,流结束后引入marked渲染富文本
- 多轮对话:扩展messages数组,存储历史聊天上下文,实现连续对话
- 中断请求:使用AbortController,支持中途停止AI生成
- 换行样式兼容:CSS增加
white-space: pre-wrap,保留AI返回换行格式
八、总结
- AI产品丝滑打字机交互核心依靠SSE流式输出,原生Fetch+ReadableStream无需第三方SDK即可实现;
buffer缓冲区是流式解析的灵魂,专门解决TCP分包截断JSON的线上致命bug;- DeepSeek接口区分流式/非流式两套返回结构,
delta与message字段切勿混用; - 生产环境优先使用流式输出提升用户体验,同时做好分片容错、异常捕获、密钥安全管理。
-
09.01
DeepSeek+Napkin AI来做PPT讲了什么-主要信息和内容重点
-
09.01
ChatBI≠NL2SQL:关于问数,聊聊我踩过的坑和一点感悟讲了什么-主要信息和内容重点
-
09.01
热备盘未自动重建!RAID5阵列崩溃后的数据恢复与文件系统修复
-
09.01
“AI公务员”来了!广东深圳首批70名正式上岗讲了什么-主要信息和内容重点
-
09.01
深入理解 TiDB 分布式事务:Percolator 模型与工程实践
-
09.01
如何在Excel中轻松清除公式而保留数据的简单做法
-
- 战士对决石像鬼
- 09.01
-
-
- 在Excel中核对相同数据的三种高效做法与技巧
- 09.01
-
-
-
下载
- |
-
-
下载
- 《行尸走肉第一章》免安装中文汉化硬盘版下载
- 单机|436 MB
- 一款以动作冒险为主题的游戏
-
-
下载
- 《街头霸王X铁拳》免安装中文汉化硬盘版下载
- 单机|111MB
- 一款非常好玩的格斗游戏
-
-
下载
- |
-
-
下载
- 《暗黑破坏神3》免安装繁体中文正式版下载
- 单机|7630 MB
- 一款以角色扮演为主题的游戏
-
-
下载
- 《马克思佩恩3》免安装硬盘版下载
- 单机|27033 MB
- 一款以第三人称射击为主题的游戏