拒绝“爱的魔力转圈圈”:Vue3 + AI 流式输出(Streaming)全链路实战解析

135 阅读6分钟

前言 :

还在让用户盯着 "Loading..." 的转圈动画发呆吗?
自从 ChatGPT 爆火后,这种“蹦字儿”的打字机效果(Streaming)已经成了 AI 应用的标配。
它不仅是视觉上的“爽”,更是用户体验(UX)的降维打击——后端边思考,前端边展示,拒绝无效等待。
今天,我们就扒开 AI 的外衣,从底层的二进制 Buffer,到 Vue3 的响应式实战,手把手带你实现一个丝滑的流式输出功能。


第一部分:不仅是“特效”,更是“魔法” (原理篇)

1.1 传统的 HTTP vs 流式传输 (Streaming)

在传统的 Web 开发中,我们的请求像是在寄快递

  1. 前端下单(发送 Request)。
  2. 后端打包好所有货物(处理完所有逻辑)。
  3. 快递员一次性把包裹送到你家门口(返回 Response)。
  4. 缺点:如果货物太多(AI 生成长文),你需要等很久才能收到包裹。

而 Streaming (流式输出)  更像是自来水管

  1. 前端打开水龙头。
  2. 后端只要有一滴水(生成了一个字),就立马顺着管子流过来。
  3. 优点:哪怕水流不大,用户也能立马看到动静,焦虑感瞬间消失。这就是 TTFB (Time To First Byte)  的胜利。

1.2 深入底层:计算机只认识 0 和 1

网络传输的不是“汉字”,而是“字节流”。

🧐 面试考点:Buffer 与 编码

Q:为什么流式输出拿到的数据是乱码或者数字?
A:  因为网络传输的是二进制数据(ArrayBuffer/Uint8Array)。

我们需要两个翻译官:

  • TextEncoder:把“人话”翻译成“机器码”(String -> Uint8Array)。
  • TextDecoder:把“机器码”翻译回“人话”(Uint8Array -> String)。

代码实战:

// 1. 准备一段文本
const msg = "你好 HTML5";

// 2. 编码 (Encoder):字符串 -> 二进制小本本
const encoder = new TextEncoder();
const myBuffer = encoder.encode(msg);

console.log(`字节长度: ${myBuffer.byteLength}`); // 输出长度,UTF-8中汉字通常占3字节
console.log(myBuffer); // Uint8Array(12) [228, 189, 160, ...] 

// 3. 各种视图操作 (ArrayBuffer 是内存,Uint8Array 是操作内存的手)
const buffer = new ArrayBuffer(12);
const view = new Uint8Array(buffer);
// 搬运数据...

// 4. 解码 (Decoder):二进制 -> 字符串
// 🌟 重点:流式输出的核心就是在这个解码环节
const decoder = new TextDecoder();
const originalText = decoder.decode(myBuffer);
console.log(originalText); // "你好 HTML5"

第二部分:Vue 3 极速基建 (环境篇)

现在的 Web 开发讲究“唯快不破”。

微信图片_20251207162455_35_93.jpg

2.1 为什么是 Vue 3 + Vite?

  • Vite:法语“快”的意思。它是前端构建工具的法拉利,冷启动极快。
  • Vue 3 (Composition API) :告别了 Vue 2 的 this 满天飞,拥抱 ref 和 reactive。

初始化一条龙:

npm init vite@latest my-ai-app -- --template vue
cd my-ai-app
npm install
npm run dev

完整代码

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

const question = ref('')
const stream = ref(true)
const content = ref('') // 单向绑定  主要的

