前端直连LLM必踩2大致命坑!BFF中转流式SSE完整方案,一套代码解决密钥泄露+分片解析报错

0 阅读8分钟

前言

做Vue AI对话页面,90%新手会踩两个线上致命大坑:

  1. 前端直接请求DeepSeek接口:API Key打包进前端,抓包/查看源码直接泄露,被盗刷高额账单
  2. 纯前端手写SSE流式解析:TCP网络分包截断JSON,疯狂报parse错误、文字丢失、乱码

很多教程只给前端极简demo,完全不提安全风险,也没有完整BFF中转层落地代码。 本文带你搭建Vite Vue3 + Express BFF后端完整流式对话架构,读完你能学到:

  1. BFF层是什么、为什么AI项目必须加中转层
  2. 完整三层调用链路:Vue前端 → Node BFF → DeepSeek大模型
  3. Express转发SSE流式响应标准写法,处理二进制流透传
  4. 优化版前端流式解析代码,极简buffer容错逻辑
  5. Vite代理跨域配置,解决前后端端口跨域问题
  6. 全套可复制运行代码 + 高频踩坑清单,直接上线使用

屏幕截图 2026-07-28 234947.png

一、先搞懂:BFF中转层解决两大核心痛点

1.1 安全痛点:前端不能存放大模型API密钥

Vite中VITE_开头环境变量会打包进前端产物,任何人打开开发者工具、抓网络请求都能拿到完整Key,存在被盗刷风险。 BFF解决方案:密钥只存在Node服务端.env,前端永远看不到真实鉴权凭证。

1.2 工程化痛点:统一接口、降低前端复杂度

  • 没有BFF:前端要处理二进制解码、分片缓存、异常捕获、接口切换逻辑,代码臃肿
  • 拥有BFF:统一封装LLM调用逻辑,前端只需要请求本地/api/stream,后续切换大模型、加限流、过滤敏感词只改后端,前端零改动

1.3 完整三层调用链路

Vue页面(5173端口) → Vite代理转发 → Express BFF(3000端口) → DeepSeek官方API

二、项目环境初始化

2.1 安装依赖

# Vue前端依赖
npm install vue
# BFF后端依赖
npm install express dotenv

2.2 环境变量区分前后端

  1. 项目根目录.env.local(BFF后端读取,不会暴露前端)
DEEPSEEK_API_KEY=sk-你的DeepSeek密钥
  1. 前端无需存放任何LLM密钥,彻底规避泄露风险

2.3 vite.config.js 代理跨域配置(关键)

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      // 匹配前端/api开头请求,转发到3000端口BFF服务
      '/api': {
        target: 'http://127.0.0.1:3000',
        changeOrigin: true,
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
})

前端请求/api/stream,Vite自动转发为http://127.0.0.1:3000/stream,消除跨域报错。

三、完整BFF后端代码 server.js(Express流式转发)

import * as dotenv from 'dotenv';
import express from 'express';
dotenv.config({ path: ['.env', '.env.local'] });

const app = express();
const port = 3000;
// 解析url参数
app.use(express.urlencoded({ extended: true }));

// 基础测试路由
app.get('/', (req, res) => {
  res.send('BFF服务运行正常,访问 /api/stream 发起AI对话');
});

// SSE流式中转核心接口
app.get('/stream', async (req, res) => {
  const { prompt } = req.query;
  if (!prompt) return res.status(400).send('prompt参数不能为空');

  const endpoint = 'https://api.deepseek.com/chat/completions';
  const apiKey = process.env.DEEPSEEK_API_KEY;
  const model = 'deepseek-v4-flash';

  // SSE标准响应头,必须设置,否则无法持续推送流
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  try {
    // 请求DeepSeek流式接口
    const llmRes = await fetch(endpoint, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model,
        stream: true,
        messages: [{ role: 'user', content: prompt }],
      }),
    });

    // 把大模型返回的二进制流直接透传给前端
    llmRes.body.pipe(res);

    // 客户端关闭连接时销毁请求,避免资源占用
    req.on('close', () => {
      llmRes.body.destroy();
      res.end();
    });
  } catch (error) {
    console.error('LLM接口请求失败:', error);
    res.write(`data: {"error":"服务异常"}\n\n`);
    res.write(`data: [DONE]\n\n`);
    res.end();
  }
});

app.listen(port, () => {
  console.log(`BFF中转服务启动成功,端口:${port}`);
});

后端核心逻辑说明

  1. SSE响应头三件套:告知浏览器保持长连接、禁用缓存,持续接收数据流
  2. pipe(res):二进制流直接透传,无需后端解析JSON,性能更高
  3. 密钥存放服务端环境变量,前端完全无法获取
  4. 监听客户端断开事件,主动销毁流,防止后台无效请求堆积

四、Vue3前端完整代码 App.vue(优化版流式解析)

<script setup>
import { ref } from 'vue'
// 页面响应式状态
const question = ref('讲一个中国龙的故事')
const stream = ref(true) // 是否开启打字机流式输出
const content = ref('')
// 存储完整回答,用于关闭流式时一次性渲染
let accumulatedText = ''

