# Vue 3 + DeepSeek 流式输出实战:从 HTTP 协议到打字机效果,40 行核心代码搞定

12 阅读17分钟

Vue 3 + DeepSeek 流式输出实战:从 HTTP 协议到打字机效果,40 行核心代码搞定

你用过 ChatGPT 吧?提问之后光标像打字机一样一个字一个字往外蹦——这背后是一套从前端到后端的完整流式输出体系。本文从 HTTP 协议层讲起,一路写到 Vue 3 组件里的 ReadableStream,并深度展开 Vue 组件化思想和响应式数据绑定这两个前端基石。读完你不仅能写出打字机效果,还会对"为什么 Vue 这么设计"有一个全新的理解。


目录

  1. 一个让你等 27 秒的产品,你还会用吗?
  2. 流式输出的底层:浏览器和服务器之间接了一根水管
  3. Vue 组件化:为什么页面不再是由标签拼成的了
  4. 响应式数据绑定:从 DOM 编程到数据驱动的范式跃迁
  5. 动手写:Vue 3 + DeepSeek 流式聊天机器人(完整代码)
  6. 六个坑,踩过才算真正会了
  7. 总结:这不止是一篇流式输出教程

一、一个让你等 27 秒的产品,你还会用吗?

1.1 两种体验,天壤之别

想象两个 AI 聊天产品,你输入"帮我写一篇 2000 字的技术文章":

产品 A:点击发送 → 进度条开始转 → 界面一片空白 → 15 秒 → 20 秒 → 27 秒 → 呼地一下,一大坨文字弹出来

产品 B:点击发送 → 文字立即开始往外蹦 → "在当今这个技术飞速发展的时代..." → 一个字一个字持续输出 → 你可以同步阅读前半部分 → 体感上没有等待。

产品 B 就是 ChatGPT、DeepSeek、Kimi 的真实体验。产品 A 是很多 AI 创业产品上线第一版的真实体验——然后用户就流失了。

"流式输出不是体验优化,是 AI 产品的体验及格线。"

1.2 为什么 LLM 天生就慢?

大语言模型(LLM)生成回复的过程,叫逐 token 自回归生成。过程是这样的:

输入 prompt → [Transformer 推理] → token_1
               [Transformer 推理] → token_2  (拼回上文,再预测下一个)
               [Transformer 推理] → token_3
               ...
               [Transformer 推理] → token_2000

每生成一个 token,都意味着一次完整的 Transformer 前向传播——几百层的矩阵乘法。参数量越大,算得越久。

如果等 2000 个 token 全部生成完再一次性返回,用户就要干等 2000 个 token 的推理时间总和。

这个等待时间,在用户体验研究里有一个专门的名字:"感知延迟(Perceived Latency)"。超过 400ms,用户就会觉得"卡了"。

1.3 最优解:一个一个给

流式输出的核心思路简单到令人发指:

不等全部生成完。生成一个 token,就发送一个 token。

服务器不再"接满一桶水再拎过来",而是和客户端之间接了一根水管——token 像水一样,生成一滴就流过来一滴。


二、流式输出的底层:浏览器和服务器之间接了一根水管

2.1 两个约定

这套机制能跑起来,靠的是前后端之间的两个简单约定:

角色做了什么
客户端请求stream: true——告诉服务器"我要水管模式"
服务端响应每推理出一个 token,立刻写入响应体,不等到全部完成

对于前端工程师来说,你需要做的就是在 fetch 请求的 body 里加上:

body: JSON.stringify({
  model: 'deepseek-v4-flash',
  messages: [{ role: 'user', content: '你的问题' }],
  stream: true   // ← 就这四个单词,水管模式开启
})

2.2 HTTP 协议层:到底怎么"流"?

从协议层面看,流式输出基于 Server-Sent Events(SSE) 或者更底层的 HTTP Chunked Transfer Encoding(分块传输编码)

核心区别:

