不用第三方SDK!Vue3原生Fetch实现DeepSeek流式输出,90%前端都会踩的分片解析坑一次性解决

0 阅读7分钟

前言

做AI聊天页面你一定遇过两种糟心体验:

  1. 关闭流式:点击提交黑屏等待3-10秒,一次性弹出全文,用户等待感极强
  2. 手写SSE流式:网络分包截断JSON,疯狂报JSON.parse解析失败,文字丢失乱码

网上很多示例只给极简demo,没有处理分片容错,上线必崩。 本文基于Vite+Vue3+原生Fetch完整实现DeepSeek对话,同时支持流式打字机/一次性返回双模式,自带buffer分片容错逻辑,看完你能学到:

  1. SSE流式输出底层二进制流传输原理
  2. ReadableStream、TextDecoder浏览器原生API完整用法
  3. buffer缓冲区解决TCP分包截断JSON的核心方案
  4. 流式/非流式接口两套分支代码完整实现
  5. 开发高频踩坑清单+修复方案,直接规避线上bug
  6. 可直接复制运行的完整单文件组件

屏幕截图 2026-07-28 234451.png

一、先搞懂:什么是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介绍

  1. response.body.getReader():创建流读取器,逐块拉取二进制数据
  2. TextDecoder():二进制Uint8Array转UTF-8字符串,解决中文乱码
  3. 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);
          // 流式专属增量文本delta
          const 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 = new TextDecoder();
  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: '))
}
  1. reader.read():异步阻塞读取,有新分片立刻返回,无数据持续等待
  2. chunkValue = buffer + 新文本:核心容错操作,把上一轮残缺片段和本次新数据拼接,保证报文完整
  3. 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}`;
  }
}
  1. line.slice(6):剔除data: 固定前缀,提取纯JSON字符串
  2. [DONE]:服务端流结束标志,终止所有循环
  3. delta.content:流式接口专属增量字段,每次仅返回本次生成的少量文字,Vue响应式追加实现逐字打字效果
  4. 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 流式SSEstream=false 一次性返回
传输方式二进制分片持续推送完整JSON单次返回
解析逻辑ReadableStream+buffer容错直接response.json()
输出字段delta.content(增量小段)message.content(全文)
用户体验边生成边展示,低等待感知等待全部生成后一次性渲染
代码复杂度高,需处理分片、异常截断极低,两行代码完成
适用场景正式AI对话产品内部简单工具、本地Demo

七、项目扩展优化方向

  1. 增加加载锁:新增loading响应式变量,请求中禁用按钮,防止重复点击
  2. 异常捕获:外层增加try/catch,处理网络失败、401密钥错误、接口限流
  3. Markdown渲染:流式输出纯文本,流结束后引入marked渲染富文本
  4. 多轮对话:扩展messages数组,存储历史聊天上下文,实现连续对话
  5. 中断请求:使用AbortController,支持中途停止AI生成
  6. 换行样式兼容:CSS增加white-space: pre-wrap,保留AI返回换行格式

八、总结

  1. AI产品丝滑打字机交互核心依靠SSE流式输出,原生Fetch+ReadableStream无需第三方SDK即可实现;
  2. buffer缓冲区是流式解析的灵魂,专门解决TCP分包截断JSON的线上致命bug;
  3. DeepSeek接口区分流式/非流式两套返回结构,deltamessage字段切勿混用;
  4. 生产环境优先使用流式输出提升用户体验,同时做好分片容错、异常捕获、密钥安全管理。