// 调用LLM
const askLLM = async () => {
  // question 可以省.value  getter
  if (!question.value) {
    alert('question 不能为空')
    return
  }
  // 用户体验
  content.value = '思考中...'
  // 请求行
  // 请求头
  // 请求体
  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,
      temperature: 2,
      messages: [
        {
          role: 'user',
          content: question.value
        }
      ]
    })
  })
  if (stream.value) {
    //流式输出
    content.value = '' // 把上一次的生成清空
    // html5 流式输出
    // 响应体的读对象
    const reader = response.body?.getReader()
    // console.log(reader)

    // 流出来的是二进制流 buffer
    const decoder = new TextDecoder()
    let done = false //流是否结束 没有
    let buffer = ''
    while (!done) {
      //只要没有完成,就一直拼接buffer
      // 解构的同时 重命名
      const { value, done: doneReading } = await reader?.read()
      // console.log(value, doneReading)
      done = doneReading
      // chunk 内容块 包含多行data; 有多少行不知道
      // data:{} 能不能传完也不知道
      const chunkValue = buffer + decoder.decode(value) //文本字符串
      // console.log(chunkValue)
      buffer = ''
      const lines = chunkValue
        .split('\n')
        .filter(line => line.startsWith('data: '))
      for (const line of lines) {
        const incoming = line.slice(6) // 干掉数据标志
        if (incoming === '[DONE]') {
          done = true
          break
        }
        try {
          // llm 流式生成, tokens 长度不定的
          const data = JSON.parse(incoming)
          const delta = data.choices[0].delta.content
          if (delta) {
            content.value += delta
          }
        } catch (err) {
          // JSON.parse 解析失败
          buffer += `data:${incoming}`
        }
      }
    }
  } else {
    const data = await response.json()
    console.log(data)
    content.value = data.choices[0].message.content
  }
}
</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>{{ content }}</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
* {
  margin: 0;
  padding: 0;
}
.container {
  display: flex;
  flex-direction: column;
  /* 主轴、次轴 */
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
</style>

微信图片_20251207162456_36_93.jpg

🔧 <script setup> 部分详解

1. 导入 ref

import { ref } from 'vue'
  • ref 是 Vue 3 中创建响应式变量的方式。
  • 对于基本类型(如字符串、数字),必须通过 .value 访问/修改其值。

2. 响应式变量定义

const question = ref('')      // 用户输入的问题
const stream = ref(true)      // 是否启用流式输出(默认开启)
const content = ref('')       // LLM 返回的内容(展示在页面上)
  • question:绑定到输入框(通过 v-model)。
  • stream:控制是否使用流式响应。
  • content:用于显示模型的回复。

3. askLLM 函数 —— 核心逻辑

✅ 输入校验
if (!question.value) {
  alert('question 不能为空')
  return
}
  • 确保用户输入了问题,否则弹出提示并终止。
✅ 设置“思考中...”提示
content.value = '思考中...'
  • 提升用户体验,表示正在处理请求。

4. 构造 API 请求

const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
  Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  'Content-Type': 'application/json'
}
  • 使用 环境变量 VITE_DEEPSEEK_API_KEY 存储 API Key(Vite 项目约定以 VITE_ 开头的变量可在前端安全使用)。
  • 请求头包含认证和内容类型。
⚠️ 注意:
  • API Key 不应硬编码在代码中,而是通过 .env 文件管理:

    VITE_DEEPSEEK_API_KEY=your_actual_api_key_here
    

5. 发送 POST 请求

const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    model: 'deepseek-chat',
    stream: stream.value,
    messages: [{ role: 'user', content: question.value }]
  })
})
  • 符合 OpenAI 风格的聊天接口格式。
  • messages 数组只包含一条用户消息(简单对话)。
  • stream 控制是否启用流式响应。