模式HTTP 协议行为Content-Type
非流式服务器生成完 → 设置 Content-Length → 一次性发送application/json
流式(SSE)不使用 Content-Length,使用 Transfer-Encoding: chunked,逐个 chunk 发送text/event-stream

LLM API(OpenAI / DeepSeek 兼容格式)的 SSE 响应长这样:

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"中"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"国"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"龙"}}]}

data: [DONE]

每一行以 data: 开头,后面是一个 JSON 字符串,里面 choices[0].delta.content 就是本次增量生成的 token。最后一行 [DONE] 表示"我说完了,收工"。

2.3 浏览器端的武器:ReadableStream + TextDecoder

在浏览器里,fetch 的响应对象 response.body 是一个 ReadableStream。通过它,我们可以一块一块地读,而不是一下全拿走:

const reader = response.body?.getReader()   // 获取"读取器"
const decoder = new TextDecoder()            // 二进制 → 文本 翻译官

while (true) {
  const { value, done } = await reader.read()  // 读一块
  if (done) break                               // 流结束了
  const text = decoder.decode(value, { stream: true })  // 解码
  // 解析 SSE 格式,提取 token...
}

三个核心角色:

角色类型职责
readerReadableStreamDefaultReader从响应体中逐块读取二进制数据
decoderTextDecoderUint8Array 二进制翻译成 JavaScript 字符串
buffer(下文会讲)string暂存不完整的 SSE 行,防止 JSON 解析失败

decoder.decode(value, { stream: true }) 中那个 { stream: true } 参数至关重要——它告诉 TextDecoder"后面还有数据,这一块可能不完整"。这样就不会把切在多字节字符(比如 UTF-8 中文)中间的不完整字节解析为乱码


三、Vue 组件化:为什么页面不再是由标签拼成的了?

在正式写代码之前,我们必须先讲清楚 Vue 组件化——不止是"怎么写",更是"为什么要这么写"。这一节值得你认真读完。

3.1 传统写法的问题:一堆散装的 HTML + CSS + JS

在没有组件化的年代,一个页面的开发方式是这样的:

index.html     ← 几百行 HTML 标签
style.css      ← 几百行 CSS 样式
main.js        ← 用 document.querySelector 到处抓元素、改内容

三个文件物理分离,但逻辑上强耦合——你改了一个 div 的 class,要去 CSS 里找对应的样式,再去 JS 里找对应的 DOM 操作。一旦项目复杂起来,维护成本指数级上升。

Facebook 的网页由 一万多个组件 组成。如果按传统模式维护,任何一个改动都可能引发连锁崩塌。

3.2 .vue 单文件组件:把相关的东西放在一起

Vue 的方案是单文件组件(Single File Component,.vue)——每个组件一个文件,每个文件包含三部分:

<template>
  <!-- 模板:动态 HTML,不是静态的标签堆积 -->
</template>

<script setup>
  // 逻辑:数据、方法、生命周期
</script>

<style scoped>
  /* 样式:这个组件的专属 CSS,不会污染全局 */
</style>

🧱 类比:组件就是乐高积木。HTML 标签是原材料(塑料颗粒),组件是用原材料造好的积木块。你用积木块搭房子,而不是用塑料颗粒一粒一粒拼。

静态 HTML vs 动态模板的区别:

<!-- 静态 HTML:写死了,永远就是这个值 -->
<div>你好,张三</div>

<!-- Vue 模板:绑定数据,数据变了显示就变 -->
<div>你好,{{ userName }}</div>

模板本质上是 HTML 的超集,多了数据绑定能力。你的关注点从"怎么改 DOM"变成了"变量现在是什么值"。

3.3 组件的三个层次

理解了组件化之后,一个 Vue 应用的架构在你眼里应该是这样的:

App.vue (根组件)
├── Header.vue      (导航栏组件)
├── Sidebar.vue     (侧边栏组件)
├── ChatArea.vue    (聊天区组件)
│   ├── Message.vue (单条消息组件) × N
│   └── InputBox.vue (输入框组件)
└── Footer.vue      (底部组件)

