ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue3 对接 DeepSeek 流式输出:SSE 解析与打字机渲染

Vue3 对接 DeepSeek 流式输出:SSE 解析与打字机渲染 上个月帮朋友改他们的客服问答页功能其实早就跑通了接的是 DeepSeek 的对话接口一问一答逻辑没毛病。但上线三天用户的吐槽集中在同一个点上点完发送之后界面上除了一个转圈什么都没有短则三四秒长的时候八九秒整段回答才“啪”一下全砸出来。有人以为卡死了反复点发送一轮对话发出去五六条请求后端日志看得人头皮发麻。问题不复杂但很典型。模型每秒能吐几十个字可它的输出被 HTTP 响应体整个包着只有连接关闭前端才能拿到完整字符串——中间的等待时间全被吞掉了。把stream开关打开让服务端边算边推、前端边收边渲染干等就变成了打字机。这篇就把 Vue 3 配 DeepSeek 做流式输出的完整链路拆开讲为什么要这么选、SSE 的数据到底长什么样、Vue 3 里怎么封装一个能复用的流式 composable、标签返回不完整怎么兜底、以及一堆只有真跑过才会遇到的坑。前端新手能照着抄作业做过一轮对话功能但效果不理想的也能在这里找到对标的实现细节。1. 为什么“干等”和“打字机”体验差了整整一个时代1.1 一次真实对话场景里的体感差异先还原一下两种体验到底差在哪。非流式的情况下用户点发送浏览器发出一个 POST请求体里带着完整的 messages 数组服务端拿到之后转发给模型模型开始一个一个 token 地生成但这些 token 先被服务端攒着等生成结束、拼成完整字符串再一次性作为响应体写回去。浏览器这边await fetch()一直挂着直到整个响应结束才 resolve。这中间用户看到的只有一个 loading 状态什么信息都没有。流式的情况下服务端在模型吐出第一个 token 的时候就往连接里写一段数据后面每生成几个 token 就写一段前端用一个 reader 不断读读到一段就追加到页面上。用户的感受是点完发送不到一秒第一个字就冒出来了然后一行一行往下淌。同样的总耗时一个是“等一个黑盒”一个是“看着它写”。这不是优化了几百毫秒的问题是心理预期完全变了——用户能在第一时间判断这个回答靠不靠谱觉得不对可以立刻点停止而不是干等着它把一整段废话生成完。从工程角度看还有一层价值长回答的生成时间可能到十几秒甚至几十秒非流式方案里这个连接全程静默中间任何一层网关Nginx、负载均衡、CDN都有超时断连的风险。流式输出让连接始终有数据流动反而更稳。1.2 流式输出到底在链路的哪一环做文章很多人第一次接流式会误以为要在前端做什么“分段请求”其实不是。真正的开关在请求体里那一个stream: true加不加它服务端的行为完全不同。加了之后服务端的响应头里会带上Content-Type: text/event-stream响应体不再是“等全部生成完再发”而是变成一条持续的、按行组织的数据流。模型每生成一小段服务端就包一个 JSON前面加上data:前缀后面跟两个换行写进连接。前端的活就变成了不要等response.json()而是拿到response.body这个可读流一块一块地读每读到一块就按 SSE 的格式切行、解析、取增量文本。response.body是一个ReadableStream浏览器原生支持不需要额外库。切行、拼缓冲、处理 JSON 解析失败这些都是纯前端逻辑跟 Vue 没关系但 Vue 负责把这些增量文本高效地渲染成用户看得见的东西。理解了这个分工后面所有代码就都好懂了。还有一个容易被忽略的点stream: true打开后usage字段通常只在最后一个 chunk 里出现如果你原来靠响应体里的usage.total_tokens做计费统计流式下得专门在收尾时从最后一个 chunk 里取不然统计会全部丢失。这是我在一个项目里踩过的坑账单对不上才发现。2. 三种流式方案的横向对比与选型2.1 SSE、WebSocket、fetch 流式读取的差异提到流式网上能搜到三种常见做法但它们的适用场景差很多选错了后面全是麻烦。方案通信方向协议基础服务端要求适用场景SSEEventSource单向服务端推HTTP必须是 GET不能带自定义请求头纯订阅类推送如通知、行情WebSocket双向独立协议需升级握手需要 ws 服务或网关支持双向实时交互如协同编辑、游戏fetch ReadableStream单向客户端拉HTTP普通 POST 即可对话生成、逐段返回的接口调用坑主要在 SSE 那一栏。浏览器的EventSourceAPI 确实好用自动重连、自动解析data:行但它是硬编码的 GET 请求而且不允许自定义请求头。这就意味着你没法把Authorization: Bearer xxx塞进去也没法传一个结构化的 messages 数组。有些团队为了用 EventSource把请求参数全塞到 URL query 里token 和对话历史赤裸裸地暴露在访问日志里这是绝对不能接受的。WebSocket 的问题在于“杀鸡用牛刀”。对话生成本质上是“我发一个问题你回一段内容”是典型的请求-响应模型只是一个响应被拆成了很多段。为了这个上一套 WebSocket服务端要维护长连接、处理心跳、做断线重连运维成本和复杂度都上去了收益却为零。2.2 我为什么最终落在 fetch ReadableStreamfetch加response.body.getReader()这个组合是当前对话类流式输出最合适的方案理由很实在它就是一个普通的 POST 请求可以带任意 header可以传 JSON body服务端不用做任何特殊适配只要支持分块传输就行。前端这边拿到的是原生 ReadableStream配合TextDecoder就能逐块解码控制粒度完全在自己手里。还有一个实际好处是能被AbortController直接掐断。用户在生成到一半时点“停止”你只需要调controller.abort()fetch 会立刻抛出一个AbortErrorreader 那头的循环随之结束连接干净关闭不会有残留。这一点在 EventSource 上是做不到的它只能靠close()断开但请求本身是 GET服务端可能还在继续生成白白浪费算力。唯一需要自己动手的是 SSE 的解析。因为走的是 fetch浏览器不会帮你把data:前缀剥掉、也不会帮你处理跨块断行。但这段解析逻辑其实只有二三十行封装一次之后到处能用比引入一个库还省心。3. 动手前的准备接口形态与项目骨架3.1 DeepSeek 开放平台接口的关键字段DeepSeek 的对话接口走的是 OpenAI 兼容格式请求地址是https://api.deepseek.com/chat/completions请求体里几个字段必须心里有数model填deepseek-chat或对应的推理模型名messages是标准的[{role, content}]数组stream设成true才是流式temperature、max_tokens按业务调。带推理能力的模型在流式过程中除了delta.content还会有一个delta.reasoning_content字段思维链的内容全在里面。如果你只读content界面上会先安静好几秒然后正文才突然开始蹦——不是接口卡了是你把思考过程丢了。一个完整的流式响应片段长这样data: {id:chat-xxx,choices:[{index:0,delta:{content:你},finish_reason:null}]} data: {id:chat-xxx,choices:[{index:0,delta:{content:好},finish_reason:null}]} data: {id:chat-xxx,choices:[{index:0,delta:{},finish_reason:stop}]} data: [DONE]每个事件以data:开头JSON 后面跟\n\n结束时会有一个独立的data: [DONE]标记。解析时看到它就该停不要傻等 reader 返回 done。注意密钥绝对不能写在前端代码里。不管是.env还是直接硬编码打包之后都会被用户扒出来。生产环境必须是自己的服务端做一层转发前端请求自家接口密钥留在服务端。下文为了讲清原理代码里会直接调 DeepSeek 接口但请务必把它当成开发调试用法。3.2 Vue 3 项目的目录与依赖清单项目骨架我用的是 Vite 加 Vue 3 组合式 API没有额外上 Pinia因为对话状态在一个组件里就能管住。目录结构大概这样src/ composables/ useDeepSeekStream.js # 流式请求封装 components/ ChatBox.vue # 对话主组件 utils/ markdown.js # 渲染与安全处理 main.js依赖上渲染 Markdown 用marked防 XSS 用dompurify代码高亮不是必须的如果要用建议上highlight.js但只在流式结束后再跑一次。这里有个选择逻辑值得说一句很多人一开始会装一堆“AI 对话 UI 组件库”结果发现样式改不动、流式渲染的时机也控制不了最后还是要自己重写。对话界面本身不复杂一个输入框、一个消息列表自己写反而更灵活尤其是流式阶段那种“内容还在变”的状态用现成组件库经常处理不好。4. 核心实现从原始字节流到逐字上屏4.1 封装可复用的流式请求 composable先把最核心的请求层写出来。目标是一个跟 Vue 解耦的 composable传入消息数组就自动发起流式请求把增量文本通过回调吐出来。// src/composables/useDeepSeekStream.js import { ref } from vue export function useDeepSeekStream(options {}) { const { endpoint https://api.deepseek.com/chat/completions, apiKey import.meta.env.VITE_DEEPSEEK_KEY, model deepseek-chat, } options const loading ref(false) const error ref(null) const fullText ref() const reasoning ref() let controller null async function send(messages, onDelta) { loading.value true error.value null fullText.value reasoning.value controller new AbortController() try { const res await fetch(endpoint, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey}, }, body: JSON.stringify({ model, messages, stream: true }), signal: controller.signal, }) if (!res.ok || !res.body) { throw new Error(请求失败${res.status}) } const reader res.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break // stream: true 是关键保证多字节字符跨块时不被截断 buffer decoder.decode(value, { stream: true }) const lines buffer.split(\n) buffer lines.pop() || for (const raw of lines) { const line raw.trim() if (!line || !line.startsWith(data:)) continue const payload line.slice(5).trim() if (payload [DONE]) { loading.value false return } try { const json JSON.parse(payload) const delta json.choices?.[0]?.delta || {} if (delta.content) { fullText.value delta.content onDelta onDelta(delta.content) } if (delta.reasoning_content) { reasoning.value delta.reasoning_content } } catch (e) { // 单个 chunk 解析失败不致命跳过即可 } } } } catch (e) { if (e.name AbortError) { // 用户主动停止不算错误 } else { error.value e.message } } finally { loading.value false } } function stop() { controller controller.abort() loading.value false } return { send, stop, loading, error, fullText, reasoning } }这段代码里有两处设计需要解释。第一处是decoder.decode(value, { stream: true })那个stream: true参数看着不起眼但没有它就是中文乱码的重灾区。原因是 UTF-8 下一个汉字占三个字节而网络分块完全可能在汉字中间切开比如第一块结尾是汉字的第一个字节第二块开头是后两个字节。不开启流式解码的话TextDecoder会把每个不完整的字节序列当成非法字符解码出。加上这个参数解码器会把不完整的尾巴先缓存住等下一块到了再拼起来解。第二处是lines.pop()。这是处理 SSE 断行的核心。假设第一块数据读进来是data: {content:你}\n\ndata: {cont按\n切完之后最后一行是不完整的如果直接解析这个残行JSON 直接报错这一段的增量就丢了。所以每次切完都把最后一行重新塞回 buffer等下一块数据到了再拼上——这就是“半包”处理也是很多人做流式时发现“偶尔少几个字”的根源。4.2 SSE 逐行解析与“半包”缓冲处理上面对半包的处理只是第一层。真正跑起来还会遇到两种更隐蔽的断包情况。第一种是同一行被拆成两块。比如data: {choices:[{delta:{content:abc和def}}]}\n\n分两次到达。上面的buffer机制已经覆盖了这种情况因为没遇到换行符之前所有内容都会一直攒在 buffer 里。第二种是 JSON 里含有转义的换行符。模型输出的内容里如果有\n在 JSON 字符串里会被转义成\\n所以按\n切行是安全的不会把一条消息切成两条。但如果内容里直接出现了真实的换行符比如模型输出了一个代码块服务端会把它转义后再包进 JSON这一点 OpenAI 兼容格式是有保证的。还有一种情况是代理层做了缓冲。有些网关或 CDN 会把流式响应攒一攒再发导致你本来该一秒收到五十个 chunk结果变成五秒收到十个。表现就是打字机一顿一顿的像卡带。排查方法是打开浏览器网络面板看请求的 EventStream 分帧时间戳如果间隔明显偏大且规律那就是中间层在缓冲。解决办法是在响应头里加X-Accel-Buffering: no或者让服务端显式声明Cache-Control: no-cache。提示如果发现流式输出一直收不到第一个 chunk先确认不是请求被浏览器缓存了。POST 一般不会被缓存但某些环境下需要给 URL 加一个时间戳参数绕开。4.3 打字机渲染与自动滚动拿到增量文本之后渲染层其实有两个选择一是每次增量都直接更新 reactive 变量让 Vue 重新渲染二是把增量堆到一个队列里按固定节奏一段一段放出来。第一种实现简单但模型吐字速度和网络分块是一阵一阵的快的时候一次来七八个字慢的时候两秒不动视觉上不平滑。第二种就是真正的“打字机”后面第 6 节会展开。先说基础版。组件里这样接script setup import { ref, nextTick, watch } from vue import { useDeepSeekStream } from /composables/useDeepSeekStream import { renderMarkdown } from /utils/markdown const input ref() const messages ref([]) const listRef ref(null) const { send, stop, loading, error } useDeepSeekStream() async function handleSend() { const text input.value.trim() if (!text || loading.value) return input.value messages.value.push({ role: user, content: text }) messages.value.push({ role: assistant, content: }) const last messages.value[messages.value.length - 1] await send( messages.value.filter(m m.content).map(({ role, content }) ({ role, content })), (delta) { last.content delta scrollToBottom() } ) } function scrollToBottom() { nextTick(() { const el listRef.value if (!el) return // 只有用户本来就贴着底部时才自动滚避免打断向上翻阅 if (el.scrollHeight - el.scrollTop - el.clientHeight 120) { el.scrollTop el.scrollHeight } }) } /script自动滚动那段判断值得单独说一句。最简单的写法是每次增量都scrollTop scrollHeight但这样会有一个体验问题用户在回答生成到一半时往上翻想看前面的内容结果每次新字符进来都把他拽回底部根本看不了。加一个“距离底部小于 120 像素才自动滚”的条件就能让用户往上翻时保持位置不动。这属于必须做的小细节做和不做用户对产品评价差很多。5. 高频坑与排查实录5.1 标签返回未完整怎么处理这是流式渲染里最棘手的一类问题也是很多人搜“标签返回未完整”时真正想解决的。它分两个层面。第一个层面是 Markdown 语法被截断。比如模型正在输出加粗文本当前收到的内容是**重要提这时候用 marked 去解析它会把两个星号原样输出页面上先出现一个孤零零的**等后续内容到了再突然变成加粗视觉上会闪一下。更严重的是代码块内容到了js\nconst a 这一步marked 解析出来的是一片没有闭合的代码区如果叠加了 highlight.js高亮器可能直接抛错整个渲染就崩了。我的处理办法是在渲染之前做一次“补全”预处理思路是数一下当前文本里未闭合的标记然后补上临时的闭合符// src/utils/markdown.js import { marked } from marked import DOMPurify from dompurify export function renderMarkdown(text) { const safe repairIncompleteMarkdown(text) const html marked.parse(safe, { breaks: true }) return DOMPurify.sanitize(html) } function repairIncompleteMarkdown(text) { let result text // 代码块出现奇数个 说明还没闭合补一个收尾 const fences (result.match(//g) || []).length if (fences % 2 1) { result \n } // 行内代码末尾单独一个反引号 const inlineTicks (result.match(/(?!)(?!)/g) || []).length if (inlineTicks % 2 1) { result } return result }这段逻辑不是万能的但能覆盖绝大多数场景。注意三个反引号的处理要放在行内反引号之前否则会互相干扰。第二个层面是 HTML 标签被截断。如果你的渲染链路允许 HTML 通过比如用了marked并关闭了转义那么div这种半截标签是很危险的浏览器可能把它和后面渲染出来的内容拼在一起导致布局整个错乱。所以DOMPurify.sanitize这一步绝对不能省它会把不完整或危险的标签清理掉。真正稳妥的做法还有一招设一个节流只有内容变化停顿超过大约 60 毫秒才触发一次完整渲染其余时间只更新纯文本。这样上游还在快速吐字的时候你不会拿半截内容去解析等它停顿了再渲染出错概率大幅下降。代价是渲染略微滞后但对观感几乎没有影响。注意不要为了省事在流式过程中直接用v-html绑原始内容。哪怕内容来自你自己的服务端中间任何一环被污染都可能变成 XSS 入口。清洗这一步是底线。5.2 空回复、断流、重复渲染的排查清单实际跑起来常见的几个现象我做了个速查表遇到问题对着看现象可能原因排查与解决一直空白最后一次性出现全文请求没带stream: true或中间层缓冲了响应检查请求体检查网关是否开启分块传输收到一半突然断掉网络抖动或服务端超时监听 reader 异常记录已收内容提供重试按钮中文出现解码没开流式模式decoder.decode(value, { stream: true })偶尔少几个字半包没处理残行被丢弃每轮split(\n)后用pop()把尾行收回 buffer内容出现重复片段组件重渲染时把同一 delta 追加了两次确认回调只绑定一次避免 watch 里再累加界面卡顿打字一顿一顿每个 chunk 都触发完整 Markdown 渲染加节流或流式期间只渲染纯文本报 429 或“服务器繁忙”触发了限流加指数退避重试前端给出明确提示而不是静默失败“服务器繁忙”这种情况特别值得说一句。流式请求已经开始、内容也收了一部分了这时候报错怎么处理我的做法是保留已经收到的内容在消息气泡下面追加一行错误提示和一个“继续生成”按钮把已有的上下文和中断位置一起带上重新请求。比整段清空重来体验好太多。5.3 性能收尾与内存管理流式渲染跑到几百轮对话之后性能问题会慢慢浮出来。第一个隐患是组件卸载时 reader 还在读。如果用户切走了页面fetch的流并不会自动停reader 循环还在跑回调还在往一个已经销毁的组件里写数据。解决办法是在onUnmounted里调一次stop()把 controller 中止掉。import { onUnmounted } from vue const { stop } useDeepSeekStream() onUnmounted(() stop())第二个隐患是消息列表无限增长。每轮对话都存着完整文本几百轮之后 DOM 节点和内存都会吃紧。实用的做法是给渲染设个上限比如只渲染最近 50 条更早的收进折叠区或者虚拟滚动。别小看这个我自己测过纯文本消息累积到五百条以上光是重新计算滚动高度就会有明显的掉帧。第三个是 Markdown 渲染的开销。marked.parse加DOMPurify.sanitize跑一次的成本不算低如果每个 chunk 都跑一秒几十次主线程直接被占满。合理解法是用一个requestAnimationFrame节流把渲染频率压到每帧最多一次视觉上完全看不出来CPU 占用能降一大截。6. 进阶让打字机更顺滑的几个小改动6.1 平滑吐字队列前面说过网络分块不均匀会导致打字一顿一顿。想让它真正像打字机需要在渲染层加一个队列所有收到的增量先入队然后用一个大约 20 到 30 毫秒一次的定时器每次从队列头部取一到两个字符放出来。这样无论上游吐得多快多慢页面上的出字速度都是恒定的。const queue [] let timer null function pushDelta(text) { queue.push(...text) if (!timer) startFlush() } function startFlush() { timer setInterval(() { if (!queue.length) { clearInterval(timer) timer null return } // 队列积压多的时候多吐一点防止越落越远 const step queue.length 60 ? 4 : queue.length 20 ? 2 : 1 const chunk queue.splice(0, step).join() currentContent.value chunk scrollToBottom() }, 24) }这里那个动态步长是关键。如果固定每次只吐一个字模型两秒吐了三百个字队列会越积越长用户看到的永远是几秒前的内容最后会出现“正文早就生成完了页面还在慢慢打字”的尴尬。根据积压量动态加速既保留了平滑感又不会落后太远。实测下来24 毫秒的间隔配 1 到 4 的动态步长观感最接近真实的打字节奏。6.2 停止生成与多轮上下文的处理停止生成看着简单实际有个坑用户点了停止但已经收到的半截内容要不要保留我的做法是保留并且在末尾加一个“已停止”的标记。因为很多时候用户只是想打断一段啰嗦的开头重新提问时前面那半截内容是有上下文价值的。多轮上下文这边每次发请求时把历史消息一起带上但要注意服务端对上下文长度是有上限的聊得太久就得截断。我的处理是保留系统提示词加最近 N 轮N 根据实际模型的上下文窗口来定同时给每条消息记录一个 token 估算值超出预算就从最早的用户消息开始丢。这个估算不用特别精确按字符数除以二粗算就够用目的是防止请求因为超长被直接拒绝。还有一点容易被忘记流式请求进行中用户又点了发送。上面的代码里用if (!text || loading.value) return挡住了但更友好的做法是把发送按钮变成一个停止按钮用户点击时调用的就是stop()交互上更顺也不会出现排队请求。这个小改动我几乎在每个项目里都会做用户反馈都很好。最后分享一个我在调试时常用的手法把每个 chunk 的时间戳和文本长度打到控制台画一条简单的到达速率曲线。什么时候是网关在缓冲、什么时候是模型本身慢、什么时候是前端渲染卡住一眼就能分出来比盲目改代码高效得多。这套东西我从第一个流式项目用到现在基本没换过。
返回列表