SSE 流式输出的工程化拐点------为什么你需要一个 BFF 层
上一篇我们写了从零到一的流式输出 demo------在前端直接 fetch DeepSeek API,用 ReadableStream + buffer + SSE 解析把 token 一个一个渲染到页面上。代码跑通了,但随之而来的是三个灵魂拷问:
- API Key 直接暴露在前端代码里 ------F12 一看
.env.local里的 Key 一览无余,这真的安全吗?- 流式解析逻辑全堆在 Vue 组件里------buffer 截断恢复、SSE 行解析、JSON.parse 容错......这些底层的脏活累活混在 UI 组件中,代码还能维护吗?
- 跨域问题 ------前端跑在
localhost:5173,后端跑在localhost:3000,同源策略直接拦截你的 fetch 请求。这篇文章不是上一篇的"续集",而是一次工程化拐点 ------我们不再让前端裸调 LLM API,而是在中间架一层 BFF(Backend For Frontend),让服务端来解决安全、复杂度和跨域这三件事。如果你还没读过上一篇,建议先去理解了二进制流、SSE 协议和 buffer 机制再来,这篇会站在那个基础上往上走一层。
一、SSE ------ 服务器主动"推"给前端的单向通道
1.1 SSE 是什么
SSE(Server-Sent Events) ,直译是"服务器发送事件"。它是一种让服务器单向推送数据到浏览器的协议。
kotlin
WebSocket(全双工) SSE(单向推送)
┌──────────┐ ←→ ┌──────────┐ ┌──────────┐ ← ┌──────────┐
│ 浏览器 │ ←→ │ 服务器 │ │ 浏览器 │ ← │ 服务器 │
│ │ ←→ │ │ │ │ ← │ │
└──────────┘ ←→ └──────────┘ └──────────┘ ← └──────────┘
双方都能发消息 只有服务器能发消息
适用场景:聊天、协作编辑 适用场景:LLM 流式输出、股票推送、通知
协议:ws:// 升级 协议:普通 HTTP,Content-Type: text/event-stream
复杂度:高(心跳、重连、帧协议) 复杂度:低(data: 行 + 换行符 = 一条消息)
思考:为什么 LLM 流式输出天然适合 SSE 而不是 WebSocket?
因为 LLM 的交互模式天然是单向的------前端发一个请求(prompt),服务器持续推送生成的 token,前端在这个过程中不需要再向服务器发任何东西。WebSocket 的全双工能力在这里是多余的(overkill),而多出来的握手开销、心跳维护、帧协议解析反而成了累赘。
1.2 SSE 的数据格式(回顾)
上一篇我们已经深入拆解过 SSE 的格式,这里用一张图快速回顾:
css
HTTP Response:
Content-Type: text/event-stream
Transfer-Encoding: chunked
data: {"id":"xxx","choices":[{"delta":{"content":"从"}}]}
data: {"id":"xxx","choices":[{"delta":{"content":"前"}}]}
data: [DONE]
data:------ SSE 的事件数据前缀,后面紧跟 JSON 字符串\n\n------ 两个换行符分隔一条消息[DONE]------ 流结束的显式信号
二、BFF 层 ------ 为前端量身定制的"中间人"
2.1 什么是 BFF?
BFF = Backend For Frontend,翻译过来就是"为前端服务的后端"。它和传统后端(纯后端 Server)有本质上的区别:
arduino
传统后端 Server BFF 层
┌────────────────────────┐ ┌────────────────────────┐
│ Java / Go / Node │ │ Node(大前端工程师自维护)│
│ MVC 架构 │ │ 为前端接口定制化服务 │
│ CRUD 接口 │ │ 聚合/裁剪后端接口 │
│ RESTful 设计 │ │ 处理流式数据、编解码 │
│ 关注:稳定性、并发、安全 │ │ 关注:前端体验、开发效率 │
│ 由后端工程师维护 │ │ 由大前端工程师维护 │
└────────────────────────┘ └────────────────────────┘
README 笔记中提到的 "MVC 开发模式"(Model-View-Controller)是传统后端最经典的设计模式:
- Model(模型)------数据层,负责与数据库交互,定义数据结构
- View(视图)------展示层,在传统后端中通常是模板引擎渲染的 HTML 页面
- Controller(控制器)------逻辑层,接收请求、调用 Model、返回 View
在前后端分离之后,View 层被前端框架(Vue/React)接管,后端的 View 退化成了 JSON 序列化器。
2.2 为什么要加 BFF 层?------ 三个必须面对的问题
README 笔记中这样描述大前端工程师的困境:
"JS 前端,后端有很多需求,接口改一下,大前端工程师自己写常见的 node 服务,来达成自身的需求"
这句话背后的真实场景是:前端需要一个新的接口(比如把三个后端微服务的数据聚合成一个响应),但后端团队排期排到了下个月。大前端工程师的选择是------自己写一个 Node.js 服务放在中间。这就是 BFF 的起源。
在我们的流式输出场景中,BFF 层解决了三个具体问题:
vbnet
问题一:API Key 安全性
┌─────────────┐ fetch + API Key ┌─────────────┐
│ 浏览器前端 │ ────────────────────→ │ DeepSeek API │
│ │ │ │
│ F12 → 源码 │ API Key 暴露在客户端! │ │
│ → Key 泄露 │ │ │
└─────────────┘ └─────────────┘
加了 BFF 之后:
┌──────────┐ fetch (不带Key) ┌──────────┐ fetch + Key ┌──────────┐
│ 浏览器前端 │ ───────────────→ │ Node BFF │ ────────────→ │ DeepSeek │
│ │ │ (3000) │ │ API │
│ 看不到Key │ │ Key在服务端│ │ │
└──────────┘ └──────────┘ └──────────┘
问题二:流式解析复杂度
前端组件 200 行 → 50% 是流式解析逻辑 → 代码难以维护
把流式解析下沉到 BFF → 前端只管消费最终数据
问题三:跨域
localhost:5173 → localhost:3000 → 同源策略阻止
Vite proxy 配置 → 把 /api/* 转发到 BFF → 同源了
三、服务器端代码全拆解 ------ server.mjs
这是本次最重要的文件。README 笔记中定义了 Node 框架开发的四个步骤:安装框架 → 实例化 app → 监听端口 → 定义路由。我们对照着代码一步步看。
3.1 环境变量安全加载
js
import * as dotenv from 'dotenv';
// node 最常用且简单的开发框架
// vite 启动的http server , 服务于前端的 5173
// 5173 前端 -》 3000 BFF 后端 -》 deepseek
// 前端发送请求到BFF层 享受服务 web server 后端 私服状态 3000
// http server
逐行注释解析:
-
import * as dotenv from 'dotenv':dotenv是 Node.js 生态中最常用的环境变量管理库。它读取.env文件中的键值对,注入到process.env对象上。为什么不用 Vite 的import.meta.env?因为 server.mjs 不是由 Vite 编译的 ------它是独立的 Node.js 进程,通过node server.mjs直接运行,Vite 的环境变量注入机制对它完全不起作用。 -
// node 最常用且简单的开发框架:这里指 Express。Express 是 Node.js 历史上最成功的 Web 框架------不是因为它功能最强(Koa、Fastify 在某些方面更优),而是因为它最简单、生态最丰富。对于 BFF 层这种轻量场景,简单恰恰是最重要的。 -
// vite 启动的http server , 服务于前端的 5173:两个端口,两个进程的架构:arduino:5173 (Vite Dev Server) :3000 (Node BFF Server) ┌──────────────────┐ ┌──────────────────┐ │ 前端静态资源 │ │ API 路由 │ │ HMR WebSocket │ │ /stream │ │ Proxy 代理 │ │ LLM API 调用 │ │ Vue SFC 编译 │ │ 流式数据处理 │ └──────────────────┘ └──────────────────┘ ↑ ↑ npm run dev node server.mjs -
// 5173 前端 -》 3000 BFF 后端 -》 deepseek:这是完整的请求链路:scssVue组件 fetch('/api/stream?prompt=...') │ ▼ Vite Dev Server (:5173) 拦截 /api/* 请求 │ ▼ (proxy转发) BFF Express Server (:3000) 持有 API Key,转发请求 │ ▼ DeepSeek API (api.deepseek.com) 返回 SSE 流 -
// 前端发送请求到BFF层 享受服务 web server 后端 私服状态 3000:BFF 在这里扮演"服务提供者"的角色------它不像传统后端那样面向所有客户端,而是专门、只服务于这一个前端项目。所以注释中说它是"私服状态"------私有的、专属的服务。
js
import express from 'express';
// 让我们的key 更安全
// 纯前端, 右键看代码,
// fetch -> bff (apiKey)
dotenv.config({
path: ['.env.local', '.env']
});
-
import express from 'express':Express 框架的核心模块。它是一个最小化的 Web 框架,只提供路由、中间件、请求/响应处理这三个核心能力,其余全部由社区中间件生态补充。对于 BFF 这种"薄薄一层"的场景,Express 的极简哲学正好匹配。 -
// 让我们的key 更安全和// 纯前端, 右键看代码:这是上篇教程最核心的"未解决问题"。上一篇的 demo 中 API Key 写在.env.local文件中,虽然不在 Git 仓库里,但 Vite 会把以VITE_为前缀的环境变量编译进客户端 JS bundle 。任何人都可以通过浏览器 F12 → Sources 面板看到你的 API Key。把 Key 移到 BFF 层之后,它只存在于服务器进程的内存和.env文件中,浏览器端完全不可见。 -
// fetch -> bff (apiKey):新的请求模型。前端的 fetch 不再携带 API Key(它根本不需要知道 Key),而是发到 BFF,由 BFF 在服务端附加 Key 后转发给 DeepSeek API。 -
dotenv.config({ path: ['.env.local', '.env'] }):注意这里传了一个数组['.env.local', '.env']。这是 dotenv 的优先级加载 机制------先尝试加载.env.local(本地覆盖配置),如果不存在再加载.env(默认配置)。这和 Vite 的环境变量加载顺序是一致的。
lua
环境变量加载优先级:
.env.local → .env → process.env(系统环境变量)
(最高优先) (次之) (兜底)
Key 的存放位置对比:
上一篇(纯前端):.env.local → Vite 编译 → 打进 JS bundle → F12 可见 ❌
本篇(BFF架构):.env.local → dotenv 加载 → process.env → 仅服务端可见 ✅
3.2 创建服务器实例并定义路由
js
const app = express(); // server app
const port = 3000;
// 路由
app.get('/', (req, res) => {
// 不断地流式输出
res.send('Hello World!'); // 一次性发送
})
-
const app = express():express()工厂函数返回一个应用实例。这个app对象就是我们整个 BFF 服务的"骨架"------后续的所有路由、中间件都挂载在它上面。 -
const port = 3000:选择 3000 端口是 Node.js 社区的惯例(就像 Vite 默认用 5173,React 用 3000)。后面 vite.config.js 的 proxy target 也是localhost:3000,两边必须一致。 -
// 路由:路由(Route)是 Web 框架最核心的概念。它定义了"当用户访问某个 URL 时,应该执行什么处理函数"。Express 的路由格式是app.METHOD(PATH, HANDLER)。 -
app.get('/', ...):定义一个 GET 请求的路由,路径是/(根路径)。这是最简单的"Hello World"路由------在浏览器打开http://localhost:3000/就能看到Hello World!。它的存在不是业务需要,而是一个"健康检查"端点------确认 BFF 服务是否正常启动。 -
// 不断地流式输出:注意这个注释!它在暗示/路由其实可以是流式的。res.send()是一次性发送(相当于response.json()),但如果换成res.write()+res.end()的模式,就可以实现流式输出。后面/stream路由就会用到。 -
res.send('Hello World!'); // 一次性发送:res.send()是 Express 的响应发送方法,它会自动设置 Content-Type 并发送完整响应。注释强调"一次性"是与流式输出的"逐块发送"做对比。
3.3 流式输出的 BFF 路由 ------ 核心
js
// 流式输出的bff层 让前端调用
app.get('/stream', async (req, res) => {
// prompt req 解析
// fetch deepseek stream:true
// llm
// console.log(req.query.request);
// res.json({
// prompt: req.query.prompt,
// })
const { prompt } = req.query;
const endpoint =
'https://api.deepseek.com/v1/chat/completions';
逐行深度解析:
-
// 流式输出的bff层 让前端调用:这是 BFF 的核心理念------把复杂的流式处理逻辑从前端组件中剥离出来,下沉到服务端。前端只需要发一个 fetch 请求,不需要关心 ReadableStream、TextDecoder、buffer 截断恢复这些底层细节。 -
app.get('/stream', async (req, res) => { ... }):定义一个异步路由处理函数。注意是async------因为内部要await fetch()等网络操作。在 Express 4.x 中,异步路由的错误处理需要手动 try/catch(后面会看到)。 -
// prompt req 解析:req(request)对象包含了前端发来的所有请求信息。req.query是 Express 自动解析 URL query string 的结果------访问/stream?prompt=你好,则req.query.prompt === '你好'。注释说"req 解析"指的就是从请求对象中提取 prompt 参数。 -
// fetch deepseek stream:true:BFF 向 DeepSeek API 发请求时,参数stream: true保持不变------BFF 不改变与前端的通信模式,它只是把流从 LLM 服务器透传到前端。当然,也可以选择在这里做转换(比如把 SSE 流解析完再传,但那就失去了流式的实时性优势)。 -
// llm:这个注释标记了 BFF 的核心职责------它是 LLM API 的"代理人" 。前端不知道 LLM API 的地址、Key、请求格式,它只知道 BFF 的/stream接口。 -
// console.log(req.query.request):被注释掉的调试代码。开发过程中用于验证前端传过来的参数是否正确。也说明作者最初可能考虑过用request作为参数名,后来改成了prompt。 -
// res.json({ prompt: req.query.prompt }):同样是调试代码的"遗骸"。在测试阶段,BFF 收到请求后先回显 prompt 给前端,确认通信链路是通的,再接入真实的 LLM 调用。这是一种逐层验证的调试策略------先确认前端到 BFF 通了,再确认 BFF 到 LLM 通了,最后打通整条链路。 -
const { prompt } = req.query:ES6 解构赋值,从req.query对象中提取prompt属性。等价于const prompt = req.query.prompt。 -
const endpoint = 'https://api.deepseek.com/v1/chat/completions':注意 endpoint 路径中的/v1/。DeepSeek API 兼容 OpenAI 的接口格式------这就是为什么 endpoint 路径长得和 OpenAI 的/v1/chat/completions一模一样。这种兼容性设计让开发者可以用同一个 SDK 切换不同的模型提供商。
3.4 向 LLM 发起流式请求
js
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization':
`Bearer ${process.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
stream: true,
messages: [{ role: 'user', content: prompt}]
})
})
console.log(response.body) // ReadableStream
} catch(err) {
}
})
逐行解析:
-
try { ... } catch(err) { ... }:告诉我们的防御性编程。fetch可能因为网络问题、DNS 解析失败、超时等原因抛出异常。当前 catch 块是空的(说明代码还在开发中),但生产环境中必须处理:至少返回一个 500 状态码和错误信息给前端,让用户知道是"服务端出了问题"而不是"页面卡死了"。 -
await fetch(endpoint, { method: 'POST', headers, body }):BFF 层向 DeepSeek API 发起 POST 请求。这里用的是 Node.js 18+ 内置的fetchAPI------和浏览器中的 fetch 用法完全一样。这是 Node.js 社区近年来的标准化趋势 ------以前需要用node-fetch或axios等第三方库,现在原生就支持。BFF 用原生 fetch 而不是 axios,零依赖带来更快的启动速度和更少的安全漏洞面。 -
Authorization: \Bearer ${process.env.VITE_DEEPSEEK_API_KEY}``:这里是安全性提升的关键点。注意对比:javascript上一篇(纯前端): Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}` → import.meta.env → Vite 编译时替换 → 暴露在客户端 JS 中 本篇(BFF 架构): Authorization: `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}` → process.env → dotenv 运行时注入 → 仅存在于 Node.js 进程中process.env的值只存在于 Node.js 进程的内存中,不会出现在任何浏览器可见的代码里。同样是读取.env.local文件中的VITE_DEEPSEEK_API_KEY,但读取的方式从"编译时注入客户端代码"变成了"运行时注入服务端进程"------安全性有了质的飞跃。 -
model: 'deepseek-v4-flash':和上一篇保持一致。Flash 版本以更快的响应速度换取略微降低的输出质量,适合流式对话场景。 -
stream: true:告诉 DeepSeek API 以 SSE 格式逐 token 返回数据。 -
console.log(response.body) // ReadableStream:在 Node.js 中,response.body同样是一个ReadableStream<Uint8Array>。这和浏览器中的行为是完全一致的------这就是 Web Streams API 的"同构"特性:只要运行时支持这个标准,代码在浏览器和 Node.js 中的行为就一模一样。BFF 拿到这个 ReadableStream 后,可以:- 直接透传给前端------保持流式特性,前端实时看到 token
- 解析后再传------把 SSE 的脏活累活在服务端做完,前端直接拿解析好的文本
笔记中说的"抽象一下,放到大前端 BFF 层",指的就是第二种方案。
3.5 启动服务器
js
app.listen(port, () => {
console.log(`服务器在${port}端口启动了`);
})
// console.log(process.env.VITE_DEEPSEEK_API_KEY)
// llm 请求 bff 来
// 后端轻量的 就这一个文件 服务器端
// npm run dev vite 服务`
// node server.mjs 运行后端进程
console.log('我是一个在前端项目中藏着的BFF程序')
逐行注释解析:
-
app.listen(port, () => {...}):Express 的启动方法。listen会创建一个 HTTP 服务器并开始监听指定端口(3000)。第二个参数是一个回调函数------服务器成功启动后执行。这里打印了一条确认信息,"服务器在3000端口启动了"让开发者知道 BFF 层已就绪。 -
// console.log(process.env.VITE_DEEPSEEK_API_KEY):被注释掉的调试代码。在开发阶段用来确认 API Key 是否被 dotenv 正确加载。注意:生产环境绝对不能打印 API Key------日志可能被收集、存储、泄露。这条注释本身也是一个"安全意识"的提醒。 -
// llm 请求 bff 来:强调 BFF 的职责------所有 LLM 相关的请求都应该发给 BFF,而不是前端直接调 LLM API。 -
// 后端轻量的 就这一个文件 服务器端:这是 BFF 哲学的精髓------它应该是轻量的 。不需要像传统后端那样有 Controller、Service、Repository 分层,不需要复杂的依赖注入框架,不需要 ORM。一个文件、几个路由、一个核心逻辑,足够了。这种轻量性让前端工程师随手就能写、随手就能维护。 -
// npm run dev vite 服务和// node server.mjs 运行后端进程:强调两个进程是独立的、需要分别启动的:vbscript终端窗口 1: npm run dev → Vite Dev Server (:5173) 终端窗口 2: node server.mjs → BFF Express Server (:3000)在实际开发中可以用
concurrently或npm-run-all把两个命令合并一次启动。 -
console.log('我是一个在前端项目中藏着的BFF程序'):非常有标识性的一行。它揭示了 BFF 的一个有趣特征------BFF 代码物理上存在于前端项目中(和 Vue 组件、vite.config.js 在同一个仓库里),但逻辑上属于后端。这就是"大前端"的含义------既写前端 UI,也写为前端服务的后端。
四、前端代码 ------ App.vue 的"瘦身"
把流式处理逻辑下沉到 BFF 之后,前端代码会明显变瘦:
js
<script setup>
// 一次性的-> 流式 前端 -> bff -> llm 请求
fetch('/api/stream?prompt=hello')
.then(res => res.json())
.then(data => {
console.log(data);
})
</script>
// 一次性的-> 流式 前端 -> bff -> llm 请求:这行注释画出了整个架构的演进轨迹:
- "一次性的"------最早的方式,前端直接请求 LLM API,
stream: false,等全部生成完一次性返回 - "流式"------上一篇实现的方式,前端直接请求 LLM API,
stream: true,前端自己处理 ReadableStream 和 SSE 解析 - "前端 -> bff -> llm 请求"------本篇的方式,前端请求 BFF,BFF 请求 LLM,前端不需要知道 LLM API 的任何细节
当前 App.vue 中的 fetch 还在使用 .then() 链式调用(而非 async/await),说明这是一个过渡版本 ------先验证前端到 BFF 的通信链路是通的(所以只有 console.log),然后再接入完整的流式处理逻辑。
模板和样式部分 与上一篇保持一致------v-model="question" 双向绑定输入框,v-model="stream" 控制流式开关,{{ content }} 展示 AI 回答。CSS 中的文档流注释(/* 文档流 是页面布局的基础 从上到下,从左到右, 流式布局 */)在上一篇已经详细解析过,不再重复。
五、跨域与代理 ------ vite.config.js 的"中转站"
5.1 同源策略:浏览器的安全防线
README 笔记中这样描述跨域问题:
"只要域名、端口、协议(http/https)不同,fetch 等请求的时候,跨域,同源策略"
同源策略(Same-Origin Policy) 是浏览器最核心的安全机制。它规定:一个页面中的 JavaScript 只能访问与当前页面同源的 HTTP 资源。"源"(Origin)由三个部分定义:
makefile
Origin = 协议 + 域名 + 端口
http://localhost:5173 ← Vite Dev Server(前端页面在这里)
http://localhost:3000 ← BFF Express Server(API在这里)
端口不同(5173 ≠ 3000)→ 跨域 → 浏览器拦截 fetch 请求 → CORS 错误
如果没有同源策略,你在浏览器里打开 evil.com 这个网站,它就能用 fetch 请求 bank.com 的 API(携带你之前登录过的 cookie),然后窃取你的银行账户信息。
5.2 Vite Proxy:开发阶段的"合法中转"
README 笔记提出了解决方案:
"vite.config.js 解决方案:请求地址改成 /api/stream,/api 标志请求后端 api 接口,不跨域了,但是 502。vite 工程 proxy 配置,代理一下请求并转发出去"
这段笔记精确地描述了一个两步走的排查过程。我们对照 vite.config.js 代码来看:
js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
// 利用vite 来解决跨域
// 发送了一个请求, vite 就会介入
server: {
// 代理请求
proxy: {
// 前端想去后端请求 /api开始
'/api': {
// 跨域, 在浏览器环境下,同源策略的安全性
target: 'http://localhost:3000',
secure: false,
// /api/stream
rewrite: path => path.replace(/^\/api/, '')
}
}
}
})
逐行注释深度解析:
-
// 利用vite 来解决跨域:这不是 Vite 的"独门绝技"------几乎所有现代前端构建工具(Webpack Dev Server、Vite、Turbopack)都内置了 proxy 功能。原理是一样的:在 Dev Server 中启动一个轻量的 HTTP 代理服务器,拦截特定路径的请求并转发到真正的后端。 -
// 发送了一个请求, vite 就会介入:这描述了 Vite Dev Server 的中间人角色 。当前端代码执行fetch('/api/stream')时:- 请求首先到达 Vite Dev Server(:5173),因为前端页面是从这里加载的(同源)
- Vite 检查请求路径是否匹配 proxy 规则
- 匹配成功 → Vite 作为代理,把请求转发到 target 地址
- 后端返回的响应 → Vite 再转发回浏览器
-
// 代理请求:代理(Proxy)的本质上就是"中间人"------客户端不直接连接服务器,而是通过代理间接连接。这里的代理是正向代理(Forward Proxy),Vite 代表浏览器去请求后端。 -
// 前端想去后端请求 /api开始:/api前缀是一个约定大于配置 的设计。所有后端 API 请求都以/api开头,这样做的好处是:- 一眼就能识别哪些请求是 API 调用,哪些是静态资源请求
- proxy 规则只需要匹配
/api这一个前缀 - rewrite 规则也很简单------把
/api前缀去掉就行了
-
target: 'http://localhost:3000':代理的目标地址------也就是 BFF Express Server 的地址。注意是localhost不是127.0.0.1------虽然在大多数系统上它们指向同一个地址,但 Node.js 的 DNS 解析行为和操作系统有关,用localhost更通用。 -
// 跨域, 在浏览器环境下,同源策略的安全性:这句注释解释了为什么必须有 proxy------同源策略是浏览器强制执行的,只在浏览器环境下生效 。服务器之间的通信(BFF → DeepSeek API)不受同源策略限制。所以 proxy 的思路就是:把跨域请求变成同源请求------浏览器请求同源的 Vite Dev Server,Vite Dev Server 再以"服务器身份"去请求后端的 BFF。 -
secure: false:允许代理到 HTTPS 服务器时使用自签名证书。在本地开发环境中,后端通常是 HTTP 的(http://localhost:3000),这个选项其实用不到。但如果后端是 HTTPS 且证书不受信任,secure: false可以让代理正常工作。 -
// /api/stream:举了一个具体的例子------前端请求/api/stream,经过 proxy 和 rewrite 后变成后端的/stream。 -
rewrite: path => path.replace(/^\/api/, ''):路径重写规则。这是一个纯函数------输入原始路径,输出重写后的路径:bash输入:/api/stream?prompt=hello 正则:/^\/api/ 匹配路径开头的 "/api" 替换:空字符串 输出:/stream?prompt=hello ← 这就是发到 BFF (localhost:3000) 的实际路径为什么要去掉
/api?因为 BFF 的后端路由定义的是/stream,不是/api/stream。/api是给 Vite proxy 看的"标记",告诉 Vite"这个请求需要代理";真实的 BFF 不需要这个前缀。
bash
完整的请求链路(一步一帧):
1. Vue 组件执行
fetch('/api/stream?prompt=你好')
2. 浏览器网络栈
目标 URL: http://localhost:5173/api/stream?prompt=你好
与当前页面同源(host: localhost, port: 5173)
→ 同源策略:放行 ✅
3. Vite Dev Server (:5173)
收到 GET /api/stream?prompt=你好
检查 proxy 规则:路径以 /api 开头 → 匹配!
执行 rewrite: /api/stream → /stream
target: http://localhost:3000
→ 转发请求到 http://localhost:3000/stream?prompt=你好
4. BFF Express Server (:3000)
收到 GET /stream?prompt=你好
路由匹配:app.get('/stream', ...)
→ 执行处理函数
5. BFF → DeepSeek API
附加 Authorization Header (从 process.env 读取 Key)
POST https://api.deepseek.com/v1/chat/completions
body: { model: 'deepseek-v4-flash', stream: true, messages: [...] }
6. DeepSeek API → BFF → Vite → 浏览器
SSE 流式响应逐层回传
六、README 笔记中的 "502" 问题分析
README 中有一段关于调试过程的描述特别值得展开:
"请求地址改成 /api/stream,/api 标志请求后端 api 接口,不跨域了,但是 502"
502 Bad Gateway 是 HTTP 状态码之一,含义是"网关错误"------代理服务器(这里指 Vite Dev Server)收到了上游服务器(BFF Express Server)的无效响应。
在开发过程中遇到 502,排查思路是:
bash
前端 fetch('/api/stream')
│
▼
Vite Proxy (:5173) ← 收到请求
│
▼
尝试转发到 http://localhost:3000/stream
│
▼
BFF 启动了吗?
├── 没启动 → 连接被拒绝 → Vite 返回 502
└── 启动了 → 正常处理 → 返回响应
502 出现的常见原因:
- BFF 服务没启动 ------只开了
npm run dev(Vite),忘了开node server.mjs(BFF) - 端口冲突------3000 端口被其他进程占用
- rewrite 规则写错------比如路径重写后变成了 BFF 上不存在的路由
README 的笔记"不跨域了,但是 502"说明同源策略已经解决了 (Vite proxy 把 /api/* 变成了同源请求),问题出在 proxy 和 BFF 之间的连接上。这是正向的开发过程------先解决跨域,再解决连通性。
七、架构全景图:三条链路的比较
我们把上一篇(纯前端调 LLM)、这一篇当前代码(BFF 转发但未处理流)、以及 BFF 完整的理想方案,放在一张图中对比:
vbnet
┌────────────────────────────────────────────────────────────────────┐
│ 三种架构方案对比 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 方案一:纯前端直接调用(上篇) │
│ ┌──────────┐ fetch + Key + SSE解析 ┌──────────┐ │
│ │ Vue 前端 │ ──────────────────────→ │ DeepSeek │ │
│ │ :5173 │ ← SSE 二进制流 ──────── │ API │ │
│ └──────────┘ └──────────┘ │
│ 问题:Key暴露、前端臃肿 │
│ │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 方案二:BFF 转发(本篇当前代码) │
│ ┌──────────┐ fetch ┌──────────┐ fetch+Key ┌──────────┐ │
│ │ Vue 前端 │ ─────────→ │ Node BFF │ ──────────→ │ DeepSeek │ │
│ │ :5173 │ │ :3000 │ │ API │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ Key安全 ✅ 前端瘦身中(代码还在迭代) │
│ │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 方案三:BFF 完整方案(下一步目标) │
│ ┌──────────┐ fetch ┌──────────┐ fetch+Key ┌──────────┐ │
│ │ Vue 前端 │ ─────→ │ Node BFF │ ─────────→ │ DeepSeek │ │
│ │ │ │ │ │ API │ │
│ │ 只接收 │ ← ─ ─ │ 解析SSE │ ← ─ ─ ─ ─ │ │ │
│ │ 纯文本 │ SSE │ 编解码 │ 二进制流 │ │ │
│ │ │ │ buffer │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ Key安全 ✅ 前端极简 ✅ 流式处理在BFF ✅ │
│ │
└────────────────────────────────────────────────────────────────────┘
八、总结:从"能用"到"工程化"
上一篇我们实现了从零到一的流式输出------前端直接撬动 LLM API,用 ReadableStream + buffer + SSE 解析把 token 逐字渲染到页面上。它证明了这件事能做成。
这篇文章在"能做成"的基础上往"做好"迈了一步:
| 维度 | 上一篇(纯前端) | 本篇(引入 BFF) |
|---|---|---|
| API Key 安全 | ❌ 暴露在客户端 JS | ✅ 仅存在于服务端进程 |
| 前端复杂度 | ❌ 流式解析 + UI 混在一起 | 逐步简化中 |
| 跨域问题 | N/A(直连 LLM) | ✅ Vite proxy 完美解决 |
| 架构扩展性 | ❌ 每次加功能改前端 | ✅ BFF 统一处理,前端只关心数据 |
| 同构能力 | N/A | ✅ Node/浏览器共享 Web Streams API |
读完这两篇文章,你脑子里应该有一条清晰的演进路线:
- 第一阶段:前端直连 LLM → 理解二进制流、SSE、buffer 机制的底层原理
- 第二阶段:引入 BFF 层 → 把安全、复杂度和跨域问题收口到服务端
- 第三阶段(下一步):BFF 完整处理流式数据 → 前端只接收解析好的纯文本,代码干净得像调用普通 API
工程化不是一步到位的------它是一层一层叠上去的。 每一层解决一个具体的问题,每一层都不引入不必要的复杂度。先让代码"能跑",再让代码"跑得优雅"。
本文与上篇《Stream is All You Need------从二进制字节到 Agent 时代》构成完整的流式输出系列。上篇侧重底层原理(二进制编码、SSE 协议、buffer 截断恢复、Vue 响应式渲染),本篇侧重工程化架构(BFF 层设计、API Key 安全、Vite Proxy、跨域解决方案)。两篇合在一起,覆盖了从底层字节到上层架构的完整知识链路。