组件化的三大价值

价值说明
封装每个组件内部 template/script/style 三者自闭环,对外只暴露接口(props)
复用Message.vue 组件写好一次,有多少条消息就渲染多少个实例
维护改聊天气泡样式?只改 Message.vue<style>,不会误伤其他组件

四、响应式数据绑定:从 DOM 编程到数据驱动的范式跃迁

4.1 不用框架时你在做什么?

一个典型的前端场景:用户点了一个按钮,你从服务器拿到数据,显示在页面上。原生 JS 的做法是这样的:

// 1. 找到 DOM 元素
const contentDiv = document.getElementById('content')

// 2. 发请求拿数据
const response = await fetch('/api/data')
const data = await response.json()

// 3. 手动把数据塞进 DOM
contentDiv.innerHTML = data.text

这叫 DOM 编程(Imperative DOM Manipulation):你以"命令"的方式告诉浏览器每一步该做什么。这种模式的致命问题是什么?

状态不一致。当同一个数据在页面 5 个地方展示,你需要在每次数据变更时,手动找到 5 个 DOM 元素并逐个更新。漏掉一个就是 bug。

4.2 Vue 的做法:声明式 + 响应式

Vue 的核心思想叫数据驱动(Data Driven)——你先声明"这个 div 显示 content 这个变量的值",然后你只管改变量,DOM 的更新框架帮你做。

<template>
  <!-- 声明:这个 div 的内容 = content 变量的值 -->
  <div>{{ content }}</div>
</template>

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

const content = ref('')

// 任何地方修改 content.value,div 自动刷新
// 你不需要 document.getElementById(...).innerHTML = ...
content.value = '你好,世界'
</script>

🧠 思维模型转换:不用框架时你想着"去找到那个 div,把它的内容改成 xxx";用 Vue 时你想着"content 现在应该是什么值"。

4.3 ref()reactive()——两个创建响应式数据的内核

Vue 3 提供了两种创建响应式数据的方式:

import { ref, reactive } from 'vue'

// ref:包装任意类型(推荐,适用范围最广)
const count = ref(0)           // 数字
const name = ref('张三')       // 字符串
const stream = ref(false)      // 布尔
// 访问/修改:.value
console.log(count.value)       // 0
count.value = 1                // 视图自动更新

// reactive:只适用于对象类型
const state = reactive({
  count: 0,
  name: '张三'
})
// 访问/修改:直接.属性,不需要 .value
console.log(state.count)       // 0
state.count = 1                // 视图自动更新

如何选择?

场景用哪个
基本类型(string、number、boolean)ref() —— reactive() 不支持基本类型
单个值,换了整个数据ref() —— ref 可以被整个替换
复杂对象,多个嵌套属性reactive()ref() 都行,ref() 用得更广

💡 一个实用建议:如果你不确定,就用 ref()。它在 Vue 3 Composition API 中是最通用的响应式单元。我在文章后面的代码中也全部使用 ref()

4.4 单向绑定 {{ }} vs 双向绑定 v-model

这是 Vue 学习过程中最重要的概念之一。很多人只知道"用 v-model 绑定表单",但不理解为什么有两种。

单向绑定 {{ }}:数据 → 视图
<div>{{ content }}</div>
<!-- 数据 content 变化 → div 内容自动更新 -->
<!-- 用户无法直接修改 div 的内容(div 不是输入控件) -->

数据流是单向的JavaScript 变量DOM 显示

双向绑定 v-model:数据 ⇄ 视图
<input v-model="question" />
<!-- question 变了 → input 显示更新 -->
<!-- 用户在 input 里打字 → question 也被更新 -->

数据流是双向的JavaScript 变量DOM 输入控件

为什么要区分?

这个区分的本质原因是:表单元素是交互入口,用户也能改数据

