从零用 Vue 3 + DeepSeek 搭建一个 AI 流式聊天机器人

前言

最近在学习 AI Chatbot 的前端开发,花了几天时间从零开始,用 Vue 3 调用 DeepSeek API,实现了一个支持"流式输出"和"非流式输出"切换的聊天机器人。这篇文章把整个过程记录下来,既帮你搞懂 Vue 3 的核心概念(组件化、响应式、数据绑定),也带你理解 AI 应用最关键的用户体验------流式输出

你将会收获:

  • Vue 3 核心概念:组件化、响应式数据、模板语法、双向绑定
  • 从二进制流到 JSON 字符串再到 JS 对象的完整数据转换链路
  • DeepSeek API 的调用方式(流式 vs 非流式)
  • 用 ReadableStream 实现打字机效果的原理
  • 一个可运行的 AI 聊天机器人项目

一、项目跑起来需要啥?

先搞清楚一个 Vue 3 项目是怎么启动的,别小看这几步------每个文件都有它的职责。

1.1 三个核心文件的分工

bash 复制代码
index.html        → 浏览器入口,提供挂载点 <div id="app">
src/main.js       → 创建 Vue 应用,挂载到 #app 上
src/App.vue       → 你的第一个组件,所有业务逻辑在这里

index.html ------ 浏览器入口

html 复制代码
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>stream-demo</title>
  </head>
  <body>
    <div id="app"></div>                              <!-- 1. 占个坑,Vue 接管这里 -->
    <script type="module" src="/src/main.js"></script> <!-- 2. 加载入口 JS -->
  </body>
</html>

<div id="app"> 是整个 Vue 应用的唯一挂载点。它本身是空的,Vue 会把它填满。类比:墙上的钉子,Vue 应用是一幅画,mount 就是把画挂到钉子上。

main.js ------ 创建并挂载 Vue 应用

js 复制代码
import { createApp } from 'vue'  // 从 Vue 库引入"创建应用"方法
import './style.css'             // 全局样式
import App from './App.vue'      // 引入根组件

createApp(App).mount('#app')     // 创建应用 → 挂到 #app 上

createApp(App) 创建了一个 Vue 应用实例。.mount('#app') 把它塞进 <div id="app">。这一步之后,#app 内部的世界就由 Vue 组件统治了。

App.vue ------ 你的组件

.vue 文件由三部分组成:

  • <template> ------ HTML 模板,负责页面结构
  • <script> ------ JS 逻辑,负责数据和交互
  • <style> ------ CSS 样式,只管这个组件的外观

这种"三合一"的设计就是组件化思想的核心:把属于同一个功能的结构、逻辑、样式封在一起,不再像传统开发那样三个文件到处找。

1.2 Vite 脚手架做了什么

浏览器不认识 .vue 文件(它只认识 HTML、CSS、JS)。所以需要 Vite(一个由 Node.js 写的构建工具)在中间做编译

复制代码
.vue 文件 → Vite 编译 → 浏览器能跑的 JS/CSS → 页面渲染

启动命令:

bash 复制代码
npm run dev     # 在 5173 端口启动 HTTP 服务
# 浏览器访问 http://localhost:5173

访问根路径 / 时,服务器默认返回 index.html,加载 main.js,Vue 应用启动------整个链条就通了。


二、Vue 3 的核心概念

2.1 组件化(Componentization)

"构成页面的最小单位不再是 HTML 标签,而是组件。"

HTML 标签像砖块(<div><button><input>),太细碎了。组件是把一堆 HTML + CSS + JS 打包成一个功能单元,像乐高积木一样拼装页面:

xml 复制代码
页面
├── <Header />
├── <Sidebar />
├── <ChatArea>
│   ├── <MessageBubble />   ← AI 对话气泡
│   ├── <MessageBubble />   ← 用户对话气泡
│   └── <InputBox />        ← 发送消息的输入框
└── <Footer />

每个组件职责单一,出问题直接定位到对应 .vue 文件,改了不影响其他组件。

