尧图精选

Vue 3 + DeepSeek 流式输出与打字机效果:SSE 解析避坑

🕒 发布时间:2026/9/19 7:28:39 📁 来源:尧图网络
1. 先想明白干等到底难在哪首字延迟与用户心理很多人第一次做 AI 对话界面都会掉进同一个思维惯性里接口能返回数据界面能把数据显示出来功能就算完成了。于是很自然地写出点发送 - 转圈 - 三秒后一整段文字啪地砸在屏幕上这种交互。功能上没毛病但只要你自己坐在那儿连续用上十几次就会产生一种很明确的别扭感那个转圈图标转得人心发慌你甚至不确定它是不是卡死了。这里的关键变量是首字延迟Time To First Token。一个完整回答可能要生成两千个字符如果等全部生成完再一次性吐出来用户感知到的等待就是总生成时间而如果服务端每生成一小段就立刻推给前端用户感知到的等待就缩短成了第一小段的生成时间。这两者之间往往差着五到十倍体验上是能不能接受和根本不想用的区别。这就是流式输出要解决的核心问题它不是什么锦上添花的特效而是把等待感从不确定的漫长改造成正在发生的过程。打字机这个词其实很精准。它描述的是一种渐进式呈现文字一个字符一个字符跳出来光标在末尾闪烁用户能明确感知到系统在工作、内容在生长。这种反馈带来的确定感是任何菊花 Loading 都替代不了的。而落到 Vue 3 这个技术栈上要实现它需要打通三件事后端的流式响应协议、浏览器端的字节流读取、响应式数据的增量更新与渲染。任何一环处理得糙一点都会导致乱码、丢字、卡顿或者标签显示不全。这篇内容适合三类人来看一是正在做聊天类 AI 界面的前端二是想搞清楚 SSE 数据在前端到底怎么被接收和拼装的同学三是已经实现了流式但遇到了各种诡异显示问题的开发者。我会把 DeepSeek 接口的调用方式讲清楚但重点一定放在前端那些真正会卡住你的细节上因为这些细节官方文档通常不写只有自己摔过才知道。提示流式输出的本质是边生成边传输边渲染前端的复杂度不在请求发出而在响应解析与渲染时机后面几节的坑几乎都集中在这里。2. DeepSeek 接口的流式开关请求体里那几个不能写错的字段DeepSeek 的对话接口走的是和主流大模型一致的设计兼容 OpenAI 的请求格式这对前端来说是个好消息意味着你不需要额外学一套新协议。请求地址是https://api.deepseek.com/chat/completions用 POST 发送 JSON鉴权靠请求头里的Authorization字段携带 API Key。真正打开流式的开关只有一个字段stream: true。就这么简单但它带来的响应形态完全不同。不开流式的时候响应是一整个 JSONchoices[0].message.content里就是全部文本开了流式之后服务端会持续返回一串以data:开头的文本行每一行是一个增量片段最后以data: [DONE]收尾。这种格式就是常说的SSEServer-Sent Events浏览器原生支持不需要额外协议。先把请求代码摆出来这是后面一切的基础const response await fetch(https://api.deepseek.com/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${import.meta.env.VITE_DEEPSEEK_KEY} }, body: JSON.stringify({ model: deepseek-chat, messages: [ { role: system, content: 你是一个简洁的技术助手 }, { role: user, content: 用三句话解释什么是流式输出 } ], stream: true, temperature: 0.7 }), signal: controller.signal })这里有几个容易忽略但很关键的点。第一API Key 绝对不能写死在浏览器端代码里。上面用import.meta.env是 Vite 的环境变量写法但即便如此打包后它仍然会出现在客户端产物中真正生产环境应该由自己的后端做一层代理转发前端只和自家后端通信。第二signal字段是配合AbortController用的用于中途打断生成这个后面单独讲。第三temperature这类采样参数对流式没有直接影响但它决定生成内容的稳定性调试阶段建议调低一点输出更可预测、更容易排查问题。响应头里还有一个细节值得留意正常返回的Content-Type会是text/event-stream并且通常是Transfer-Encoding: chunked也就是分块传输。这意味着你拿到的response.body是一个可读流ReadableStream而不是一段已经拼好的字符串你必须主动去读它。字段不开流式开流式响应类型单个 JSON 对象SSE 文本流内容位置choices[0].message.contentchoices[0].delta.content传输方式一次性返回分块持续推送结束标志响应自然结束data: [DONE]这个对照表建议收藏因为很多人在改造老代码时最容易犯的错就是忘记把message.content改成delta.content结果流是收到了但每次读到的都是undefined然后在控制台里一脸懵。我自己第一次就栽在这儿排查了小半天才发现字段名换了。3. 在 Vue 3 里啃字节流为什么我最终放弃了 axios说到发请求很多人第一反应是用 axios因为它封装得好、拦截器好用。但在流式场景下axios 在浏览器端并不适合处理这种持续推送的响应。原因在于浏览器里的 axios 底层依赖 XMLHttpRequest而 XHR 对响应体流的支持一直很别扭虽然有onprogress事件能拿到部分数据但用起来非常不优雅而且不同浏览器行为不一致很容易踩坑。Node 环境下 axios 配合responseType: stream是可以的但那是服务端的玩法。浏览器的正路是Fetch API。fetch返回的response.body是一个标准的 ReadableStream配合getReader()就能一块一块地读数据。这个组合是流式处理的标准姿势也是官方文档里推荐的方案。所以但凡要做浏览器端流式我的选择都是明确的用 fetch放弃 axios。这不是为了时髦而是 XHR 这条路本身就走不通。读取字节流最朴素的写法是这样const reader response.body.getReader() const decoder new TextDecoder(utf-8) while (true) { const { done, value } await reader.read() if (done) break const text decoder.decode(value) console.log(text) }这段代码能跑但如果你直接拿它去接流式接口几乎必然会遇到两个问题而这两个问题正是新手最容易翻车的地方。第一个是多字节字符被切断。中文在 UTF-8 编码下占三个字节而网络分块传输时一个中文字符完全可能被拆到两个不同的 chunk 里。如果你对每个 chunk 都独立调用decoder.decode(value)那个被切成两半的字符就会变成乱码。解决办法是给 TextDecoder 加上{ stream: true }选项const text decoder.decode(value, { stream: true })加上这个选项后解码器会把不完整的字节留在内部缓冲区等下一个 chunk 来了再拼起来一起解码。这个参数几乎没人第一次就会注意但不加就是满屏乱码而且现象很随机有时候正常有时候不正常非常难查。第二个是SSE 帧被切断JSON 解析报错。服务端推过来的数据是以\n\n为分隔的帧但网络传输的 chunk 边界和帧边界完全没关系。也就是说你某次read()拿到的内容可能是data: {choices:[{delta:{con这样半截的东西直接JSON.parse必然抛错。这个问题的解法是引入缓冲区把每次读到的内容追加到一个字符串 buffer 里按\n\n切分最后一段留到下一轮。这个模式我管它叫攒帧是流式解析的核心套路下一节展开讲。注意reader.read()是异步的每次返回一个{ done, value }对象。done为 true 时表示流已结束此时不要再尝试解析value它通常是 undefined。4. 攒帧的完整实现buffer、切分与 [DONE] 收尾理解了上面两个坑攒帧的逻辑其实就顺理成章了。核心思想一句话概括永远不要假设每次读到的是一帧完整数据用缓冲区兜底切完剩下的留到下一轮。听起来简单但写的时候有非常多细节会决定你的实现是能跑还是稳。先看完整实现async function readStream(response, onDelta) { const reader response.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) const frames buffer.split(\n\n) // 最后一段可能是不完整的帧留在 buffer 里等下一轮 buffer frames.pop() for (const frame of frames) { const line frame.trim() if (!line || !line.startsWith(data:)) continue const payload line.slice(5).trim() if (payload [DONE]) return try { const json JSON.parse(payload) const delta json.choices?.[0]?.delta?.content if (delta) onDelta(delta) } catch (err) { console.warn(跳过异常帧, payload) } } } }这里有几处值得逐句掰开说。首先是frames.pop()这个操作它是整个逻辑的灵魂。因为缓冲区里最后一段极可能是被网络截断的半帧如果把它也拿去解析要么 JSON 报错要么丢掉本该拼上的后续字符。把它弹出来留在 buffer 里等于把不完整的部分推迟到下一轮处理这样每一轮的解析都只面对完整帧。这个思路和 TextDecoder 的{ stream: true }是一脉相承的都是把不完整的留到下次。然后是line.startsWith(data:)的判断。SSE 格式规定字段名后面跟冒号冒号后的第一个空格会被忽略所以data: {...}和data:{...}都可能出现。我用slice(5).trim()统一处理去掉可能的前导空格。有些人习惯用replace(data: , )但如果服务端没加空格就会漏掉还是一刀切更稳。json.choices?.[0]?.delta?.content这行用了可选链是防御性写法。有些流式响应的首帧或尾帧会带上finish_reason而不带content或者干脆是空的 delta直接取delta.content会抛错。用可选链加条件判断稳妥很多。这也是为什么我不会对每个 delta 做多余的校验而是把过滤逻辑收敛到这一行。最后[DONE]的处理我直接用return结束整个函数。这既终止了循环也顺便把 reader 释放了GC 会处理。有洁癖的话可以显式调reader.cancel()效果一样。提示如果接口可能返回 SSE 以外的格式建议在切帧前先判断响应头Content-Type是否包含text/event-stream避免拿 JSON 响应硬套流式解析那样只会一路报错。再补一个真实遇到的坑某些代理层或中间件会做压缩优化把 SSE 的换行符重新拼装导致\n\n的分隔被破坏。判断特征是 buffer 越攒越大、始终切不出帧。真遇上这种情况可以退一步按data:关键字做二次切分或者干脆改用\n加长度校验的容错方式。这类问题不多见但一旦遇到光看前端代码是找不到根的得从网络链路上排查。5. 打字机效果落地 Vue 3响应式更新与未闭合标签的处理数据能正确解析出来了接下来是把它渲染成打字机。这一步在 Vue 3 里有个天然的陷阱如果你每收到一个 delta 就改一次响应式变量而变量又绑着一大段文本在做实时渲染性能会非常糟糕。尤其是长回答几百次高频更新叠加 Markdown 解析页面会肉眼可见地掉帧。先说数据层。我习惯把它抽成一个组合式函数逻辑内聚、便于复用import { ref, shallowRef } from vue export function useChatStream() { const content ref() const loading ref(false) let controller null async function send(prompt) { controller new AbortController() loading.value true content.value const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt }), signal: controller.signal }) await readStream(response, (delta) { content.value delta }) loading.value false } function stop() { controller?.abort() loading.value false } return { content, loading, send, stop } }这里content用ref就够了字符串拼接本身开销不大。真正需要注意的是渲染层。如果你用的是 Markdown 渲染绝不能每次 delta 都跑一遍完整解析。我的做法是分成流式阶段和完成阶段两套渲染策略流式进行中先用纯文本或轻量渲染展示保证每帧只做最小工作流结束或显式停止后再切到完整的 Markdown 渲染。这就引出一个几乎人人都会遇到的具体问题标签返回不完整怎么处理。模型输出的内容里经常带 Markdown 或代码块流式过程中很容易出现下面这些半截状态代码块 只出现了开头结尾的三个反引号还没来加粗**只出现了一次还在等另一半链接[文字](括号没闭合数学公式$只开没关。如果直接把这种半截内容丢给 Markdown 渲染器轻则显示成一堆裸露的星号和反引号重则整个渲染结构崩掉。处理思路有三种按复杂度递增方案做法适用场景纯文本兜底流式期间不渲染 Markdown结束再渲染对实时样式要求低标签补全统计未闭合符号临时补齐再渲染需要实时样式增量解析用支持流式容错的渲染库按块解析追求极致体验我自己的项目里常用第二种实现不复杂效果也够用。核心逻辑是渲染前先做一次符号配对检查function balanceMarkdown(text) { // 处理代码块奇数个 就补一个 const fences (text.match(//g) || []).length if (fences % 2 1) text \n // 处理行内代码奇数个反引号 const ticks (text.match(/(?!)(?!)/g) || []).length if (ticks % 2 1) text // 处理加粗奇数个 ** const bolds (text.match(/\*\*/g) || []).length if (bolds % 2 1) text ** return text }这个函数不用追求完美它的目标只是让不完整的内容在渲染时不至于炸掉。补充一点心得这段逻辑要放在渲染前而不是数据存储时因为存储的必须是原始内容否则等真实的后半段来了你补的那些符号就变成脏数据了。这是很典型的展示层逻辑不要污染数据层很多新手会把补全后的内容存进content结果最终内容里多出一堆奇奇怪怪的符号。另外还有一个视觉细节值得做光标动画。在文本末尾挂一个闪烁的光标元素流结束就移除它。这个小小的交互能显著强化正在输入的感知。实现上就是根据loading状态动态挂一个span classcursor配合 CSS 的animation做闪烁即可。.stream-cursor::after { content: ▋; animation: blink 1s step-end infinite; } keyframes blink { 50% { opacity: 0; } }6. 中断、重试与限流让这套东西真正能上生产把流式跑通只是及格线能在真实场景里稳定运行才算过关。这里有几件必须处理的事每一件我都见人栽过跟头。第一件是中途打断。用户点了发送看到模型开始胡说八道想停下来重新问这时候必须能终止请求。AbortController就是干这个的前面代码里已经埋了controller。调用controller.abort()之后正在进行的reader.read()会抛出一个AbortError你需要把它捕获掉别让它冒泡成未处理错误try { await readStream(response, onDelta) } catch (err) { if (err.name AbortError) { console.log(用户主动打断) } else { // 真正的网络错误需要提示重试 } }这里有个细节打断之后不要清空已经收到的内容。用户往往是想保留前面部分、重新接着问你一把清空前面的生成就白费了。我的做法是打断时保留content只结束 loading 状态。第二件是响应式更新的节流。模型吐字速度可能非常快一秒钟几十个 delta 完全正常。每个 delta 都触发一次 DOM 更新对浏览器是不小的压力尤其在移动端。解决办法是把高频更新合并到动画帧里用requestAnimationFrame做批量 flushlet pending let rafId null function appendDelta(delta) { pending delta if (rafId) return rafId requestAnimationFrame(() { content.value pending pending rafId null }) }这样一帧内收到再多的 delta也只会触发一次响应式更新。实测下来长回答的流畅度提升非常明显尤其是在低端安卓机上从能感觉到数字在跳变成了稳定的打字机效果。这个技巧不复杂但收益很高。第三件是错误重连与降级。流式连接比普通请求更容易断网络抖动、服务端超时、网关掐连接都会导致中途断流。这里要区分两种情况一种是连接建立前就失败了比如鉴权错误、参数错误这种要直接提示用户另一种是中途断了已经收到一部分内容这种我倾向于保留已收到的内容并在末尾标注生成中断同时提供一个重试入口让用户决定是继续还是重来。直接清屏重试是很粗暴的做法用户会骂人。第四件是输入与并发的保护。生成过程中要禁用发送按钮防止用户在上一轮还没结束时又发一条导致两条流交叉写入同一个content页面直接乱套。如果确实需要支持多会话并发那就得给每个会话维护独立的 reader 和 content 容器复杂度会上升一个台阶早期阶段不建议这么做。注意生成过程中如果用户切换到别的页面浏览器可能会挂起或节流后台标签页的 rAF导致更新卡住。这种场景下建议同时监听document.visibilityState切回前台时做一次全量同步。最后聊聊调试经验。流式的问题最难查的地方在于它时好时坏因为 chunk 边界是随机的。我常用的排查手段是把每次read()的原始返回打到控制台观察切帧是否正常、有没有半截 JSON。如果本地怎么都复现不了可以临时把接口换成慢速推字的模拟服务人为控制推字间隔和分块大小把那些边界情况逼出来。这种模拟服务非常好用建议每个做流式的人都搭一个比对着真实接口反复重试高效得多。回到最开始那个问题干等到打字机的转变表面上只是一个渲染方式的调整实际上是整个链路的重新设计请求方式从 axios 换成 fetch数据从整块 JSON 变成要攒帧的字节流渲染从一次性变成增量还要兼顾打断、节流和错误处理。这几件事每一件单独看都不难但拼在一起就是流式输出这套东西真正的门槛所在。把这些细节都稳住之后你得到的不只是一个会打字的效果而是一个用户愿意一直用下去的对话界面。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →