当大模型流式输出遇上BFF架构前端终于可以“躺平”了前言一个前端开发的“非分之想”“咱们要加一个AI聊天功能而且要流式输出就像ChatGPT那样打字机效果。”产品经理轻描淡写地扔过来一句话我却盯着屏幕上DeepSeek的API文档陷入了沉思。直接在前端调接口API Key赤裸裸地暴露在浏览器里等于把保险柜密码贴在门上。用fetch硬接流式数据那意味着我要在前端手动处理ReadableStream、解码二进制、解析data:前缀、拼接碎片化文本……光是想想这些血压就上来了。更麻烦的是产品经理要求的不只是一个“能跑的Demo”而是一个可维护、可扩展的企业级功能。如果将来要换模型、要加鉴权、要做日志监控难道每次都要改前端代码、重新打包发布吗这不合理。我理想中的方案是前端只用关心UI和用户交互所有跟“流”相关的脏活累活全部交给一个中间层去搞定。这个中间层能藏住密钥、能转发流式数据、还能随时扩展新能力——说白了就是给前端配一个“御用翻译官”。于是BFFBackend For Frontend走进了我的视野。当BFF碰上SSEServer-Sent Events所有问题都迎刃而解。核心思路BFF负责“翻译”LLM的流式方言前端只需听懂“标准普通话”。一、BFF大前端的“御用翻译官”1.1 什么是BFFBFF全称Backend For Frontend翻译过来就是“为前端服务的后端”。常规架构里前端直接调后端的Java/Go接口但有时候后端接口设计是为通用业务服务的不太“体贴”前端的特定场景。比如后端返回的数据结构字段太多前端用不上需要聚合多个接口的数据需要处理一些特殊协议比如WebSocket、SSE这时候BFF层就登场了前端(Vue/React) → Node(BFF) → 后端(Java/Go/LLM)BFF层由前端团队维护前端需要什么数据格式BFF就给什么格式。这就像前端在Java大后端面前配了个“自己人”好说话。1.2 为什么BFF适合做流式输出中转流式输出SSE/Streaming对前端来说有几个痛点二进制流对象需要解码数据格式需要解析比如SSE的data:前缀错误处理需要兼容多种异常情况连接状态需要管理把这些复杂逻辑塞到前端既增加了打包体积又让代码难以维护。把脏活累活交给BFF前端只需要一个fetch拿到处理好的数据就行。这就是BFF的“翻译官”价值。BFF不是多此一举而是把复杂性留在服务端把简单留给前端。二、实战从0搭一个BFF流式服务我们先用Vite创建一个Vue项目然后在项目根目录下建一个server.mjs这就是我们的BFF服务器。2.1 初始化BFF服务// server.mjs import * as dotenv from dotenv; import express from express; // 加载环境变量API Key放这里 dotenv.config({ path: [.env.local, .env] }); const app express(); const port 3000; // 健康检查路由 app.get(/, (req, res) { res.send(BFF Server is running ); }); app.listen(port, () { console.log(BFF 服务已启动: http://localhost:${port}); });这段代码在做什么dotenv.config()加载.env或.env.local中的环境变量我们会在里面存放VITE_DEEPSEEK_API_KEY这样API Key就只存在于服务端前端无法窥探。express最轻量的Node框架用来快速搭建HTTP服务。app.get(/)定义一个健康检查路由方便验证服务是否正常。app.listen(3000)BFF独立监听3000端口与前端Vite服务默认5173完全隔离。注意server.mjs和前端项目在同一个代码仓库但运行时是两个独立进程。前端工程化工具Vite负责编译和热更新BFF负责业务逻辑转发。两者各司其职互不干扰。启动方式# 终端1启动BFF node server.mjs # 终端2启动前端 npm run dev2.2 核心流式转发路由接下来是重头戏——/stream路由。它要做三件事接收前端的prompt参数带着API Key去请求DeepSeek的流式接口把流式数据实时透传给前端我们按照用户给定的代码来搭建// server.mjs 继续添加 app.get(/stream, async (req, res) { // prompt req 解析 // fetch deepseek stream: true // llm // console.log(req.query.prompt); // res.json({ // prompt: req.query.prompt, // }) const { prompt } req.query; const endpoint https://api.deepseek.com/v1/chat/completions; try { const response await fetch(endpoint, { method: POST, headers: { Authorization: Bearer ${process.env.VITE_DEEPSEEK_API_KEY}, Content-Type: application/json, }, body: JSON.stringify({ model: deepseek-v4-flash, messages: [ { role: user, content: prompt, } ], stream: true, }), }) console.log(response.body); // ReadableStream } catch (err) { console.error(fetch 失败:, err.message); } })逐段解析接收参数const { prompt } req.query从URL查询字符串中获取用户输入。前端请求/stream?prompt你好这里就能拿到prompt你好。构造请求DeepSeekendpointDeepSeek的聊天补全API地址。headers通过Authorization传递API Key从环境变量读取前端永远拿不到这个Key保证了安全性。body设置model为deepseek-v4-flashmessages为用户消息最关键的是stream: true——这告诉DeepSeek以流式方式返回数据。发起fetch请求await fetch(endpoint, {...})会向DeepSeek发送HTTP请求。由于stream: true服务器不会一次性返回完整JSON而是分块发送SSE格式的数据流。拿到响应流response.body是一个ReadableStream对象它代表了从DeepSeek接收到的原始字节流。这里我们只是用console.log打印了它实际上我们还需要读取这个流、解析SSE格式、并将数据逐块写回给前端。目前这段代码只是“打通了”BFF到LLM的连接但还没有把数据转发给前端。错误捕获如果网络超时、API Key无效或DeepSeek服务异常fetch会抛出错误我们捕获后打印日志。但这里也没有把错误信息返回给前端前端会一直等待。流式输出的本质是“边生产边消费”BFF就是那个传送带上的分拣员。三、跨域Vite代理一把梭3.1 跨域是怎么产生的浏览器有一个同源策略协议、域名、端口三个完全相同才算“同源”否则任何fetch或XMLHttpRequest请求都会触发跨域限制。我们的场景前端服务http://localhost:5173Vite默认BFF服务http://localhost:3000Express端口不同5173 vs 3000所以前端直接请求http://localhost:3000/stream会被浏览器拦截报CORS错误。为什么浏览器要这么“多管闲事”为了防止恶意网站窃取用户数据。如果任意网站都能随意请求其他域名的接口那你的银行账户可能就危险了。3.2 解决跨域的常见方式方式一CORS跨域资源共享在BFF服务端设置响应头res.setHeader(Access-Control-Allow-Origin, *);简单粗暴但生产环境如果BFF暴露在外网允许所有来源存在风险。如果允许携带凭证Cookie还需要设置Access-Control-Allow-Credentials: true且不能使用*必须指定具体域名同时浏览器会发送预检请求OPTIONS增加复杂度。方式二JSONP只支持GET请求且存在安全风险现在基本弃用。方式三反向代理在开发环境用Vite代理生产环境用Nginx。这是最推荐的方式因为它对前端完全透明且不需要后端额外配置CORS。3.3 Vite代理的原理Vite开发服务器内置了一个HTTP代理功能它的工作方式类似于Nginx的反向代理前端请求一个同源的地址比如/api/stream相对路径默认同源5173。Vite开发服务器接收到这个请求后发现/api前缀匹配了代理规则。Vite作为“中间人”代替前端向真正的目标服务器http://localhost:3000发起请求。拿到响应后Vite再原样返回给前端。对浏览器来说它始终在和localhost:5173通信不存在跨域。跨域被“消化”在了服务器之间而服务器之间的请求不受同源策略限制。3.4 代理配置详解// vite.config.js import { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:3000, // BFF服务地址 changeOrigin: true, rewrite: (path) path.replace(/^/api/, ), // 去掉/api前缀 } } } });每个配置项的含义/api匹配所有以/api开头的请求路径例如/api/stream、/api/chat。target代理转发的目标地址这里指向BFF的3000端口。changeOrigin: true修改请求头中的Origin字段为目标地址的Origin。有些后端服务会校验Origin开启这个选项可以避免被拒绝。如果不开启Origin仍然是localhost:5173BFF可能因为不信任该来源而拒绝。rewrite路径重写函数。我们想把/api/stream转发到BFF的/stream因为BFF没有/api前缀所以用正则去掉/api。工作流程示例前端请求: /api/stream?prompthello ↓ (Vite拦截) 代理转发: http://localhost:3000/stream?prompthello ↓ (BFF响应) BFF返回: 流式数据 ↓ (Vite透传) 前端接收: 同源数据 ✅3.5 为什么不直接在BFF配置CORS你可能会想我在BFF里加一行res.setHeader(Access-Control-Allow-Origin, *)不就解决了当然可以但有以下弊端生产环境风险生产环境中BFF可能部署在独立域名下如果直接允许所有来源*存在安全隐患容易遭受CSRF攻击。需要额外配置如果BFF要支持携带Cookie等凭证CORS配置会更复杂需要指定具体域名、处理预检请求等代码臃肿。多环境不一致开发环境加CORS生产环境可能又需要去掉容易出错。相比之下Vite代理只在开发环境生效生产环境我们通常会使用Nginx或云服务商的网关来做反向代理策略统一。开发环境用Vite代理生产环境用Nginx两种环境保持一致的转发逻辑减少心智负担。金句Vite代理就像给前端配了个“通关文牒”跨域不存在的。3.6 生产环境怎么办生产环境部署时你的前端静态文件打包后的dist通常由Nginx托管BFF服务可能运行在另一个端口或另一个容器。此时在Nginx中配置反向代理location /api/ { proxy_pass http://localhost:3000/; # 注意末尾的/会去掉前缀 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 针对SSE流式需要禁用缓冲 proxy_buffering off; proxy_cache off; }关键点proxy_pass末尾的/会去掉匹配的前缀/api/所以/api/stream会被转发到/stream。proxy_buffering off非常重要对于流式输出Nginx默认会缓冲响应等到完整接收后再发给客户端这会破坏SSE的实时性。关闭缓冲后数据会立即透传给前端。这样开发和生产环境的请求路径都是/api/stream前端代码无需任何修改。最后BFF不是银弹但它是把好刀BFF不是万能的它增加了部署成本和维护复杂度。但在以下场景它物超所值✅ 需要对接多个第三方API✅ 需要处理特殊协议SSE/WebSocket✅ 需要隐藏敏感信息API Key✅ 前端需要定制化数据格式如果你的项目符合以上任意一条BFF值得拥有。回到开头的故事用了BFF之后前端代码从200行精简到30行产品经理满意后端同事也不用为了我的小需求改接口。