前言 :
还在让用户盯着 "Loading..." 的转圈动画发呆吗?
自从 ChatGPT 爆火后,这种“蹦字儿”的打字机效果(Streaming)已经成了 AI 应用的标配。
它不仅是视觉上的“爽”,更是用户体验(UX)的降维打击——后端边思考,前端边展示,拒绝无效等待。
今天,我们就扒开 AI 的外衣,从底层的二进制 Buffer,到 Vue3 的响应式实战,手把手带你实现一个丝滑的流式输出功能。
第一部分:不仅是“特效”,更是“魔法” (原理篇)
1.1 传统的 HTTP vs 流式传输 (Streaming)
在传统的 Web 开发中,我们的请求像是在寄快递:
- 前端下单(发送 Request)。
- 后端打包好所有货物(处理完所有逻辑)。
- 快递员一次性把包裹送到你家门口(返回 Response)。
- 缺点:如果货物太多(AI 生成长文),你需要等很久才能收到包裹。
而 Streaming (流式输出) 更像是自来水管:
- 前端打开水龙头。
- 后端只要有一滴水(生成了一个字),就立马顺着管子流过来。
- 优点:哪怕水流不大,用户也能立马看到动静,焦虑感瞬间消失。这就是 TTFB (Time To First Byte) 的胜利。
1.2 深入底层:计算机只认识 0 和 1
网络传输的不是“汉字”,而是“字节流”。
🧐 面试考点:Buffer 与 编码
Q:为什么流式输出拿到的数据是乱码或者数字?
A: 因为网络传输的是二进制数据(ArrayBuffer/Uint8Array)。
我们需要两个翻译官:
- TextEncoder:把“人话”翻译成“机器码”(String -> Uint8Array)。
- TextDecoder:把“机器码”翻译回“人话”(Uint8Array -> String)。
代码实战:
// 1. 准备一段文本
const msg = "你好 HTML5";
// 2. 编码 (Encoder):字符串 -> 二进制小本本
const encoder = new TextEncoder();
const myBuffer = encoder.encode(msg);
console.log(`字节长度: ${myBuffer.byteLength}`); // 输出长度,UTF-8中汉字通常占3字节
console.log(myBuffer); // Uint8Array(12) [228, 189, 160, ...]
// 3. 各种视图操作 (ArrayBuffer 是内存,Uint8Array 是操作内存的手)
const buffer = new ArrayBuffer(12);
const view = new Uint8Array(buffer);
// 搬运数据...
// 4. 解码 (Decoder):二进制 -> 字符串
// 🌟 重点:流式输出的核心就是在这个解码环节
const decoder = new TextDecoder();
const originalText = decoder.decode(myBuffer);
console.log(originalText); // "你好 HTML5"
第二部分:Vue 3 极速基建 (环境篇)
现在的 Web 开发讲究“唯快不破”。
2.1 为什么是 Vue 3 + Vite?
- Vite:法语“快”的意思。它是前端构建工具的法拉利,冷启动极快。
- Vue 3 (Composition API) :告别了 Vue 2 的 this 满天飞,拥抱 ref 和 reactive。
初始化一条龙:
npm init vite@latest my-ai-app -- --template vue
cd my-ai-app
npm install
npm run dev
完整代码
<script setup>
import { ref } from 'vue'
const question = ref('')
const stream = ref(true)
const content = ref('') // 单向绑定 主要的
// 调用LLM
const askLLM = async () => {
// question 可以省.value getter
if (!question.value) {
alert('question 不能为空')
return
}
// 用户体验
content.value = '思考中...'
// 请求行
// 请求头
// 请求体
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream: stream.value,
temperature: 2,
messages: [
{
role: 'user',
content: question.value
}
]
})
})
if (stream.value) {
//流式输出
content.value = '' // 把上一次的生成清空
// html5 流式输出
// 响应体的读对象
const reader = response.body?.getReader()
// console.log(reader)
// 流出来的是二进制流 buffer
const decoder = new TextDecoder()
let done = false //流是否结束 没有
let buffer = ''
while (!done) {
//只要没有完成,就一直拼接buffer
// 解构的同时 重命名
const { value, done: doneReading } = await reader?.read()
// console.log(value, doneReading)
done = doneReading
// chunk 内容块 包含多行data; 有多少行不知道
// data:{} 能不能传完也不知道
const chunkValue = buffer + decoder.decode(value) //文本字符串
// console.log(chunkValue)
buffer = ''
const lines = chunkValue
.split('\n')
.filter(line => line.startsWith('data: '))
for (const line of lines) {
const incoming = line.slice(6) // 干掉数据标志
if (incoming === '[DONE]') {
done = true
break
}
try {
// llm 流式生成, tokens 长度不定的
const data = JSON.parse(incoming)
const delta = data.choices[0].delta.content
if (delta) {
content.value += delta
}
} catch (err) {
// JSON.parse 解析失败
buffer += `data:${incoming}`
}
}
}
} else {
const data = await response.json()
console.log(data)
content.value = data.choices[0].message.content
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="askLLM">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<div>{{ content }}</div>
</div>
</div>
</div>
</template>
<style scoped>
* {
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
/* 主轴、次轴 */
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
button {
padding: 0 10px;
margin-left: 6px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
</style>
🔧 <script setup> 部分详解
1. 导入 ref
import { ref } from 'vue'
ref是 Vue 3 中创建响应式变量的方式。- 对于基本类型(如字符串、数字),必须通过
.value访问/修改其值。
2. 响应式变量定义
const question = ref('') // 用户输入的问题
const stream = ref(true) // 是否启用流式输出(默认开启)
const content = ref('') // LLM 返回的内容(展示在页面上)
question:绑定到输入框(通过v-model)。stream:控制是否使用流式响应。content:用于显示模型的回复。
3. askLLM 函数 —— 核心逻辑
✅ 输入校验
if (!question.value) {
alert('question 不能为空')
return
}
- 确保用户输入了问题,否则弹出提示并终止。
✅ 设置“思考中...”提示
content.value = '思考中...'
- 提升用户体验,表示正在处理请求。
4. 构造 API 请求
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
- 使用 环境变量
VITE_DEEPSEEK_API_KEY存储 API Key(Vite 项目约定以VITE_开头的变量可在前端安全使用)。 - 请求头包含认证和内容类型。
⚠️ 注意:
-
API Key 不应硬编码在代码中,而是通过
.env文件管理:VITE_DEEPSEEK_API_KEY=your_actual_api_key_here
5. 发送 POST 请求
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream: stream.value,
messages: [{ role: 'user', content: question.value }]
})
})
- 符合 OpenAI 风格的聊天接口格式。
messages数组只包含一条用户消息(简单对话)。stream控制是否启用流式响应。
🌊 流式响应处理(stream.value === true)
这是最复杂的部分,用于处理 Server-Sent Events (SSE) 风格的流式响应。
步骤分解:
① 清空旧内容
content.value = ''
② 获取可读流(ReadableStream)
const reader = response.body?.getReader()
const decoder = new TextDecoder() // 将 Uint8Array 转为字符串
let done = false
let buffer = '' // 用于拼接不完整的 chunk
由于网络传输是分块的,一个
data: {...}可能被拆成多个 chunk,所以需要buffer拼接。
③ 循环读取流
while (!done) {
const { value, done: doneReading } = await reader?.read()
done = doneReading
const chunkValue = buffer + decoder.decode(value)
buffer = ''
value是Uint8Array类型的二进制数据。decoder.decode(value)转为字符串。- 拼接到
buffer中(处理跨 chunk 的情况)。
④ 按行分割并过滤有效数据
const lines = chunkValue
.split('\n')
.filter(line => line.startsWith('data: '))
-
DeepSeek 的流式响应格式类似:
data: {"choices": [{"delta": {"content": "H"}}]} data: {"choices": [{"delta": {"content": "e"}}]} data: [DONE]
⑤ 解析每一行
for (const line of lines) {
const incoming = line.slice(6) // 去掉 "data: "
if (incoming === '[DONE]') {
done = true
break
}
try {
const data = JSON.parse(incoming)
const delta = data.choices[0].delta.content
if (delta) {
content.value += delta // 实时追加到页面
}
} catch (err) {
// 如果 JSON 解析失败(比如 chunk 不完整),暂存回 buffer
buffer += `data:${incoming}`
}
}
- 关键点:如果
JSON.parse失败,说明当前incoming不是完整 JSON(可能被截断),于是把它放回buffer,等下一次循环再拼接。
这个
catch块里的buffer +=data:${incoming}`` 是为了处理跨 chunk 的 JSON 字符串。例如:
- 第一个 chunk:
"data: {"choices": [{"delta": {"cont"- 第二个 chunk:
"ent": "Hello"}}]}"合起来才是合法 JSON。
🔄 整体流程图(简化版)
text
编辑
读取二进制块
↓
解码为字符串 + 拼接 buffer → chunkValue
↓
按 \n 分割 → 多行
↓
过滤出 data: 开头的行
↓
对每行:
├─ 如果是 [DONE] → 结束
├─ 否则尝试 JSON.parse
├─ 成功 → 提取 content,追加到页面
└─ 失败 → 放回 buffer(等下次拼接)
📦 非流式响应处理(stream.value === false)
const data = await response.json()
content.value = data.choices[0].message.content
- 直接解析完整 JSON 响应。
- 结构与 OpenAI 兼容:
choices[0].message.content是最终回答。
🖼️ <template> 部分
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="askLLM">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<div>{{ content }}</div>
</div>
</div>
</div>
v-model="question":双向绑定输入框。@click="askLLM":点击按钮触发请求。v-model="stream":复选框控制流式开关。{{ content }}:显示模型回复(响应式更新)。
第四部分:防坑指南与面试深挖 (进阶篇)
这时候面试官可能会推了推眼镜,开始问你深层次的问题。
4.1 💀 致命问题:中文乱码与边界截断
Q:如果一个汉字占 3 个字节,但是网络传输时,恰好把这 3 个字节切分到了两个不同的 chunk 里(比如前 2 个字节在 chunk A,第 3 个字节在 chunk B),TextDecoder 会怎么处理?
-
错误理解:分别解码,结果出现两个 `` (乱码符号)。
-
正确做法:TextDecoder 内部维护了状态。
- 在代码中看到 decoder.decode(value, { stream: true }) 了吗?
- stream: true 告诉解码器:“嘿,后面还有数据呢,如果这个 chunk 结尾有半个汉字,先别硬解,存到缓存里,等下一个 chunk 来了拼起来再解。”
- 划重点:一定要在循环中使用同一个 decoder 实例,并开启 stream: true。
4.2 💀 为什么不用 Axios?
Q:平时请求都用 Axios,为什么流式输出要用原生 Fetch?
A:
- Axios 基于 XMLHttpRequest (XHR)。XHR 在处理流式数据时比较笨重,通常是等待整个响应完成,或者通过复杂的 onprogress 处理文本。
- Fetch API 原生支持 ReadableStream,是处理流式二进制数据的现代标准,性能更好,控制粒度更细。
4.3 💀 后端怎么配合?
前端写好了,后端不能直接 return "结果"。后端(比如 Node.js/Python)需要设置 Header:
Content-Type: text/event-stream
Transfer-Encoding: chunked
这告诉浏览器:“我还没说完,保持连接,我会一段一段给你发数据。”
第五部分:总结
从今天的实战中,我们学到了:
- 体验优先:流式输出(Streaming)利用了人类的感知延迟,极大地优化了 AI 交互体验。
- 数据本质:网络传输的本质是二进制 Buffer,利用 TextDecoder 是还原真相的关键。
- Vue3 响应式:ref 让数据驱动视图变得异常简单,完美契合流式数据的频闪更新。