前端直连LLM必踩2大致命坑!BFF中转流式SSE完整方案,一套代码解决密钥泄露+分片解析报错
前言做Vue AI对话页面90%新手会踩两个线上致命大坑前端直接请求DeepSeek接口API Key打包进前端抓包/查看源码直接泄露被盗刷高额账单纯前端手写SSE流式解析TCP网络分包截断JSON疯狂报parse错误、文字丢失、乱码很多教程只给前端极简demo完全不提安全风险也没有完整BFF中转层落地代码。本文带你搭建Vite Vue3 Express BFF后端完整流式对话架构读完你能学到BFF层是什么、为什么AI项目必须加中转层完整三层调用链路Vue前端 → Node BFF → DeepSeek大模型Express转发SSE流式响应标准写法处理二进制流透传优化版前端流式解析代码极简buffer容错逻辑Vite代理跨域配置解决前后端端口跨域问题全套可复制运行代码 高频踩坑清单直接上线使用一、先搞懂BFF中转层解决两大核心痛点1.1 安全痛点前端不能存放大模型API密钥Vite中VITE_开头环境变量会打包进前端产物任何人打开开发者工具、抓网络请求都能拿到完整Key存在被盗刷风险。BFF解决方案密钥只存在Node服务端.env前端永远看不到真实鉴权凭证。1.2 工程化痛点统一接口、降低前端复杂度没有BFF前端要处理二进制解码、分片缓存、异常捕获、接口切换逻辑代码臃肿拥有BFF统一封装LLM调用逻辑前端只需要请求本地/api/stream后续切换大模型、加限流、过滤敏感词只改后端前端零改动1.3 完整三层调用链路Vue页面(5173端口) → Vite代理转发 → Express BFF(3000端口) → DeepSeek官方API二、项目环境初始化2.1 安装依赖# Vue前端依赖npminstallvue# BFF后端依赖npminstallexpress dotenv2.2 环境变量区分前后端项目根目录.env.localBFF后端读取不会暴露前端DEEPSEEK_API_KEYsk-你的DeepSeek密钥前端无需存放任何LLM密钥彻底规避泄露风险2.3 vite.config.js 代理跨域配置关键import{defineConfig}fromviteimportvuefromvitejs/plugin-vueexportdefaultdefineConfig({plugins:[vue()],server:{proxy:{// 匹配前端/api开头请求转发到3000端口BFF服务/api:{target:http://127.0.0.1:3000,changeOrigin:true,rewrite:pathpath.replace(/^\/api/,)}}}})前端请求/api/streamVite自动转发为http://127.0.0.1:3000/stream消除跨域报错。三、完整BFF后端代码 server.jsExpress流式转发import*asdotenvfromdotenv;importexpressfromexpress;dotenv.config({path:[.env,.env.local]});constappexpress();constport3000;// 解析url参数app.use(express.urlencoded({extended:true}));// 基础测试路由app.get(/,(req,res){res.send(BFF服务运行正常访问 /api/stream 发起AI对话);});// SSE流式中转核心接口app.get(/stream,async(req,res){const{prompt}req.query;if(!prompt)returnres.status(400).send(prompt参数不能为空);constendpointhttps://api.deepseek.com/chat/completions;constapiKeyprocess.env.DEEPSEEK_API_KEY;constmodeldeepseek-v4-flash;// SSE标准响应头必须设置否则无法持续推送流res.setHeader(Content-Type,text/event-stream);res.setHeader(Cache-Control,no-cache);res.setHeader(Connection,keep-alive);res.flushHeaders();try{// 请求DeepSeek流式接口constllmResawaitfetch(endpoint,{method:POST,headers:{Authorization:Bearer${apiKey},Content-Type:application/json,},body:JSON.stringify({model,stream:true,messages:[{role:user,content:prompt}],}),});// 把大模型返回的二进制流直接透传给前端llmRes.body.pipe(res);// 客户端关闭连接时销毁请求避免资源占用req.on(close,(){llmRes.body.destroy();res.end();});}catch(error){console.error(LLM接口请求失败,error);res.write(data: {error:服务异常}\n\n);res.write(data: [DONE]\n\n);res.end();}});app.listen(port,(){console.log(BFF中转服务启动成功端口${port});});后端核心逻辑说明SSE响应头三件套告知浏览器保持长连接、禁用缓存持续接收数据流pipe(res)二进制流直接透传无需后端解析JSON性能更高密钥存放服务端环境变量前端完全无法获取监听客户端断开事件主动销毁流防止后台无效请求堆积四、Vue3前端完整代码 App.vue优化版流式解析script setup import { ref } from vue // 页面响应式状态 const question ref(讲一个中国龙的故事) const stream ref(true) // 是否开启打字机流式输出 const content ref() // 存储完整回答用于关闭流式时一次性渲染 let accumulatedText // 核心请求函数 async function update() { if (!question.value.trim()) return content.value accumulatedText // 请求本地BFF代理接口不会跨域、无密钥泄露 const url /api/stream?prompt${encodeURIComponent(question.value)} const response await fetch(url) const reader response.body.getReader() const decoder new TextDecoder(utf-8, { stream: true }) 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下次拼接 buffer lines.pop() || // 逐行解析有效data报文 for (const line of lines) { if (!line.startsWith(data: )) continue const dataStr line.slice(6) // 流结束标识直接跳过 if (dataStr [DONE]) continue try { const json JSON.parse(dataStr) // 提取增量文字 const deltaText json.choices?.[0]?.delta?.content || accumulatedText deltaText // 流式开启则实时更新页面打字机效果 if (stream.value) { content.value accumulatedText } } catch (err) { // JSON解析失败直接忽略残缺片段已存入buffer continue } } } // 关闭流式全部接收完成后一次性渲染全文 if (!stream.value) { content.value accumulatedText } } /script template div classcontainer !-- 提问输入区 -- div label输入提问/label input classinput v-modelquestion placeholder输入你的问题 / button clickupdate提交AI问答/button /div !-- 流式开关 AI回答展示 -- div classoutput div stylemargin-bottom:8px; label开启Streaming流式打字机/label input typecheckbox v-modelstream / /div div stylewhite-space: pre-wrap;{{ content }}/div /div /div /template style scoped .container { display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; height: 100vh; font-size: 0.85rem; padding: 20px; } .input { width: 300px; padding: 4px 8px; } .output { margin-top: 12px; min-height: 300px; width: 100%; text-align: left; line-height: 1.7; } button { padding: 4px 12px; margin-left: 8px; cursor: pointer; } /style前端流式优化点对比纯前端直连方案请求地址统一代理只访问本地/api/stream不暴露第三方LLM接口极简buffer容错分割后把最后一行残缺报文放回buffer逻辑更简洁稳定TextDecoder({stream:true})解决跨分片中文乱码问题分离accumulatedText完整缓存支持一键切换流式/一次性渲染增加输入空值拦截避免无效请求五、完整运行步骤新建.env.local填入DeepSeek密钥终端启动BFF后端服务nodeserver.js新开终端启动Vite前端npmrun dev打开页面输入提问勾选/取消Streaming测试两种输出模式六、开发高频踩坑清单必看坑1前端直接请求LLM接口密钥泄露被盗刷现象上线后账单暴涨开发者工具Network面板可见完整Authorization密钥解决方案全部请求走BFF中转密钥仅存Node服务端环境变量坑2BFF未设置SSE响应头流式一次性返回现象页面等待全部生成后才展示文字无逐字打字效果修复必须添加text/event-stream、no-cache、keep-alive三个响应头坑3Vite代理502跨域报错原因BFF后端未启动、端口不匹配、代理rewrite路径错误检查确认BFF运行在3000端口vite代理配置正确坑4TCP分包截断JSON控制台频繁parse报错根源网络分片把一条data: JSON拆成两段解决代码中buffer缓存末尾残缺行下一轮循环拼接完整再解析坑5中文跨分片出现乱码问号修复new TextDecoder(utf-8, { stream: true })开启流式解码坑6切换流式开关文字错乱、重复叠加优化每次请求重置accumulatedText完整缓存分开实时渲染与最终渲染逻辑七、两种架构方案对比方案前端直连LLMBFF中转架构本文方案密钥安全极低极易泄露极高密钥仅存服务端接口维护切换模型需修改全部前端代码仅修改后端前端无感知前端复杂度高需处理鉴权、流、异常低仅处理本地流式解析扩展能力无法加限流、敏感词过滤后端统一加权限、限流、校验线上风险盗刷、接口暴露安全可控生产推荐八、项目扩展优化方向增加请求限流后端引入express-rate-limit防止恶意刷接口POST传参替代URL拼接避免长prompt参数超长改用body传递提问中断生成搭配AbortController实现停止AI输出功能多轮对话扩展BFF接口接收完整messages数组支持连续聊天异常统一处理后端捕获401/429/500错误标准化错误报文返回前端Markdown渲染流接收完成后使用marked解析富文本格式九、总结生产环境绝对禁止前端直连大模型APIBFF中转层是安全底线杜绝密钥泄露盗刷SSE流式输出核心依靠二进制长连接buffer缓存是解决TCP分片截断的必备容错逻辑Vite代理转发解决前后端跨域Express通过pipe透传LLM二进制流性能最优整套架构分层清晰前端负责页面交互、BFF负责安全中转、大模型负责文本生成可直接落地商用AI对话产品。