Vue 3 + DeepSeek 流式输出:SSE 打字机渲染与代理实践

发布时间:2026/9/18 9:32:23
Vue 3 + DeepSeek 流式输出:SSE 打字机渲染与代理实践 做 AI 对话类产品的人多半踩过这个场景用户点下发送按钮屏幕上什么都不动转圈圈转了三五秒然后整段回答啪地一下全糊出来。用户下意识会觉得卡了、点错了甚至重复点发送进而触发重复请求。我在做一个基于 Vue 3 的 AI 助手面板时就吃过这个亏后来把后端 DeepSeek 的响应改成流式输出前端再配合打字机式的逐字渲染整个交互质感立刻不一样了。这篇就围绕 Vue 3 DeepSeek 流式输出这条链路把从原理到落地、从代理层设计到 markdown 截断处理的全过程拆开讲清楚尽量给到能直接抄作业的代码和踩坑记录。1. 先搞清楚流式输出到底解决什么问题1.1 干等和打字机的体验鸿沟要理解流式输出的价值先得看非流式调用发生了什么。传统的一次性请求里服务端要把整段回答全部生成完才通过一个 HTTP 响应整体返回。大模型生成一段两三百字的回答通常要几秒钟这几秒里前端拿不到任何数据只能显示 loading。用户感知到的就是我发出去之后什么都看不到这种不确定感是体验的头号杀手。流式输出把这条时间线拆开了。模型每生成一个 token可以粗略理解为一个字或半个词就立刻通过响应流推给前端前端收到一个就渲染一个。用户看到的第一个字可能在一秒内就出现了后面文字像打字机一样源源不断冒出来。同样是三秒生成完整回答非流式是三秒空白加瞬间全出流式是一秒后开始持续出字用户主观上会觉得后者快得多哪怕总耗时一模一样。这背后是心理学上的进度反馈效应人只要看到事情在推进对等待的耐受度会显著提高。从工程角度看流式输出还有个隐性好处它天然契合大模型的生成方式。大模型本质是自回归的一个 token 一个 token 往外吐服务端本来就具备边生成边发送的条件。DeepSeek 的接口在请求体里带上stream: true返回的就直接是 SSEServer-Sent Events格式的事件流服务端不需要攒完再发。这点后面会展开。提示流式和非流式的总耗时相差不大甚至流式因为多次小包传输、连接开销总时间可能略长一点点。它换来的是首字延迟TTFT的大幅下降这才是体验的关键指标。1.2 首字延迟才是真正该盯的指标很多团队在做性能优化时盯着总响应时间但在对话场景里用户最敏感的是首字延迟。一个总耗时四秒、首字一秒的回答体验远好于总耗时三秒、首字三秒的回答。流式输出就是冲着首字延迟去的。要压首字延迟链路每一环都得看前端到代理层的网络往返、代理层到 DeepSeek 的建连时间、模型本身的排队和首 token 生成时间。前面能优化的部分有限模型侧的首 token 时间也基本不可控但把首字不显示变成首字尽快显示这件事前端和后端代理都有明确的可做空间。我实测下来同样的 DeepSeek 接口非流式模式下用户要等三到五秒才见字改成流式后首字普遍能压到一秒上下网络好的时候几百毫秒就出了。这个提升对留存的影响比后端抠掉两百毫秒计算时间要大得多。2. 技术选型为什么是 Vue 3 配 DeepSeek2.1 Vue 3 的响应式系统天然适配高频更新流式输出意味着状态会以毫秒级频率更新每收到一个 chunk 就可能要改一次文本。如果框架的响应式机制开销大这种高频更新会带来明显的性能问题。Vue 3 用 Proxy 重写了响应式系统配合编译期的静态提升和 Patch Flag 优化在频繁更新单个响应式字符串时的开销控制得比较好。另一个原因是 Vue 3 的 Composition API 很适合把流式接收这段逻辑抽成一个 composable。你可以写一个useStreamChat把请求、解析、状态管理、错误处理全塞进去组件里只负责渲染。这种组织方式比 Options API 的写法在复用和测试上干净很多尤其是当你有多个对话入口、多个模型切换时优势很明显。后面我会把这段 composable 的完整实现给出来。2.2 DeepSeek 接口的调用形态DeepSeek 的对话接口兼容 OpenAI 的格式base_url一般是https://api.deepseek.com对话补全走/chat/completions。请求体里放model、messages、stream这些字段。开启流式只要把stream设为true响应头里的Content-Type会变成text/event-streambody 就是一串 SSE 事件。一个最简的请求体长这样{ model: deepseek-chat, messages: [ { role: system, content: 你是一个简洁的助手 }, { role: user, content: 用三句话解释什么是流式输出 } ], stream: true }模型选型上deepseek-chat是通用对话模型deepseek-reasoner偏向推理。做打字机效果时推理类模型可能会有思维链内容前端要决定是展示还是折叠这个后面单独说。2.3 项目初始化与依赖清单项目脚手架用 Vite 起 Vue 3 项目最省事npm create vitelatest stream-chat -- --template vue cd stream-chat npm install流式解析这块浏览器原生就有fetch和ReadableStream不装额外依赖也能做。如果团队更习惯用 SDK可以装对应厂商的客户端库但我不太推荐在浏览器里直接用官方 SDK 的密钥直连模式原因见下一节。渲染 markdown 建议只装marked或markdown-it这类轻量解析器再加个highlight.js做代码高亮就够了别为了打字机效果装一堆 UI 库。依赖越少打包体积越小流式渲染时的性能也越稳。这一点在移动端尤其明显。3. 后端代理层密钥绝不能进浏览器3.1 为什么必须自己搭一层代理新手最容易犯的错是把 DeepSeek 的 API Key 直接写在前端代码里。往浅了说打包后的 JS 里能搜到密钥往深了说密钥一旦泄露别人可以用来跑你的额度账单算你的。浏览器的请求也无从保护任何打开开发者工具的人都能看到完整密钥和请求内容。正确的做法是前端只请求自己的后端由后端拿着密钥去调 DeepSeek再把流式响应透传回来。这层代理还顺手解决了跨域问题、请求日志、限流、对话历史落库这些需求。代理层不需要多复杂一个转发接口就够。3.2 用 Node 写一个最小可用的流式代理我这里用 Node 加 Express 写一个转发接口核心是把 DeepSeek 返回的流原样 pipe 回前端中间不做缓冲。很多人踩的坑是在代理层用await response.json()把响应整个读完再返回那样流式就废了等于白做。要保证流式必须用流的方式转发。import express from express const app express() app.use(express.json()) const DEEPSEEK_KEY process.env.DEEPSEEK_API_KEY app.post(/api/chat, async (req, res) { const upstream await fetch(https://api.deepseek.com/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${DEEPSEEK_KEY} }, body: JSON.stringify({ model: deepseek-chat, messages: req.body.messages, stream: true }) }) res.setHeader(Content-Type, text/event-stream; charsetutf-8) res.setHeader(Cache-Control, no-cache, no-transform) res.setHeader(Connection, keep-alive) res.setHeader(X-Accel-Buffering, no) const reader upstream.body.getReader() const decoder new TextDecoder() while (true) { const { done, value } await reader.read() if (done) break res.write(decoder.decode(value, { stream: true })) } res.end() }) app.listen(3000)这段代码里有几个细节值得单独说。Cache-Control里带上no-transform是为了防止某些中间层压缩或改写响应体破坏 SSE 格式。X-Accel-Buffering: no是针对反向代理的不加的话响应可能被缓冲住看起来又变回干等了。decoder.decode要传{ stream: true }因为一个多字节的 UTF-8 字符可能被拆在两个 chunk 里不加这个参数会出现乱码。3.3 密钥和配置的正确管理方式密钥走环境变量本地开发用.env文件部署时用平台的密钥管理。.env记得加进.gitignore千万别提交上去。生产环境的代理最好再加一层简单的鉴权比如校验前端带来的登录态 token防止接口被人白嫖。代理层还可以做点限流比如限制单 IP 每分钟的请求数避免被刷。这些非核心但有用视项目规模决定要不要加。4. 前端核心fetch 读流与 SSE 解析4.1 用 fetch 读取响应流浏览器里读流式响应用fetch拿到的response.body是一个ReadableStream通过getReader()拿到读取器循环read()就能一块一块地拿数据。这里的关键还是那个老问题chunk 的切分不保证落在事件的边界上。SSE 的基本格式是每个事件以data:开头事件之间用空行\n\n分隔流结束时会有一个data: [DONE]。因为网络传输的分片是随机的你可能收到半个事件也可能一次收到两个半事件。所以不能每收到一个 chunk 就解析一次要维护一个缓冲区按\n\n切分出完整事件剩下的半截留在缓冲区等下次拼接。4.2 一个能直接用的 useStreamChat 实现我把解析逻辑抽成了一个 composable处理了缓冲、解码、事件切分和结束标记。import { ref } from vue export function useStreamChat() { const content ref() const loading ref(false) const error ref() async function send(messages) { content.value error.value loading.value true try { const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }) }) if (!resp.ok) throw new Error(请求失败: ${resp.status}) const reader resp.body.getReader() const decoder new TextDecoder() let buffer while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) const parts buffer.split(\n\n) buffer parts.pop() ?? for (const part of parts) { const line part.trim() if (!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?.content if (delta) content.value delta } catch { // 半截 JSON 不处理等下一轮拼接 } } } } catch (e) { error.value e.message ?? 未知错误 } finally { loading.value false } } return { content, loading, error, send } }这里有个容易忽略的点buffer.split(\n\n)之后最后的元素可能是不完整的事件用pop()取出来留在 buffer 里等下一个 chunk 拼上再解析。如果直接用forEach处理所有切分结果最后一截不完整的 JSON 会 parse 失败导致丢字。JSON.parse外面套 try/catch 也是防御性的虽然理论上按\n\n切出来的都是完整事件但万一上游格式有微小差异抛异常会中断整个循环。宁可静默跳过也不要让整条流断掉。4.3 组件里怎么把内容渲染出来组件侧就干净多了调用 composable 拿状态和发送方法即可。输入框、消息列表、发送按钮的绑定都很直观。template div classchat div classmessages div v-for(m, i) in history :keyi classmsg{{ m.content }}/div div classmsg streaming {{ content }}span v-ifloading classcursor|/span /div /div div classinput-row input v-modeldraft keyup.enteronSend placeholder说点什么 / button :disabledloading clickonSend发送/button /div /div /template script setup import { ref } from vue import { useStreamChat } from ./useStreamChat const draft ref() const history ref([]) const { content, loading, send } useStreamChat() async function onSend() { if (!draft.value.trim() || loading.value) return const userMsg { role: user, content: draft.value } history.value.push(userMsg) draft.value await send([...history.value]) history.value.push({ role: assistant, content: content.value }) content.value } /script那个闪烁的光标|是打字机效果的灵魂用一个v-ifloading控制显隐配合 CSS 的 blink 动画用户一眼就知道还在生成中。这个细节几乎不花成本但体验提升立竿见影。5. 高频更新下的性能与渲染优化5.1 为什么逐字 setState 会卡流式输出最直接的做法是每收到一个 delta 就往content.value上拼但如果模型的响应速度很快一秒可能触发几十次响应式更新每次都带动组件重新渲染。文本短的时候看不出来对话长了之后每次重渲染都要重新处理整段文本开销会累积。一个常见的优化是节流渲染。接收侧照常实时拼接到一个普通变量但用requestAnimationFrame或一个几十毫秒的定时器把最新内容批量刷到响应式状态上。人眼对屏幕上文字变化的感知有个上限每秒刷新十几二十次已经足够顺滑没必要每个 token 都触发一次 DOM 更新。5.2 markdown 渲染与截断问题的处理如果回答里带 markdown比如代码块、列表、加粗渲染就复杂了。流式过程中markdown 天然是不完整的最典型的是代码块的三反引号只来了一半或者一个加粗的**只出现了一侧。直接丢给 markdown 解析器可能出现渲染错乱甚至把后面的内容全吞进代码块里。处理思路是解析前先做个补全。检查未闭合的代码围栏数量是奇数就临时补一个检查未闭合的行内标记酌情补上。渲染完再交给真实内容。这样每一帧的中间态看起来也是正常的不会出现大段乱码。还有一种做法是分块渲染把已经确认完整的部分比如已闭合的代码块之前的内容用 markdown 渲染最后不确定的一小段用纯文本展示。等流结束后再整体做一次 markdown 渲染。我一般用前者补全逻辑写起来不复杂视觉连贯性更好。function completeMarkdown(text) { // 统计未闭合的代码围栏 const fences (text.match(//g) || []).length if (fences % 2 ! 0) text \n return text }这个函数很小但能挡掉一大类渲染异常。实际用的时候再根据你的内容类型补上对行内代码、引用的检查。5.3 消息列表的 key 与虚拟滚动对话长了之后消息列表本身也会成为性能瓶颈。列表项的key一定不能用 index要用稳定唯一的 id否则流式追加新消息时Vue 会因为 key 变化而大面积重渲染。历史消息多到几百条时上虚拟滚动是迟早的事不过那是另一个话题初期把 key 和渲染节流做好就够撑一阵。6. 踩过的坑与排查手册流式输出这套东西原理不难但真正落地时坑集中在几个地方。我把自己和团队遇到过的典型问题整理成一张表方便对照排查。现象可能原因排查与解决前端完全没反应最后一次性全出代理层或网关做了缓冲检查代理是否用流转发加X-Accel-Buffering: no中文出现乱码解码时没开 stream 模式TextDecoder的decode传{ stream: true }偶尔丢字、跳字缓冲区切分逻辑有问题按\n\n切分后保留最后一个不完整片段内容重复或错位事件边界判断错误确认以data:开头、空行分隔、正确处理[DONE]markdown 渲染错乱中途截断的三反引号解析前补全未闭合的代码围栏长对话越来越卡每个 token 都触发重渲染节流刷新稳定列表 key有一个坑特别隐蔽某些反向代理或 CDN 默认会对响应做 gzip 压缩压缩算法需要攒够一定数据量才输出结果把流式给攒成了非流式。遇到这种情况要么在响应头里关掉对这条路径的压缩要么明确声明Content-Encoding: identity。我自己就被这个卡了大半天一开始还以为是 DeepSeek 接口的问题后来抓包才发现数据在网关那里被拦住了。还有一个关于标签返回未完整的问题在流式渲染 HTML 或特殊标记时很常见。比如回答里嵌了自定义标签前一个 chunk 只到了div下一个 chunk 才补上。如果中间态直接丢给浏览器解析可能被当成文本展示甚至破坏布局。稳妥的做法同样是维护一个待确认尾巴遇到可能未闭合的标记先不渲染等后续 chunk 拼完整了再处理。这和 markdown 围栏补全是同一类思路本质都是别急着渲染不完整的中间态。注意调试流式问题时浏览器的 Network 面板要选 EventStream 或查看原始响应而不是看 Preview 里格式化后的结果后者可能把流式响应显示成非流式的样子让你误判。排查这类问题我习惯分三段定位先确认后端到 DeepSeek 这一段是不是流式在代理层打日志看reader.read()是不是多次返回再确认代理到前端这一段看浏览器的响应是不是逐步到达最后才看前端解析。哪一段是一次性到达问题就在那一段的前面。这个方法能快速把排查范围缩小到一个环节比到处改代码高效得多。关于错误处理流式场景下有个细节连接中断时前端拿到的可能不是标准的 reject而是reader.read()返回 done 但内容不完整或者直接抛一个网络异常。要在 composable 里区分正常结束和异常中断前者收尾后者给用户一个可重试的提示。别让用户对着一个永远转圈的界面发呆。7. 还能怎么扩展这套方案把这套流式链路跑通之后能叠加的东西不少。最直接的是多轮对话的上下文管理每次发送把历史消息一起带上但注意别无限增长超出模型上下文窗口前要做截断或摘要。可以在代理层按 token 数估算简单点按字符数粗略限制也行。再进一步是推理模型的处理。如果切换到带思考过程的模型流里会有reasoning_content之类的字段前端可以把这部分折叠展示或者用一个灰色的小区域显示思考中主答案部分照常打字机输出。这样既不打断体验又能体现模型在推理用户会觉得更透明。还有并发和取消。用户生成到一半想重新提问得能中止当前请求。AbortController配合 fetch 就能做到把这个 controller 暴露给组件点取消时abort()同时处理好中止后的收尾别让半截内容留在界面里。这个功能在真实产品里几乎是刚需因为用户改主意的频率比想象中高。我个人在实际操作里的体会是流式输出这套东西的价值一半在技术实现一半在那些不起眼的细节一个闪烁的光标、一次及时的首字、一个不乱跳的 markdown 渲染。技术骨架搭好之后真正拉开产品差距的往往是这些体验层面的打磨。骨架你照着上面的代码搭半天能跑起来细节上多花的时间会在用户留存上还回来。