🔥 保姆级教程|SSE + BFF + 跨域三件套,从零实现 ChatGPT 流式输出(附完整代码)

🔥 保姆级教程|SSE + BFF + 跨域三件套,从零实现 ChatGPT 流式输出(附完整代码)

摘要 :ChatGPT 为什么能一个字一个字地"蹦"出来?这背后是 SSE 技术。而要让前端顺利接上 SSE,还绕不开 BFF 架构跨域问题。本文从零开始,掰开了揉碎了讲透这三个知识点,附完整可运行代码。


📑 目录

  • 前言:为什么你一定要学这个?
  • [第一部分:SSE 是什么?](#第一部分:SSE 是什么? "#-%E7%AC%AC%E4%B8%80%E9%83%A8%E5%88%86sse-%E6%98%AF%E4%BB%80%E4%B9%88")
  • [第二部分:BFF 是什么?为什么需要它?](#第二部分:BFF 是什么?为什么需要它? "#-%E7%AC%AC%E4%BA%8C%E9%83%A8%E5%88%86bff-%E6%98%AF%E4%BB%80%E4%B9%88%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E5%AE%83")
  • 第三部分:跨域问题深度解析
  • 第四部分:完整代码实战
  • [第五部分:SSE 流式输出完整实现](#第五部分:SSE 流式输出完整实现 "#-%E7%AC%AC%E4%BA%94%E9%83%A8%E5%88%86sse-%E6%B5%81%E5%BC%8F%E8%BE%93%E5%87%BA%E5%AE%8C%E6%95%B4%E5%AE%9E%E7%8E%B0")
  • [第六部分:常见踩坑 & 解决方案](#第六部分:常见踩坑 & 解决方案 "#-%E7%AC%AC%E5%85%AD%E9%83%A8%E5%88%86%E5%B8%B8%E8%A7%81%E8%B8%A9%E5%9D%91--%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88")
  • 第七部分:最终完整代码汇总
  • 重点总结

📌 前言:为什么你一定要学这个?

2026 年了,AI 应用遍地开花。你打开 ChatGPT、Kimi、豆包,输入一个问题,答案像打字机一样一个字一个字地出现。

但你有没有想过:这到底是怎么实现的? 🤔

如果你用过 fetch 请求数据,你知道它的工作方式是:发请求 → 等待 → 一次性拿到全部数据

但 AI 的回答可能有几千字,让用户等 10 秒才看到结果?体验太差了!

所以需要一种技术,让服务器一点一点地 把数据推给前端------这就是 SSE

而当你自己动手写的时候,你会发现:

  1. 前端直接请求 AI 接口?跨域了! 浏览器不让你发!
  2. API Key 写在前端代码里?不安全! 用户右键就能看到!

于是你需要一个中间层------BFF,来帮你解决这些问题。

今天这篇文章,一次性把这三个知识点全讲透。 👇


🎯 本文适合谁

  • ✅ 有基础的 HTML/CSS/JS 知识,想了解 SSE 的前端初学者
  • ✅ 用过 Vue/React,但没接触过后端的前端同学
  • ✅ 被跨域问题折磨过,想彻底搞懂的开发者
  • ✅ 想接入 AI 流式输出,但不知道怎么做的同学

🛠️ 环境准备

开始之前,请确保你的电脑上已经安装了:

工具 版本要求 检查命令 说明
Node.js >= 18.0 node -v 运行 BFF 后端需要
npm >= 9.0 npm -v 安装依赖需要
编辑器 VS Code 推荐 --- 写代码用

💡 如果 node -v 提示找不到命令,请先去 Node.js 官网 下载安装。


📚 第一部分:SSE 是什么?

🍕 用外卖来理解 SSE

想象你点了一份外卖,有两种送餐方式:

方式 描述 对应技术
方式 A 外卖小哥把所有菜做好了,一次性给你送来 普通 HTTP 请求(fetch
方式 B 厨师做好一道,就让骑手送一道过来 SSE(Server-Sent Events)

方式 A:你得等所有菜都做好才能开吃,可能要等 30 分钟。 方式 B:第一道菜 5 分钟就到了,你可以先吃着,后面的菜陆续送到。

SSE 就是方式 B ------服务器做好一部分数据,就立刻推给前端,前端可以边接收边展示

📖 SSE 的正式定义

SSE(Server-Sent Events,服务器发送事件) 是一种 HTTP 协议允许服务器向客户端单向、持续地推送数据的技术。

关键特点:

  • 🔗 基于 HTTP:不需要 WebSocket 那样的新协议,就是普通 HTTP
  • 📡 单向通信 :只有服务器能推数据给客户端(客户端发请求用普通 fetch
  • 📝 文本格式 :传输的是纯文本,格式简单(每行以 data: 开头)
  • 🔄 自动重连 :连接断了,浏览器的 EventSource API 会自动尝试重连
  • 🚀 实时推送:服务器有新数据就立刻推,不用客户端反复轮询

📡 SSE 的数据长什么样?

SSE 的格式非常简单,就是纯文本,每条消息以 data: 开头,以 \n\n(两个换行)结尾:

css 复制代码
data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: {"choices":[{"delta":{"content":"啊"}}]}

data: [DONE]

就这么简单!没有复杂的二进制协议,就是一行一行的文本

💡 补充 :浏览器原生提供了 EventSource API 来接收 SSE,自带自动重连。但在 AI 流式输出场景中,我们通常用 fetch + ReadableStream 手动解析,因为 EventSource 只支持 GET 请求,无法发送 POST body。

🔍 SSE vs WebSocket vs 轮询

特性 轮询(Polling) SSE WebSocket
方向 客户端 → 服务器 服务器 → 客户端 双向
协议 HTTP HTTP WS/WSS
复杂度 中等
实时性 差(有延迟) 最好
自动重连 ❌(需手动)
适合场景 低频更新 AI 流式输出、通知推送 聊天室、游戏

💡 一句话总结:如果你只需要服务器推数据给客户端(比如 AI 对话),用 SSE 就够了;如果需要双向实时通信(比如聊天室),用 WebSocket。


📚 第二部分:BFF 是什么?为什么需要它?

🏪 用餐厅来理解 BFF

想象你去一家高级餐厅吃饭:

复制代码
你(前端)→ 服务员(BFF)→ 厨房(后端)

你不会直接冲进厨房对厨师说:"给我来一份牛排,七分熟,少放盐!"

而是告诉服务员,服务员帮你翻译成厨房能理解的语言,下单、等菜、摆盘,最后端到你面前。

BFF 就是这个"服务员"。

📖 BFF 的正式定义

BFF = Backend For Frontend(为前端服务的后端),是一个轻量级的中间层服务,专门为前端应用量身定制接口。

🤔 为什么前端需要 BFF?

你可能会问:前端直接请求后端 API 不就行了?为什么还要加一层?

来看三个痛点 👇

痛点 1:API Key 安全问题 🔐
javascript 复制代码
// ❌ 错误做法:API Key 写在前端
const response = await fetch('https://api.openai.com/v1/chat/completions', {
    headers: {
        'Authorization': 'Bearer sk-xxxxx' // 用户右键查看源码就能看到!
    }
});

后果:任何人都能看到你的 API Key,拿去滥用,你的账户被刷爆 💸

javascript 复制代码
// ✅ 正确做法:通过 BFF 代理,Key 放在服务端
// 前端只需要请求自己的 BFF
const response = await fetch('/api/stream?prompt=你好');

// BFF 层(server.mjs)负责带上 Key 去请求真正的 API
// 用户看不到 Key,安全!
痛点 2:跨域问题 🚫

前端运行在 http://localhost:5173,AI API 在 https://api.xiaomimimo.com

域名不同 = 跨域 = 浏览器直接拦截你的请求!

但如果请求发给同域名下的 BFF(localhost:3000),BFF 再去请求 AI API(服务端没有跨域限制),问题就解决了。

痛点 3:数据格式转换 🔧

AI 返回的数据格式可能很复杂(SSE 流、二进制数据),前端直接处理很麻烦。

BFF 可以帮前端预处理数据,让前端只关心展示。

🏗️ 完整架构图

bash 复制代码
┌──────────────┐   ①前端请求    ┌──────────────┐  ②带上API Key  ┌──────────────┐
│              │  /api/stream?  │              │  请求AI接口    │              │
│    前端       │ ────────────→ │   BFF 层      │ ────────────→ │   AI 服务器   │
│  Vue/React   │ localhost:5173 │   Express    │  api.xiaomim  │  (LLM API)  │
│              │ ←──────────── │   :3000      │ ←──────────── │              │
│              │  ③流式数据回来  │              │  ④流式响应     │              │
└──────────────┘               └──────────────┘               └──────────────┘

📚 第三部分:跨域问题深度解析

🚨 什么是跨域?

同源策略 :浏览器的安全机制,要求网页只能请求同源的资源。

同源 = 协议 + 域名 + 端口,三者完全一致

举个例子:

当前页面 请求地址 是否同源 结果
http://localhost:5173 http://localhost:5173/api ✅ 同源 正常请求
http://localhost:5173 http://localhost:3000/api 端口不同 跨域被拦截!
http://localhost:5173 https://api.xiaomimimo.com 域名+协议不同 跨域被拦截!

💥 为什么会这样?

这是浏览器的安全保护机制。想象一下,如果没有同源策略:

  1. 你打开了一个恶意网站 evil.com
  2. 这个网站的 JS 偷偷请求 bank.com/api/transfer?to=黑客&amount=10000
  3. 因为你的浏览器里有 bank.com 的登录 Cookie,请求自动带上了认证信息
  4. 你的钱就被转走了 💀

同源策略就是为了防止这种攻击!

🔧 用 Vite Proxy 解决跨域

在我们的项目中,情况是这样的:

arduino 复制代码
前端(Vite): http://localhost:5173
BFF 后端:    http://localhost:3000

两个端口不同 → 跨域!

解决方案:让 Vite 充当"中间人",帮我们转发请求。

javascript 复制代码
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      // 当前端请求的 URL 以 /api 开头时
      '/api': {
        // 把请求转发到这个地址
        target: 'http://localhost:3000',
        // 修改请求头中的 Host 为目标地址(虚拟主机场景需要)
        changeOrigin: true,
        secure: false,
        // 把 /api 去掉,比如 /api/stream → /stream
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
})
🔍 逐步拆解这个配置

第 1 步:前端发起请求

javascript 复制代码
// App.vue 中
fetch('/api/stream?prompt=hello')
// 浏览器实际请求:http://localhost:5173/api/stream?prompt=hello

第 2 步:Vite 拦截请求

Vite 发现 URL 以 /api 开头,命中了 proxy 规则,于是:

  • 不让这个请求去 localhost:5173(那里没有这个接口)
  • 而是转发http://localhost:3000

第 3 步:路径重写

javascript 复制代码
rewrite: (path) => path.replace(/^\/api/, '')
// /api/stream?prompt=hello → /stream?prompt=hello

第 4 步:BFF 收到请求

Express 服务器在 localhost:3000 收到了 /stream?prompt=hello,正常处理!

📊 完整请求流程图
bash 复制代码
前端代码: fetch('/api/stream?prompt=hello')
        │
        ▼
┌───────────────────────────────────────────┐
│  浏览器: 这个请求是发给 localhost:5173 的    │
│  Vite: /api 命中了我的 proxy 规则!         │
│  Vite: 我帮你转发到 localhost:3000          │
│  路径重写: /api/stream → /stream            │
└───────────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────────┐
│  Express 服务器 (localhost:3000)           │
│  收到请求: GET /stream?prompt=hello        │
│  处理逻辑: 请求 AI API,返回流式数据        │
└───────────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────────┐
│  浏览器: 收到 SSE 流式响应                   │
│  前端代码: 逐块读取 → 解析 → 渲染到页面      │
└───────────────────────────────────────────┘

💡 核心原理 :Vite 的 dev server 和前端页面在同一个端口(5173),所以 Vite 代理的请求不算跨域。而 Vite(Node.js 环境)去请求 localhost:3000,服务端之间没有跨域限制!


📚 第四部分:完整代码实战(从零搭建)

🛠️ 项目结构

bash 复制代码
stream-bff/
├── .env.local          # API Key 等敏感配置(不提交到 git!)
├── .env                # 默认环境变量
├── .gitignore          # Git 忽略文件
├── server.mjs          # BFF 后端(Express)
├── vite.config.js      # Vite 配置(含 proxy)
├── package.json        # 项目依赖
└── src/
    ├── App.vue         # 前端页面
    └── main.js         # 入口文件

Step 1:初始化项目

bash 复制代码
# 创建 Vue + Vite 项目
npm create vue@latest stream-bff
cd stream-bff

# 安装依赖
npm install express dotenv

package.json 中的关键依赖:

json 复制代码
{
  "dependencies": {
    "dotenv": "^16.4.7",
    "express": "^5.2.1",
    "vue": "^3.5.34"
  }
}

Step 2:配置环境变量

bash 复制代码
# .env.local(存放你的 API Key,不要提交到 git!)
MIMO_API_KEY=sk-你的密钥
MIMO_API_BASE_URL=https://api.xiaomimimo.com/v1
MIMO_MODEL=mimo-v2.5-pro

⚠️ 重要安全提示 :这些环境变量不以 VITE_ 开头!这是故意的。

  • VITE_ 前缀是 Vite 的约定:以它开头的变量会被注入到前端代码中,前端通过 import.meta.env.VITE_XXX 就能读到
  • 我们的 API Key 只在 BFF 服务端(server.mjs)使用,绝不能暴露给浏览器
  • 所以不用 VITE_ 前缀,确保 Key 只存在于 Node.js 进程环境中
  • server.mjs 中通过 process.env.MIMO_API_KEY 读取,这是 Node.js 的方式,安全!

Step 3:编写 BFF 层(核心!)

javascript 复制代码
// server.mjs
import 'dotenv/config';  // dotenv v16+ ESM 推荐写法,一行搞定
import express from 'express';

// ========== 1. 加载环境变量 ==========
// dotenv/config 会自动读取项目根目录的 .env 文件
// 如果同时有 .env 和 .env.local,可以用以下写法指定优先级:
// import { config } from 'dotenv';
// config({ path: ['.env.local', '.env'] });

// ========== 2. 创建 Express 应用 ==========
const app = express();
const port = 3000;

// ========== 3. 定义路由 ==========

// 测试路由:访问 http://localhost:3000/ 返回 Hello World
app.get('/', (req, res) => {
    res.send('Hello World! BFF is running 🚀');
})

// 核心路由:SSE 流式接口
// 前端会请求: /stream?prompt=你好
app.get('/stream', async(req, res) => {
    // 从 URL 参数中获取用户的 prompt
    // 例如: /stream?prompt=你好 → prompt = "你好"
    const { prompt } = req.query;

    // AI API 的地址
    const endpoint = 'https://api.xiaomimimo.com/v1/chat/completions';

    try {
        // ========== 4. 向 AI 服务器发起流式请求 ==========
        const response = await fetch(endpoint, {
            method: 'POST',
            headers: {
                // ⭐ 使用环境变量中的 API Key(安全!用户看不到)
                // 注意:这里读的是 process.env,不是 import.meta.env
                'Authorization': `Bearer ${process.env.MIMO_API_KEY}`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                // 使用环境变量中的模型名称
                model: process.env.MIMO_MODEL,
                // ⭐ 关键:stream: true 表示开启流式输出
                stream: true,
                messages: [
                    {
                        role: 'user',
                        content: prompt  // 用户的问题
                    }
                ]
            })
        });

        // response.body 是一个 ReadableStream(可读流)
        // 数据会一点一点地从 AI 服务器流过来
        // 具体怎么转发给前端,见第五部分的完整实现
        console.log('AI 响应流已建立:', response.status);
    }
    catch (error) {
        console.log('❌ 请求出错:', error);
        res.status(500).json({ error: '请求失败' });
    }
});

// ========== 5. 启动服务器 ==========
// 注意:先定义路由,再 listen,这是 Express 的最佳实践
app.listen(port, () => {
    console.log(`✅ BFF 服务器已启动: http://localhost:${port}`);
})

console.log('我是一个在前端项目中藏着的 BFF 程序 🤫')
🔑 代码关键点解析

为什么要用 dotenv

ini 复制代码
.env.local 文件:
MIMO_API_KEY=sk-c0t3rgnbasfltud7efv5pojt...

server.mjs 中:
process.env.MIMO_API_KEY  →  读取到密钥

好处:
1. 密钥和代码分离,代码提交到 git 不会泄露密钥
2. 不同环境(开发/测试/生产)可以用不同的密钥

为什么要 stream: true

javascript 复制代码
// stream: false(默认)→ AI 生成完所有内容,一次性返回
// 用户要等很久才能看到结果 😫

// stream: true → AI 生成一部分就返回一部分
// 用户立刻就能看到内容,体验好! 😊

为什么 app.listen 放在路由定义之后?

javascript 复制代码
// ✅ 推荐:先定义路由,再启动监听
app.get('/stream', handler);  // 先定义
app.listen(port);             // 再启动

// ⚠️ 虽然放前面也能工作(Express 会异步处理),
//    但先定义后启动更符合直觉,也更容易理解代码执行顺序

Step 4:配置 Vite 代理(解决跨域)

javascript 复制代码
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      // 匹配规则:所有以 /api 开头的请求
      '/api': {
        // 转发目标:BFF 服务器
        target: 'http://localhost:3000',
        // 修改 Host 头为目标地址(虚拟主机场景需要)
        changeOrigin: true,
        secure: false,
        // 路径重写:去掉 /api 前缀
        // /api/stream → /stream
        // /api/user  → /user
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
})
🧠 为什么要 rewrite?
ini 复制代码
前端请求: /api/stream?prompt=hello

如果不 rewrite:
  BFF 收到: /api/stream?prompt=hello
  BFF 定义的是 /stream 路由,匹配不上!404 ❌

rewrite 之后:
  BFF 收到: /stream?prompt=hello
  匹配到了 app.get('/stream', ...),正常处理! ✅

Step 5:启动项目

bash 复制代码
# 终端 1:启动 BFF 后端
node server.mjs
# 输出: ✅ BFF 服务器已启动: http://localhost:3000

# 终端 2:启动前端开发服务器
npm run dev
# 输出: Local: http://localhost:5173/

💡 为什么需要两个终端? 因为 BFF(Express)和前端(Vite)是两个独立的服务,需要分别启动。VS Code 可以按 ``Ctrl+``` 打开多个终端。


📚 第五部分:SSE 流式输出完整实现

前面的代码搭建好了项目骨架,但 /stream 路由还没有真正把 AI 的流式数据转发给前端。下面我们来实现完整的 SSE 流式输出

🔄 BFF 层:转发 SSE 流

javascript 复制代码
// server.mjs - /stream 路由完善版
app.get('/stream', async(req, res) => {
    const { prompt } = req.query;
    const endpoint = 'https://api.xiaomimimo.com/v1/chat/completions';

    try {
        const response = await fetch(endpoint, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${process.env.MIMO_API_KEY}`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                model: process.env.MIMO_MODEL,
                stream: true,
                messages: [{ role: 'user', content: prompt }]
            })
        });

        // ⭐ 设置 SSE 响应头
        // 告诉前端:这是一个事件流,请持续接收
        res.setHeader('Content-Type', 'text/event-stream');
        res.setHeader('Cache-Control', 'no-cache');           // 不缓存,实时推送
        res.setHeader('Connection', 'keep-alive');             // 保持 TCP 连接
        res.setHeader('X-Accel-Buffering', 'no');              // 禁用 nginx 缓冲(生产环境必须)

        // ⭐ 获取 AI 返回的可读流
        const reader = response.body.getReader();
        // TextDecoder:把二进制数据(Uint8Array)解码成人类可读的文本
        const decoder = new TextDecoder('utf-8');

        // ⭐ 监听客户端断开连接
        // 当用户关闭页面或取消请求时,中断读取,释放资源
        let aborted = false;
        req.on('close', () => {
            aborted = true;
            reader.cancel();
        });

        // ⭐ 循环读取流中的数据
        while (true) {
            // 如果客户端已断开,停止读取
            if (aborted) break;

            const { done, value } = await reader.read();

            // done = true 表示流结束了
            if (done) {
                res.write('data: [DONE]\n\n'); // SSE 结束标志
                res.end();
                break;
            }

            // value 是二进制数据(Uint8Array),需要解码成文本
            const chunk = decoder.decode(value, { stream: true });

            // 将数据块发送给前端
            // SSE 格式:每条消息以 "data: " 开头,以 "\n\n" 结尾
            res.write(`data: ${chunk}\n\n`);
        }
    }
    catch (error) {
        console.log('❌ 请求出错:', error);
        // 确保在出错时也正确结束响应
        if (!res.headersSent) {
            res.status(500).json({ error: '请求失败' });
        } else {
            res.end();
        }
    }
});
🔑 关键概念解释