// 核心请求函数
async function update() {
  if (!question.value.trim()) return
  content.value = ''
  accumulatedText = ''

  // 请求本地BFF代理接口,不会跨域、无密钥泄露
  const url = `/api/stream?prompt=${encodeURIComponent(question.value)}`
  const response = await fetch(url)
  const reader = response.body.getReader()
  const decoder = new TextDecoder('utf-8', { stream: true })
  let buffer = ''

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

    // 二进制转文本,stream:true兼容跨分片中文
    buffer += decoder.decode(value, { stream: true })
    // 按换行分割所有报文
    const lines = buffer.split('\n')
    // 最后一行大概率是不完整分片,存入buffer下次拼接
    buffer = lines.pop() || ''

    // 逐行解析有效data报文
    for (const line of lines) {
      if (!line.startsWith('data: ')) continue
      const dataStr = line.slice(6)
      // 流结束标识,直接跳过
      if (dataStr === '[DONE]') continue
      try {
        const json = JSON.parse(dataStr)
        // 提取增量文字
        const deltaText = json.choices?.[0]?.delta?.content || ''
        accumulatedText += deltaText
        // 流式开启则实时更新页面,打字机效果
        if (stream.value) {
          content.value = accumulatedText
        }
      } catch (err) {
        // JSON解析失败直接忽略,残缺片段已存入buffer
        continue
      }
    }
  }
  // 关闭流式:全部接收完成后一次性渲染全文
  if (!stream.value) {
    content.value = accumulatedText
  }
}
</script>

<template>
  <div class="container">
    <!-- 提问输入区 -->
    <div>
      <label>输入提问:</label>
      <input class="input" v-model="question" placeholder="输入你的问题" />
      <button @click="update">提交AI问答</button>
    </div>

    <!-- 流式开关 + AI回答展示 -->
    <div class="output">
      <div style="margin-bottom:8px;">
        <label>开启Streaming流式打字机:</label>
        <input type="checkbox" v-model="stream" />
      </div>
      <div style="white-space: pre-wrap;">{{ content }}</div>
    </div>
  </div>
</template>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-start;
  height: 100vh;
  font-size: 0.85rem;
  padding: 20px;
}
.input {
  width: 300px;
  padding: 4px 8px;
}
.output {
  margin-top: 12px;
  min-height: 300px;
  width: 100%;
  text-align: left;
  line-height: 1.7;
}
button {
  padding: 4px 12px;
  margin-left: 8px;
  cursor: pointer;
}
</style>

前端流式优化点(对比纯前端直连方案)

  1. 请求地址统一代理:只访问本地/api/stream,不暴露第三方LLM接口
  2. 极简buffer容错:分割后把最后一行残缺报文放回buffer,逻辑更简洁稳定
  3. TextDecoder({stream:true}):解决跨分片中文乱码问题
  4. 分离accumulatedText完整缓存,支持一键切换流式/一次性渲染
  5. 增加输入空值拦截,避免无效请求

五、完整运行步骤

  1. 新建.env.local,填入DeepSeek密钥
  2. 终端启动BFF后端服务
node server.js
  1. 新开终端启动Vite前端
npm run dev
  1. 打开页面输入提问,勾选/取消Streaming测试两种输出模式

六、开发高频踩坑清单(必看)

坑1:前端直接请求LLM接口,密钥泄露被盗刷

现象:上线后账单暴涨,开发者工具Network面板可见完整Authorization密钥 解决方案:全部请求走BFF中转,密钥仅存Node服务端环境变量

坑2:BFF未设置SSE响应头,流式一次性返回

现象:页面等待全部生成后才展示文字,无逐字打字效果 修复:必须添加text/event-streamno-cachekeep-alive三个响应头

坑3:Vite代理502跨域报错

原因:BFF后端未启动、端口不匹配、代理rewrite路径错误 检查:确认BFF运行在3000端口,vite代理配置正确

坑4:TCP分包截断JSON,控制台频繁parse报错

根源:网络分片把一条data: JSON拆成两段 解决:代码中buffer缓存末尾残缺行,下一轮循环拼接完整再解析

坑5:中文跨分片出现乱码问号

修复new TextDecoder('utf-8', { stream: true })开启流式解码

坑6:切换流式开关文字错乱、重复叠加

优化:每次请求重置accumulatedText完整缓存,分开实时渲染与最终渲染逻辑

七、两种架构方案对比

方案前端直连LLMBFF中转架构(本文方案)
密钥安全极低,极易泄露极高,密钥仅存服务端
接口维护切换模型需修改全部前端代码仅修改后端,前端无感知
前端复杂度高,需处理鉴权、流、异常低,仅处理本地流式解析
扩展能力无法加限流、敏感词过滤后端统一加权限、限流、校验
线上风险盗刷、接口暴露安全可控,生产推荐

八、项目扩展优化方向

  1. 增加请求限流:后端引入express-rate-limit,防止恶意刷接口
  2. POST传参替代URL拼接:避免长prompt参数超长,改用body传递提问
  3. 中断生成:搭配AbortController,实现停止AI输出功能
  4. 多轮对话:扩展BFF接口接收完整messages数组,支持连续聊天
  5. 异常统一处理:后端捕获401/429/500错误,标准化错误报文返回前端
  6. Markdown渲染:流接收完成后使用marked解析富文本格式

九、总结

  1. 生产环境绝对禁止前端直连大模型API,BFF中转层是安全底线,杜绝密钥泄露盗刷;
  2. SSE流式输出核心依靠二进制长连接,buffer缓存是解决TCP分片截断的必备容错逻辑;
  3. Vite代理转发解决前后端跨域,Express通过pipe透传LLM二进制流,性能最优;
  4. 整套架构分层清晰:前端负责页面交互、BFF负责安全中转、大模型负责文本生成,可直接落地商用AI对话产品。