┌─────────────────────────────────────────────────┐
│                  Vue 数据模型                       │
│                                                   │
│   question = ref('你好')                           │
│   content  = ref('')                              │
│                                                   │
│    ┌──────────────┐      ┌──────────────────┐    │
│    │  {{ content }}│      │v-model="question" │   │
│    │   单向 ↓      │      │   双向 ⇄          │    │
│    │  数据→视图    │      │  数据⇄视图         │    │
│    │              │      │                   │    │
│    │  只展示      │      │  用户可以修改       │    │
│    └──────────────┘      └───────────────────┘    │
└─────────────────────────────────────────────────┘

保持这个心智模型能帮你避免大量 bug:

默认用单向绑定。只有用户需要输入操作的元素(<input><textarea><select><checkbox> 等)才用 v-model

4.5 <script setup> 语法糖

Vue 3 引入了 <script setup>——它让代码更简洁:

<!-- 传统写法:需要显式 return -->
<script>
export default {
  setup() {
    const count = ref(0)
    return { count }   // 必须在 return 里暴露,template 才能用
  }
}
</script>

<!-- <script setup>:自动暴露,无需 return -->
<script setup>
const count = ref(0)
// count 自动在 template 里可用
</script>

<script setup> 里的所有顶层变量和函数,自动暴露给 template。它就像一个"宏函数"——编译时自动帮你做 return。目前是 Vue 3 社区的标准写法,接下来的代码我也全部用它。


五、动手写:Vue 3 + DeepSeek 流式聊天机器人(完整代码)

5.1 项目搭建(3 分钟)

npm create vite@latest stream-demo -- --template vue
cd stream-demo
npm install

创建 .env.local(Vite 自动读取,以 VITE_ 开头的变量暴露给前端):

VITE_DEEPSEEK_API_KEY=sk-你的deepseek-api-key

⚠️ .env.local 一定要加到 .gitignore!把 API Key 提交到 Git 仓库是安全灾难。

5.2 入口文件:index.htmlsrc/main.js

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Stream Demo</title>
</head>
<body>
  <!-- Vue 的"挂载点"——整个 App 都会被渲染在这个 div 内部 -->
  <div id="app"></div>
  <!-- 入口 JS,type="module" 开启 ES Module -->
  <script type="module" src="/src/main.js"></script>
</body>
</html>
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'

// 创建 Vue 应用实例,挂载到 #app
createApp(App).mount('#app')

核心概念:#app挂载点——Vue 会接管这个 div 内部的一切,用响应式数据驱动渲染。外部不受影响。

5.3 核心组件 App.vue:完整代码

下面是完整、可直接运行的 App.vue,每行关键代码都有注释标注其背后的 Vue 概念:

<template>
  <!--
    Vue 模板(Template):
    - 不是静态 HTML,而是可以绑定数据的"动态 HTML"
    - {{ }} 是单向数据绑定:数据变了,这里自动更新
    - v-model 是双向数据绑定:用户输入反向传回数据
  -->
  <div class="container">
    <!--
      v-model="question":
      双向绑定。用户在 input 里打字 → question 自动更新
      question 被代码改了 → input 的显示自动更新
    -->
    <input
      type="text"
      v-model="question"
      placeholder="输入你的问题"
    />
    <button @click="update">发送</button>

    <!--
      v-model="stream" 用在 checkbox 上:
      勾选 → stream = true,取消 → stream = false
    -->
    <label>
      <input type="checkbox" v-model="stream" />
      流式输出
    </label>

    <!--
      {{ content }}:
      单向绑定。content 变量变了,这里自动刷新
      这里不需要 v-model——这一行不是输入控件,纯展示
    -->
    <div class="output">{{ content }}</div>
  </div>
</template>

<script setup>
/*
  <script setup>:Vue 3 语法糖
  - 所有顶层变量和函数自动暴露给 <template>
  - 不需要手动 return
  - ref() 创建响应式数据状态
*/
import { ref } from 'vue'

// ========== 三个响应式状态 ==========
// ref() 包装的数据是"响应式"的:数据改了 → 视图自动更新
// 访问/修改时需要 .value
const question = ref('讲一个关于中国龙的故事')
const stream = ref(false)
const content = ref('')

