Vue3+Vite实现LLM流式响应的四层对齐实战
发布时间:2026/9/15 23:02:32 作者:尧图编辑部 阅读量:1,286

1. 为什么流式输出不是“加个loading图标”就完事了Vue3 Vite 做 LLM 流式响应很多人第一反应是“不就是用 fetch 拿数据、v-model 绑定个响应框吗”——我去年带三个实习生做内部知识助手时也这么想。结果上线第一天用户反馈“输入问题后页面卡住5秒然后‘唰’一下全吐出来跟没流式一样”。我们查监控发现前端拿到的 response.body 是一个完整的 JSON 字符串根本没走流式通道。后来翻了三天文档才明白流式不是后端“能发”前端“就能收”而是前后端必须在协议层、传输层、解析层、渲染层四层严丝合缝地对齐漏一环流式就退化成普通请求。核心关键词SSEServer-Sent Events在这里不是可选项而是必选项。它和 WebSocket 的本质区别在于SSE 是单向、轻量、基于 HTTP 的长连接天然适配 LLM 的“只输出不交互”场景而 WebSocket 需要双向心跳、状态管理、错误重连反而增加复杂度。更关键的是Vite 开发服务器默认不支持 SSE 连接复用生产环境 Nginx 又默认 60 秒超时这些坑全得手动填。热搜词里反复出现的stream disconnected before completion: idle timeout waiting for sse和before completion: idle timeout waiting for sse90% 都是卡在这两个环节。这个项目适合三类人一是正在用 Vue3 做 AI 应用的前端工程师需要把模型响应从“等结果”升级为“看思考过程”二是全栈开发者想打通从 Vite 开发环境到 Nginx 生产部署的完整链路三是技术面试者Vue3 面试题里“如何实现流式响应”已成高频题但网上答案多停留在 fetch textDecoder漏掉了 Vite 代理配置、SSE 心跳保活、Vue3 响应式更新性能优化等真实战场细节。接下来我会把这四层对齐拆解到每一行代码、每一个配置项、每一次重连尝试不讲原理只讲你明天就能抄作业的操作。2. 四层对齐设计协议、传输、解析、渲染缺一不可2.1 协议层为什么必须用 SSE 而不是 fetch streamingLLM 流式响应的本质是服务端持续推送 token前端逐段接收并渲染。HTTP/1.1 的 chunked encoding 理论上支持流式但浏览器 fetch API 的response.body.getReader()在实际使用中存在三大硬伤Chrome 110 对 fetch streaming 的 abort 行为异常当用户快速切换对话时旧请求的 reader 未完全关闭新请求的 reader 会报TypeError: Failed to execute read on ReadableStreamDefaultReader: ReadableStream is locked这个问题在 Vue3 的 Composition API 中尤其难捕获Safari 完全不支持 fetch streaming 的textDecoder.decode()分块解析只能等到整个 stream 结束才触发done: true彻底失去流式意义Vite 开发服务器的 proxy 机制与 fetch streaming 冲突Vite 的server.proxy默认将请求转发给后端但对 streaming 响应体的处理不完善容易出现net::ERR_INCOMPLETE_CHUNKED_ENCODING。而 SSE 天然规避这些问题它基于标准 HTTP 协议所有现代浏览器原生支持服务端只需按data: xxx\n\n格式推送前端用EventSource监听message事件即可Vite 的 proxy 对 SSE 的text/event-streamMIME 类型有专门适配。我实测过在同一台 Mac M1 上fetch streaming 在 Safari 下平均延迟 3.2 秒才开始渲染首 token而 SSE 稳定在 480ms 内。提示不要被curl sse这类命令行工具误导。curl -N http://localhost:3000/api/chat确实能打印出 SSE 数据但这只是验证服务端是否正确输出不代表前端能稳定接收。真正的验证必须用new EventSource()在浏览器控制台执行并观察 Network 面板中该请求的状态是否为pending长连接存活而非finished短连接结束。2.2 传输层Vite 开发环境与 Nginx 生产环境的双重陷阱Vite 的server.proxy配置表面简单实则暗藏玄机。常见错误写法// vite.config.ts —— 错误示范 export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true, } } } })这段代码在普通 API 请求下没问题但对 SSE 会失败。原因在于SSE 连接需要保持长连接而 Vite 默认的 proxy 会在每次请求后关闭 socket导致EventSource频繁重连。正确配置必须显式开启ws: true并设置secure: false开发环境// vite.config.ts —— 正确配置 export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true, ws: true, // 关键启用 WebSocket 代理SSE 复用此通道 secure: false, // 开发环境 HTTP 不校验证书 // 以下两项防止 Nginx 环境下的超时问题开发环境也建议加上 headers: { Connection: keep-alive, Cache-Control: no-cache } } } } })生产环境部署到 Nginx 后另一个致命陷阱浮现Nginx 默认proxy_read_timeout为 60 秒而 LLM 推理常需 2~5 分钟。若服务端 60 秒内无数据推送Nginx 会主动断开连接前端收到error事件控制台打印stream disconnected before completion: idle timeout waiting for sse。解决方案不是调大 timeout治标而是让服务端每 30 秒发一次空心跳# FastAPI 后端示例其他框架同理 app.get(/api/chat) async def chat_stream(request: Request): async def event_generator(): # 发送初始消息 yield data: {status: started}\n\n # 模拟 LLM 推理 for token in llm_generate(): yield fdata: {json.dumps({token: token})}\n\n await asyncio.sleep(0.05) # 控制流速 # 关键结束前发送心跳防止 Nginx 断连 yield data: \n\n # 空 data 行即心跳 yield data: {status: completed}\n\n return StreamingResponse( event_generator(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive } )Nginx 配置必须同步调整# nginx.conf location /api/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; # 关键延长读取超时但更重要的是启用心跳 proxy_read_timeout 300; # 5分钟 proxy_buffering off; # 关闭缓冲确保实时推送 # 心跳保活每30秒发一次空包 proxy_socket_keepalive on; }2.3 解析层EventSource 的局限性与手写 Parser 的必要性EventSourceAPI 看似完美const es new EventSource(/api/chat)监听message事件。但实际项目中它有三个无法绕过的缺陷无法自定义请求头SSE 规范禁止设置Authorization等敏感 header导致鉴权失败。热搜词sse鉴权就是为此而来自动重连机制不可控es.onopen触发后若连接断开EventSource会按指数退避策略重连首次1秒第二次2秒第三次4秒...而 LLM 场景需要立即重试或优雅降级数据格式强耦合event: messagedata: xxx的格式要求后端严格遵循一旦后端返回data: {error:xxx}而非data: {token:x}前端无法区分业务错误和网络错误。因此我放弃EventSource改用fetchReadableStream手写 Parser虽增加 20 行代码却换来完全控制权// composables/useSSE.ts export function useSSE(url: string, options: { headers?: Recordstring, string, onToken?: (token: string) void, onError?: (err: Error) void, onEnd?: () void }) { let controller: AbortController | null null; const connect async () { controller new AbortController(); try { const response await fetch(url, { method: GET, headers: options.headers || {}, signal: controller.signal }); if (!response.ok) throw new Error(HTTP ${response.status}); if (response.headers.get(content-type) ! text/event-stream) { throw new Error(Invalid content-type); } const reader response.body?.getReader(); if (!reader) throw new Error(No readable stream); // 手写 Parser逐行读取识别 data: 字段 let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer new TextDecoder().decode(value); const lines buffer.split(\n); buffer lines.pop() || ; // 保留不完整行 for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6).trim(); if (data) { try { const parsed JSON.parse(data); if (parsed.token) { options.onToken?.(parsed.token); } else if (parsed.error) { options.onError?.(new Error(parsed.error)); } } catch (e) { options.onError?.(e as Error); } } } } } options.onEnd?.(); } catch (err) { options.onError?.(err as Error); // 主动重连逻辑在此处实现而非依赖 EventSource setTimeout(() connect(), 1000); } }; const disconnect () { controller?.abort(); }; return { connect, disconnect }; }这个 Parser 的核心价值在于header 可传、错误可捕获、重连可定制、格式可扩展。比如后续要支持llm powered autonomous agents的多 step 输出只需在onToken回调中判断parsed.step planning即可分阶段渲染。2.4 渲染层Vue3 响应式与流式更新的性能博弈流式输出最大的幻觉是“只要数据到了视图就该立刻更新”。但在 Vue3 中频繁触发ref.value token会导致两个严重问题DOM 频繁重排每个 token 都触发一次textContent更新浏览器需反复计算布局1000 个 token 就是 1000 次 layout响应式系统过载ref的trigger函数在每次赋值时都执行依赖收集当 token 流速达 20 token/s 时effect队列堆积UI 卡顿。解决方案是批量更新 requestIdleCallback// composables/useStreamingText.ts export function useStreamingText() { const rawText ref(); const displayText ref(); let buffer ; let rafId: number | null null; // 每次收到 token先存入 buffer不立即更新 DOM const appendToken (token: string) { buffer token; // 使用 requestIdleCallback 在浏览器空闲时批量更新 if (!rafId) { rafId requestIdleCallback(() { rawText.value buffer; buffer ; rafId null; }, { timeout: 1000 }); // 最多等待1秒强制更新 } }; // 计算显示文本避免直接绑定 rawText 导致过度渲染 watch(rawText, () { // 添加 typewriter 效果每 30ms 渲染一个字符 const chars rawText.value.split(); let i 0; const render () { if (i chars.length) { displayText.value chars.slice(0, i 1).join(); i; requestAnimationFrame(render); } }; render(); }, { immediate: true }); return { displayText, appendToken }; }这样做的效果是后端每秒推送 50 个 token前端每秒只触发 2~3 次 DOM 更新且通过requestAnimationFrame保证动画流畅。实测在 2021 款 MacBook Pro 上1000 token 的渲染耗时从 1200ms 降至 280ms。3. 实操全流程从 Vite 初始化到 Nginx 部署的每一步3.1 环境初始化Vite Vue3 TypeScript 的最小可行配置不要用npm create vuelatest生成的模板它默认包含一堆与流式无关的插件如vue/devtools反而增加调试复杂度。从零开始npm create vitelatest my-llm-app -- --template vue-ts cd my-llm-app npm install关键修改vite.config.tsimport { defineConfig } from vite import vue from vitejs/plugin-vue // 重点关闭 Vite 的 HMR 热更新对 SSE 的干扰 export default defineConfig({ plugins: [vue()], server: { port: 3000, host: localhost, // 关键禁用 HMR 对 /api 的监听避免与 SSE 冲突 hmr: { overlay: false, protocol: wss, port: 3001 }, proxy: { /api: { target: http://localhost:8000, changeOrigin: true, ws: true, secure: false, headers: { Connection: keep-alive, Cache-Control: no-cache } } } } })package.json中添加开发脚本{ scripts: { dev: vite --host, build: vue-tsc --noEmit vite build, preview: vite preview } }注意--host参数必须加否则 Vite 默认只监听127.0.0.1手机真机调试时无法访问。这是vue3安装及环境配置中常被忽略的细节。3.2 前端核心组件ChatBox.vue 的完整实现!-- src/components/ChatBox.vue -- template div classchat-container div classchat-messages refmessagesRef div v-for(msg, index) in messages :keyindex classmessage :class{ user: msg.role user, assistant: msg.role assistant } div classmessage-content span v-ifmsg.role assistant classtyping-indicator v-showmsg.isStreaming ▌ /span span v-htmlrenderMarkdown(msg.content)/span /div /div /div div classchat-input textarea v-modelinputValue keydown.enter.preventsendMessage placeholder输入问题... rows1 / button clicksendMessage :disabledisSending发送/button /div /div /template script setup langts import { ref, onMounted, onUnmounted, nextTick } from vue import { marked } from marked import { useSSE } from /composables/useSSE import { useStreamingText } from /composables/useStreamingText const messages refArray{ role: user | assistant, content: string, isStreaming?: boolean }([]) const inputValue ref() const isSending ref(false) const messagesRef refHTMLElement | null(null) const { displayText, appendToken } useStreamingText() // 初始化 SSE 连接 let sseConnection: ReturnTypetypeof useSSE | null null const sendMessage async () { if (!inputValue.value.trim() || isSending.value) return // 添加用户消息 messages.value.push({ role: user, content: inputValue.value }) // 清空输入框 inputValue.value isSending.value true // 创建新的 assistant 消息占位符 const assistantMsgIndex messages.value.length messages.value.push({ role: assistant, content: , isStreaming: true }) // 启动 SSE 连接 sseConnection useSSE(/api/chat, { headers: { Authorization: Bearer ${localStorage.getItem(token) || }, Content-Type: application/json }, onToken: (token) { // 将 token 追加到 assistant 消息 if (messages.value[assistantMsgIndex]) { messages.value[assistantMsgIndex].content token } // 触发流式渲染 appendToken(token) }, onError: (err) { console.error(SSE Error:, err) // 替换占位符为错误消息 messages.value[assistantMsgIndex] { role: assistant, content: ❌ 请求失败${err.message} } isSending.value false }, onEnd: () { // 结束流式移除 typing 指示器 if (messages.value[assistantMsgIndex]) { messages.value[assistantMsgIndex].isStreaming false } isSending.value false } }) sseConnection.connect() } // 滚动到底部 const scrollToBottom () { nextTick(() { if (messagesRef.value) { messagesRef.value.scrollTop messagesRef.value.scrollHeight } }) } onMounted(() { scrollToBottom() }) onUnmounted(() { sseConnection?.disconnect() }) // Markdown 渲染简化版生产环境请用 marked 或 remark const renderMarkdown (text: string) { return text .replace(/\*\*(.*?)\*\*/g, strong$1/strong) .replace(/\*(.*?)\*/g, em$1/em) .replace(/\n/g, br/) } /script style scoped .chat-container { display: flex; flex-direction: column; height: 100vh; max-width: 800px; margin: 0 auto; } .chat-messages { flex: 1; overflow-y: auto; padding: 20px; background: #f9f9f9; } .message { margin-bottom: 16px; line-height: 1.5; } .message.user { text-align: right; } .message.assistant { text-align: left; } .message-content { display: inline-block; max-width: 80%; padding: 12px 16px; border-radius: 18px; word-break: break-word; } .message.user .message-content { background: #007bff; color: white; border-bottom-right-radius: 4px; } .message.assistant .message-content { background: white; color: #333; border-bottom-left-radius: 4px; } .typing-indicator { display: inline-block; width: 12px; height: 12px; background: #007bff; border-radius: 50%; margin-right: 4px; animation: pulse 1.5s infinite; } keyframes pulse { 0% { opacity: 0.4; } 50% { opacity: 1; } 100% { opacity: 0.4; } } .chat-input { padding: 16px; background: white; border-top: 1px solid #eee; } .chat-input textarea { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; resize: none; font-size: 14px; margin-bottom: 8px; min-height: 40px; max-height: 120px; } .chat-input button { width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; } .chat-input button:disabled { background: #ccc; cursor: not-allowed; } /style这个组件的关键设计点消息索引绑定assistantMsgIndex记录新消息在数组中的位置确保 token 总是追加到正确的消息项避免多轮对话时错乱滚动优化nextTick确保 DOM 渲染完成后再滚动scrollHeight比scrollTop 999999更精准错误降级onError中直接替换消息内容为错误提示而非清空整个聊天记录用户体验更友好Typing 指示器用 CSS 动画替代 GIF减少资源加载border-radius: 50%配合animation: pulse模拟光标闪烁。3.3 后端对接FastAPI Llama.cpp 的流式接口假设你用llama.cpp本地运行模型后端用 FastAPI# main.py from fastapi import FastAPI, Request, BackgroundTasks from fastapi.responses import StreamingResponse from starlette.middleware.cors import CORSMiddleware import asyncio import json import subprocess import sys app FastAPI() app.add_middleware( CORSMiddleware, allow_origins[*], allow_credentialsTrue, allow_methods[*], allow_headers[*], ) # 全局模型进程避免每次请求重启 model_process None def start_model_server(): global model_process if model_process is None or model_process.poll() is not None: # 启动 llama.cpp server model_process subprocess.Popen([ ./llama-server, -m, ./models/llama-3-8b.Q4_K_M.gguf, -c, 2048, --port, 8080, --host, 0.0.0.0 ], stdoutsubprocess.PIPE, stderrsubprocess.STDOUT) app.on_event(startup) async def startup_event(): start_model_server() app.get(/api/chat) async def chat_stream(request: Request): # 解析前端传来的 query 参数 query request.query_params.get(q, ) if not query: return StreamingResponse( iter([data: {error: Missing query}\n\n]), media_typetext/event-stream ) # 构造 llama.cpp 的 curl 请求 cmd [ curl, -s, -X, POST, http://localhost:8080/completion, -H, Content-Type: application/json, -d, json.dumps({ prompt: fQ: {query}\nA:, stream: True, temperature: 0.7, max_tokens: 512 }) ] async def event_generator(): try: # 启动子进程 proc await asyncio.create_subprocess_exec( *cmd, stdoutasyncio.subprocess.PIPE, stderrasyncio.subprocess.PIPE ) # 读取 stdout 流 while True: line await proc.stdout.readline() if not line: break # llama.cpp 的 streaming 输出是 JSON 行 try: data json.loads(line.decode()) if content in data and data[content]: yield fdata: {json.dumps({token: data[content]})}\n\n except json.JSONDecodeError: continue # 发送结束信号 yield data: {status: completed}\n\n except Exception as e: yield fdata: {{error: {str(e)}}}\n\n return StreamingResponse( event_generator(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, Access-Control-Allow-Origin: * } )部署时注意llama.cpp的llama-server必须编译时启用AVX2和CUDA如有 GPU否则推理速度极慢。-c 2048设置 context length--port 8080是 llama-server 的端口与 FastAPI 的/api/chat分离便于独立扩缩容。3.4 Nginx 生产部署从证书到超时的完整配置假设你的域名是llm.yourcompany.comVite 构建产物在/var/www/llm-app# /etc/nginx/sites-available/llm.yourcompany.com upstream backend { server 127.0.0.1:8000; # FastAPI 服务 } server { listen 443 ssl http2; server_name llm.yourcompany.com; # SSL 证书 ssl_certificate /etc/letsencrypt/live/llm.yourcompany.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/llm.yourcompany.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # 静态文件 location / { root /var/www/llm-app; try_files $uri $uri/ /index.html; add_header Cache-Control public, max-age31536000, immutable; } # API 代理 location /api/ { proxy_pass http://backend/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; # SSE 关键配置 proxy_read_timeout 300; # 5分钟 proxy_buffering off; # 关闭缓冲 proxy_cache off; # 禁用缓存 proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 心跳保活 proxy_socket_keepalive on; keepalive_timeout 300; } # HTTP 重定向 listen 80; return 301 https://$host$request_uri; }验证步骤sudo nginx -t检查语法sudo systemctl reload nginx重载配置浏览器访问https://llm.yourcompany.com打开 DevTools Network 面板筛选api/chat观察 Status 是否为pendingPreview 是否持续追加data: {...}\n\n执行curl -N https://llm.yourcompany.com/api/chat?qtest确认返回流式数据。注意proxy_buffering off是 SSE 的生死线。如果开启 bufferingNginx 会等整个响应结束才转发流式失效。这是win服务器 nginx 部署vue3项目中最常被忽略的配置。4. 常见问题与排查技巧实录从超时到乱码的实战手册4.1 “stream disconnected before completion” 的五层排查法这个错误在热搜词中高频出现但原因千差万别。我整理了一套从外到内的排查顺序层级检查项命令/操作预期结果修复方案DNS 网络层域名解析是否正常nslookup llm.yourcompany.com返回正确 IP检查 DNS 配置或 hosts 文件TLS 层HTTPS 证书是否有效openssl s_client -connect llm.yourcompany.com:443 -servername llm.yourcompany.com显示Verify return code: 0 (ok)重新申请 Lets Encrypt 证书Nginx 层proxy_read_timeout 是否生效sudo nginx -T | grep proxy_read_timeout输出proxy_read_timeout 300;修改 nginx.conf 并重载后端层服务端是否发送心跳curl -N http://localhost:8000/api/chat?qtest每30秒出现空行\n\n在后端 event_generator 中添加yield data: \n\n前端层EventSource 是否被拦截浏览器控制台执行new EventSource(https://llm.yourcompany.com/api/chat)触发onopen事件检查 CORS 配置确保Access-Control-Allow-Origin: *实操心得90% 的 case 是卡在 Nginx 层。曾有个客户反馈“本地 OK线上超时”我让他执行curl -v https://llm.yourcompany.com/api/chat?qtest发现响应头中有X-Proxy-Cache: HIT说明 CDN 缓存了 SSE 响应。解决方案是在 Nginx 中添加add_header X-Proxy-Cache MISS;强制不缓存。4.2 中文乱码与 emoji 显示异常LLM 输出中文时前端常出现 符号或 emoji 显示为方块。这不是字体问题而是编码协商失败服务端未声明 charsetSSE 响应头必须包含Content-Type: text/event-stream; charsetutf-8前端 TextDecoder 未指定 utf-8new TextDecoder(utf-8)而非new TextDecoder()Vite proxy 丢弃 charsetVite 的 proxy 默认不透传 charset需在 proxy 配置中显式设置。修复方法// vite.config.ts proxy: { /api: { target: http://localhost:8000, changeOrigin: true, ws: true, secure: false, // 关键透传 charset configure: (proxy, _options) { proxy.on(proxyRes, (proxyRes, req, res) { proxyRes.headers[content-type] text/event-stream; charsetutf-8; }); } } }后端 FastAPI 中return StreamingResponse( event_generator(), media_typetext/event-stream; charsetutf-8, // 显式声明 headers{...} )前端 Parser 中const decoder new TextDecoder(utf-8); // 显式指定 buffer decoder.decode(value);4.3 Vue3 响应式更新卡顿performance 面板诊断指南当流式输出变慢先打开 Chrome DevTools 的 Performance 面板录制 5 秒操作查看 Main 线程若看到大量patch、queueJob、trigger任务堆积说明响应式系统过载检查 Layout若Layout任务频繁且耗时高说明 DOM 更新过于频繁定位 JS Heap若内存持续增长可能是ref或computed泄漏。针对性优化避免在循环中触发响应式for (let i 0; i tokens.length; i) { text.value tokens[i] }改为text.value tokens.join()使用 shallowRef 减少依赖追踪对不需要深度响应式的大型对象如历史消息列表用shallowRef手动控制更新时机如前述requestIdleCallback方案或使用nextTick批量更新。4.4 跨域与鉴权SSE 的 Authorization 头绕过方案SSE 规范禁止前端设置Authorization头但生产环境必须鉴权。三种可行方案方案实现方式优点缺点适用场景URL Query 参数/api/chat?tokenxxx简单兼容性好token 暴露在 URL 和日志中内网环境或短期 tokenCookie 传递document.cookie auth_tokenxxx后端读取 Cookie符合 HTTP 规范安全需配置withCredentials: true跨域需credentials: include主流方案推荐Referer 验证后端检查Referer头是否为白名单域名无需前端改动Referer 可伪造安全性低仅作辅助验证推荐 Cookie 方案