基于Vue3+DeepSeek实现流式打字机效果

 更新时间:2026年07月29日 08:50:55   作者:YIAN  
大模型时代,流式输出(打字机效果)早已成为对话类产品的标配,本文基于 Vue3 + Vite 项目,从底层二进制流到上层业务逻辑,逐行拆解流式响应的处理全流程,把 Buffer 缓存、粘包处理、字段兼容这些核心细节一次性讲透,需要的朋友可以参考下

引言

大模型时代,流式输出(打字机效果)早已成为对话类产品的标配。但很多同学接入 DeepSeek 等大模型 API 时,直接照搬示例代码却频繁踩坑:JSON 解析报错、内容随机丢失、页面出现 null 字符串、思考过程无法展示……

本文基于 Vue3 + Vite 项目,从底层二进制流到上层业务逻辑,逐行拆解流式响应的处理全流程,把 Buffer 缓存、粘包处理、字段兼容这些核心细节一次性讲透。

一、先搞懂:流式输出到底是什么?

1.1 为什么必须做流式?

大模型生成回答不是一次性算出完整结果,而是逐 Token(可以理解为逐字 / 逐词)生成。如果等到全部生成完再返回,用户会面临几秒甚至几十秒的空白等待,体验极差。

流式输出的核心价值就是:边生成、边返回、边渲染,用户看到第一个字的时间从 “等完整回答” 缩短到 “生成第一个 Token”,极大降低等待焦虑。

1.2 流式协议标准:SSE 数据格式

当前主流大模型的流式接口,基本都遵循 SSE(Server-Sent Events)规范,返回的是持续的二进制文本流,规则非常明确:

  • 传输载体是 Uint8Array(8 位无符号整数数组,0-255,对应一个个字节的二进制数据)
  • 业务层面以 \n 换行符分割每条独立消息
  • 每条消息固定以 data: 开头,后面紧跟 JSON 格式的内容
  • 全部生成结束后,会推送一条 data: [DONE] 作为结束标记

对应到真实传输中,流的原始内容大概是这样:

data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]

这里还有一个工程设计细节:LLM 生成过程中的分片 JSON 只保留核心字段,尽量精简。目的是降低单条数据包体积,提升网络传输效率,保证打字机的流畅度;等全部生成结束后,再返回完整的统计信息(Token 消耗、结束原因等),兼顾速度与完整性。

1.3 绕不开的问题:为什么一定要 Buffer?

很多人疑惑:我直接读数据、转字符串、解析 JSON 不行吗?

不行,因为网络传输的分片是不受业务控制的。TCP 协议只负责把数据传过去,不会按我们的 \n 分界线来切分。一次 read() 读取到的内容,可能出现三种情况:

  1. 刚好是完整的 1 条或多条 data: 消息
  2. 只拿到半条消息(后半截在下一次读取里)
  3. 前半条是上一轮残留的,后半条是新的

如果直接解析,半截 JSON 必然触发 JSON.parse 报错,还会导致内容丢失。Buffer 的作用就是缓存这部分残缺数据,等下一轮读取到新数据后拼在一起再处理,行业里也常叫 “粘包处理”。

二、项目基础配置

2.1 环境准备

我们用 Vue3 + Vite 做前端项目,调用 DeepSeek 官方的流式接口。先在项目根目录的 .env 文件里配置 API 密钥:

VITE_DEEPSEEK_API_KEY=你的DeepSeek密钥

2.2 开发环境跨域处理

注意:前端直接请求 https://api.deepseek.com 会触发浏览器 CORS 跨域限制。开发阶段可以在 vite.config.js 里配置代理:

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://api.deepseek.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  }
})

请求地址换成 /api/chat/completions 即可。生产环境建议通过后端服务转发,不要把密钥暴露在前端。

三、核心代码逐行拆解:流式处理全流程

下面我们以 Vue3 SFC 组件为载体,从发起请求到渲染文字,把每一步逻辑讲透。

3.1 初始化:拿到流读取器

请求接口时开启 stream: true,响应体 response.body 就是一个 ReadableStream 可读流对象。

const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    model: 'deepseek-v4-flash',
    messages: [{ role: 'user', content: question.value }],
    stream: stream.value
  })
})

const reader = response.body?.getReader() // 创建读取器
const decoder = new TextDecoder('utf-8') // 二进制→字符串解码器
let done = false // 流结束标记
let buffer = '' // 残缺数据缓存