ReadableStream 是什么?

diff 复制代码
想象一根水管 🚰:
- 水(数据)从一端(AI 服务器)流过来
- 你从另一端(reader.read())一节一节地接水
- 每次 read() 可能接到一小节水(一个 chunk)
- done = true 表示水流完了

TextDecoder 是什么?

csharp 复制代码
网络传输的是二进制数据(0 和 1),人类看不懂
TextDecoder 把二进制翻译成文字:
  [0xE4, 0xBD, 0xA0] → "你"
  [0xE5, 0xA5, 0xBD] → "好"

X-Accel-Buffering: no 是什么?

arduino 复制代码
如果你的项目部署在 nginx 反向代理后面,
nginx 默认会"攒"一批数据再一起发给浏览器,
这会导致 SSE 流不能实时推送!

加上这个头,告诉 nginx:"别攒了,直接转发!"

🎨 前端:接收 SSE 流并实时展示

vue 复制代码
<!-- App.vue - 完整版 -->
<script setup>
import { ref } from 'vue'

// 响应式数据
const question = ref('')   // 用户输入的问题
const content = ref('')    // AI 返回的内容
const loading = ref(false) // 加载状态

// 提交问题
const update = async () => {
    if (!question.value.trim()) return
    if (loading.value) return  // 防止重复提交

    content.value = ''   // 清空之前的内容
    loading.value = true // 显示加载状态

    try {
        // 发起请求:通过 Vite proxy 转发到 BFF
        // /api/stream?prompt=xxx → Vite 代理 → localhost:3000/stream?prompt=xxx
        const response = await fetch(
            `/api/stream?prompt=${encodeURIComponent(question.value)}`
        )

        // 检查 HTTP 状态码
        if (!response.ok) {
            throw new Error(`请求失败: ${response.status}`)
        }

        // ⭐ 使用 ReadableStream 读取 SSE 数据
        // ReadableStream 就像一根水管,数据一块一块地流过来
        const reader = response.body.getReader()
        // TextDecoder 把二进制数据翻译成文字
        const decoder = new TextDecoder('utf-8')

        // ⭐ 缓冲区:处理 chunk 边界截断问题
        // reader.read() 返回的数据块边界是任意的
        // 一个 chunk 可能只包含半条 SSE 消息
        // 所以我们需要用 buffer 来暂存不完整的数据
        let buffer = ''

        while (true) {
            const { done, value } = await reader.read()
            if (done) break

            // 把二进制数据解码成文本,追加到缓冲区
            buffer += decoder.decode(value, { stream: true })

            // 按换行符分割,逐行解析
            const lines = buffer.split('\n')
            // ⭐ 最后一行可能不完整(被 chunk 边界截断了)
            // 留到下次 read() 再处理
            buffer = lines.pop() || ''

            for (const line of lines) {
                // 跳过空行和结束标志
                if (line.startsWith('data: ') && line !== 'data: [DONE]') {
                    try {
                        // 去掉 "data: " 前缀,解析 JSON
                        const json = JSON.parse(line.slice(6))
                        // 提取 AI 回复的文字内容
                        const text = json.choices?.[0]?.delta?.content || ''
                        // ⭐ 逐字追加到页面,实现打字机效果
                        content.value += text
                    } catch (e) {
                        // 忽略非 JSON 行(如空行、注释等)
                    }
                }
            }
        }
    } catch (error) {
        // 网络错误、API 错误等
        content.value = `❌ 请求出错: ${error.message}`
    } finally {
        // 无论成功还是失败,都要关闭加载状态
        loading.value = false
    }
}
</script>

