引言
在现代 Web 应用中,用户越来越期待即时反馈。当集成大语言模型(LLM)如 DeepSeek、ChatGPT 等时,传统的"发送请求 → 等待完整响应 → 显示结果"模式已无法满足体验需求。理想情况是:服务器每生成一个词,客户端就能立即看到。
为此,许多 LLM 提供商支持 流式输出(Streaming Response) ,通过 HTTP 分块传输编码(chunked transfer encoding)持续推送数据。然而,直接在前端调用这些流式 API 存在严重安全隐患和功能限制。本文介绍一种更优雅、安全且可维护的方案:由后端服务作为代理,接收远程流式响应,并通过 Server-Sent Events(SSE)推送给前端。
SSE是一种基于HTTP的服务器向客户端单向推送数据的标准化通信机制。在Web应用中,当需要实现服务端持续向浏览器传输更新时,SSE提供了一种轻量且高效的解决方案。相较于传统的轮询或长轮询,SSE通过建立持久化的HTTP连接,允许服务器在数据生成后立即推送给客户端,从而显著降低延迟并减少不必要的网络开销。
一、为什么不能在前端直接处理流式请求?
我们先来看一段典型的前端流式请求代码:
js
const update = async () => {
// 检查输入是否为空,若为空则直接返回
if(!input){
return
}
// 设置输出状态为"思考中...",表示正在处理请求
setOutput('思考中。。。')
// 定义API端点URL
const endpoint = 'https://api.deepseek.com/chat/completions'
// 设置请求头,包括内容类型和认证信息
const headers = {
'Content-Type': 'application/json', // 设置请求内容类型为JSON
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}` // 使用环境变量中的API密钥进行认证
}
// 发送POST请求到API
const response = await fetch(endpoint, {
method: 'POST', // 使用POST方法发送请求
headers: headers, // 设置请求头
body: JSON.stringify({ // 将请求体转换为JSON字符串
model: "deepseek-chat", // 指定使用的模型为deepseek-chat
messages: [{role: "user", content: input}], // 设置消息内容,角色为user,内容为用户输入
stream:stream // 设置是否使用流式传输
})
})
// 解析响应数据
if (stream) {
clearOutput() // 清空之前的输出内容
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8') // 创建UTF-8解码器
let done = false // 标记流是否读取完成
let buffer = '' // 用于存储不完整的块数据
while (!done) {
// 从读取器中读取数据块,并解构出值和完成状态
const { value, done: readerDone } = await reader.read()
// 更新完成状态
done = readerDone
// 将缓冲区中的数据与当前解码后的值合并
const chunkValue = buffer +decoder.decode(value)
// 清空缓冲区
buffer = ''
// 按行分割数据,并过滤出以 'data: ' 开头的行
const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
console.log(lines) // 打印处理后的行数据
// 遍历所有行
for(const line of lines){
// 去除 'data: ' 前缀,获取实际数据
const incoming = line.slice(6)
// 检查是否为结束标记
if(incoming === '[DONE]'){
done = true
break // 遇到结束标记,退出循环
}
// 解析 JSON 数据
const data =JSON.parse(incoming)
// 更新输出,将新的内容追加到之前的输出中
setOutput(prevOutput => prevOutput + data.choices[0].delta.content)
}
}
}else{
const data = await response.json()
// 更新输出状态为API返回的消息内容
setOutput(data.choices[0].message.content)
// 在控制台打印响应数据,用于调试
console.log(data)
}
❌ 存在的问题:
-
API 密钥暴露风险
将
VITE_DEEPSEEK_API_KEY
放在前端环境变量中,最终仍会打包进 JS 文件,任何用户都可以通过 DevTools 查看或抓包获取密钥。 -
无法设置某些请求头
浏览器出于安全考虑,禁止 JavaScript 设置部分敏感请求头(如
Connection
,Transfer-Encoding
等),可能导致与某些 API 不兼容。 -
跨域问题复杂化
第三方 API 可能未开启 CORS 或不允许
fetch
的特定配置,导致预检失败。 -
连接管理困难
需手动管理
ReadableStream
、解码、缓冲区拼接、错误重试等,逻辑复杂易出错。 -
不利于统一鉴权与限流
若未来需要做用户级访问控制、频率限制、日志记录等,必须依赖后端介入。
✅ 结论:流式请求应由后端发起,前端只负责展示结果。
二、解决方案:后端代理 + SSE
我们将采用如下架构:
scss
[前端]
↓ (HTTP 请求)
[Node.js 后端]
↓ (流式请求 + 认证)
[DeepSeek API]
后端收到 DeepSeek 的流式响应后,边读取边解析 ,然后通过 SSE(Server-Sent Events) 将每一个增量文本实时推送给前端。
✅ 优势:
- 安全:API Key 保存在服务端环境变量中
- 灵活:可在代理层添加鉴权、缓存、日志、重试等逻辑
- 实时:前端通过 SSE 自动接收更新,无需轮询
- 标准化:SSE 是 W3C 标准,浏览器原生支持自动重连、事件类型区分等特性
三、服务端实现(Node.js + Express)
1. 创建 SSE 路由
js
// routes/sse.js
import express from 'express';
import axios from 'axios'; // 使用 axios 支持流式响应
import { TextDecoder } from 'util'; // Node.js 内置
const router = express.Router();
router.post('/chat-stream', async (req, res) => {
const { input } = req.body;
if (!input) {
return res.status(400).json({ error: '输入不能为空' });
}
// 设置 SSE 响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Nginx 关键配置:禁用缓冲
});
const decoder = new TextDecoder('utf-8');
let buffer = ''; // 缓冲未完整解析的数据
try {
const upstreamResponse = await axios({
method: 'POST',
url: 'https://api.deepseek.com/chat/completions',
headers: {
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
},
data: {
model: 'deepseek-chat',
messages: [{ role: 'user', content: input }],
stream: true
},
responseType: 'stream' // 关键:以流的形式接收
});
// 监听数据流
upstreamResponse.data.on('data', chunk => {
buffer += decoder.decode(chunk, { stream: true });
// 按行分割,注意可能有多条或半条
const lines = buffer.split('\n');
buffer = ''; // 清空缓冲(实际应保留不完整的部分)
for (const line of lines) {
if (line.trim() === '') continue;
if (!line.startsWith('data:')) continue;
const dataStr = line.slice(5).trim(); // 去掉 "data:"
if (dataStr === '[DONE]') {
res.write('data: [DONE]\n\n'); // 转发结束信号
res.end();
return;
}
try {
const json = JSON.parse(dataStr);
const content = json.choices[0]?.delta?.content;
if (content) {
res.write(`data: ${content}\n\n`); // 符合 SSE 协议格式
}
} catch (err) {
console.error('解析失败:', err);
}
}
});
upstreamResponse.data.on('end', () => {
res.end();
});
upstreamResponse.data.on('error', (err) => {
console.error('上游流错误:', err);
res.write(`data: [ERROR] ${err.message}\n\n`);
res.end();
});
} catch (error) {
console.error('请求失败:', error);
res.write(`data: [ERROR] 请求异常,请稍后再试。\n\n`);
res.end();
}
});
export default router;
⚠️ 注意:
buffer
处理需谨慎,若一行被截断,应保留到下次再处理。- 生产环境中应使用更健壮的行解析器(如逐字节扫描
\n
)。X-Accel-Buffering: no
是 Nginx 必须设置的,否则会缓冲整个响应。
四、前端实现(EventSource + 动态渲染)
1. 发起 SSE 连接
js
function startStream(input) {
// 清空旧输出
setOutput('');
// 创建 EventSource 实例
const eventSource = new EventSource(`/api/chat-stream`);
// 接收消息
eventSource.onmessage = (event) => {
const data = event.data;
if (data === '[DONE]') {
eventSource.close();
return;
}
if (data.startsWith('[ERROR]')) {
setOutput(prev => prev + '\n\n系统错误:' + data);
eventSource.close();
return;
}
// 正常文本增量
setOutput(prev => prev + data);
};
eventSource.onerror = (err) => {
console.error('SSE 错误:', err);
eventSource.close();
};
// 发送输入内容(可通过其他方式传递,例如先 POST 触发)
fetch('/api/start-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input })
}).catch(console.error);
}
💡 提示:由于
EventSource
不支持自定义请求头,通常先用普通 POST 请求触发对话,再开启 SSE 连接拉取流。
五、关键技术点详解
1. UTF-8 解码中断问题
UTF-8 字符可能是 1~4 字节,网络传输中可能在一个字节中间切断。因此:
js
buffer += decoder.decode(chunk, { stream: true }); // 使用 { stream: true } 告知解码器后续还有数据
这能确保多字节字符不会被错误解析。
2. 行切割与缓冲策略
不要简单地 split('\n')
后清空 buffer
,而应判断最后一行是否完整:
js
const lines = buffer.split('\n');
buffer = lines.pop(); // 最后一行可能不完整,留作下一次处理
for (const line of lines) {
// 处理完整行
}
3. SSE 协议格式要求
每条消息必须以 data:
开头,末尾两个换行符结束:
kotlin
data: Hello world
data: How are you?
data: [DONE]
浏览器会自动合并同一事件的多行 data:
字段。
4. 自动重连机制
EventSource
默认会在连接断开后自动尝试重连(间隔几秒)。可通过监听 onopen
和 onerror
控制行为。