从“等得花儿都谢了”到“打字机式输出”:AI 流式交互的前端实战

📅 2026/7/14 11:16:06
从“等得花儿都谢了”到“打字机式输出”:AI 流式交互的前端实战
当 LLM 生成内容时用户等的不是结果是“它在为我思考”的安心感。而作为前端我们的使命就是把这种安心感通过一行行代码传递给用户。你有没有遇到过这样的场景点击一个“生成”按钮然后屏幕转圈圈转啊转……10秒、20秒、甚至一分钟过去了用户可能已经关掉页面了。这不是网络问题这是推理耗时问题。传统的 HTTP 请求响应模式是“攒够一波再发货”——服务器把完整结果算完才返回。对于普通接口几百毫秒也就忍了。但 LLM 生成一段文字动辄几秒到几十秒让用户干等这是 AI 产品的用户体验第一道坎。今天我们从网络协议到 Vue 源码级别的实战手把手拆解流式输出Streaming的实现让你的 AI 应用从“加载中……”进化成“打字机式输出”。一、痛点直击为什么 AI 响应这么慢先说结论慢的不是网络是推理本身。大语言模型生成文本是逐 token 推理的过程。你问一个复杂问题模型要经过多层 Transformer 计算每生成一个 token 都要依赖前面已经生成的内容。问题越复杂、要求越长耗时就越成倍增长。传统模式下服务器必须等所有 token 都生成完毕才组装成一个完整的 HTTP 响应返回给前端。这就好比你去餐厅点菜厨师非得把所有菜全做好才一起端上来——你饿着肚子等半天第一道菜其实早就出锅了。解决方案的核心思路与其让用户干等不如生成一个吐一个实时展示。二、流式输出本质给 HTTP 接一根“水管”流式输出不是什么玄学技术说白了就是把一次性的完整响应拆分成多个小块分批发给客户端。从网络协议层面理解客户端在请求体中声明stream: true服务器端保持连接不断开每生成一个 token 就立即写入响应流客户端通过ReadableStreamAPI 逐块读取边读边拼边渲染这种模式下HTTP 连接不再是“请求-响应-断开”的一次性通道而变成了一根持续流水的水管。流式输出不是把响应变快了而是把“等待”变成了“陪伴”。LLM 服务器和客户端之间就像接了一根管子生成的 token 像水流一样源源不断流向客户端。三、实战Vue DeepSeek 流式输出完整实现话不多说直接上代码。我们用 Vite Vue 3 搭建一个最简单的 AI 对话界面。3.1 项目初始化与环境配置npm create vitelatest stream-demo -- --template vue cd stream-demo npm install在项目根目录创建.env.local文件VITE_DEEPSEEK_API_KEYsk-你的DeepSeek API Key注意Vite 会读取.env.local中的环境变量通过import.meta.env.VITE_*访问。以VITE_开头的变量才会被暴露给客户端。3.2 界面搭建一个极简的 AI 聊天组件这是我们的App.vue结构非常清爽template div classcontainer div label输入: /label !-- 看这里v-model 是主角 -- input typetext classinput v-modelquestion / button clickupdate提交/button /div div classoutput div labelStreaming/label input typecheckbox v-modelstream / /div div classoutput-content{{ content }}/div /div /div /template3.3 Vue 响应式原理解析这段代码到底在干什么重磅必看在深入流式读取的二进制战场之前我们先停下来好好盘一盘刚才写的这段 Vue 代码。别小看这几行它把 Vue 3 最核心的“三板斧”全用上了。中级开发者看懂这一节功力至少涨半年。1. 组件化思想.vue 文件就是前端的“乐高积木”在远古的 jQuery 时代我们操作的是 DOM 元素而在 Vue/React 时代我们操作的是组件。App.vue这个文件就是一块独立的乐高积木。它把模板HTML、逻辑JS和样式CSS封装在一个文件中。为什么要这么做因为现实中的网页比如 Facebook是由成千上万个这样的积木拼成的比如一个输入框、一个弹窗、一个列表项。只有封装好了才能实现复用和高内聚低耦合方便团队协作和维护。2.script setupVue 3 的“语法蜜糖”你注意到script setup这个标签了吗这是 Vue 3 独有的编译时语法糖。在没有它之前Options API你得写export default { data(){}, methods:{} }数据和函数来回跳转代码量一大就让人头晕。加上setup后顶层所有的变量、函数无需return直接就能在template中使用。说白了setup就像一座桥梁把 JS 逻辑区的“数据”和“方法”自动架设到 HTML 模版区省去了繁琐的挂载步骤让代码极度扁平化。3.ref让普通变量拥有“超能力”的响应式心脏这是 Vue 3 最核心的 API。我们来看这行代码import { ref } from vue const content ref(); // 初始为空字符串为什么我们不直接用let content 因为原生 JS 变量改变时页面不会自动刷新。ref的作用就是将一个普通值包装成一个响应式对象。在 JS 逻辑中我们必须通过.value来修改它例如content.value 新字。而在template模板中Vue 会自动帮我们“解包”所以我们直接写{{ content }}就能拿到最新的值。记忆口诀JS 里改值要加.value模板里直接用Vue 帮你跑腿。4.v-model双向数据流的“魔法棒”这是全文最精彩的地方。请看这行代码input typetext v-modelquestion /v-model本质上是一个语法糖它背后做了两件事单向数据绑定 (:value)把question变量的值渲染到输入框里。监听输入事件 (input)当用户在输入框打字时实时把新值赋回给question变量。这就形成了一个完美的闭环数据驱动视图视图反过来更新数据。我们不再需要document.getElementById(input).value这种 DOM 操作了只需要关心question这个变量的状态即可。我们在提交时打印question.value拿到的就是用户打完的最新内容。这就是数据驱动Data Driven的魅力——我们只操作数据DOM 由 Vue 帮我们高效地 Diff 和更新。3.4 核心逻辑流式 vs 非流式对比来看script setup部分的核心请求逻辑script setup import { ref } from vue // 定义响应式状态 const question ref(讲一个关于中国龙的故事) const stream ref(false) // 这个开关可以让我们对比两种模式 const content ref() 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 // 关键根据 checkbox 决定是否开启流式 }) }) if (stream.value) { // 流式输出边生成边渲染 await handleStream(response) } else { // 非流式输出等全部生成完再渲染会卡顿 const data await response.json() content.value data.choices[0].message.content } } /script非流式的逻辑很简单await response.json()会一直等待直到服务器把完整响应传回来然后一次性赋值渲染。用户在几秒内看到的都是“思考中……”体验一般。流式的处理就精彩了我们单独拆开来看。四、流式读取核心ReadableStream TextDecoder 硬核拆解这是全文最硬核的部分也是前端工程师真正需要掌握的“AI 时代底层能力”const handleStream async (response) { // 1. 获取可读流的读取器 const reader response.body?.getReader() // 2. 创建文本解码器二进制 - 字符串 const decoder new TextDecoder() let buffer // 缓冲区解决网络分包乱序问题 let done false while (!done) { // 3. 不断读取数据块 const { value, done: doneReading } await reader.read() done doneReading if (done) break // 4. 解码二进制数据并拼接到缓冲区 buffer decoder.decode(value, { stream: true }) // 5. 按行拆分SSE 协议约定以 \n 分隔 const lines buffer.split(\n) buffer lines.pop() || // 保留不完整的行防止截断 for (const line of lines) { if (line.startsWith(data: )) { const payload line.slice(6) // 去掉 data: 前缀 if (payload [DONE]) { done true break } try { const parsed JSON.parse(payload) // 6. 提取增量 token const token parsed.choices?.[0]?.delta?.content || // 7. 拼接到响应式数据中 - 触发页面自动更新 content.value token } catch (e) { // 忽略非 JSON 行的解析错误 } } } } }这段代码在做什么逐行拆解response.body.getReader()获取响应体的读取器Reader这是一个可读流。TextDecoder将二进制数据Uint8Array解码为字符串。while (!done)循环持续读取直到服务器发来[DONE]标记。buffer缓冲区因为网络传输是按 TCP 包来的一块数据可能包含不完整的行所以必须维护缓冲区拼接。按行解析SSEServer-Sent Events格式约定每行以data:开头解析 JSON 获取delta.content增量文本。拼接到content.value这就是 Vue 响应式的高光时刻每一次.value的修改都会触发 Vue 的依赖收集系统自动执行 DOM 更新我们完全不需要手动操作 DOM。流式读取的本质不是“快”而是“早”——让用户第一时间看到输出哪怕只是一个字。而 Vue 的响应式系统则是让这些文字“丝滑”流入页面的幕后英雄。五、踩坑与优化那些文档不会告诉你的事5.1 环境变量不生效Vite 项目中.env.local的变量必须以VITE_开头。重启开发服务器后生效。如果还是不行检查vite.config.js中的envPrefix配置。5.2[DONE]标记的处理不同 API 提供商的结束标记可能不同。DeepSeek 使用data: [DONE]OpenAI 也是类似。解析时务必判断这个标记否则会陷入死循环或解析报错。5.3 缓冲区边界问题资深工程师的必经之坑网络传输是按包来的一个data:行可能被切成两半分两次到达。所以必须维护一个buffer每次 decode 后先把buffer decoded再按\n分割最后把不完整的部分存回buffer。这个坑不踩一次流式输出就永远是 bug 频出的状态。5.4 UI 自动滚动到底部当内容不断变长时用户希望看到最新内容。利用 Vue 的nextTick在 DOM 更新后滚动import { nextTick } from vue // 在 content.value token 后 await nextTick() const outputEl document.querySelector(.output-content) outputEl?.scrollTo({ top: outputEl.scrollHeight, behavior: smooth })5.5 错误处理与重试机制流式请求也可能失败网络超时、服务端报错。建议在外层包一层try-catch并在 catch 中给content.value赋一个友好的错误提示同时把stream状态重置。六、写在最后流式输出是 AI 产品的体验底线如果你只记住了今天文章里的一件事我希望是流式输出不是炫技是刚需。Vue 不是框架是提升用户体验的得力伙伴。当一个 AI 应用需要生成较长的内容时非流式输出带来的等待焦虑足以让用户流失。而流式输出不仅降低了感知延迟更传递了一种“它正在为我思考”的陪伴感。对于前端工程师来说掌握流式输出的实现已经不再是一个加分项而是 AI 时代的基础能力。ReadableStream、TextDecoder、SSE 解析——这些 API 正在从“边缘知识”变成“核心技能”。同时深入理解ref的响应式原理和v-model的双向绑定机制能让你在面对复杂 AI 交互时更加游刃有余。