你有没有好奇过,ChatGPT、DeepSeek 这些大模型的回答,为什么不是一次性返回,而是一个字一个字地"蹦"出来?这背后藏着前端、后端、甚至一个"中间层"的精妙协作。今天我们就来拆解它。
一、先抛问题:流式输出到底难在哪?
如果你写过一个普通的接口请求,大概是这样:
js
fetch('/api/data').then(res => res.json()).then(data => console.log(data))
一次性返回,简单粗暴。但大模型的回答动辄几百上千字,等它全部生成完再返回,用户早就跑了。
于是有了流式输出(Streaming):模型生成一个 token,就推一个 token 给前端,边生成边显示。
可问题来了------前端处理流式输出并不简单:
- 二进制流对象(
ReadableStream)要解码 data:字段要解析- 各种异常、断流要处理
前端业务已经够复杂了,能不能把这些脏活累活剥离出去?
二、BFF 层:前端的"私人助理"
2.1 什么是 BFF?
前端(Vue/React) ------> BFF(Node) ------> Backend(Java/Go)
BFF = Backend For Frontend,为前端服务的后端。
它不是纯后端,也不是纯前端,而是夹在中间的一层 Node 服务,专门替前端"挡子弹":
- 接口改字段?BFF 改一下就行,不用等后端排期
- 流式解码复杂?BFF 处理完,吐给前端干净的数据
- API Key 不想暴露?BFF 帮你藏起来
2.2 为什么 BFF 特别适合"大前端工程师"?
传统分工里,前端写 Vue/React,后端写 Java/Go。但现实是------前端有太多细碎的需求(改个字段、聚合几个接口、做个流式解析),等后端排期太慢。
Node 的出现让前端工程师能自己写轻量后端:一个 server.mjs 文件就是一个服务。于是"大前端"的概念诞生了------前端 + BFF,一个人全包。
三、Node 框架开发:三行代码起一个服务
BFF 层用 Node 怎么写?最经典的就是 Express:
js
import express from 'express';
const app = express(); // 实例化一个 server app
const port = 3000;
app.get('/', (req, res) => {
res.send('Hello World!'); // 一次性发送
});
app.listen(port, () => {
console.log(`服务器在${port}端口启动了`);
});
三步走:引入框架 → 实例化 app → 监听端口。就这么简单,一个 HTTP 服务就起来了。
四、流式输出的 BFF 封装
前端不想碰 ReadableStream 的解码细节?那就让 BFF 来。前端只需要:
js
fetch('/api/stream?prompt=hello')
.then(res => res.json())
.then(data => console.log(data));
干净、清爽。复杂的流式逻辑全在 BFF 层的 /stream 路由里:
js
app.get('/stream', async (req, res) => {
const { prompt } = req.query;
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 }]
})
});
// response.body 是一个 ReadableStream
// 在 BFF 层解码、解析 data: 字段、再吐给前端
});
为什么要放在 BFF 层做?
| 原因 | 说明 |
|---|---|
| 降低前端复杂度 | 二进制流解码、data: 解析全在 BFF 完成,前端只管拿干净数据 |
| API Key 安全 | 纯前端放 key,右键看源码就泄露;放 BFF,前端永远看不到 |
| 职责清晰 | 前端专注 UI,BFF 专注数据加工,各司其职 |
五、跨域问题:同源策略挡了路
BFF 写好了,前端 fetch('/api/stream') 一请求------跨域报错。
5.1 什么是跨域?
只要域名、端口、协议 有一个不同,浏览器就会触发同源策略保护机制,拦截请求。
- 前端 Vite:
http://localhost:5173 - BFF Express:
http://localhost:3000
端口不同 → 跨域。
5.2 Vite Proxy:让请求"假装"同源
既然跨域是浏览器机制,那让请求不出浏览器 不就行了?Vite 的 proxy 配置就是这个思路:
js
// vite.config.js
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000', // 转发到 BFF
secure: false,
rewrite: path => path.replace(/^\/api/, '') // /api/stream → /stream
}
}
}
});
请求流程变成这样:
bash
前端 fetch('/api/stream')
↓
Vite 拦截(同源,5173,不跨域)
↓
proxy 去掉 /api 前缀,转发到 :3000/stream
↓
BFF 的 /stream 路由处理
浏览器看到的始终是 5173,同源,不触发跨域保护。 Vite 在背后悄悄帮你把请求转发到了 3000 端口。
六、完整请求链路一览
javascript
┌──────────┐ fetch('/api/stream') ┌──────────┐ fetch + stream ┌──────────┐
│ Vue 前端 │ ──────────────────────▶ │ Vite Proxy│ ─────────────────▶ │ BFF │ ────────▶ DeepSeek
│ :5173 │ (同源,不跨域) │ (5173 内) │ (:3000/stream) │ Express │
└──────────┘ └──────────┘ └──────────┘
▲ │
│ BFF 解码 ReadableStream,吐干净 JSON │
└──────────────────────────────────────────────────────────────────────┘
三层职责清晰:
- 前端:只管发请求、渲染 UI
- Vite Proxy:解决跨域,转发请求
- BFF:藏 Key、调 LLM、解码流、返回干净数据
七、关键概念速查表
| 概念 | 一句话理解 |
|---|---|
| SSE | Server Sent Event,服务器单向推送事件,流式输出的底层机制 |
| BFF | Backend For Frontend,替前端挡子弹的 Node 中间层 |
| 流式输出 | 边生成边返回,用户不用等全部生成完 |
| 同源策略 | 浏览器安全机制,域名/端口/协议任一不同即拦截 |
| Vite Proxy | 让请求"假装同源",在背后转发到真实后端 |
| ReadableStream | 二进制流对象,需要解码后才能读取 |
八、总结
大模型的流式输出,表面上是"一个字一个字蹦",背后却是一条精心设计的链路:
- 流式输出解决"用户等待"问题
- BFF 层解决"前端复杂度"问题
- Vite Proxy解决"跨域"问题
- 环境变量解决"API Key 安全"问题
每一层都在替下一层挡麻烦,这就是工程化的魅力。