2.2 响应式数据(Reactive)------ 数据变了,页面自动更新

这是 Vue 和传统 DOM 编程最大的区别:

js 复制代码
// 传统方式:手动操作 DOM
document.querySelector('.content').innerHTML = '思考中....'

// Vue 方式:声明响应式数据,只管改数据
const content = ref('')
content.value = '思考中....'   // 改数据 → 页面自动刷新

ref() 是 Vue 3 里把普通数据变成响应式数据 的方法。它给数据装了一个"追踪器"------Vue 知道谁在用这个数据(模板里的 {{ content }}),数据一变就通知那边更新。

js 复制代码
import { ref } from 'vue'

const count = ref(1)       // 数字,初始值 1
const question = ref('')   // 字符串,初始值空
const stream = ref(false)  // 布尔值,初始 false

// JS 里读写需要 .value
count.value = 2            // 改了,模板里 {{ count }} 自动变 2
console.log(count.value)   // 读也要 .value

三种初始值模式:

js 复制代码
const count = ref(0)        // 数字从 0 开始
const content = ref('')     // 字符串从空开始,等接口返回再填
const list = ref([])        // 数组从空开始,等着 push 数据
const user = ref(null)      // 对象从 null 开始,等接口返回再赋值

2.3 <script setup> ------ 语法糖

Vue 3 新增的 <script setup> 让你不用手动导出数据给模板:

vue 复制代码
<script setup>
const count = ref(1)   // 顶层变量自动暴露给模板
</script>

<template>
  {{ count }}          <!-- 直接用,不用 return -->
</template>

对比老的写法需要 return

js 复制代码
export default {
  setup() {
    const count = ref(1)
    return { count }   // 必须手动 return
  }
}

<script setup> 把这个 return 步骤省掉了------这就是"语法糖":功能一样,写起来更甜。

2.4 模板语法大全

{{ }} ------ 插值表达式(单向数据绑定)

html 复制代码
<p>{{ content }}</p>   <!-- JS 数据 → 页面,用户不能改 -->

用在标签里面的文字内容。数据只从 JS 流向页面。

: ------ 属性绑定(v-bind 简写)

html 复制代码
<input :value="question" />        <!-- value 属性跟着 question 变 -->
<input :placeholder="'请输入' + question" />
<button :disabled="question === ''">提交</button>
<img :src="imageUrl" />

用在标签身上 的属性。:v-bind: 的简写,二者等价。

v-model ------ 双向绑定

html 复制代码
<input v-model="question" />        <!-- 文本:绑字符串 -->
<input type="checkbox" v-model="stream" />  <!-- 勾选框:绑布尔值 -->

v-model 本质是 :value + @input 的语法糖:

html 复制代码
<!-- v-model="question" 等价于 -->
<input :value="question" @input="question = $event.target.value" />

不同表单控件绑定的类型:

控件 v-model 绑的值
<input type="text"> 字符串
<input type="checkbox"> 布尔值(true/false)
<select> 字符串(选中项的 value)
<textarea> 字符串

v-if ------ 条件渲染

html 复制代码
<div v-if="stream">流式模式已开启</div>
<!-- stream 为 true 时显示,false 时这个 div 直接从 DOM 移除 -->

@click ------ 事件绑定(v-on 简写)

html 复制代码
<button @click="update">提交</button>
<!-- 点击时执行 update 函数 -->

三、"数据"到底是什么?------ 状态的概念

编程课本上常说"变量",但在 Vue 里,更准确的说法是状态(State)

状态 = 描述当前页面长什么样的数据。

js 复制代码
const question = ref('讲一个关于中国龙的故事')  // 输入框里有什么 → 一个"状态"
const stream   = ref(false)                    // 勾没勾流式 → 一个"状态"
const content  = ref('')                       // AI 返回了什么 → 一个"状态"

传统写法里,要"看"页面上的东西,你只能去读 DOM:

js 复制代码
const text = document.querySelector('.input').value  // 取值靠查 DOM

