🌟 Vue 3 实现流式输出:打造会“打字”的AI对话应用 ✍️

178 阅读6分钟

在现代前端开发中,Vue 3 凭借其简洁的语法和强大的响应式系统,成为构建交互式 Web 应用的首选框架之一。而随着大语言模型(LLM)的普及,如何实现“流式输出”——即像 ChatGPT 那样逐字生成内容的效果——也成为了开发者关注的重点。

本文将带你一步步使用 Vue 3 + Vite + Fetch 流式 API 构建一个支持流式输出的大模型对话应用,并深入讲解:

  • 🧱 如何初始化项目
  • 🔁 Vue 3 响应式数据的核心机制
  • 📡 调用 LLM 接口的完整流程
  • 💬 流式输出的底层原理
  • 🧩 value, buffer, done, incoming 等变量的作用
  • 📦 如何实现“一包一包”地读取并拼接数据

一、项目初始化:搭建开发环境 🛠️

我们使用目前最流行的前端脚手架 Vite 来快速创建项目。

npm init vite

按照提示选择:

  • Framework: Vue ⚙️
  • Variant: JavaScript 💻

然后进入项目目录并安装依赖:

cd your-project-name
npm install
npm run dev

此时你已经拥有了一个基于 Vue 3 的现代化开发环境!

💡 小贴士:Vite 是由 Vue 作者尤雨溪主导开发的构建工具,启动速度快、热更新快,非常适合 Vue 开发。⚡


二、Vue 3 基础结构解析 🧩

Vue 单文件组件(.vue 文件)采用“三明治”结构:

<template>
  <!-- 模板 HTML -->
</template>

<script setup>
  // 逻辑代码
</script>

<style scoped>
  /* 样式 */
</style>

🔑 核心概念:响应式数据(Reactivity)

传统 JavaScript 是命令式的:你要手动获取 DOM、监听事件、修改内容。
而 Vue 的核心思想是 声明式编程 + 响应式数据

例如:

import { ref } from 'vue'
const count = ref(0)

这里的 count 是一个响应式对象。当它的 .value 改变时,所有在模板中引用它的部分会自动更新。

<div>{{ count }}</div>

即使你在 setTimeout 中修改它:

setTimeout(() => {
  count.value = 100
}, 2000)

页面也会自动刷新!这就是 Vue 的魔力所在。✨


三、实现大模型调用:非流式 vs 流式 🔄

我们要调用的是 DeepSeek API,它支持两种模式:

