流式输出(Streaming)是AI应用体验的分水岭——用户不再傻等几十秒,而是看到文字逐字生成,仿佛AI在“思考并打字”。但如果你只调通了API就收工,那就亏大了。今天我们用Vue3 + DeepSeek实战,不仅搞懂流式输出的每一帧数据长啥样,还会扒开Vite的热更新(HMR)和Vue的响应式系统,看看它们是怎么“偷懒”做到局部刷新的。
一、Agent 开发时代,前端工程师的“偷懒哲学”
现在的Agent越来越像人,正在走向AGI。作为开发者,我们的核心策略变成了:“将AI擅长的交给Agent,我们负责审核和接管不擅长的部分。”
落到实际开发中,比如我要写一个Vue3对话界面:
- 没有必要从0开始写
src/App.vue和index.html—— 这种重复性体力活,直接让Agent去Github拉一个模板项目,或者用Vite脚手架一把梭。 - 我们只关注核心业务逻辑:比如如何优雅地处理AI的流式数据,以及如何让页面高效地渲染这些数据。
这引出了今天的两大主角:
- 数据流的“流式输出”:AI怎么把话一句一句吐给你。
- 前端的“局部热更新”:页面怎么做到只改该改的地方,绝不整页刷新。
为什么你需要关注流式输出
这两年 Agent 越来越像人,从“回答机器”进化到“协作伙伴”。但一个很容易被忽视的细节是:交互体验。如果每次提问都要转圈等待完整响应,用户会产生“卡顿感”,甚至怀疑系统挂了。
流式输出(Streaming)解决了这个问题:服务端一边生成,浏览器一边展示,一个字一个字蹦出来,体验直逼真人聊天。
说白了,就是把“一次性返回”变成“分块推送”。技术上就是SSE(Server-Sent Events)或WebSocket的变种,但当下大多数 LLM API 都支持通过stream: true参数开启,响应体变成一个ReadableStream二进制流。
说白了,这两者的底层逻辑惊人地一致:都是“增量更新(Incremental Update)”。AI流式输出是数据层面的增量,Vue响应式是状态层面的增量,Vite HMR是文件模块层面的增量。把这三个串起来,你就是全链路性能优化专家。
二、极速搭建:带开关的对话组件
我们采用 Vite + Vue3 搭建,省去一堆配置。核心就是一个页面:输入问题、勾选是否流式、点击提交,下方显示回答。
<script setup> import { ref } from 'vue' // 响应式状态 const question = ref('讲一个中国龙的故事') const content = ref('') const stream = ref(true) // 核心开关:控制是否开启流式 const update = async () => { // ... API 调用逻辑(待会详解) } </script> <template> <div class="container"> <div> <label>输入:</label> <input class="input" v-model="question" /> <button @click="update">提交</button> </div> <div class="output"> <label><input type="checkbox" v-model="stream" /> 开启流式</label> <div style="white-space: pre-wrap;">{{ content }}</div> </div> </div> </template>三、非流式 vs 流式:体验的分水岭
在update函数中,我们首先判断是否开启流式。非流式就是最常见的fetch+await response.json(),等后端完全生成后一次性拿到结果。
// 非流式分支 if (!stream.value) { const response = await fetch(endpoint, { /* ... */ }) const data = await response.json() content.value = data.choices[0].message.content // 一次性赋值 }痛点:如果生成一篇800字作文,用户要盯着白屏转圈10秒钟,体验极差。
而流式输出则像打开了水龙头。response.body不再是一个完整的JSON对象,而是一个ReadableStream(可读流)。我们要做的就是用getReader()一口一口“嘬”出来,再拼成完整的句子。
四、硬核拆解:二进制碎片变成屏幕汉字的“四阶段蜕变”
这是全网最细的数据流解析,我带你截取网络电缆到屏幕像素之间的每一步数据形态。
第一阶段:原始二进制(Uint8Array)
当await reader.read()执行时,拿到的value是一个Uint8Array(二进制数组)。它长这样:
// 控制台打印 value Uint8Array(97) [ 100, 97, 116, 97, 58, 32, 123, 34, 105, 100, ... ]这里的100, 97, 116, 97就是字符串"data"的 ASCII 码。你看得懂吗?看不懂就对了,必须解码。
第二阶段:解码后的原始文本字符串(含 SSE 协议)
使用TextDecoder解码后,二进制变成了人类可读的SSE(Server-Sent Events)协议格式文本:
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"中"}}]} data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"国"}}]} data: [DONE]注意一个大坑:网络分包不保证按\n换行切开。可能第一次拿到的字符串是"data: {"cho",第二次拿到"ices":[{"delta":{"content":"龙"}}]}"。这就是我们为什么需要buffer(缓冲区)来拼接被切碎的JSON。
第三阶段:按行切割并提取纯 JSON
我们把拼接完整的字符串按\n拆成数组,只保留以data:开头的行,并去掉前缀:
// 过滤后的纯 JSON 字符串 '{"id":"chatcmpl-xxx","choices":[{"delta":{"content":"龙"}}]}'第四阶段:解析 JSON 并增量拼接到页面
解析JSON后,我们取出深埋在choices[0].delta.content里的增量文本(Delta),然后执行 Vue 的响应式赋值:
const json = JSON.parse(payload); const delta = json.choices[0].delta.content; // 取出 "龙" content.value += delta; // 拼接到响应式状态,触发页面更新这就是完整的数据流:二进制字节流 → UTF-8 文本流 → 按行分割 → JSON 解析 → 增量文本。每一步我们都看得清清楚楚。
循环读取,逐块处理
while (!done) { const { value, done: doneReading } = await reader.read() done = doneReading // 把新数据拼接到 buffer 后面,再整体解析 const chunk = buffer + decoder.decode(value, { stream: true }) buffer = '' // 按行切分,只保留 data: 开头的行 const lines = chunk.split('\n') .filter(line => line.startsWith('data: ')) // 解析每一行 for (const line of lines) { const jsonStr = line.slice(6) // 去掉 "data: " if (jsonStr === '[DONE]') { done = true break } try { const parsed = JSON.parse(jsonStr) const delta = parsed.choices[0].delta?.content if (delta) { content.value += delta // 逐步追加到界面 } } catch (e) { // 容错:偶尔可能收到不完整的 JSON,继续 } } }这里有几个关键点值得掰开揉碎讲。
坑一:为什么需要buffer?
因为网络传输是分块的,一个 chunk 可能包含多条data:,也可能一条data:被切割成两半。我们无法保证每次read()返回的内容正好以换行符结尾。所以定义一个buffer缓存上次未处理完的尾巴,拼接到新数据前面再分割。
坑二:decoder.decode(value, { stream: true })的作用
如果不传{ stream: true },解码器会假设这是最后一个片段,可能把多字节字符(如中文)切碎导致乱码。加上该选项后,解码器会保留未完成的字符片段,等待下一次解码时拼接。这是处理流式文本的标准姿势。
坑三:何时停止?
服务端会在流结束时发送一个data: [DONE]标记,或者在done变为true时退出循环。我们两重保险:检测到[DONE]时主动设置done = true,同时循环条件也检查done。
坑四:解析 JSON 时可能抛异常
因为流式数据可能不完整(比如一个 JSON 对象被拆到两个 chunk 中),但我们按行切分一般能保证每行是完整的,但为了稳妥,还是加上try...catch,以免一个解析错误导致整个组件崩溃。
五、深入 Vue 底层:执行content.value += delta后,Vue 干了什么“局部手术”?
这是整个环节中最优雅的一跳。你可能会想:“我只是改了个字符串,Vue 怎么就知道只改那一个<div>里面的文字,而不把整个 App 重绘一遍?”
这就不得不提 Vue 3 的“响应式系统”和“虚拟 DOM 打补丁(Patch)”机制了。这个过程分为精密的 4 步:
- 响应式拦截(Proxy 劫持)
content是一个ref,底层基于Proxy。当你执行content.value = '新字符串'时,Vue 的setter被触发,它派发更新(Trigger),通知所有依赖了content的副作用函数去重新执行。 - 组件渲染函数执行(生成新虚拟 DOM)
Vue 会重新执行组件的render函数,生成一棵全新的虚拟 DOM 树(VNode Tree)。注意,是整个组件的 VNode 树都重新生成了,但这步操作在内存中极快,开销忽略不计。 - Diff 算法(找不同)
这是“局部更新”的灵魂。Vue 拿着新的 VNode 树和旧的 VNode 树进行同层比较(Patch):它发现外层的容器没变,内部的属性没变,直到找到文本节点,发现它的textContent从"中国"变成了"中国龙"。 - 原生 DOM 操作(打补丁)
Vue 精准定位到那个文本 DOM 节点,执行最底层的原生 JS API:nativeNode.textContent = '中国龙'。
整个过程完全没有销毁重建外层容器,也没有影响输入框的聚焦状态和滚动条位置。这就是“局部热更新”在前端渲染层的本质——微创手术,只改病变细胞。
六、冷知识:Vite 的 HMR 和 Vue 的响应式更新有何异同?
你的直觉没错,Vite 开发时的热更新(Hot Module Replacement)也是“局部刷新”的一种,但它们有本质区别,面试时千万别混淆:
| 维度 | Vite HMR(热模块替换) | Vue 响应式更新(本文场景) |
|---|---|---|
| 触发时机 | 开发时,你修改了.vue文件并保存 | AI 返回数据,content.value发生变化 |
| 传输媒介 | WebSocket(Vite Dev Server 推送到浏览器) | HTTP/HTTPS(DeepSeek API 返回数据) |
| 更新粒度 | 模块级别(替换整个.vue组件逻辑或 CSS) | DOM 节点级别(仅修改文本节点的textContent) |
| 是否保留状态 | 尽力保留,但有时会丢失(需import.meta.hot处理) | 百分百保留,页面其他状态纹丝不动 |
| 核心算法 | 基于es-module-lexer分析依赖链 | 基于虚拟 DOM 的diff算法 |
一句话总结:Vite HMR 是“代码换血”,Vue 响应式是“细胞微创手术”。我们的 AI 流式输出利用的是后者,所以即使 AI 一秒吐 20 次数据,页面依然丝滑且输入框内容完好无损。
七、生产级完整代码(集成了最健壮的粘包处理)
下面是将上述所有深度解析落地的最终代码。你只需在根目录创建.env.local并填入VITE_DEEPSEEK_API_KEY=你的key即可运行。
<script setup> import { ref } from 'vue' const question = ref('讲一个中国龙的故事') const content = ref('') const stream = ref(true) const update = async () => { if (!question.value) return content.value = '思考中...' const endpoint = 'https://api.deepseek.com/v1/chat/completions' const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}` } const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ model: 'deepseek-chat', // 请根据官方文档确认模型名 messages: [{ role: 'user', content: question.value }], stream: stream.value }) }) if (!response.ok) { content.value = '请求失败,请检查网络或 API Key' return } if (stream.value) { content.value = '' const reader = response.body?.getReader() const decoder = new TextDecoder() let done = false let buffer = '' // 缓冲区,用于处理被截断的 chunk while (!done) { const { value, done: doneReading } = await reader.read() done = doneReading // 关键点:解码时加上 { stream: true } 防止多字节中文被截断乱码 const chunk = buffer + decoder.decode(value, { stream: true }) buffer = '' // 按换行符拆分,SSE 协议标准 const lines = chunk.split('\n').filter(line => line.startsWith('data: ')) for (const line of lines) { const payload = line.slice(6).trim() // 去掉 "data: " 前缀 if (payload === '[DONE]') { done = true break } try { const json = JSON.parse(payload) const delta = json.choices[0].delta?.content if (delta) { // Vue 响应式赋值:触发虚拟 DOM Diff 和局部 Patch content.value += delta } } catch (e) { // 如果 JSON 解析失败,可能是 buffer 还不够完整,忽略本次,等下一个 chunk // 极端情况可以将当前行放回 buffer,这里为了简洁略过 } } } } else { const data = await response.json() content.value = data.choices[0].message.content } } </script> <template> <div class="container"> <div> <label>输入:</label> <input class="input" v-model="question" /> <button @click="update">提交</button> </div> <div class="output"> <label><input type="checkbox" v-model="stream" /> 开启流式</label> <div style="white-space: pre-wrap; margin-top: 8px;">{{ content }}</div> </div> </div> </template> <style scoped> .container { display: flex; flex-direction: column; align-items: start; padding: 20px; height: 100vh; font-size: 0.9rem; } .input { width: 300px; } .output { margin-top: 16px; width: 100%; min-height: 200px; border: 1px solid #ddd; padding: 12px; border-radius: 4px; } </style>八、写在最后:从 Agent 开发范式看前端基建
笔记里提到“Agent擅长的交给Agent,不擅长的我们接管”。其实在流式交互中,大模型擅长“生成内容”,而 Vue 擅长“高效渲染”。我们作为工程师,不需要去手动操作DOM,也不需要去处理WebSocket的二进制原始包——fetch+ReadableStream+ Vue 响应式替我们挡掉了所有脏活累活。
但只有当你搞懂了Uint8Array怎么变成delta,delta怎么触发Diff,你才能在遇到粘包乱码、渲染卡顿、内存泄漏时,快速定位并解决。
“流式交互不是炫技,而是对用户时间的尊重;局部更新不是玄学,而是对浏览器性能的敬畏。”希望这篇硬核拆解,能让你在未来的 Agent 应用开发中,既懂业务调参,也懂底层原理。