Vue 反过来了------数据 JS 是真相来源,页面的 DOM 只是它的镜像。你只管 JS 数据,页面自动跟着数据走。这就是"数据驱动(Data Driven)"。


四、表单的例外 ------ 什么时候需要双向绑定?

{{ }} 是单向的:JS 数据 → 页面。用户没法在页面上改这个值。

但表单不一样------用户要在输入框里打字,输入的内容得传回 JS。所以需要双向

复制代码
JS 改 question.value   →  输入框自动更新       (JS → 页面)
用户打字改输入框内容    →  question.value 自动更新 (页面 → JS)
question.value 变了    →  {{ question }} 也更新 (JS → 页面)

v-model 就是在表单元素上用的双向绑定工具。


五、数据类型和转换 ------ 从 0101 到页面显示

这是一个很基础但容易绕晕的点。从 DeepSeek 服务器到你的 {{ content }},数据经历了多次形态变化:

5.1 完整链路

css 复制代码
① 二进制流(网线上跑的原始 0 和 1)
   │
   │  fetch 自动接收,包进 Response 对象
   ↓
② Response 对象
   │     方式1:response.json()         → 一次性读完 → 解析成 JS 对象
   │     方式2:response.body.getReader() → 一小块一小块读(流式用这个)
   ↓
③ JSON 字符串(纯文本,人能读的中间格式)
   │     例:'{"choices":[{"message":{"content":"你好"}}]}'
   │
   │  JSON.parse(json字符串)   → 字符串变 JS 对象
   │  JSON.stringify(js对象)   → JS 对象变字符串(发请求时用)
   ↓
④ JS 对象(代码里能用 . 取值操作)
   │     例:{ choices: [{ message: { content: '你好' } }] }
   │
   │  data.choices[0].message.content  → 取出最终的内容字符串
   ↓
⑤ JS 基本类型
     字符串 '你好'      → 赋给 content.value → {{ content }} 显示
     布尔值 true/false  → 控制开关、显隐
     数字 123           → 计数器、计数

5.2 两个关键转换

JSON.stringify() ------ 发请求时:JS 对象 → JSON 字符串

js 复制代码
// JS 对象(在内存里,代码可以 . 访问)
const body = {
  model: 'deepseek-v4-flash',
  messages: [{ role: 'user', content: '你好' }],
  stream: false
}

// JSON.stringify 之后 → 变成纯文本字符串(可以在网络上传输)
// '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"你好"}],"stream":false}'

HTTP 请求只能传文本。你没法把一个 JS 对象直接塞进请求体,得先 "装箱" 成 JSON 字符串。

response.json() ------ 收响应时:二进制流 → JS 对象

js 复制代码
const response = await fetch(endpoint, {...})  // response 里包着二进制流
const data = await response.json()              // 读出来 → 解析 → JS 对象
content.value = data.choices[0].message.content // 现在可以 . 取值了

5.3 总结表

形态 是什么 在哪用 人能读吗 代码能 . 操作吗
二进制流 0 1 的字节 网线上跑 ❌ 乱码 ❌ 不能
JSON 字符串 文本 网络传输的载体 ✅ 能 ❌ 不能(它只是字)
JS 对象 数据结构 代码里操作 ✅ 能 ✅ 能
JS 基本类型 字符串/数字/布尔 代码里赋值、显示 ✅ 能 ✅ 能

六、正题:流式输出 vs 非流式输出

6.1 问题

AI 模型推理生成内容耗时很长(Transformer 的推理计算量很大,问题越难越长耗时越久)。如果等它全部生成完再一次性返回,用户盯着白屏干等十几秒------体验极差。

6.2 解决:流式(Streaming)

不用等全部生成完。模型每生成一个 token(可以理解为一个字或一个词),就立刻推送到客户端。客户端不断拼接这些 token,效果就像一个打字机实时往外吐字。

vbnet 复制代码
stream: true  →  "你好" "我" "是" "一" "个" ... 一个个推过来 → 打字机效果
stream: false →  等 10 秒 → 一次性全部返回 → 用户干等