🌊 流式响应处理(stream.value === true

这是最复杂的部分,用于处理 Server-Sent Events (SSE) 风格的流式响应。

步骤分解:

① 清空旧内容
content.value = ''
② 获取可读流(ReadableStream)
const reader = response.body?.getReader()
const decoder = new TextDecoder() // 将 Uint8Array 转为字符串
let done = false
let buffer = '' // 用于拼接不完整的 chunk

由于网络传输是分块的,一个 data: {...} 可能被拆成多个 chunk,所以需要 buffer 拼接。

③ 循环读取流
while (!done) {
  const { value, done: doneReading } = await reader?.read()
  done = doneReading
  const chunkValue = buffer + decoder.decode(value)
  buffer = ''
  • value 是 Uint8Array 类型的二进制数据。
  • decoder.decode(value) 转为字符串。
  • 拼接到 buffer 中(处理跨 chunk 的情况)。
④ 按行分割并过滤有效数据
const lines = chunkValue
  .split('\n')
  .filter(line => line.startsWith('data: '))
  • DeepSeek 的流式响应格式类似:

    data: {"choices": [{"delta": {"content": "H"}}]}
    data: {"choices": [{"delta": {"content": "e"}}]}
    data: [DONE]
    
⑤ 解析每一行
for (const line of lines) {
  const incoming = line.slice(6) // 去掉 "data: "
  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) {
    // 如果 JSON 解析失败(比如 chunk 不完整),暂存回 buffer
    buffer += `data:${incoming}`
  }
}
  • 关键点:如果 JSON.parse 失败,说明当前 incoming 不是完整 JSON(可能被截断),于是把它放回 buffer,等下一次循环再拼接。

这个 catch 块里的 buffer += data:${incoming}`` 是为了处理跨 chunk 的 JSON 字符串。例如:

  • 第一个 chunk: "data: {"choices": [{"delta": {"cont"
  • 第二个 chunk: "ent": "Hello"}}]}" 合起来才是合法 JSON。
🔄 整体流程图(简化版)
text
编辑
读取二进制块
     ↓
解码为字符串 + 拼接 buffer → chunkValue
     ↓
按 \n 分割 → 多行
     ↓
过滤出 data: 开头的行
     ↓
对每行:
   ├─ 如果是 [DONE] → 结束
   ├─ 否则尝试 JSON.parse
        ├─ 成功 → 提取 content,追加到页面
        └─ 失败 → 放回 buffer(等下次拼接)

📦 非流式响应处理(stream.value === false

const data = await response.json()
content.value = data.choices[0].message.content
  • 直接解析完整 JSON 响应。
  • 结构与 OpenAI 兼容:choices[0].message.content 是最终回答。

🖼️ <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>{{ content }}</div>
    </div>
  </div>
</div>
  • v-model="question":双向绑定输入框。
  • @click="askLLM":点击按钮触发请求。
  • v-model="stream":复选框控制流式开关。
  • {{ content }}:显示模型回复(响应式更新)。

第四部分:防坑指南与面试深挖 (进阶篇)

这时候面试官可能会推了推眼镜,开始问你深层次的问题。

4.1 💀 致命问题:中文乱码与边界截断

Q:如果一个汉字占 3 个字节,但是网络传输时,恰好把这 3 个字节切分到了两个不同的 chunk 里(比如前 2 个字节在 chunk A,第 3 个字节在 chunk B),TextDecoder 会怎么处理?

  • 错误理解:分别解码,结果出现两个 `` (乱码符号)。

  • 正确做法:TextDecoder 内部维护了状态。

    • 在代码中看到 decoder.decode(value, { stream: true }) 了吗?
    • stream: true 告诉解码器:“嘿,后面还有数据呢,如果这个 chunk 结尾有半个汉字,先别硬解,存到缓存里,等下一个 chunk 来了拼起来再解。”
    • 划重点:一定要在循环中使用同一个 decoder 实例,并开启 stream: true。

4.2 💀 为什么不用 Axios?

Q:平时请求都用 Axios,为什么流式输出要用原生 Fetch?
A:

  • Axios 基于 XMLHttpRequest (XHR)。XHR 在处理流式数据时比较笨重,通常是等待整个响应完成,或者通过复杂的 onprogress 处理文本。
  • Fetch API 原生支持 ReadableStream,是处理流式二进制数据的现代标准,性能更好,控制粒度更细。

4.3 💀 后端怎么配合?

前端写好了,后端不能直接 return "结果"。后端(比如 Node.js/Python)需要设置 Header:

Content-Type: text/event-stream
Transfer-Encoding: chunked

这告诉浏览器:“我还没说完,保持连接,我会一段一段给你发数据。”


第五部分:总结

从今天的实战中,我们学到了:

  1. 体验优先:流式输出(Streaming)利用了人类的感知延迟,极大地优化了 AI 交互体验。
  2. 数据本质:网络传输的本质是二进制 Buffer,利用 TextDecoder 是还原真相的关键。
  3. Vue3 响应式:ref 让数据驱动视图变得异常简单,完美契合流式数据的频闪更新。