AI 对话还在等完整回复?教你用 SSE + BFF 实现 ChatGPT 同款"逐字输出"

4 阅读6分钟

AI 对话还在等完整回复?教你用 SSE + BFF 实现 ChatGPT 同款"逐字输出"

为什么 ChatGPT 可以一个字一个字往外蹦,而你调的 AI 接口只能"转圈等 30 秒然后啪地一下全出来"?答案很简单,就差这一个中间层。


先看两种体验的差距

❌ 传统方式:一次请求,等全部返回

用户: "帮我写一篇关于 Vue 3 的文章" 
      ↓
      转圈……转圈……转圈……(30秒)
      ↓
      啪!5000 字一次性全部出来

用户的感受:"怎么这么慢?是不是卡了?刷新一下吧。"(然后你的 LLM Token 白花了)

✅ SSE 流式方式:一个字一个字往外蹦

用户: "帮我写一篇关于 Vue 3 的文章"
      ↓
      Vue
      Vue 3
      Vue 3 是
      Vue 3 是一款渐进式
      Vue 3 是一款渐进式 JavaScript 框架……
      ↓
      每个字出来,用户都在阅读,根本不觉得在等

这就是 ChatGPT、Claude、Kimi 等所有主流 AI 产品都在用的方式。

实现它,你只需要两个东西:SSE(Server-Sent Events) + BFF(Backend For Frontend)

今天我就带你从零落地一个 AI 流式对话应用。


一、先搞清楚架构:为什么要加一个 BFF 层?

很多人第一反应是:"前端直接调 LLM API 不就行了?"

// ❌ 天真地以为可以这样
fetch('https://api.openai.com/v1/chat/completions', {
  headers: { 'Authorization': 'Bearer sk-xxx' },
  body: JSON.stringify({ model: 'gpt-4', messages: [...] })
})

然后你会发现三个致命问题:

问题后果
跨域(CORS)浏览器直接拦截,请求都发不出去
API Key 暴露你的 sk-xxx 直接写在前端代码里,谁都能看到
响应格式不适配LLM 返回的原始 SSE 数据结构,前端不能直接用

这就像你穿着睡衣去参加董事会——不是说不行,但一定会出问题。

正确的架构是这样的:

┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│   Vue 3 前端  │ ──→  │  Express BFF │ ──→  │   LLM API   │
│  (Vite 开发)  │ ←──  │  (端口3000)   │ ←──  │  (OpenAI等)  │
└──────────────┘ SSE  └──────────────┘ SSE  └──────────────┘
     用户看到                     ↑
     逐字输出               BFF 在这里做:
                            - 解决跨域
                            - 隐藏 API Key
                            - 转换数据格式
                            - 添加业务逻辑

BFF(Backend For Frontend) 不是新概念,但在 AI 时代它有了新的意义——它是前端和 AI 大模型之间的 "专属翻译官"


二、动手写代码:Vite 代理配置

首先解决开发环境的跨域问题。用 Vite 的 proxy 功能,一条配置搞定:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      // 所有 /api 开头的请求,自动转发给后端
      '/api': {
        target: 'http://localhost:3000',  // BFF 服务地址
        secure: false,
        // 把 /api/stream 变成 /stream
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
})

这一段的逻辑非常巧妙:

浏览器发送: GET /api/stream?prompt=hello
              ↓
Vite Dev Server 拦截到 /api 前缀
              ↓
改写路径: /stream?prompt=hello
              ↓
转发到: http://localhost:3000/stream?prompt=hello
              ↓
Express 处理请求 → 转发给 LLM → 流式返回

你已经有了一个零跨域问题的开发环境,前端代码里只需写相对路径:

// 前端代码,简洁得像在同域调用
fetch('/api/stream?prompt=写一篇Vue3教程')

不需要写死 http://localhost:3000,不需要处理 CORS headers。Vite 帮你扛下了所有。


三、核心实现:前端 SSE 消费

3.1 最朴素的方式 —— 感受原始 SSE 数据

先来一段最简单的前端代码,看看从"一次性请求"到"流式请求"的变化有多大:

// ❌ 一次性请求 —— 等全部数据到了才能处理
fetch('/api/stream?prompt=hello')
  .then(res => res.json())
  .then(data => {
    console.log(data)  // 30秒后才有输出
  })

