vue.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > vue.js > Vue3 DeepSeek流式打字机效果

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

作者:YIAN

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

引言

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

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

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

1.1 为什么必须做流式?

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

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

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

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

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

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 = '' // 残缺数据缓存

这里两个核心工具:

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: '))

举个直观例子:

第二轮读到新数据:"世界"}\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流式打字机效果的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文