这里两个核心工具:

  • reader:用来从流里 “一口一口” 读取二进制数据,每次调用 read() 都会返回 { value, done }
  • decoder:把读取到的 Uint8Array 二进制数据解码为可读的 UTF-8 字符串

3.2 循环读取:两层结束条件

外层用 while(!done) 持续读取,直到流结束。done 会在两种情况下被置为 true

  1. 流本身关闭:reader.read() 返回的 doneReadingtrue
  2. 业务结束:读取到 data: [DONE] 标记
while (!done) {
  const { value, done: doneReading } = await reader?.read()
  done = doneReading
  // ... 处理数据
}

3.3 最关键:Buffer 粘包处理

这是绝大多数人写错的地方。错误写法是直接清空 buffer,分割后丢弃尾部数据,导致残缺内容永久丢失。

正确逻辑只有三步:拼接 → 分割 → 存残片

// 1. 拼接:上一轮残留的buffer + 本轮新解码的内容
const chunkValue = buffer + decoder.decode(value)

// 2. 分割:按换行符切成一个个片段
const allChunks = chunkValue.split('\n')

// 3. 存残片:弹出最后一段,大概率是不完整的,存回buffer留给下一轮
buffer = allChunks.pop()

// 剩下的都是完整行,过滤出合法的 data: 消息
const lines = allChunks.filter(line => line.startsWith('data: '))

举个直观例子:

  • 第一轮读到:data: {"content":"你"}\ndata: {"content":"好
  • split('\n') 得到 ['data: {"content":"你"}', 'data: {"content":"好']
  • pop() 取出后半截 'data: {"content":"好' 存入 buffer
  • 只处理前面完整的那一行

第二轮读到新数据:"世界"}\n,和 buffer 拼接后就变成了完整的一行,正常解析。

3.4 逐行解析:消息处理与结束判断

遍历过滤后的完整行,先切掉 data: 前缀,再做分支处理:

for (const line of lines) {
  const incoming = line.slice(6) // 切掉 "data: " 6个字符
  
  // 遇到结束标记,终止循环
  if (incoming === '[DONE]') {
    done = true
    break
  }

  try {
    const data = JSON.parse(incoming)
    // 防御:防止 choices 为空导致报错
    if (!data.choices?.length) continue

    // 只取回答正文,null/undefined 兜底为空字符串
    const text = data.choices[0].delta.content ?? ''
    if (text) {
      content.value += text
    }
  } catch (err) {
    // 极端情况解析失败,重新拼回 data: 前缀存入 buffer
    buffer = `data: ${incoming}`
  }
}

这里有两个必须注意的细节:

  1. 空值兜底:DeepSeek 的分片里 content 可能为 null(比如思考阶段、结束分片),不用 ?? '' 兜底会直接拼出 null 字符串。
  2. 边界防御:接口异常、格式变动时,choices 可能为空,直接写 choices[0] 会导致白屏,必须加判空。

如果你使用的是带思考能力的模型(如 deepseek-reasoner),还需要额外取 reasoning_content 字段,拼接逻辑改成:

const delta = data.choices[0].delta
const reasoning = delta.reasoning_content ?? ''
const answer = delta.content ?? ''
content.value += reasoning + answer

3.5 非流式降级处理

如果关闭流式,就走常规的 JSON 解析:

} else {
  const data = await response.json()
  content.value = data.choices[0].message.content
}

四、完整可运行组件代码

下面是修复了所有边界问题的完整 Vue3 组件,可直接复制使用:

<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 endpoint = '/api/chat/completions' // 走Vite代理,避免跨域
  const headers = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
  }

  try {
    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 (!response.ok) throw new Error('请求失败')

    if (stream.value) {
      content.value = ''
      const reader = response.body?.getReader()
      const decoder = new TextDecoder('utf-8')
      let done = false
      let buffer = ''

      while (!done) {
        const { value, done: doneReading } = await reader?.read()
        done = doneReading

        // 拼接上一轮残留数据
        const chunkValue = buffer + decoder.decode(value)
        const allChunks = chunkValue.split('\n')
        // 尾部残缺数据存回buffer
        buffer = allChunks.pop()
        // 过滤有效消息行
        const lines = allChunks.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)
            if (!data.choices?.length) continue

            const delta = data.choices[0].delta
            // 仅拼接回答正文,如需思考过程可加上 reasoning_content
            const text = delta.content ?? ''
            if (text) content.value += text
          } catch (e) {
            buffer = `data: ${incoming}`
          }
        }
      }
    } else {
      const data = await response.json()
      content.value = data.choices[0].message.content
    }
  } catch (err) {
    content.value = `出错了:${err.message}`
  }
}
</script>

