在做一个AI交互的小项目时,遇到个头疼的问题 —— 大模型生成回答太慢了。用户点了发送之后,要等好几秒才能看到完整回复,期间界面一动不动,很容易让人以为卡了。查了很多资料,发现普遍用 SSE 来解决这个问题,试了一下果然好用,今天就把这个过程拆解开来讲讲。
为什么需要流式输出?
先说说为啥要搞流式输出。普通的 HTTP 请求就像寄快递,服务器得把所有东西打包好才发货,用户只能等着。但大模型生成内容不一样,它是一个词一个词 "想" 出来的(业内叫 token),就像人打字一样。如果等它全想完再返回,用户得盯着空白屏幕等半天,体验特别差。
流式输出就是让服务器 "边想边说",生成一个词就立刻发给前端,前端收到就显示一个词。虽然总耗时可能差不多,但用户能实时看到内容在变化,心理上会觉得快很多。这招在 AI 聊天机器人里几乎是标配毕竟用户体验太重要了。
什么是 SSE?
一开始我以为要搞 WebSocket,后来发现根本没必要。WebSocket 是双向通信的,就像打电话,两边都能说。但流式输出只需要服务器往前端发数据,前端不用给服务器回消息,这时候 SSE 就够用了。
SSE 全称是 Server-Sent Events,翻译过来就是服务器发送事件。它本质上还是 HTTP 协议,但做了点改造 —— 让连接保持打开状态,服务器可以随时往里面塞数据。你可以把它理解成广播,前端一旦 "调频" 到这个频道,就能一直收到服务器发的内容。
对比一下 SSE 和 WebSocket:
| 特性 | WebSocket | SSE |
|---|---|---|
| 通信方向 | 双向(全双工) | 单向(服务器到客户端) |
| 协议 | ws:// 或 wss:// | http:// 或 https:// |
| 复杂度 | 较高(需要处理连接状态) | 较低(类似普通 HTTP) |
| 浏览器支持 | 现代浏览器均支持 | 除 IE 外均支持 |
| 断线重连 | 需要手动实现 | 原生支持自动重连 |
| 二进制支持 | 支持 | 仅支持文本 |
可以看到,SSE 在以下场景中具有明显优势:
- 服务器主动推送数据(如股票行情、实时通知)
- 不需要客户端向服务器发送数据
- 希望实现简单且稳定的实时通信
对咱们做聊天机器人来说,SSE 简直是量身定做的。
前端怎么收 SSE 数据?
前端实现特别简单,浏览器自带了 EventSource 对象,专门用来处理 SSE。就像打开一个水龙头,打开之后数据就会源源不断流过来。
// 连接到服务器的SSE接口
const source = new EventSource('/sse');
// 监听消息事件,服务器发数据就会触发
source.onmessage = function(event) {
// event.data就是服务器发过来的内容
const messages = document.getElementById('messages');
messages.innerHTML += event.data + '<br>';
}
// 连接成功时触发
source.onopen = function() {
console.log('连接上了,准备接收数据~');
}
// 出错时触发
source.onerror = function(error) {
console.error('出问题了:', error);
}
这里有几个细节要注意:
- EventSource 默认会自动重连,如果连接断了,它会每隔几秒重试一次,省了我们好多事
- 可以通过 source.readyState 查看连接状态:0 是正在连接,1 是已连接,2 是已关闭
- 不需要的时候记得调用 source.close () 关掉连接,不然会一直占着资源 我一开始犯了个傻,把接口地址写错了,结果控制台一直报错,后来才发现 EventSource 的 url 必须和当前页面同源,或者服务器配置了 CORS 才行。
后端怎么发 SSE 数据?
后端稍微复杂点,得改造一下 HTTP 响应,让它变成一个持续输出的流。我用的是 Express 框架,核心就是设置几个特殊的响应头。
app.get('/sse', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
//强制立即发送已设置的响应头
res.flushHeaders();
res.write('data: 开始接收数据啦\n\n');
// 模拟每秒发一次数据
const timer = setInterval(() => {
res.write(`data: 当前时间 ${new Date().toLocaleTimeString()}\n\n`);
}, 1000);
// 当客户端断开连接时,清理定时器
req.on('close', () => {
clearInterval(timer);
res.end();
});
});
这里的坑特别多:
- 数据格式必须严格按照
data: 内容\n\n来,少个换行都不行。如果内容有多行,每行都得加data:前缀 - 服务器不能调用 res.end (),不然连接就断了,得一直保持打开
- 要监听 req 的 close 事件,用户关掉页面时及时清理资源,不然会内存泄漏
- 本地测试没问题,部署到服务器时可能被 nginx 之类的反向代理拦截,需要配置 proxy_buffering off
我一开始就是因为没处理 close 事件,结果开着页面刷新几次,服务器上就挂着一堆定时器,后来看日志才发现这个问题。
怎么在 LLM 聊天机器人里用?
把 SSE 用到聊天机器人里其实很简单,核心就是把大模型生成的内容实时推给前端。
后端调用大模型 API 的时候,一般可以拿到一个流对象,我们只需要监听这个流的 data 事件,收到一点内容就立刻通过 SSE 发出去:
app.get('/chat', async (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
const response = await axios.post(
DEEPSEEK_API_URL,
{
model: "deepseek-chat",
messages: [
{ role: "user", content: userPrompt } // 用户问题
],
stream: true,
max_tokens: 1024
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${DEEPSEEK_API_KEY}`
},
responseType: 'stream' // 声明响应为流
}
);
// 监听流的data事件
response.data.on('data', (chunk) => {
res.write(`data: ${chunk}\n\n`);
});
response.data.on('end', () => {
res.end();
});
});
前端收到数据后,除了显示出来,还可以加点打字机效果,体验更好:
// 接收 SSE 流式数据
eventSource.onmessage = (event) => {
if (event.data === '[STREAM_END]') {
// 流结束,关闭连接并恢复按钮
eventSource.close();
sendBtn.disabled = false;
return;
}
// 拼接并更新 AI 回复内容
aiMessageDiv.innerHTML += event.data;
answerEl.scrollTop = answerEl.scrollHeight;
};
我还加了个小优化,就是当收到的内容包含换行符时,自动分段,看起来更舒服。
下面附上简单聊天机器人的完整代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DeepSeek + SSE 流式聊天示例</title>
<style>
.chat-container {
width: 800px;
margin: 20px auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
}
#chat-messages {
height: 400px;
overflow-y: auto;
margin-bottom: 20px;
padding: 10px;
border: 1px solid #f0f0f0;
border-radius: 4px;
}
.user-message {
text-align: right;
margin: 5px 0;
padding: 8px 12px;
background: #e6f7ff;
border-radius: 4px;
}
.ai-message {
text-align: left;
margin: 5px 0;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 4px;
}
.input-area {
display: flex;
gap: 10px;
}
#prompt {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 10px 20px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="chat-container">
<h2>DeepSeek 流式聊天(SSE 实现)</h2>
<div id="chat-messages"></div>
<div class="input-area">
<input type="text" id="prompt" placeholder="请输入你的问题...">
<button onclick="sendMessage()" id="send-btn">发送</button>
</div>
</div>
<script>
let eventSource = null;
const messagesDiv = document.getElementById('chat-messages');
const promptInput = document.getElementById('prompt');
const sendBtn = document.getElementById('send-btn');
// 发送消息并建立 SSE 连接
function sendMessage() {
const prompt = promptInput.value.trim();
if (!prompt) return;
sendBtn.disabled = true;
messagesDiv.innerHTML += `<div class="user-message">你:${prompt}</div>`;
promptInput.value = '';
scrollToBottom();
if (eventSource) eventSource.close();
eventSource = new EventSource(`/stream?prompt=${encodeURIComponent(prompt)}`);
const aiMessageDiv = document.createElement('div');
aiMessageDiv.className = 'ai-message';
aiMessageDiv.innerHTML = 'DeepSeek:';
messagesDiv.appendChild(aiMessageDiv);
// 接收 SSE 流式数据
eventSource.onmessage = (event) => {
if (event.data === '[STREAM_END]') {
eventSource.close();
sendBtn.disabled = false;
return;
}
aiMessageDiv.innerHTML += event.data;
scrollToBottom();
};
eventSource.onerror = (error) => {
console.error('SSE 连接错误:', error);
aiMessageDiv.innerHTML += '<br>(连接出错,请重试)';
eventSource.close();
sendBtn.disabled = false;
};
}
// 滚动到最新消息
function scrollToBottom() {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
</script>
</body>
</html>
const express = require('express');
const http = require('http');
const axios = require('axios'); // 用于调用 DeepSeek API
const app = express();
const server = http.createServer(app);
// 配置 DeepSeek API(需替换为你的 API Key)
const DEEPSEEK_API_KEY = '';
const DEEPSEEK_API_URL = 'https://api.deepseek.com/v1/chat/completions';
app.use(express.static(__dirname));
app.get('/', (req, res) => {
res.sendFile(__dirname + '/chat.html');
});
// SSE 流式接口
app.get('/stream', async (req, res) => {
const userPrompt = req.query.prompt;
if (!userPrompt) {
return res.status(400).send('请输入问题');
}
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*' // 跨域配置(生产环境需限制域名)
});
try {
// 调用 DeepSeek API(流式模式)
const response = await axios.post(
DEEPSEEK_API_URL,
{
model: "deepseek-chat",
messages: [
{ role: "user", content: userPrompt }
],
stream: true,
max_tokens: 1024
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${DEEPSEEK_API_KEY}`
},
responseType: 'stream'
}
);
// 监听 DeepSeek 流数据,通过 SSE 推送给前端
response.data.on('data', (chunk) => {
// DeepSeek 流式返回格式为:data: { ... }\n\n(需解析)
const chunkStr = chunk.toString().trim();
const lines = chunkStr.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trim(); // 去除 "data: " 前缀
if (jsonStr === '[DONE]') {
res.write('data: [STREAM_END]\n\n');
return;
}
try {
const data = JSON.parse(jsonStr);
const content = data.choices[0]?.delta?.content;
if (content) {
res.write(`data: ${content}\n\n`);
}
} catch (e) {
console.error('解析 DeepSeek 响应失败:', e);
}
}
}
});
// 流结束时关闭响应
response.data.on('end', () => {
res.end();
});
// 监听客户端断开连接,清理资源
req.on('close', () => {
response.data.destroy(); // 终止 DeepSeek 流
res.end();
});
} catch (error) {
console.error('调用 DeepSeek API 失败:', error.message);
res.write(`data: 调用失败:${error.message}\n\n`);
res.write('data: [STREAM_END]\n\n');
res.end();
}
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`服务已启动,访问 http://localhost:${PORT} 体验`);
});
效果如下:
最后想说的
学 SSE 的过程其实也是在理解 HTTP 协议的本质。以前总觉得 HTTP 就是 "请求 - 响应" 一次就完,没想到还能这么玩 —— 保持连接不断,持续发送数据。
对前端来说,掌握 SSE 不只是多了个技能,更重要的是理解 "用户体验" 的真正含义。有时候技术不用搞得多复杂,像 SSE 这样简单直接的方案,反而能解决大问题。
如果你也在做聊天机器人或者需要实时展示数据的项目,不妨试试 SSE,代码不多,效果却很明显。有什么问题欢迎在评论区交流,我也是个刚学没多久的新手,大家一起进步~