<template>
    <div class="container">
        <!-- 输入区域 -->
        <div>
            <label>输入:</label>
            <input
                class="input"
                v-model="question"
                placeholder="请输入你的问题..."
                @keyup.enter="update"
            />
            <button @click="update" :disabled="loading">
                {{ loading ? '生成中...' : '提交' }}
            </button>
        </div>

        <!-- 输出区域 -->
        <div class="output">
            <!-- AI 回复内容 -->
            <div class="content">{{ content }}</div>
        </div>
    </div>
</template>

<style>
.container {
    display: flex;
    flex-direction: column;
    align-items: start;
    justify-content: start;
    height: 100vh;
    font-size: 0.85rem;
    padding: 20px;
}
.input {
    margin-top: 10px;
    min-height: 30px;
    width: 300px;
    text-align: left;
}
button {
    padding: 0 10px;
    margin-left: 6px;
}
button:disabled {
    opacity: 0.6;
    cursor: not-allowed;
}
.output {
    margin-top: 20px;
}
.content {
    white-space: pre-wrap;  /* 保留换行符,自动换行 */
    line-height: 1.8;
}
</style>
📊 数据流动过程
swift 复制代码
AI 服务器返回(OpenAI 兼容格式):
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{"content":"啊"}}]}
data: [DONE]

        ↓ BFF 转发(原样转发)

