SSE 流式输出的工程化拐点——为什么你需要一个 BFF 层

SSE 流式输出的工程化拐点------为什么你需要一个 BFF 层

上一篇我们写了从零到一的流式输出 demo------在前端直接 fetch DeepSeek API,用 ReadableStream + buffer + SSE 解析把 token 一个一个渲染到页面上。代码跑通了,但随之而来的是三个灵魂拷问:

  1. API Key 直接暴露在前端代码里 ------F12 一看 .env.local 里的 Key 一览无余,这真的安全吗?
  2. 流式解析逻辑全堆在 Vue 组件里------buffer 截断恢复、SSE 行解析、JSON.parse 容错......这些底层的脏活累活混在 UI 组件中,代码还能维护吗?
  3. 跨域问题 ------前端跑在 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:这是完整的请求链路:

    scss 复制代码
    Vue组件 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+ 内置的 fetch API------和浏览器中的 fetch 用法完全一样。这是 Node.js 社区近年来的标准化趋势 ------以前需要用 node-fetchaxios 等第三方库,现在原生就支持。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 后,可以:

    1. 直接透传给前端------保持流式特性,前端实时看到 token
    2. 解析后再传------把 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)

    在实际开发中可以用 concurrentlynpm-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') 时:

    1. 请求首先到达 Vite Dev Server(:5173),因为前端页面是从这里加载的(同源)
    2. Vite 检查请求路径是否匹配 proxy 规则
    3. 匹配成功 → Vite 作为代理,把请求转发到 target 地址
    4. 后端返回的响应 → 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 出现的常见原因:

  1. BFF 服务没启动 ------只开了 npm run dev(Vite),忘了开 node server.mjs(BFF)
  2. 端口冲突------3000 端口被其他进程占用
  3. 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

读完这两篇文章,你脑子里应该有一条清晰的演进路线:

  1. 第一阶段:前端直连 LLM → 理解二进制流、SSE、buffer 机制的底层原理
  2. 第二阶段:引入 BFF 层 → 把安全、复杂度和跨域问题收口到服务端
  3. 第三阶段(下一步):BFF 完整处理流式数据 → 前端只接收解析好的纯文本,代码干净得像调用普通 API

工程化不是一步到位的------它是一层一层叠上去的。 每一层解决一个具体的问题,每一层都不引入不必要的复杂度。先让代码"能跑",再让代码"跑得优雅"。


本文与上篇《Stream is All You Need------从二进制字节到 Agent 时代》构成完整的流式输出系列。上篇侧重底层原理(二进制编码、SSE 协议、buffer 截断恢复、Vue 响应式渲染),本篇侧重工程化架构(BFF 层设计、API Key 安全、Vite Proxy、跨域解决方案)。两篇合在一起,覆盖了从底层字节到上层架构的完整知识链路。

相关推荐
科技新芯1 小时前
2026 WAIC首日:千问首款AI智能体耳机亮相
人工智能
不简说1 小时前
JS 代码技巧 vol.4 — 10 个异步并发控制,Promise.all 这帮兄弟的踩坑实录
前端·javascript·面试
依然范特东1 小时前
动手学深度学习笔记--训练注意、梯度问题
人工智能·笔记·深度学习
财复视界2 小时前
科源制药医药主业筑基,脑机接口与具身智能打开成长空间
大数据·人工智能·物联网
CoderIsArt2 小时前
切片辅助超推理(SAHI):一种用于小目标检测的通用框架
人工智能·目标检测·计算机视觉
沫儿笙2 小时前
安川弧焊机器人混合气动态调节技术
人工智能·机器人
Larcher2 小时前
当 AI 学会写代码:一个自动生成 React 项目的 Agent 实战
人工智能·设计模式·程序员
m0_466525292 小时前
东软医疗光子 CT 实现国产高端影像从跟跑到领跑
人工智能
智能体与具身智能2 小时前
TVA 本质内涵与核心特征(系列)
人工智能·python·智能体视觉