模式特点使用场景
❌ 非流式(stream: false一次性返回完整结果快速获取短文本
✅ 流式(stream: true分块返回,逐字输出模拟人类打字效果

我们将重点讲解 流式输出 的实现过程。


四、发送请求:请求行、请求头、请求体详解 📤

要调用 DeepSeek 的 /chat/completions 接口,我们需要构造一个标准的 HTTP 请求。

✅ 请求行(Request Line)🌐

POST https://api.deepseek.com/chat/completions

✅ 请求头(Headers)🔐

const headers = {
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  'Content-Type': 'application/json'
}
  • 🔐 Authorization: 认证凭证,需提前在 DeepSeek 官网 获取 API Key。
  • 📄 Content-Type: 表示我们发送的是 JSON 数据。

⚠️ 安全提示:API Key 应保存在 .env 文件中,不要硬编码在代码里!

.env 文件示例:

VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Vite 会自动加载以 VITE_ 开头的环境变量。

✅ 请求体(Body)📦

{
  model: "deepseek-chat",
  stream: true,
  messages: [
    { role: "user", content: "讲一个喜羊羊和灰太狼的故事不低于20字" }
  ]
}
  • 🤖 model: 指定使用的模型
  • 🌊 stream: 是否启用流式输出
  • 💬 messages: 对话历史数组,遵循 ChatML 格式

五、接收流式响应:逐块读取数据 📥

这是本文最核心的部分!

当服务器开启 stream: true 后,它不会一次性返回 JSON,而是通过 HTTP 流(Streaming Response) 发送多个小数据块。

每个数据块格式如下:

data: {"choices":[{"delta":{"content":"今"}}}
data: {"choices":[{"delta":{"content":"天"}}}
data: {"choices":[{"delta":{"content":"天"}}}
...
data: [DONE]

我们要做的就是:

  1. 🔍 打开读取器
  2. 📦 一块一块读取二进制流
  3. 🔤 解码成字符串
  4. ➗ 拆分成行
  5. ➕ 提取 content 并拼接到界面上

🔧 关键技术点:ReadableStream 和 TextDecoder

const reader = response.body.getReader();
const decoder = new TextDecoder();
  • 🔁 response.body 是一个 ReadableStream
  • 📖 getReader() 创建一个读取器
  • 🔤 TextDecoderUint8Array 转为可读字符串

六、流式处理流程详解 🔄

让我们深入分析这段核心代码:

const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';

while (!done) {
  // 结构高级用法把done改名为doneReading
  const { value, done: doneReading } = await reader.read();// 返回值{ value: Uint8Array, done: boolean }
  done = doneReading;

  const chunkValue = buffer + decoder.decode(value);// 把获得的二进制数解码
  buffer = '';

  const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '));// 以/n切割解码后的数据

  for (const line of lines) {
    const incoming = line.slice(6); // 去掉 "data: "得到json数据
    if (incoming === '[DONE]') {
      done = true;
      break;
    }

    try {
      const data = JSON.parse(incoming);
      const delta = data.choices[0].delta.content;
      if (delta) {
        content.value += delta; // 响应式更新!
      }
    } catch (err) {
      buffer += `data: ${incoming}\n`; // 解析失败,暂存到 buffer
    }
  }
}

📌 变量作用详解 🧾

变量类型作用图标说明
valueUint8Array本次读取到的原始二进制数据📦 数据包
doneboolean表示流是否结束✅/❌ 结束标志
bufferstring缓存未完整解析的数据片段🧊 缓冲区
chunkValuestring当前批次解码后的完整字符串(含 buffer)🔤 文本流
linesarray拆分出的每一行 data: ...➗ 分割线
incomingstring去掉前缀后的 JSON 字符串📥 输入流

🔄 循环逻辑说明 🔄

  1. 📖 读取数据块

    const { value, done } = await reader.read()
    
    • 📦 value: 二进制数据(可能为空)
    • done: 是否已读完全部数据
  2. 🔤 解码 + 拼接 buffer

    const chunkValue = buffer + decoder.decode(value);
    
    • 上一轮可能有未解析完的内容留在 buffer 🧊
    • 本轮回合并新数据后再整体处理
  3. ➗ 按行拆分并过滤有效数据

    .split('\n').filter(line => line.startsWith('data: '))
    
    • HTTP 流通常每行一个 data: {...}
    • 忽略空行或其他控制信息
  4. ➕ 提取内容并更新界面

    const delta = data.choices[0].delta.content;
    if (delta) {
      content.value += delta;
    }
    
    • ✍️ delta.content 是本次新增的文本
    • 修改 content.value → 触发 Vue 响应式更新 → 页面自动刷新!💥
  5. ⚠️ 错误处理:不完整的 JSON

    catch (err) {
      buffer += `data: ${incoming}\n`;
    }
    
    • 如果 JSON.parse 失败,说明这个 incoming 不完整
    • 把它重新加回 buffer,等待下一次数据到来再尝试解析

✅ 这种设计确保了在网络波动或分包不均的情况下仍能正确还原完整内容。

来看看数据的格式:

image.png

上面的数组是read读取的二进制数;

下面的多个data代表一次读取的数据量,图中这一次读取了三个data

可以看到去掉data: 后才是json数据可以解码

最下面的data值为 [DONE] 代表llm输出完成


七、完整代码回顾与优化建议 🧑‍💻

以下是整合后的完整代码(含注释):

<script setup>
import { ref } from 'vue'

// 📝 用户输入的问题
const question = ref('讲一个喜羊羊和灰太狼的故事不低于20字')
// 🌊 是否启用流式输出
const stream = ref(true)
// 💬 输出内容(响应式)
const content = ref("")

// 🤖 调用大模型
const askLLM = async () => {
  if (!question.value.trim()) {
    alert('请输入问题')
    return
  }

  // 🧼 清空上一次结果,提升用户体验
  content.value = '思考中...'

  try {
    const endpoint = 'https://api.deepseek.com/chat/completions'
    const headers = {
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
      'Content-Type': 'application/json'
    }

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

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${await response.text()}`)
    }

    if (stream.value) {
      // 🌊 流式处理
      content.value = ""
      const reader = response.body.getReader()
      const decoder = new TextDecoder()
      let done = false
      let buffer = ''

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

        // 🧩 拼接缓冲区和当前数据
        const chunkValue = buffer + decoder.decode(value, { stream: true })
        buffer = ''

        // ➗ 按行分割,只保留 data: 开头的
        const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))

        for (const line of lines) {
          const raw = line.slice(6) // 移除 "data: "

          if (raw === '[DONE]') {
            done = true
            break
          }

          try {
            const data = JSON.parse(raw)
            const text = data.choices?.[0]?.delta?.content
            if (text) {
              content.value += text // ✨ 响应式更新
            }
          } catch (err) {
            // ⚠️ JSON 解析失败,可能是不完整包
            buffer += line + '\n'
          }
        }
      }
    } 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>
      <label>📝 输入:</label>
      <input class="input" v-model="question" />
      <button @click="askLLM">📤 提交</button>
    </div>

    <div class="output">
      <div>
        <label>🌊 Streaming</label>
        <input type="checkbox" v-model="stream" />
        <div class="content">{{ content }}</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
* { margin: 0; padding: 0; }
.container {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-start;
  height: 100vh;
  font-size: 0.85rem;
  padding: 20px;
  background: #f9f9fb;
}
.input {
  width: 300px;
  padding: 6px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
button {
  padding: 6px 12px;
  margin-left: 8px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:hover {
  background: #0056b3;
}
.output {
  margin-top: 20px;
  min-height: 300px;
  width: 100%;
  border: 1px solid #ccc;
  padding: 10px;
  background: white;
  border-radius: 6px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  white-space: pre-wrap;
  word-break: break-word;
  font-family: 'Courier New', monospace;
  color: #333;
}
</style>

八、总结:Vue 3 如何赋能流式输出?📊

技术点Vue 的优势图标
ref()自动追踪依赖,数据变则视图变🔁
v-model双向绑定表单,无需手动监听 input 事件↔️
{{ content }}模板自动订阅响应式数据变化👀
content.value += delta简单赋值即可触发 UI 更新💥

正是 Vue 的响应式系统,让我们可以专注于业务逻辑,而不是繁琐的 DOM 操作。


九、拓展建议 🚀

  1. 🌀 添加 loading 动画

    const loading = ref(false)
    
  2. 💬 支持多轮对话 维护一个 messages 数组,每次追加用户和 AI 的消息。

  3. 📋 复制功能 添加按钮复制生成内容。

  4. 🔁 错误重试机制 网络异常时提供重试选项。

  5. 📱 移动端适配 使用 Flex 或 CSS Grid 布局自适应屏幕。


十、结语 🎉

通过本教程,你不仅学会了如何用 Vue 3 构建一个现代化前端应用,还掌握了:

✅ HTTP 流式传输原理
✅ ReadableStream 与 TextDecoder 的使用
✅ 如何处理不完整数据包
✅ Vue 响应式系统的实际应用

这些技能不仅可以用于对接大模型,也能应用于日志推送、实时聊天、文件上传进度等任何需要“持续接收数据”的场景。

现在,就去试试让你的应用“说”出第一句话吧!

🚀 “从前有一只聪明的喜羊羊,他总能用智慧战胜灰太狼……” —— 正是通过这一字一字的流式输出,让机器仿佛有了生命。🧠💬


📌 GitHub 示例仓库模板(可选)
你可以将此项目发布为开源模板,帮助更多人快速上手 Vue + LLM 开发。

祝你 coding 愉快!👨‍💻👩‍💻