前端逐条解析:
buffer += "data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\n\n"
  → 解析出 "你" → content.value = "你"
buffer += "data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\n"
  → 解析出 "好" → content.value = "你好"
buffer += "data: {\"choices\":[{\"delta\":{\"content\":\"啊\"}}]}\n\n"
  → 解析出 "啊" → content.value = "你好啊"
"data: [DONE]" → 流结束,停止读取

💡 为什么需要 buffer? 因为 reader.read() 返回的数据块边界是任意的。AI 服务器可能一次发 10 条消息,也可能一条消息被分成 3 个 chunk 发过来。没有 buffer,半条消息就会 JSON 解析失败,导致数据丢失。


📚 第六部分:常见踩坑 & 解决方案

🐛 坑 1:502 Bad Gateway

现象 :前端请求 /api/stream 返回 502

原因:BFF 服务器没有启动!

bash 复制代码
# 解决:确保 BFF 在运行
node server.mjs

🐛 坑 2:CORS 跨域错误

现象 :浏览器控制台报 Access-Control-Allow-Origin 错误

原因:直接从前端请求了 BFF,没有走 Vite proxy

javascript 复制代码
// ❌ 错误:直接请求 BFF 地址(跨域!)
fetch('http://localhost:3000/stream?prompt=hello')

