昨天写 BFF 对接 DeepSeek 流式接口,fetch 发出去请求,response 也拿到了,200 OK,心想稳了。随手 console.log(response.body) 想看看返回啥,控制台蹦出来这么个东西:

我盯着看了半分钟。locked: false,啥意思?没上锁?那数据呢?我那么大一段流式返回的数据呢?
先聊聊这个藏在前端项目里的 BFF
说正事之前,先扯两句为什么要搞这么个 BFF 层。
可能有人会问,直接前端调大模型接口不行吗?行啊,你把 API Key 写前端代码里,F12 一扒就拿走,到时候账单炸了别哭。而且前端直接处理流,要分包、要解析 data: 前缀、要处理断线重连,逻辑堆在组件里乱糟糟的,调试起来也麻烦。
什么是 BFF?说人话就是「为前端服务的后端」。以前前端要改个接口字段、加个参数,得找后端排期,等评审,等上线。现在大前端自己用 Node 写个薄薄的中间层,藏在前端项目里,就干转发、聚合、脱敏这点事,自己就能搞定,不用看人脸色。
我这个项目更简单,整个 BFF 就一个 server.mjs 文件,express 起个服务占 3000 端口,和 vite 的 5173 各跑各的。启动的时候开两个终端,一个 npm run dev 起前端,一个 node server.mjs 起 BFF,完事。
启动的时候控制台还会打印一句「我是一个在前端项目中藏着的 BFF 程序」,是我写着玩的,每次看到都觉得很形象 ------ 它就躲在前端工程里,不声不响干活。
环境变量我用 dotenv 管着,.env.local 和 .env 两个文件,本地调试的 key 放 local 里,不会提交到 git。启动时自动注入 3 个环境变量,base_url、api_key、model 名称,够用了。
第一次写流式接口,人都麻了
回到最开始的问题。我之前写普通接口写惯了,拿到 response 就 res.json() 或者 res.send() 怼回去。这次我想当然地以为,流式接口不就是加个 stream: true 吗,能有多难?
于是我写出了第一版代码:
javascript
app.get('/stream', async (req, res) => {
const { prompt } = req.query;
const endpoint = process.env.VITE_DEEPSEEK_API_BASE_URL;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.VITE_DEEPSEEK_API_MODEL,
messages: [{ role: 'user', content: prompt }],
stream: true // 我加了流式啊!
})
});
console.log(response.body); // 就是这里打出了那个 ReadableStream
res.send(response.body); // 我直接发回去不行吗?
})
运行,前端请求一发,收到个 [object Object]。
我当时还纳闷,不应该啊,返回的不是流吗?怎么变成对象了。后来才反应过来,res.send 会把参数转成字符串,直接把流对象 toString 了,当然是 [object Object]。
行,那我用 pipe 总行吧?Node 里流不都是 pipe 来 pipe 去的。我改成 response.body.pipe(res),刷新页面,直接报错:pipe is not a function。
我人都傻了。这不是流吗?怎么会没有 pipe?
ReadableStream 到底是个啥?
查了半小时资料我才搞明白,坑在这:Node 18+ 内置的这个 fetch,返回的是 Web 标准的 ReadableStream,不是 Node.js 自己那套 Stream API。两套东西长得像,名字都叫流,API 完全不通用。
打个比方吧。你家接水管,大模型那头开水龙头,水(数据)顺着管子流过来。这个 ReadableStream 就是那根水管本身,不是水管里的水。你不能直接把水管递给用户,得把这根水管接到用户家的水龙头上,水才能流过去。
locked: false 的意思也很简单:这根水管还没人接,处于空闲状态。一旦你调用 getReader() 拿了读取器,它就会变成 locked: true,代表这根管子被占用了,不能再被别的读取器读。
注意区分两种流 Node.js 原生的 stream 和 Web 标准的 ReadableStream 不是一回事,API 不通用。node 18+ 内置的 fetch 返回的是 Web 标准流,不能直接调用
.pipe()方法。
那怎么把 Web 流里的数据读出来?两种办法:
- 调用
.getReader()拿到读取器,循环调用reader.read()一块块读 - 用
for await...of直接遍历流,语法更简洁
我选了第二种,写起来省事。
把流正确地送回前端
光读出来还不行,还得正确地送给前端,让浏览器认出这是 SSE 流式响应。
首先响应头必须设对,这是命门,少一个都可能白干:
javascript
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
Content-Type: text/event-stream告诉浏览器:这是 SSE 流式响应,别等全部加载完,来一点渲染一点Cache-Control: no-cache禁止缓存,不然浏览器可能会等缓存满了才吐数据Connection: keep-alive保持长连接
我一开始漏了 Cache-Control,浏览器死活不逐字输出,非要等全部生成完才一次性显示,排查了 20 分钟才发现是这个问题。别问,问就是试过。
头设好之后,就可以一边读大模型返回的流,一边写给前端了:
javascript
app.get('/stream', async (req, res) => {
const { prompt } = req.query;
const endpoint = process.env.VITE_DEEPSEEK_API_BASE_URL;
// 这三个头,漏一个都别想出效果
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
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: process.env.VITE_DEEPSEEK_API_MODEL,
messages: [{ role: 'user', content: prompt }],
stream: true
})
});
// 遍历 Web 流,拿到一块就往前端写一块
for await (const chunk of response.body) {
res.write(chunk);
}
res.end();
} catch (err) {
console.error('流式请求出错:', err);
res.end();
}
})
改完再跑,大模型返回的数据终于能一点点流到前端了。
其实这里我还走了个弯路:一开始想在 BFF 层把所有 chunk 拼起来,解析成完整文本再处理。后来拍了自己一下,我都拼起来了还要流式干嘛?直接等全部返回不就完了。BFF 层在流式场景下的核心职责就是「透传」,拿到一块送一块,别攒着,攒着就失去流式的意义了。
跨域?vite 代理一把梭
流通了,新问题来了:前端跑在 5173 端口,BFF 跑在 3000 端口,端口不一样,跨域了。
我第一反应是装 cors 中间件,npm i cors,导入,app.use(cors()),三秒钟搞定。写完突然拍大腿,我这是 vite 项目啊,自带代理啊!我装什么 cors?
直接在 vite.config.js 里加几行配置:
javascript
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^/api/, '')
}
}
}
})
完事。
原理很简单:浏览器的同源策略只限制浏览器直接发的请求,服务器之间发请求不受限制。vite 的开发服务器相当于一个中间代理人,前端把请求发给 5173 的 /api/stream(同域,不跨域),vite 拦截到这个请求,帮你转发到 3000 的 /stream,拿到结果再还给前端。
连跨域中间件都省了,还更安全 ------ 线上部署的时候,前端和 BFF 放在同一个域名下,路径前缀区分开,连代理配置都不用改。
为什么不用 EventSource? 浏览器原生有个 EventSource 对象,专门用来接 SSE。但它有个硬伤:只支持 GET 请求,没法在请求体里传复杂的对话历史、参数配置。对接大模型接口基本都要传一长串消息列表,所以一般都用 fetch + 手动读流的方式,更灵活。
前端怎么接这个流?
后端搞定了,前端也不能用老办法接。最开始我前端写的是:
javascript
fetch('/api/stream?prompt=hello')
.then(res => res.json())
.then(data => console.log(data))
这肯定不行,res.json() 会等整个响应结束才解析,流式等于白做。
正确的做法和后端类似,也是拿 response.body 的读取器,循环读 chunk,解码之后拼到页面上:
javascript
async function fetchStream(prompt) {
const response = await fetch(`/api/stream?prompt=${encodeURIComponent(prompt)}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码二进制块,拼到结果里
result += decoder.decode(value, { stream: true });
// 更新页面,实现打字机效果
content.value = result;
}
}
这里用 TextDecoder 解码是因为 chunk 是 Uint8Array 二进制数据,不是字符串。{ stream: true } 这个参数很重要,告诉解码器这是流式数据,可能会有多字节字符被拆在两个 chunk 里,别乱解码。
配合 Vue 的响应式,每读到一块就更新 content,页面上就会出现逐字输出的打字机效果了。完整的组件大概长这样:
javascript
<script setup>
import { ref } from 'vue';
const question = ref('');
const content = ref('');
const stream = ref(true);
async function update() {
content.value = '';
const response = await fetch(`/api/stream?prompt=${encodeURIComponent(question.value)}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
content.value += decoder.decode(value, { stream: true });
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div><label>Streaming</label><input type="checkbox" v-model="stream"/></div>
<div>{{ content }}</div>
</div>
</div>
</template>
回头看,SSE 到底是什么
踩完这一圈坑,再回头看 SSE 这个概念,就清晰多了。
SSE 全称 Server Sent Events,服务器发送事件。说人话就是:基于普通 HTTP 协议的、服务器单向推数据的长连接。
你可以这么理解:
- 普通 HTTP 请求:你问一句,对方答一句,说完就挂电话
- WebSocket:双方打通电话,你一句我一句,双向聊天
- SSE:你打过去,对方一直说,你只能听,单向广播
对于大模型生成、消息通知这种「客户端问一次,服务器持续返回」的场景,SSE 刚好够用。它比 WebSocket 轻量,不用升级协议,不用处理复杂的连接状态,普通 HTTP 就能搞,运维成本低很多。
BFF 层在这里的价值,就是把大模型原生的流式接口封装一层,前端不用关心底层是 SSE 还是别的格式,不用处理鉴权 key,不用处理异常重试,只管拿文本渲染就行。
几个踩过的坑,别再踩了
这一趟跑下来,踩了好几个低级坑,列出来给大家避避:
- 别把 ReadableStream 当数据。它是通道,不是数据本身,必须读出来才能用。Web 流和 Node 流 API 不通用,别上来就调 pipe。
- SSE 响应头是命门 。
Content-Type必须是text/event-stream,Cache-Control记得关缓存,不然浏览器会攒着不输出。 - 别在 BFF 层攒数据。流式的核心就是低延迟,来一块发一块,攒齐了再发还不如用普通接口。
- 前端别用 res.json () 接流 。用了就等于白做流式,会等全部返回才解析。要用
response.body.getReader()一点点读。 - vite 代理记得重写路径 。
/api/stream转发到后端要去掉/api前缀,不然会 404。我第一次配的时候忘了 rewrite,调了半天 404,蠢得不行。
最后说两句
其实这套东西真不难,技术含量也不高。就是第一次接触流式编程的时候,看着那个 ReadableStream 对象容易懵,不知道从哪下手。踩过一遍坑就清楚了,本质就是接水管:一头接大模型,一头接前端,中间别堵着,别把水管当水递出去就行。
BFF 也不是什么高大上的架构,就是大前端时代的一个实用小技巧。小项目、内部工具这么搞特别爽,一个人就能干完全栈的活,不用等排期,不用跟人扯皮。但如果是高并发、大流量的正经业务,该上 Java/Go 后端还是得上,它的定位就是给前端减负,不是替代后端。
你们做流式输出的时候有没有遇到过什么奇葩坑?比如 nginx 反向代理缓存导致不流式,或者各种奇怪的编码问题?评论区聊聊,我也攒点经验。