从零搭建 Vue3 + DeepSeek 流式对话应用 —— 深入理解 SSE 与流式输出

5 阅读6分钟

用户体验的核心在于“快”与“实时”,而流式输出(Streaming)正是 AI 对话产品中提升体验的关键技术。本文将从零开始,带你手写一个 Vue3 流式对话 Demo,并深入解析其背后的原理。

一、为什么要做流式输出?

在传统的 HTTP 请求中,客户端发起请求后,需要等待服务端完全处理完毕,再将完整结果一次性返回。这种方式在面对 LLM(大语言模型)  这类需要大量推理计算的场景时,会带来明显的等待焦虑——用户盯着空白屏幕,不知道发生了什么。

流式输出(Streaming)  的解决方案是:生成一个 token,就输出一个 token。就像水流一样,LLM 服务器和客户端之间“接了一根管子”,token 源源不断地流向客户端,实时展示在界面上。

这种  “打字机效果”  极大地提升了用户体验,也是目前主流 AI 聊天产品的标配。

二、技术背景:SSE 与 ReadableStream

流式输出依赖服务端和客户端的双向约定:

  • 服务端约定:支持 stream: true 参数,开启后边生成边输出。
  • 客户端约定:请求时携带 stream: true,并准备处理流式响应。

在浏览器端,我们通过 Fetch API 返回的 response.body 获取一个 ReadableStream 可读流,然后逐块读取、解析、渲染。

如果你还不熟悉 SSE(Server-Sent Events)或 ReadableStream,没关系,本文会带你一步步实现。

三、项目概览

先来看看我们的项目结构:

text

stream-demo/
├── .vscode/
├── node_modules/
├── public/
├── src/
│   ├── assets/
│   ├── components/
│   ├── App.vue          # 根组件,包含所有逻辑
│   ├── main.js          # 入口文件
│   └── style.css        # 全局样式
├── .env.local           # 环境变量(存放 API Key)
├── .gitignore
├── index.html
├── package.json
└── pnpm-lock.yaml

这是一个标准的 Vite + Vue3 项目,我们主要关注 App.vue 和 .env.local

四、Vue 3 基础速览

如果你已经熟悉 Vue 3,可以跳过这一节。但为了照顾新手,我们快速梳理几个核心概念:

1. 组件化(Component)

Vue 组件以 .vue 文件为单位,将 模板(HTML)逻辑(JS)  和 样式(CSS)  封装在一起,形成可复用、易维护的“乐高积木”。Facebook 的网页由一万多个组件组成,足见其威力。

2. 数据绑定与响应式

  • 单向数据流:模板中使用 {{ count }} 绑定数据,数据变化时视图自动更新。
  • 双向数据绑定:表单元素(如 <input>)使用 v-model,用户输入可以自动同步到数据状态。

3. <script setup> 语法糖

Vue 3 新增的编译时语法糖,让组件代码更简洁。配合 ref / reactive 定义响应式数据,且无需 return,模板中可直接使用。

五、核心代码解析(App.vue 逐行注释)

下面是完整的 App.vue 代码,我加上了详尽的注释,方便你理解每一部分的作用。

vue