// ✅ 正确:通过 Vite proxy(不跨域)
fetch('/api/stream?prompt=hello')

🐛 坑 3:API Key 泄露

现象:用户在浏览器 DevTools 的 Network 面板中看到了 API Key

原因 :前端代码中硬编码了 Key,或者环境变量用了 VITE_ 前缀

javascript 复制代码
// ❌ 危险!Key 暴露在前端
headers: { 'Authorization': 'Bearer sk-xxxxx' }

// ❌ 危险!VITE_ 前缀会被 Vite 注入前端
// .env: VITE_API_KEY=sk-xxxxx
// 前端代码: import.meta.env.VITE_API_KEY ← 可以读到!

// ✅ 安全!Key 只在 BFF 层使用,不用 VITE_ 前缀
// .env: MIMO_API_KEY=sk-xxxxx
// server.mjs: process.env.MIMO_API_KEY ← 只有 Node.js 能读

🐛 坑 4:SSE 流数据丢失 / 中文乱码

现象:流式输出的文字有缺失,或者中文显示为乱码

原因:没有使用 buffer 处理 chunk 边界截断

javascript 复制代码
// ❌ 错误:直接 split,半条消息会丢失
const lines = chunk.split('\n')

// ✅ 正确:用 buffer 暂存不完整数据
let buffer = ''
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''  // 最后一行可能不完整,留到下次

