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 |
| 内存占用 | 整个响应存在内存里 | 按需消费,用完即丢 |
但是这段代码有两个问题:
- 原生 SSE 解析很痛苦 —— 字段名是
data:前缀,要手动解析 - 没有错误处理 —— 网络断了怎么办?
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
- 安全隔离:API Key 只存在于
process.env,前端代码零敏感信息 - 协议转换:LLM 的原始响应 → 前端友好的格式
- 流式转发:LLM 的 SSE chunk 原样透传给前端,不做缓冲
- 业务增强:可以在这里加日志、限流、鉴权、多模型切换
五、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?
| SSE | WebSocket | |
|---|---|---|
| 通信方向 | 单向(服务器 → 客户端) | 双向 |
| 协议 | HTTP | 独立协议(ws://) |
| 实现复杂度 | ⭐(fetch API 即可) | ⭐⭐⭐ |
| 自动重连 | ✅ 内置 | ❌ 需手动实现 |
| 代理/防火墙友好 | ✅ 纯 HTTP | ⚠️ 可能被拦截 |
| AI 对话场景 | ✅ 完美匹配 | ❌ 杀鸡用牛刀 |
AI 对话场景下,前端只需发送一次请求,后端持续返回——标准的单向流,SSE 是最佳选择。 WebSocket 的双向通信能力在这里完全用不上。
六、给初学者的"一句话总结"
如果你觉得前面内容太多,记住这四句话就够了:
- SSE:让服务器可以持续向浏览器推送数据,就像打开了一个永不挂断的电话
- BFF:前端和后端之间的"中间翻译官",解决跨域、隐藏密钥、转换数据
- Vite Proxy:开发时不需要写死
localhost:3000,写/api/xxx就行 - 流式体验:用户不等待,Token 不浪费,体验质的飞跃
写在最后
2026 年了,AI 应用的用户体验已经从"能不能用"进化到了"好不好用"。
一个转圈 30 秒的 AI 对话和一个逐字输出的 AI 对话,技术上的差距可能只有 50 行代码,但用户感知上的差距是天壤之别——前者让人觉得"这玩意是不是坏了",后者让人觉得"哇,它在思考"。
而实现这个"哇"体验的全部秘密,就是:SSE + BFF + Vite Proxy。
三个东西都很简单,拼在一起,就是 ChatGPT 同款的流式体验。
💡 如果觉得有帮助,点个赞👍支持一下,后续还会分享更多 AI 全栈实战经验~
🐛 欢迎在评论区分享你的 SSE 踩坑经历或流式实现方案!