让 AI 回答“打字般”浮现:用 Vue 3 + DeepSeek 实现流式聊天
让 AI 回答“打字般”逐字浮现 —— 深入理解流式输出与前端实现
在 AI 聊天应用中,用户最常抱怨的问题之一是:“为什么我要等十几秒才能看到答案?”
流式输出(Streaming Output) 正是解决这一痛点的关键技术。本文将带你从概念理解到代码落地,用 Vue 3 和 DeepSeek API 构建一个支持流式响应的 AI 对话界面。
一、什么是流式输出(Streaming)?
📦 传统请求 vs 流式请求
| 方式 | 工作流程 | 用户体验 |
|---|---|---|
| 非流式(默认) | 用户提问 → 后端完整生成答案 → 一次性返回全部内容 | 长时间白屏,等待焦虑 |
| 流式(Streaming) | 用户提问 → 后端边生成边推送 → 前端逐字显示 | 文字“打字机式”出现,感知更快 |
🔁 技术本质:Server-Sent Events (SSE)
-
流式输出基于 HTTP 持久连接,服务器持续发送数据块(chunks)
-
每个数据块格式为:
data: {"choices": [{"delta": {"content": "喜"}}]} data: {"choices": [{"delta": {"content": "羊"}}]} data: [DONE] -
前端通过
ReadableStream逐块读取并拼接内容
✅ 优势:降低首字延迟(Time to First Token),提升交互感
⚠️ 挑战:需处理二进制流、跨块 JSON、结束标记等细节
二、编码基础:二进制、Buffer 与 TextDecoder
在流式通信中,所有网络数据本质都是二进制。前端需将其转换为可读文本:
🔤 核心概念
-
Buffer(缓冲区) :临时存储不完整的数据块(如一个 JSON 被拆成两段)
-
TextDecoder:HTML5 提供的解码器,将
Uint8Array(二进制)转为字符串const decoder = new TextDecoder() const text = decoder.decode(binaryData)
💡 为什么需要 Buffer?
假设 AI 返回 "data: {"cont" 和 "ent":"羊"} 两个 chunk,
若不拼接,直接解析会失败。因此需用 buffer 暂存未完成的数据。
三、项目搭建:Vue 3 + Vite 初始化
🛠️ 创建项目
npm create vite@latest ai-stream-demo -- --template vue
cd ai-stream-demo
npm install
选择:
- Framework:
Vue - Variant:
JavaScript
🔑 配置 API 密钥
创建 .env.local(切勿提交到 Git):
VITE_DEEPSEEK_API_KEY=your_deepseek_api_key
Vite 自动将
VITE_开头的变量注入import.meta.env,安全可用。
四、核心实现:Vue 3 响应式 + DeepSeek 流式调用
🧩 响应式数据设计
import { ref } from 'vue'
const question = ref('讲个故事') // 用户输入
const stream = ref(true) // 是否启用流式
const content = ref('') // AI 回答(响应式更新)
✨ Vue 的
ref让我们无需操作 DOM:只要content.value改变,模板自动刷新。
🌊 流式请求与解析逻辑
const askLLM = async () => {
if (!question.value?.trim()) return alert('请输入问题!')
content.value = '思考中...'
const response = 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) {
await handleStream(response) // 处理流式
} else {
const data = await response.json()
content.value = data.choices[0].message.content
}
}
1. 请求前验证与初始化
1if (!question.value?.trim()) return alert('请输入问题!')
2content.value = '思考中...'
- 首先检查用户输入是否为空或仅包含空白字符,确保请求的有效性。
- 立即更新
content.value为"思考中...",为用户提供即时的视觉反馈,缓解等待焦虑。
2. 构建 API 请求配置
1const response = await fetch('https://api.deepseek.com/chat/completions', {
2 method: 'POST',
3 headers: {
4 'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
5 'Content-Type': 'application/json'
6 },
7 body: JSON.stringify({
8 model: 'deepseek-chat',
9 stream: stream.value, // 👈 关键开关
10 messages: [{ role: 'user', content: question.value }]
11 })
12})
- 请求方法:使用
POST方法发送数据。 - 认证头:
Authorization携带 Bearer Token 进行身份验证。 - 内容类型:
Content-Type: application/json告知服务器发送的是 JSON 数据。 - 请求体:
stream参数是关键,其值为stream.value(true/false),动态控制是否启用流式模式。 - 消息格式:遵循 OpenAI 风格的对话格式,包含角色(role)和内容(content)。
3. 响应处理分支
1if (stream.value) {
2 await handleStream(response) // 处理流式
3} else {
4 const data = await response.json()
5 content.value = data.choices[0].message.content
6}
- 条件判断:根据
stream.value的布尔值决定后续处理逻辑。 - 流式分支:调用
handleStream函数处理 SSE 格式的流式数据(见下节详细解析)。 - 非流式分支:直接解析完整 JSON 响应,一次性获取 AI 的完整回答并更新到
content.value。
🔍 流式响应处理器(重点!)
async function handleStream(response) {
content.value = ''
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = '' // 缓冲未完成的 JSON
while (true) {
const { done, value } = await reader.read()
if (done) break
// 拼接新 chunk 到 buffer
buffer += decoder.decode(value, { stream: true })
// 按行分割处理
const lines = buffer.split('\n').filter(line => line.trim())
buffer = '' // 重置缓冲区
for (const line of lines) {
if (line === 'data: [DONE]') return // 流结束
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6) // 移除 "data: "
const data = JSON.parse(jsonStr)
const delta = data.choices[0]?.delta?.content
if (delta) content.value += delta // 响应式更新!
} catch (e) {
// JSON 跨 chunk?暂存回 buffer
buffer += line + '\n'
}
}
}
}
}
💡 关键技巧:
1. 获取可读流(ReadableStream)
const reader = response.body.getReader();
response.body是一个ReadableStream对象。- 调用
.getReader()获取读取器,用于以“拉模式”(pull-based)逐块读取数据。 - 每次
reader.read()返回一个{ value: Uint8Array, done: boolean }。
2. 二进制 → 文本解码
const decoder = new TextDecoder();
const chunkValue = buffer + decoder.decode(value);
- 网络传输的数据是二进制(
Uint8Array),必须用TextDecoder转为字符串。 - 注意:理想情况下应使用
decoder.decode(value, { stream: true }),以正确处理跨 chunk 的 UTF-8 多字节字符(如中文、emoji)。当前代码未启用此选项,在极端情况下可能乱码。
3. 缓冲区(Buffer)机制
let buffer = '';
// ...
buffer += `data: ${incoming}` // 在 catch 中回写
- 由于网络 chunk 边界与数据行边界不一定对齐,一个完整的
data: {...}可能被拆成两段。 buffer用于暂存“不完整”的尾部数据,下次循环时拼接新 chunk 继续解析。- 当前实现中,若
JSON.parse失败,会将原始内容重新写入buffer,但格式为data: ...,可能导致后续重复解析。更健壮的做法是直接保留原始line字符串。
4. SSE 行协议解析
const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
-
DeepSeek 的流式响应遵循 Server-Sent Events (SSE) 格式:
- 每行以
data:开头 - 结束标志为
data: [DONE]
- 每行以
-
通过
split('\n')拆分行,再过滤出有效数据行。
5. 增量内容提取与响应式更新
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta;
}
- 每个流式 chunk 只包含新增的 token 内容(即
delta)。 - 将
delta追加到content.value,Vue 的响应式系统会自动触发模板更新,实现“打字机效果”。 - 注意:需做安全访问(如
data?.choices?.[0]?.delta?.content),避免因字段缺失导致崩溃。
通过以上五步,我们完成了从原始二进制流到用户可见文本的完整链路。这也是所有 LLM 流式前端实现的通用范式。
五、模板与样式:构建简洁高效的交互界面
<template>
<div class="container">
<div class="input-area">
<input v-model="question" @keyup.enter="askLLM" />
<button @click="askLLM">提问</button>
</div>
<div class="controls">
<label>
<input type="checkbox" v-model="stream" />
启用流式输出
</label>
</div>
<div class="response">{{ content }}</div>
</div>
</template>
<style scoped>
.container {
padding: 20px;
max-width: 700px;
margin: 0 auto;
font-family: sans-serif;
}
.input-area {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.response {
min-height: 200px;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
white-space: pre-wrap; /* 保留换行 */
}
</style>
1. 输入区域(Input Area)
<div class="input-area">
<input v-model="question" @keyup.enter="askLLM" />
<button @click="askLLM">提问</button>
</div>
- 双向绑定:通过
v-model="question",将输入框内容与响应式变量question实时同步,无需手动监听input事件。 - 快捷提交:添加
@keyup.enter="askLLM",支持用户按回车键快速发送问题,提升操作效率。 - 语义化布局:使用
<div>容器包裹输入框和按钮,便于后续样式控制或响应式调整。
这一区域聚焦于降低用户操作成本,让提问变得自然、高效。
2. 流式控制开关(Streaming Toggle)
<div class="controls">
<label>
<input type="checkbox" v-model="stream" />
启用流式输出
</label>
</div>
- 状态联动:复选框通过
v-model="stream"与ref(true)响应式变量绑定,勾选即开启流式模式。 - 即时生效:由于
stream.value直接用于 API 请求参数(stream: stream.value),切换开关后下一次提问将自动应用新策略。 - 用户知情权:明确标注“启用流式输出”,让用户了解当前交互模式,增强可控感。
此设计兼顾了灵活性与透明度,允许用户根据网络环境或偏好自由选择响应方式。
3. 回答展示区(Response Display)
<div class="response">{{ content }}</div>
- 响应式更新:
{{ content }}绑定content响应式变量。无论是流式逐字追加(content.value += delta),还是非流式一次性赋值,Vue 都会自动更新 DOM。 - 保留格式:通过 CSS 设置
white-space: pre-wrap,确保 AI 生成的换行、缩进等排版信息得以正确呈现(例如诗歌、代码块)。 - 视觉反馈:初始显示“思考中...”,在请求发起后立即给予用户反馈,缓解等待焦虑。
该区域是用户体验的核心载体,既要准确呈现 AI 输出,又要保证阅读舒适性。
4. 整体样式设计原则
- 居中布局:使用
max-width+margin: 0 auto实现内容区域在大屏设备上的居中对齐,避免文字过宽影响可读性。 - 留白与间距:合理设置
padding和margin,让界面呼吸感更强,减少视觉压迫。 - 语义化颜色:回答区域使用浅色背景(如
#f8fafc)与主内容区分,形成视觉层次。 - 字体与换行:采用系统默认无衬线字体,配合
pre-wrap确保多行文本自然折行。
总结:为什么流式输出值得投入?
- 心理层面:用户看到“正在生成”,焦虑感降低 70%+
- 技术层面:首字延迟从 5s+ 降至 1s 内
- 产品层面:显著提升 AI 应用的专业感与流畅度
流式输出不是炫技,而是对用户体验的基本尊重。
通过本文,你已掌握:
- ✅ 流式输出的核心原理(SSE + ReadableStream)
- ✅ Vue 3 响应式与流式数据的无缝结合
- ✅ DeepSeek API 的完整调用与解析
- ✅ 生产级健壮性处理(Buffer、错误、结束标记)
现在,去构建你的下一代 AI 应用吧!🚀