// ========== 核心方法 ==========
const update = async () => {
  // 空输入校验——不是 DOM 编程,只是检查变量值
  if (!question.value) return
  content.value = '思考中....'

  // 构建请求
  const response = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // API Key 从 Vite 环境变量读取(只有 VITE_ 前缀的变量才能被前端访问)
      Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
    },
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value   // ← stream: true = 水管模式,stream: false = 桶模式
    })
  })

  // ========== 岔路口 ==========

  if (stream.value) {
    // 🚰 水管模式:逐块读取,打字机效果
    content.value = ''                                    // 清空,准备拼接
    const reader = response.body?.getReader()              // 拿到"读取器"
    const decoder = new TextDecoder()                      // "翻译官":二进制 → 文本
    let done = false
    let buffer = ''                                       // 缓冲区:暂存不完整的 SSE 行

    while (!done) {
      const { value, done: doneReading } = await reader.read()
      done = doneReading                                  // 流读取完成(包括 [DONE] 信号)
      if (!value) continue

      // 解码二进制块。{ stream: true } 防止中文被截断成乱码
      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n')                    // SSE 按行分割
      buffer = lines.pop() || ''                          // 最后一行可能不完整,留到下一轮

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue          // 跳过非 data 行(空行等)
        const data = line.slice(6).trim()                  // 去掉 "data: " 前缀
        if (data === '[DONE]') { done = true; break }      // 服务器说"说完了"
        try {
          const delta = JSON.parse(data).choices?.[0]?.delta?.content
          if (delta) content.value += delta               // ← 这行一执行,{{ content }} 自动刷新
        } catch (e) {
          // SSE 数据包可能不完整,忽略解析失败的行
        }
      }
    }
  } else {
    // 🪣 桶模式:等全部生成完,一次性拿
    const data = await response.json()
    content.value = data.choices[0].message.content       // ← 同样,只改数据,不管 DOM
  }
}
</script>

<style scoped>
/* scoped:这些样式只作用于当前组件,不会污染别的组件 */
.container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}
.output {
  margin-top: 20px;
  white-space: pre-wrap;
}
</style>

5.4 关键行逐行注解:Vue 概念到代码的映射

上面代码中,每一行都在映射前文讲过的概念。我挑 6 个最容易产生困惑的地方重点讲:

const question = ref('讲一个关于中国龙的故事')

这是 ref() 创建响应式数据。为什么不用 let question = '...'?因为普通的 let 变量不是响应式的——改了值 Vue 不知道,视图不会更新。ref() 在内部用 Proxygetter/setter 拦截了读写操作,通知 Vue "这个值变了,去更新视图"。

v-model vs {{ }} 的区别,代码里体现得明明白白

<!-- 用户需要输入 → 双向 v-model -->
<input type="text" v-model="question" />

<!-- 纯展示,用户不需要输入 → 单向 {{ }} -->
<div>{{ content }}</div>

判断标准很简单:问自己"用户能不能在这个 HTML 元素上修改值?"能 → v-model,不能 → {{ }}

response.body?.getReader() 中的 ?.

这是 JavaScript 的可选链操作符(Optional Chaining)。如果 response.bodynullundefined,表达式直接返回 undefined 而不会抛出 Cannot read properties of null。在流式输出中,某些网络错误可能导致 body 为空,?. 是一道防线。

decoder.decode(value, { stream: true })

第二个参数 { stream: true } 告诉 TextDecoder:"后面还有数据,这一块可能不完整"。为什么需要这个?一个中文汉字在 UTF-8 里占 3 个字节。如果网络分包恰好切在一个汉字的第 2 个字节,没有 { stream: true } 就会产生乱码

buffer = lines.pop() || ''

SSE 数据按 \n 分行。但网络包可能恰好切到一行中间——这一行的前半部分在上一个包里,后半部分在下一个包里。buffer 就负责暂存不完整的最后一行,等下一轮数据到达后拼上再处理。这是典型的"流式-缓冲区"模式。