// ✅ 流式请求 —— 数据来了立刻就处理
fetch('/api/stream?prompt=hello')
  .then(response => {
    const reader = response.body.getReader()
    const decoder = new TextDecoder()

    function read() {
      reader.read().then(({ done, value }) => {
        if (done) return
        // 每收到一个 chunk,立刻显示
        const text = decoder.decode(value)
        console.log(text)  // 立刻有输出!
        read()  // 继续读下一个 chunk
      })
    }
    read()
  })

区别有多大?看这个表格:

一次性请求流式请求
首次响应时间30 秒< 0.5 秒
用户体验干等边看边等
取消成本浪费全部 Token浪费极少的 Token
内存占用整个响应存在内存里按需消费,用完即丢

但是这段代码有两个问题:

  1. 原生 SSE 解析很痛苦 —— 字段名是 data: 前缀,要手动解析
  2. 没有错误处理 —— 网络断了怎么办?

3.2 进阶:构建一个完整的流式对话组件

下面是一个可以直接用的 Vue 3 流式对话组件:

<script setup>
import { ref } from 'vue'

const question = ref('')
const content = ref('')
const stream = ref(true)  // 是否开启流式
const loading = ref(false)

async function update() {
  if (!question.value.trim()) return

  loading.value = true
  content.value = ''

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

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

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

        buffer += decoder.decode(value, { stream: true })

        // 按 SSE 协议解析:每条消息以 \n\n 结尾
        const lines = buffer.split('\n')
        buffer = lines.pop() || ''  // 最后一个可能不完整,留在buffer

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6)  // 去掉 "data: " 前缀
            if (data === '[DONE]') continue
            try {
              const parsed = JSON.parse(data)
              // 逐字追加到界面上
              content.value += parsed.choices?.[0]?.delta?.content || ''
            } catch (e) {
              // 非 JSON 数据,可能是纯文本 chunk
              content.value += data
            }
          }
        }
      }
    } else {
      // === 一次性模式 ===
      const response = await fetch(
        `/api/chat?prompt=${encodeURIComponent(question.value)}`
      )
      const data = await response.json()
      content.value = data.content
    }
  } catch (error) {
    content.value = `❌ 请求失败: ${error.message}`
  } finally {
    loading.value = false
  }
}
</script>

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

    <!-- 模式选择 -->
    <div class="mode-switch">
      <label>
        <input type="checkbox" v-model="stream" />
        Streaming(流式输出)
      </label>
      <span class="hint">开启后体验 ChatGPT 同款逐字输出</span>
    </div>

    <!-- 输出区 -->
    <div class="output">
      <div v-if="loading && !content" class="loading-dots">
        思考中<span class="dots"></span>
      </div>
      <div class="content">{{ content }}</div>
    </div>
  </div>
</template>

3.3 三个关键细节

细节一:decoder.decode(value, { stream: true })

这个 { stream: true } 参数至关重要。UTF-8 编码中,一个中文字符是 3 个字节。如果不加这个参数,当 chunk 边界恰好切在一个中文字符的中间时,你会看到乱码。

// ❌ 不加 stream: true —— 可能出现乱码
decoder.decode(value)

// ✅ 加了 stream: true —— TextDecoder 会保留不完整的字节
//    等下一个 chunk 到了再拼接完整后解码
decoder.decode(value, { stream: true })

细节二:缓冲区管理

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() || ''  // 最后一行可能不完整,放回缓冲区

  // 处理完整的行...
}

这个 buffer 机制保证了:即使 SSE 消息被网络拆包了,也能正确拼接和解析。

细节三:[DONE] 信号

LLM 的 SSE 响应会用 data: [DONE] 标记结束:

if (data === '[DONE]') continue  // 跳过结束标记,不显示

四、Express BFF 服务端实现

前端搞定了,来看看 BFF 层怎么写。这里假设后端是一个 Express 服务(端口 3000):

// server.js (BFF 层)
import express from 'express'
import 'dotenv/config'

const app = express()
const PORT = 3000

// LLM 配置(API Key 只存在于服务端,前端永远看不到)
const LLM_API_URL = 'https://api.openai.com/v1/chat/completions'
const LLM_API_KEY = process.env.OPENAI_API_KEY  // 从 .env 读取

