Vue 3 实现 LLM 流式输出的完整 Demo 教程

109 阅读5分钟

Vue 3 实现 LLM 流式输出的完整 Demo 教程

想象一下:你问 AI 一个问题,它不是沉默几秒后“砰”地甩出一大段答案,而是像一位沉思的作家,一个字、一个词、一句话地在你眼前“写”出来——文字如溪流般缓缓流淌,思绪仿佛触手可及。这种边想边说的体验,就是“流式输出”(Streaming Output)的魅力所在。

在 AI 应用遍地开花的今天,用户体验早已不只是功能是否实现,更是等待是否值得、交互是否流畅。而流式输出,正是让 AI 回答从“冷冰冰的结果”变成“有温度的对话”的关键一步。本文将带你用 Vue 3 + Vite,亲手打造一个能“看着 AI 写字”的前端 Demo,全程代码清晰、原理透彻、效果惊艳!


一、搭好舞台:用 Vite 三分钟起个 Vue 3 项目

开发就像演戏,得先搭好舞台。Vite 就是那个又快又稳的舞台搭建师——它不用打包,直接用浏览器原生支持的 ES 模块,启动速度飞快。

打开终端,敲下:

npm init vite

接着按提示操作:

  • 项目名?随便起,比如 ai-stream-demo
  • 框架选 Vue
  • 语言选 JavaScript

回车!几秒后,一个崭新的 Vue 3 项目就躺在你面前了。进入目录,装依赖,跑起来:

cd ai-stream-demo
npm install
npm run dev

浏览器自动打开,一个干净的 Vue 起始页跃然眼前。我们的主角 App.vue 就在这里,它将承载整个流式对话的魔法。


二、让数据“活”起来:Vue 3 的响应式魔法

在传统 JS 里,你要改页面文字,得先 getElementById,再 .innerText = ...,像拧螺丝一样机械。但 Vue 说:“别管 DOM,只管数据!”

我们用 ref() 声明一个“会呼吸”的变量:

import { ref } from 'vue'

const content = ref('') // 初始是空,但它“活”着!

只要 content.value 一变,模板里 {{ content }} 的地方就会自动更新——就像给数据装了神经,一动全身都知。

这正是我们要的效果:AI 每吐出一个字,我们就往 content 里塞一点,页面立刻跟上,用户就能看到文字“生长”的过程。


三、流式输出:为什么它让人“上头”?

普通 API 调用像点外卖:下单 → 等 → 一次性端上整桌菜。
流式输出则像看厨师现场烹饪:切菜、爆香、翻炒……每一步都看得见。

对 LLM 来说,生成 100 个字可能需要 5 秒。非流式模式下,用户前 4.9 秒面对的是空白或“加载中”;而流式模式,第 0.5 秒就能看到第一个词,心理感受天差地别。

开启流式,只需在请求体加一行:

{ "stream": true }

服务端就会用 SSE(Server-Sent Events) 协议,把回答切成小块,像发短信一样一条条推过来,格式如下:

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

我们的任务,就是把这些碎片拼成完整的句子,并实时显示。


四、动手实现:让 AI 在你眼前“写字”

下面是完整的 App.vue 代码,每一行都经过精心打磨:

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

// 用户输入的问题
const question = ref('讲一个喜羊羊和灰太狼的搞笑故事,不少于30字')
// 是否启用流式
const stream = ref(true)
// AI 正在写的“草稿”
const content = ref('')

const askLLM = async () => {
  if (!question.value.trim()) return alert('问题不能为空哦!')
  
  // 先清空上次结果
  content.value = stream.value ? '🧠 正在思考...' : ''

  const res = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
  })

  // 🌊 流式处理:逐字接收
  if (stream.value && res.body) {
    content.value = '' // 开始写字!
    const reader = res.body.getReader()
    const decoder = new TextDecoder()
    let buffer = '' // 用于拼接跨 chunk 的数据

    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      // 把二进制转成字符串,并保留未完成的部分
      const chunk = decoder.decode(value, { stream: true })
      const lines = (buffer + chunk).split('\n').filter(line => line.startsWith('data: '))
      buffer = ''

      for (const line of lines) {
        const payload = line.slice(6) // 去掉 "data: "
        if (payload === '[DONE]') return

        try {
          const data = JSON.parse(payload)
          const text = data.choices?.[0]?.delta?.content
          if (text) content.value += text // ✨ 关键!追加文字
        } catch (e) {
          // 如果 JSON 解析失败(比如被截断),暂存到 buffer
          buffer += line + '\n'
        }
      }
    }
  } 
  // 📦 非流式:一次性拿完整答案
  else {
    const data = await res.json()
    content.value = data.choices?.[0]?.message?.content || '哎呀,AI 罢工了~'
  }
}
</script>

<template>
  <div class="container">
    <h1>✨ 看 AI 写字 ✨</h1>
    
    <div class="input-area">
      <input v-model="question" placeholder="输入你的问题..." />
      <button @click="askLLM">提问</button>
      <label>
        <input type="checkbox" v-model="stream" />
        启用流式输出(推荐!)
      </label>
    </div>

    <div class="output-box">
      <p>{{ content || '等待你的问题...' }}</p>
    </div>
  </div>
</template>

<style scoped>
.container {
  max-width: 800px;
  margin: 40px auto;
  padding: 20px;
  font-family: 'Segoe UI', sans-serif;
}
.input-area {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 20px;
  flex-wrap: wrap;
}
input {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 6px;
  width: 300px;
}
button {
  padding: 10px 16px;
  background: #4f46e5;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}
.output-box {
  min-height: 200px;
  padding: 16px;
  background: #f8fafc;
  border-radius: 8px;
  border: 1px solid #e2e8f0;
  white-space: pre-wrap;
  line-height: 1.6;
}
</style>

别忘了在项目根目录创建 .env 文件,填入你的 DeepSeek API Key:

VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx

💡 提示:.env 文件不要提交到 Git!把它加入 .gitignore


五、背后的技术细节:如何优雅处理“数据碎片”?

流式输出最棘手的问题是:网络传输不分 JSON 边界。一个 {"content":"你好"} 可能被切成 {"content":"你好"} 两块。

我们的解决方案是:

  1. buffer 缓存不完整的行;
  2. 每次新数据到来,先和 buffer 拼接;
  3. \n 分割,只处理以 data: 开头的完整行;
  4. 解析失败的,重新塞回 buffer,等下一块来“补全”。

这种“拼图式”处理,确保了即使在网络抖动下,也能稳定还原原始数据。


六、结语:让技术有温度

这个 Demo 不只是代码,更是一种产品思维的体现:技术不该让用户等待,而应让用户参与过程。当文字在屏幕上逐字浮现,AI 不再是黑箱,而成了一个“正在思考的朋友”。

你可以在此基础上继续拓展:

  • 添加自动滚动到底部
  • 支持多轮对话历史
  • 加入打字机动画
  • 用 Web Worker 防止主线程卡顿

愿你在构建 AI 应用的路上,不仅追求智能,更传递温度。现在,去运行你的项目,亲眼看看 AI 是如何“写字”的吧!✍️