🐛 坑 5:生产环境 SSE 流不实时(nginx 缓冲)

现象:开发环境正常,部署到服务器后 SSE 数据不是逐条到达,而是一次性返回

原因:nginx 反向代理默认会缓冲响应

nginx 复制代码
# 解决方案 1:nginx 配置中关闭缓冲
location /api/stream {
    proxy_buffering off;
    proxy_cache off;
}

# 解决方案 2:在 BFF 中设置响应头(已在代码中添加)
res.setHeader('X-Accel-Buffering', 'no');

🐛 坑 6:用户中途关闭页面导致资源泄漏

现象:用户刷新页面或关闭标签页,BFF 还在继续读取 AI API 的响应

原因:没有监听客户端断开事件

javascript 复制代码
// ✅ 解决:监听 req 的 close 事件
let aborted = false;
req.on('close', () => {
    aborted = true;
    reader.cancel(); // 取消读取,释放资源
});

while (true) {
    if (aborted) break; // 客户端已断开,停止循环
    const { done, value } = await reader.read();
    // ...
}

📚 第七部分:最终完整代码汇总(可直接复制)

📄 .env.local

bash 复制代码
# ⚠️ 不要提交到 git!加入 .gitignore
# 不用 VITE_ 前缀,确保只在 BFF 服务端可用
MIMO_API_KEY=sk-你的密钥
MIMO_API_BASE_URL=https://api.xiaomimimo.com/v1
MIMO_MODEL=mimo-v2.5-pro