app.get('/stream', async (req, res) => {
  const { prompt } = req.query

  // 设置 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')  // 禁用 Nginx 缓冲

  // 允许前端通过 EventSource 连接(如果需要)
  // res.setHeader('Access-Control-Allow-Origin', '*')

  try {
    // 向 LLM 发起流式请求
    const llmResponse = await fetch(LLM_API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${LLM_API_KEY}`
      },
      body: JSON.stringify({
        model: 'gpt-4',
        messages: [{ role: 'user', content: prompt }],
        stream: true  // ← 关键!告诉 LLM 要流式返回
      })
    })

    // 把 LLM 的流式响应,直接管道传输给前端
    const reader = llmResponse.body.getReader()

    // 发送一个初始事件,让前端知道连接成功
    res.write(`data: ${JSON.stringify({ status: 'start' })}\n\n`)

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

      // 直接把 LLM 的 chunk 转发给前端
      const text = new TextDecoder().decode(value)
      res.write(`data: ${text}\n\n`)
    }

    // 发送结束信号
    res.write('data: [DONE]\n\n')
    res.end()
  } catch (error) {
    res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`)
    res.end()
  }
})

// 一次性接口 对比用
app.get('/chat', async (req, res) => {
  const { prompt } = req.query

  const llmResponse = await fetch(LLM_API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${LLM_API_KEY}`
    },
    body: JSON.stringify({
      model: 'gpt-4',
      messages: [{ role: 'user', content: prompt }],
      stream: false  // 一次性返回
    })
  })

  const data = await llmResponse.json()
  res.json({ content: data.choices[0].message.content })
})

app.listen(PORT, () => {
  console.log(`BFF Server running on http://localhost:${PORT}`)
})

BFF 层的四重作用

graph TB
    subgraph 前端
        A[Vue 3 应用]
    end
    subgraph BFF层 Express 3000
        B[隐藏 API Key]
        C[转换数据格式]
        D[添加业务逻辑]
        E[流式转发]
    end
    subgraph LLM
        F[OpenAI / Claude / ...]
    end

    A -->|fetch /api/stream| B
    B --> C
    C --> D
    D --> E
    E -->|Bearer Token| F
    F -->|SSE chunks| E
    E -->|SSE chunks| A
  1. 安全隔离:API Key 只存在于 process.env,前端代码零敏感信息
  2. 协议转换:LLM 的原始响应 → 前端友好的格式
  3. 流式转发:LLM 的 SSE chunk 原样透传给前端,不做缓冲
  4. 业务增强:可以在这里加日志、限流、鉴权、多模型切换

五、SSE 核心知识点速查

5.1 SSE 的响应头

Content-Type: text/event-stream    ← 必须!告诉浏览器这是 SSE
Cache-Control: no-cache            ← 禁止缓存
Connection: keep-alive             ← 保持连接
X-Accel-Buffering: no              ← 禁止 Nginx 缓冲(生产环境重要!)

5.2 SSE 消息格式

data: 这是一条消息\n\n
data: {"text": "JSON也可以"}\n\n
data: [DONE]\n\n

每条消息以 data: 开头,以 \n\n 结尾。双换行是消息边界。

5.3 为什么不用 WebSocket?

SSEWebSocket
通信方向单向(服务器 → 客户端)双向
协议HTTP独立协议(ws://)
实现复杂度⭐(fetch API 即可)⭐⭐⭐
自动重连✅ 内置❌ 需手动实现
代理/防火墙友好✅ 纯 HTTP⚠️ 可能被拦截
AI 对话场景完美匹配❌ 杀鸡用牛刀

AI 对话场景下,前端只需发送一次请求,后端持续返回——标准的单向流,SSE 是最佳选择。 WebSocket 的双向通信能力在这里完全用不上。


六、给初学者的"一句话总结"

如果你觉得前面内容太多,记住这四句话就够了:

  1. SSE:让服务器可以持续向浏览器推送数据,就像打开了一个永不挂断的电话
  2. BFF:前端和后端之间的"中间翻译官",解决跨域、隐藏密钥、转换数据
  3. Vite Proxy:开发时不需要写死 localhost:3000,写 /api/xxx 就行
  4. 流式体验:用户不等待,Token 不浪费,体验质的飞跃

写在最后

2026 年了,AI 应用的用户体验已经从"能不能用"进化到了"好不好用"。

一个转圈 30 秒的 AI 对话和一个逐字输出的 AI 对话,技术上的差距可能只有 50 行代码,但用户感知上的差距是天壤之别——前者让人觉得"这玩意是不是坏了",后者让人觉得"哇,它在思考"。

而实现这个"哇"体验的全部秘密,就是:SSE + BFF + Vite Proxy

三个东西都很简单,拼在一起,就是 ChatGPT 同款的流式体验。


💡 如果觉得有帮助,点个赞👍支持一下,后续还会分享更多 AI 全栈实战经验~

🐛 欢迎在评论区分享你的 SSE 踩坑经历或流式实现方案!