类比:水流,接根管子从 LLM 服务器接到 chatbot 客户端。生成的 token 就像水流一样不断流过来。

6.3 约定

这套机制需要前后端配合:

  • 客户端发请求时body 里带 "stream": true
  • 服务器收到后 :看到 stream: true,就生成一段吐一段,不一次性返回

七、实战:完整的 Chatbot 代码逐段讲解

7.1 模板部分 ------ UI

vue 复制代码
<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input type="text" class="input" v-model="question">
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
        <div v-if="stream">
          <label>Streaming</label>
        </div>
        <div>
          {{content}}
        </div>
      </div>
    </div>
  </div>
</template>

一步步分解:

  1. <input v-model="question"> ------ 输入框和 question 双向绑定。用户打字,question.value 自动更新。
  2. <button @click="update"> ------ 点击触发 update 函数,发送请求。
  3. <input type="checkbox" v-model="stream"> ------ 勾选框绑 stream(布尔值),决定是否开流式。
  4. <div v-if="stream"> ------ streamtrue 才显示"Streaming"标签。
  5. {{content}} ------ 显示 AI 返回的内容。不管是流式还是非流式,最终都渲染到这里。

7.2 脚本部分 ------ 逻辑

7.2.1 定义状态

js 复制代码
import { ref } from 'vue'

const question = ref('讲一个关于中国龙的故事')  // 用户输入的问题
const stream = ref(false)                      // 是否开启流式输出
const content = ref('')                        // AI 返回的内容

// 三个 ref,三个独立的状态。各管各的,互不干扰。

7.2.2 update 函数 ------ 完整流程