📄 .gitignore(追加)

bash 复制代码
# 环境变量文件(包含密钥,不能提交)
.env.local
.env

📄 vite.config.js(完整)

javascript 复制代码
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        secure: false,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
})

📄 server.mjs(完整)

javascript 复制代码
import 'dotenv/config';
import express from 'express';

const app = express();
const port = 3000;

// 测试路由
app.get('/', (req, res) => {
    res.send('Hello World! BFF is running 🚀');
});

// SSE 流式接口
app.get('/stream', async (req, res) => {
    const { prompt } = req.query;
    const endpoint = 'https://api.xiaomimimo.com/v1/chat/completions';

    try {
        const response = await fetch(endpoint, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${process.env.MIMO_API_KEY}`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                model: process.env.MIMO_MODEL,
                stream: true,
                messages: [{ role: 'user', content: prompt }]
            })
        });

        // 设置 SSE 响应头
        res.setHeader('Content-Type', 'text/event-stream');
        res.setHeader('Cache-Control', 'no-cache');
        res.setHeader('Connection', 'keep-alive');
        res.setHeader('X-Accel-Buffering', 'no');

        // 读取 AI 响应流
        const reader = response.body.getReader();
        const decoder = new TextDecoder('utf-8');

        // 监听客户端断开
        let aborted = false;
        req.on('close', () => {
            aborted = true;
            reader.cancel();
        });

        // 循环转发数据
        while (true) {
            if (aborted) break;
            const { done, value } = await reader.read();
            if (done) {
                res.write('data: [DONE]\n\n');
                res.end();
                break;
            }
            const chunk = decoder.decode(value, { stream: true });
            res.write(`data: ${chunk}\n\n`);
        }
    } catch (error) {
        console.log('❌ 请求出错:', error);
        if (!res.headersSent) {
            res.status(500).json({ error: '请求失败' });
        } else {
            res.end();
        }
    }
});

app.listen(port, () => {
    console.log(`✅ BFF 服务器已启动: http://localhost:${port}`);
});

📄 src/App.vue(完整)

vue 复制代码
<script setup>
import { ref } from 'vue'

const question = ref('')
const content = ref('')
const loading = ref(false)

const update = async () => {
    if (!question.value.trim()) return
    if (loading.value) return

    content.value = ''
    loading.value = true

    try {
        const response = await fetch(
            `/api/stream?prompt=${encodeURIComponent(question.value)}`
        )

        if (!response.ok) {
            throw new Error(`请求失败: ${response.status}`)
        }

        const reader = response.body.getReader()
        const decoder = new TextDecoder('utf-8')
        let buffer = ''

        while (true) {
            const { done, value } = await reader.read()
            if (done) break

            buffer += decoder.decode(value, { stream: true })
            const lines = buffer.split('\n')
            buffer = lines.pop() || ''

            for (const line of lines) {
                if (line.startsWith('data: ') && line !== 'data: [DONE]') {
                    try {
                        const json = JSON.parse(line.slice(6))
                        const text = json.choices?.[0]?.delta?.content || ''
                        content.value += text
                    } catch (e) {}
                }
            }
        }
    } catch (error) {
        content.value = `❌ 请求出错: ${error.message}`
    } finally {
        loading.value = false
    }
}
</script>

<template>
    <div class="container">
        <div>
            <label>输入:</label>
            <input
                class="input"
                v-model="question"
                placeholder="请输入你的问题..."
                @keyup.enter="update"
            />
            <button @click="update" :disabled="loading">
                {{ loading ? '生成中...' : '提交' }}
            </button>
        </div>
        <div class="output">
            <div class="content">{{ content }}</div>
        </div>
    </div>
</template>

<style>
.container {
    display: flex;
    flex-direction: column;
    align-items: start;
    justify-content: start;
    height: 100vh;
    font-size: 0.85rem;
    padding: 20px;
}
.input {
    margin-top: 10px;
    min-height: 30px;
    width: 300px;
}
button {
    padding: 0 10px;
    margin-left: 6px;
}
button:disabled {
    opacity: 0.6;
    cursor: not-allowed;
}
.output {
    margin-top: 20px;
}
.content {
    white-space: pre-wrap;
    line-height: 1.8;
}
</style>

💡 重点总结

🎯 三个核心概念

概念 一句话解释 解决什么问题
SSE 服务器持续推送数据给前端 AI 流式输出、实时通知
BFF 前端和后端之间的中间层 安全(隐藏 API Key)、数据转换
Vite Proxy 开发时的跨域解决方案 前端请求转发到不同端口的服务

🔗 请求链路总结

javascript 复制代码
前端 fetch('/api/stream?prompt=你好')
    ↓ Vite Proxy 拦截,转发到 localhost:3000
BFF 收到 /stream?prompt=你好
    ↓ 带上 API Key,请求 AI 服务器
AI 返回 SSE 流(data: ...)
    ↓ BFF 逐块转发给前端
前端用 buffer + JSON.parse 解析,逐字显示

🔐 环境变量安全速查

场景 变量命名 读取方式 是否暴露给浏览器
BFF 服务端专用 MIMO_API_KEY process.env.MIMO_API_KEY ❌ 安全
前端可用 VITE_XXX import.meta.env.VITE_XXX ⚠️ 会暴露

🔗 参考资料


💬 交流讨论

你在做 AI 流式输出的时候遇到过哪些坑?或者对 SSE、BFF、跨域有什么疑问?欢迎在评论区讨论!👇


**觉得有用?点个赞👍收藏⭐关注👆

掘金推荐标签前端 SSE BFF 跨域 Node.js Express Vite AI 流式输出

相关推荐
嘿丨嘿15 小时前
VLA 入门(六):VLA 如何进行强化学习后训练?
人工智能·python·深度学习·机器人
触底反弹16 小时前
🔥 从零搭建 RAG 知识库:爬虫→分词→向量化→检索,一步都不能错
javascript·人工智能·面试
zhou lily17 小时前
超自动化落地:RPA+AI如何打通业务流程的“最后一公里”?
人工智能·自动化·rpa
tyqtyq2217 小时前
HarmonyOS AI 应用开发实战:简历项目经历改写系统
人工智能·学习·华为·生活·harmonyos
小柯南敲键盘17 小时前
批量图片翻译与视频字幕一站式解决高效跨境电商沟通难题
大数据·人工智能·python·音视频
先吃饱再说17 小时前
LLM 流式输出的“中间商”方案:BFF 层到底在做什么?
llm·vite
FreeBuf_17 小时前
仅用六分钟,黑客借助Gemini CLI自主构建并迁移C&C僵尸网络
网络·人工智能
xd18557855518 小时前
表情包配文-基于鸿蒙的AI表情包配文生成应用开发实践
人工智能·华为·harmonyos·鸿蒙
石山代码18 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·人工智能