<template>
  <div class="container">
    <div class="input-row">
      <label>输入:</label>
      <input v-model="question" class="input" />
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <label class="stream-toggle">
        <input type="checkbox" v-model="stream" />
        开启流式输出
      </label>
      <div class="content">{{ content }}</div>
    </div>
  </div>
</template>

<style scoped>
.container {
  max-width: 800px;
  margin: 40px auto;
  padding: 0 20px;
}
.input-row {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-bottom: 20px;
}
.input {
  flex: 1;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
button {
  padding: 8px 16px;
  background: #165DFF;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.output {
  border: 1px solid #eee;
  border-radius: 8px;
  padding: 16px;
  min-height: 300px;
  background: #fafafa;
}
.stream-toggle {
  display: block;
  margin-bottom: 12px;
  font-size: 14px;
  color: #666;
}
.content {
  line-height: 1.6;
  white-space: pre-wrap;
}
</style>

五、最后

流式输出看似只是 “打字机效果”,背后其实是网络传输、流处理、边界容错等一系列工程细节的集合。一个健壮的流式实现,既要保证文字不丢失、不报错,也要兼容模型的各种字段格式。

放到 Agent 开发的大背景下,流式能力更是基础中的基础 —— 未来的智能体不再是 “一次性返回结果”,而是边思考、边调用工具、边输出结果,流式交互就是承载这种动态过程的最佳载体。把底层的流处理逻辑吃透,后续做更复杂的 Agent 交互才会得心应手。

以上就是基于Vue3+DeepSeek实现流式打字机效果的详细内容,更多关于Vue3 DeepSeek流式打字机效果的资料请关注脚本之家其它相关文章!

相关文章

  • Vue Router4如何正确重置路由的实例详解

    Vue Router4如何正确重置路由的实例详解

    在使用 Vue 3 和 Vue Router 4 开发中大型 SPA 应用时,我们经常会遇到需要动态添加或删除路由的场景,下面小编来和大家讲讲Vue Router4如何正确重置路由吧
    2025-05-05
  • 详解如何优雅的进行Vue的状态管理

    详解如何优雅的进行Vue的状态管理

    随着项目的发展和复杂性的增加,对 Vuex 进行更深入的了解和使用就变得非常重要,本篇文章将带您探索 Vuex 的进阶使用,包括模块化、命名空间、getter 的高级用法等,需要的朋友可以参考下
    2023-09-09
  • vue3中watch与watchEffect的区别

    vue3中watch与watchEffect的区别

    vue3 新增的 Composition API中的 watchEffect 和 watch都可在 setup() 函数中使用,本文重点介绍vue3中watch与watchEffect的区别,感兴趣的朋友一起看看吧
    2023-02-02
  • Vue透传Attributes使用解析

    Vue透传Attributes使用解析

    本文主要介绍了Vue透传Attributes使用解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-02-02
  • vue+elementUI封装一个根据后端变化的动态table(完整代码)

    vue+elementUI封装一个根据后端变化的动态table(完整代码)

    这篇文章主要介绍了vue+elementUI,封装一个根据后端变化的动态table,实现了自动生成和插槽两个方式,主要把el-table 和el-pagination封装在一起,结合示例代码给大家介绍的非常详细,需要的朋友可以参考下
    2022-08-08
  • vue监听页面中的某个div的滚动事件并判断滚动的位置

    vue监听页面中的某个div的滚动事件并判断滚动的位置

    本文主要介绍了vue监听页面中的某个div的滚动事件并判断滚动的位置,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-03-03
  • 浅谈vue中的data与_data的关系是什么

    浅谈vue中的data与_data的关系是什么

    在Vue实例中,data属性是用户定义的用于存储数据的对象,而_data是Vue内部用于存储数据的对象,本文主要介绍了浅谈vue中的data与_data的关系是什么,感兴趣的可以了解一下
    2023-12-12
  • Vue自定义全局弹窗组件操作

    Vue自定义全局弹窗组件操作

    这篇文章主要介绍了Vue自定义全局弹窗组件操作,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-08-08
  • 浅析vue component 组件使用

    浅析vue component 组件使用

    本篇文章主要介绍了vue component 组件使用 ,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-03-03
  • VUE中Echarts的resize事件报错和移除windows的事件问题

    VUE中Echarts的resize事件报错和移除windows的事件问题

    这篇文章主要介绍了VUE中Echarts的resize事件报错和移除windows的事件问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-07-07

最新评论