js 复制代码
const update = async () => {
  // 1. 空值守卫:输入为空就不发请求,避免浪费
  if (!question.value) return

  // 2. 设置"加载中"状态,给用户即时反馈
  content.value = '思考中....'

  // 3. 准备请求配置
  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Content-Type': 'application/json',                              // 告诉服务器"我发的是 JSON"
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}` // API Key 验证身份
  }

  // 4. 发送请求
  const response = await fetch(endpoint, {
    method: 'POST',    // POST 方式提交,body 被加密(HTTPS),比 GET 安全
    headers,
    body: JSON.stringify({   // 把 JS 对象打包成 JSON 字符串
      model: 'deepseek-v4-flash',
      messages: [{
        role: 'user',
        content: question.value     // 用户的问题
      }],
      stream: stream.value           // 关键参数:是否流式
    })
  })

  // 5. 根据 stream 分支处理响应
  if (stream.value) {
    // ===== 流式输出 =====
    content.value = ''

    // 5a. 获取读取器(把二进制流一小块一小块读出来)
    const reader = response.body?.getReader()      // ?. 安全访问,body 为 null 时不报错
    const decoder = new TextDecoder()              // 解码器:把二进制字节转成文本
    let done = false                               // 是否读完的标志
    let buffer = ''                                // 缓冲区,用来拼碎片

    while (!done) {                                // 一直读,直到 done === true
      const { value, done: doneReading } = await reader.read()
      // value 是 Uint8Array(十进制的字节数组,底层是二进制)
      // doneReading 是 true 时说明读完了

      done = doneReading
      if (value) {
        buffer += decoder.decode(value, { stream: !done })  // 解码二进制 → 文本 → 拼接到 buffer
      }
      content.value = buffer   // 每读一块就更新页面 → 打字机效果!
    }
  } else {
    // ===== 非流式输出 =====
    // 服务器一次性返回全部结果,直接读完
    const data = await response.json()
    content.value = data.choices[0].message.content
    // 取响应里的这个路径:choices 数组 → 第 0 个 → message → content
  }
}

7.3 请求参数说明

发给 DeepSeek 的 body 里有哪些参数:

参数 说明
model 'deepseek-v4-flash' 模型名称,flash 版更快更便宜
messages [{ role: 'user', content: ... }] 对话历史数组,role: 'user' 表示用户说的话
stream stream.value true = 流式输出,false = 一次性返回
temperature 0.5 随机性,0~2,越高越"放飞",越低越保守
max_tokens 1024 最多生成多少个 token
top_p 0.5 核采样参数,控制词汇选择范围
frequency_penalty 0.0 重复惩罚,大于 0 减少重复
presence_penalty 0.0 话题惩罚,大于 0 鼓励聊新话题

7.4 Authorization 认证是什么?

js 复制代码
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
  • Authorization ------ HTTP 请求头字段,用于身份验证
  • Bearer ------ 令牌的前缀类型(一种约定格式),表示"持票人认证"
  • import.meta.env.VITE_DEEPSEEK_API_KEY ------ 从环境变量文件 .env.local 里读取你的 DeepSeek API Key

Vite 会自动读取项目根目录的 .env.local 文件。你需要在里面写:

ini 复制代码
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx

上面代码中我们用到了 import.meta.env.VITE_DEEPSEEK_API_KEY,这个 import.meta.env 是 Vite 提供的,不是 Vue 的。通过它就能在浏览器端读到 .env.local 文件里的环境变量(注意:只有以 VITE_ 开头的变量才会被暴露给前端代码)。


八、流式输出的原理深入

8.1 ReadableStream 是什么?

fetch 返回的 response.body 是一个 ReadableStream(可读流)。它不是一次性给你所有数据,而是像一个水龙头------你可以拧开一点接一点。

scss 复制代码
DeepSeek 服务器
     │
     │  把一个一个 token 推到"水管"里
     ↓
response.body(ReadableStream 水管)
     │
     │  reader.read() 一次拧开一点,接住一个 chunk
     ↓
你的代码 → decoder.decode() 解码 → 拼到 buffer → 更新 content.value → 页面显示

8.2 读取器(Reader)的工作方式

js 复制代码
const reader = response.body?.getReader()  // 从水管上装一个"阀门"

const { value, done } = await reader.read()
// value    = 这一批读到的字节(Uint8Array 类型,十进制数组,底层是二进制)
// done     = true/false,流读完了就是 true

每次 reader.read() 返回一小块数据(chunk)。DeepSeek 流式模式下,一个 chunk 可能包含一个或多个 token。

8.3 TextDecoder ------ 二进制 → 文本

网线上跑的是二进制字节。人读不了,代码也不好直接处理。所以用 TextDecoder 把它解码成文本:

js 复制代码
const decoder = new TextDecoder()
const text = decoder.decode(value, { stream: true })
// 参数 stream: true 表示"还有更多数据在后面"
// 这样 decoder 知道不完整的汉字不要强行翻译,等下一块拼好了再说

8.4 为什么用 ?.(可选链)

js 复制代码
const reader = response.body?.getReader()

某些特殊情况(比如 HTTP 204 状态码或跨域限制)下 response.body 可能是 null。如果直接 response.body.getReader() 就会报错崩溃。?. 是安全访问------bodynull 时自动停下返回 undefined,不报错。


九、API Key 的正确存放方式

js 复制代码
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`

不要 把 API Key 直接写在代码里(有泄露风险)。应该放在 .env.local 文件中:

ini 复制代码
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Vite 构建时会读取这个文件,import.meta.env.VITE_DEEPSEEK_API_KEY 会被替换成对应的值。(注意:只有以 VITE_ 开头的变量才会暴露给前端代码。)


十、从 data.choices[0].message.content 理解 JSON 取值的路径

DeepSeek 返回的 JSON 结构大概长这样:

json 复制代码
{
  "id": "chatcmpl-xxx",
  "object": "chat.completion",
  "created": 1234567890,
  "model": "deepseek-v4-flash",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "你好!我可以帮你做什么?"
      },
      "finish_reason": "stop"
    }
  ]
}

取值路径的拆解:

js 复制代码
data                     // 整个响应对象
data.choices             // 找到 choices 数组
data.choices[0]          // 取数组的第 0 个元素(第一个,通常只有一个)
data.choices[0].message  // 取里面的 message 对象
data.choices[0].message.content  // 最终取到回复文本

这就是 JS 对象的"点语法(dot notation)"------一层层深入取出你想要的值。


