Vue 3 + DeepSeek 流式输出实战:从 HTTP 协议到打字机效果,40 行核心代码搞定
你用过 ChatGPT 吧?提问之后光标像打字机一样一个字一个字往外蹦——这背后是一套从前端到后端的完整流式输出体系。本文从 HTTP 协议层讲起,一路写到 Vue 3 组件里的
ReadableStream,并深度展开 Vue 组件化思想和响应式数据绑定这两个前端基石。读完你不仅能写出打字机效果,还会对"为什么 Vue 这么设计"有一个全新的理解。
目录
- 一个让你等 27 秒的产品,你还会用吗?
- 流式输出的底层:浏览器和服务器之间接了一根水管
- Vue 组件化:为什么页面不再是由标签拼成的了
- 响应式数据绑定:从 DOM 编程到数据驱动的范式跃迁
- 动手写:Vue 3 + DeepSeek 流式聊天机器人(完整代码)
- 六个坑,踩过才算真正会了
- 总结:这不止是一篇流式输出教程
一、一个让你等 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...
}
三个核心角色:
| 角色 | 类型 | 职责 |
|---|---|---|
reader | ReadableStreamDefaultReader | 从响应体中逐块读取二进制数据 |
decoder | TextDecoder | 把 Uint8Array 二进制翻译成 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.html 和 src/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() 在内部用 Proxy 或 getter/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.body 是 null 或 undefined,表达式直接返回 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: true → response.body.getReader() → TextDecoder → SSE 解析 → 拼到 ref 变量。40 行核心代码,就这么简单。
第二层:Vue 为什么这么设计。 组件化不是语法糖,是把"相关性"作为组织原则(template + script + style 放在一个文件里);响应式不是偷懒工具,是思维模式的跃迁——从操作 DOM 到管理状态。
第三层:前端工程师的价值在哪。 不是把 UI 画出来,而是把用户体验打磨到极致。一个打字机效果,可能比一个花哨的动画更能留住用户。
如果这篇文章帮你理解了流式输出和 Vue 的设计思想,欢迎 点赞 👍、收藏 ⭐、评论 💬 三连支持。你的反馈是我写下去的最大动力。
分类:
前端标签:Vue.jsDeepSeekSSE前端开发AI