<template>
  <!-- 
    模板部分:定义界面结构
    数据绑定 {{ message }} 会动态渲染
  -->
  <div class="container">
    <!-- 输入区域 -->
    <div>
      <label>输入:</label>
      <!-- v-model 实现双向数据绑定 -->
      <input type="text" class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>

    <!-- 输出区域 -->
    <div class="output">
      <div>
        <label>Streaming</label>
        <!-- checkbox 绑定 stream 状态 -->
        <input type="checkbox" v-model="stream" />
        <div v-if="stream">
          <label>Streaming 模式已开启</label>
        </div>
        <div>
          <!-- 渲染 LLM 返回的内容 -->
          {{ content }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
// ============================================================
// 脚本部分:组件逻辑
// ============================================================

// 1. 导入 Vue 响应式 API
import { ref } from 'vue'

// 2. 定义响应式状态
const question = ref('讲一个中国龙的故事')  // 用户问题
const stream = ref(false)                  // 是否开启流式输出
const content = ref('')                    // LLM 返回的内容

// 3. 核心方法:发送请求并处理响应
const update = async () => {
  // 校验:问题不能为空
  if (!question.value) return

  // 设置初始状态
  content.value = '思考中......'

  // ==========================================================
  // 配置请求参数
  // ==========================================================
  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Content-Type': 'application/json',
    // 从环境变量读取 API Key,安全性更高
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
  }

  // 发起 fetch 请求
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value  // 关键:是否开启流式输出
    })
  })

  // ==========================================================
  // 分支一:流式输出(stream: true)
  // ==========================================================
  if (stream.value) {
    content.value = ''  // 清空占位文字

    // 获取响应体的可读流
    const reader = response.body?.getReader()
    const decoder = new TextDecoder()  // 解码器:二进制 → 文本
    let done = false
    let buffer = ''  // 缓冲区,用于处理不完整的 SSE 数据

    while (!done) {
      // 读取数据块(二进制 Unit8Array)
      const { value, done: doneReading } = await reader.read()
      done = doneReading

      // 解码二进制数据,stream: true 表示可能还有后续数据
      const chunk = decoder.decode(value, { stream: true })
      buffer += chunk

      // 按换行符分割,得到每一条 SSE 数据
      const lines = buffer.split('\n').filter(line => line && line !== 'data: [DONE]')

      // 保留未处理完的碎片(可能被截断)
      buffer = buffer.slice(buffer.lastIndexOf('\n') + 1)

      // 逐行解析,提取 token
      for (const line of lines) {
        const jsonStr = line.replace('data: ', '')
        try {
          const resJson = JSON.parse(jsonStr)
          // delta.content 是本次新增的 token,不是完整回复
          const token = resJson.choices[0].delta?.content || ''
          // 追加到响应式变量,页面自动更新(打字机效果)
          content.value += token
        } catch (err) {
          // 遇到解析失败的碎片,跳过继续
          continue
        }
      }
    }
  } 
  // ==========================================================
  // 分支二:非流式输出(stream: false)
  // ==========================================================
  else {
    const data = await response.json()
    content.value = data.choices[0].message.content
  }
}

// 小彩蛋:演示响应式数据更新(注释掉)
// let count = ref(1)
// setTimeout(() => {
//   count.value = 2
// }, 2000)
</script>

<style>
/* 样式部分:略,可根据需要自定义 */
</style>

六、流式输出核心原理解析

6.1 什么是 SSE?

SSE(Server-Sent Events)是一种基于 HTTP 的轻量级推送技术。服务端返回的 Content-Type 为 text/event-stream,数据格式为:

text

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

每一条 data: 开头的数据都是一个独立的 JSON 片段,客户端按行解析并逐步渲染。

6.2 ReadableStream 处理流程

  1. 获取读取器response.body.getReader()
  2. 逐块读取reader.read() 返回 { value, done }
  3. 解码:使用 TextDecoder 将二进制转为文本
  4. 分割与解析:按 \n 分割,过滤空行和 [DONE] 标记
  5. 增量渲染:将 delta.content 追加到 content.value,触发 Vue 响应式更新

6.3 缓冲区(Buffer)的作用

由于网络传输的不可控性,一个完整的 SSE 数据包可能被拆分成多个 chunk。缓冲区 buffer 暂存未处理完的碎片,确保数据完整解析。

七、环境变量配置(.env.local)

在项目根目录创建 .env.local 文件,Vite 会自动读取:

env

VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VITE_DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions

注意:以 VITE_ 开头的变量才会暴露给客户端,且 .env.local 应加入 .gitignore,避免密钥泄露。

八、运行项目

bash

# 安装依赖
pnpm install

# 启动开发服务器
pnpm run dev

访问 http://localhost:5173,输入问题,勾选 "Streaming",点击提交,即可看到打字机效果。

九、总结与思考

通过这个小 Demo,我们不仅学会了如何在 Vue3 中集成 DeepSeek API,还深入理解了:

知识点要点
流式输出边生成边传输,提升用户体验
SSE 协议data: 格式,[DONE] 结束标记
ReadableStream浏览器原生 API,处理二进制流
TextDecoder解码二进制数据为文本
Vue 响应式ref + v-model 实现数据驱动视图
环境变量Vite 通过 import.meta.env 读取 .env.local

前端工程师的职责,不仅仅是实现功能,更是打造极致的用户体验。流式输出正是 AI 产品中“体验优先”的典型实践。