十一、完整项目结构一览

java 复制代码
stream demo/
├── index.html            ← 浏览器入口 + 挂载点 <div id="app">
├── package.json          ← 项目依赖:vue、vite、@vitejs/plugin-vue
├── vite.config.js        ← Vite 配置
├── .env.local            ← 环境变量,存 API Key(VITE_DEEPSEEK_API_KEY)
├── .gitignore            ← git 忽略文件列表
├── README.md             ← 项目说明
├── public/
│   └── favicon.svg       ← 网站图标
└── src/
    ├── main.js           ← Vue 应用入口:createApp + mount
    ├── style.css         ← 全局样式
    ├── App.vue           ← 主组件:模板 + 逻辑 + 样式
    ├── assets/           ← 静态资源(图片等)
    └── components/       ← 子组件目录

十二、总结:每一步在完整流程里的位置

把整个交互流程串起来:

sql 复制代码
1. 用户在输入框打字
   v-model → question.value 自动更新

2. 用户勾选/取消 Streaming
   v-model → stream.value 自动更新(true/false)

3. 用户点击"提交"
   @click → 触发 update 函数

4. update 函数
   a. 空值检查 → 设置 content = '思考中....'(页面即时反馈)
   b. 构建请求头(Content-Type + Authorization)
   c. 把数据 (question, stream) 用 JSON.stringify 打包
   d. fetch(endpoint, { method:'POST', body:... }) 发请求

5. 服务器收到请求
   → DeepSeek API 处理
   → stream: false → 一次性返回全部 JSON
   → stream: true  → 推流式数据块

6. 客户端处理响应
   → 非流式:response.json() → data.choices[0].message.content → 赋值
   → 流式:response.body.getReader() → 循环 read() → decoder 解码 → 拼接 → 赋值

7. 页面渲染
   → content.value 更新 → {{ content }} 自动刷新 → 用户看到结果

整个过程中,没有一行 document.querySelectorinnerHTML。Vue 的响应式系统全程接管了"数据 → 页面"的同步------这就是现代前端框架的价值。


核心概念速查表

概念 一句话
组件化 HTML+CSS+JS 打包成一个 .vue 功能单元,像乐高积木
响应式 ref() 包裹的数据改了,页面自动更新
{{ }} 单向:JS 数据 → 页面文字,用于标签内容
:属性 单向:JS 数据 → 标签属性,如 :value :src :disabled
v-model 双向:JS ↔ 表单,用户打字反过来改 JS 数据
v-if 条件渲染:true 显示,false 移除
@事件 事件绑定:如 @click="函数"
ref() 创建响应式数据,.value 读写,模板自动解包
JSON.stringify JS 对象 → JSON 字符串(发请求前打包)
response.json() 二进制流 → JS 对象(收响应后拆包)
ReadableStream 水管,流式读取一小块一小块拿
?. 安全访问,为 null 时不报错
"状态" 描述当前页面长什么样的数据,改状态 = 改页面
流式输出 一个字一个字吐(打字机),不等全部生成完
相关推荐
weedsfly2 小时前
前端开发中的装饰器模式——给函数和组件“加壳”的艺术
前端·javascript·面试
浩瀚地学3 小时前
【面试算法笔记】0202-链表-基本功能实现
java·经验分享·笔记·算法·面试
KaifuZeng3 小时前
通信与接口协议面试问题汇总一
单片机·嵌入式硬件·面试·通信与接口协议
卷福同学4 小时前
AI编程出海第一步:别急着写代码,先找到老外真正愿意付费的需求
人工智能·后端·面试
KaifuZeng5 小时前
模电面试问题汇总五
单片机·嵌入式硬件·面试·模电
zzz_23685 小时前
【Java实习面试算法冲刺】回溯
java·算法·面试
顾昂_16 小时前
内存泄漏排查和 Chrome DevTools 使用教程
前端·javascript·面试
KaifuZeng16 小时前
模电面试问题汇总一
单片机·嵌入式硬件·面试·模电