content.value += delta 执行后,为什么 HTML 自动更新?

因为在 <template> 里写了 {{ content }},Vue 建立了 content 和这个 DOM 节点之间的依赖追踪。当 content.value 变化,Vue 的响应式系统自动找到所有"订阅"了这个数据的 DOM 节点,并更新它们。全程不需要 document.querySelector().innerHTML

5.5 运行

npm run dev
# 打开 http://localhost:5173
# 输入问题 → 勾选/取消"流式输出" → 对比两种体验

六、六个坑,踩过才算真正会了

坑 1:[DONE] 信号处理

如果不判断 [DONE],代码尝试 JSON.parse('[DONE]'),直接 SyntaxError

// ✅ 正确做法
const data = line.slice(6).trim()
if (data === '[DONE]') { done = true; break }  // 先判断,再 parse
try {
  const json = JSON.parse(data)
  // ...
} catch (e) {}

坑 2:UTF-8 多字节字符被截断

中文字符占 3 字节。如果网络包把字符劈成两半,单独解码就是乱码。

// ❌ 错误:不带 stream: true
decoder.decode(value)  // 可能产生 �

// ✅ 正确:stream: true 告诉解码器"后面还有"
decoder.decode(value, { stream: true })

坑 3:JSON 解析失败

SSE 的 data: 行可能不完整,直接 JSON.parse() 会抛异常。

// ✅ 用 try-catch 包住,不完整的行静默跳过
// 下一轮 buffer 会把残缺行补齐
try {
  const delta = JSON.parse(data).choices?.[0]?.delta?.content
} catch (e) { /* 跳过 */ }

坑 4:done 变量死循环

流式读取的 while 循环中,done 变量的更新最容易出错:

// ❌ 忘了更新 done——死循环
const { value } = await reader.read()

// ✅ 正确:doneReading 解构出来,喂给循环条件
const { value, done: doneReading } = await reader.read()
done = doneReading

坑 5:buffer 不清理

如果当前 chunk 解码后刚好以 \n 结尾,lines.pop() 会返回空字符串 ""。不处理就会累积。

buffer = lines.pop() || ''  // ✅ || '' 兜底

坑 6:用户快速重复请求

用户点了"发送",3 秒没反应又点了一次——上一次请求还没结束,两个流式响应的数据串在一起,UI 惨不忍睹。

let abortController = null

const update = async () => {
  abortController?.abort()            // 取消上次未完成的请求
  abortController = new AbortController()

  const response = await fetch(endpoint, {
    // ...
    signal: abortController.signal    // 绑定 AbortSignal
  })
}

七、总结:这不止是一篇流式输出教程

写这篇文章的时候,我有一个想法,觉得很重要,提出来跟大家探讨:

AI 产品的核心体验,是前端的主战场,不是后端写完 API 就完事的。

流式输出、打字机效果、Token 级别的渲染控制——这些是区分"能用"和"好用"的分水岭。而实现它们的责任,在前端。

这篇文章真正想传递的有三层:

第一层:流式输出怎么做。 stream: trueresponse.body.getReader()TextDecoder → SSE 解析 → 拼到 ref 变量。40 行核心代码,就这么简单。

第二层:Vue 为什么这么设计。 组件化不是语法糖,是把"相关性"作为组织原则(template + script + style 放在一个文件里);响应式不是偷懒工具,是思维模式的跃迁——从操作 DOM 到管理状态。

第三层:前端工程师的价值在哪。 不是把 UI 画出来,而是把用户体验打磨到极致。一个打字机效果,可能比一个花哨的动画更能留住用户。


如果这篇文章帮你理解了流式输出和 Vue 的设计思想,欢迎 点赞 👍、收藏 ⭐、评论 💬 三连支持。你的反馈是我写下去的最大动力。

分类前端 标签Vue.js DeepSeek SSE 前端开发 AI