1.什么是SSE
SSE(Server-Sent Events)是一种让服务器能够主动向客户端推送数据的技术,特别适用于需要实时更新的场景。比如大模型请求这种就特别使用SSE。我们在请求大模型的时候,大模型响应通常会使用很长的时间,比如使用多个大模型的工作流的情况,不可能等全部返回完成才响应,比如每完成一步就可以向前端发送消息,这样可以即时响应,体验就会很好。
下面使用SSE实现模拟大模型的问答过程。
2.实现过程
- 浏览器使用SSE 建立连接
可以创建一个随机ID,通过URL将参数传递到后端,后端创建一个 id 和 SseEmitter 的实例对象建立一个映射。注意前端SSE不能发送参数到后端,不过可以通过URL参数获取Query 参数也是在地址栏的。 - 使用ajax 发送问题请求到后端
后端接受ID后 获取 SseEmitter 前端持续发送消息。
在生产环境中,可以使用用户的token 作为 URL参数。
3.实现代码
3.1 后端代码
- service 代码
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class QaService {
private final ConcurrentHashMap<String, SseEmitter> userEmitters = new ConcurrentHashMap<>();
public void addConnection(String userId, SseEmitter emitter) {
userEmitters.put(userId, emitter);
emitter.onCompletion(() -> userEmitters.remove(userId));
emitter.onTimeout(() -> userEmitters.remove(userId));
emitter.onError((Throwable throwable) -> userEmitters.remove(userId));
}
public SseEmitter getConnection(String userId) {
return userEmitters.get(userId);
}
// 模拟大模型流式回答
public void answerQuestion(String userId, String question) {
SseEmitter emitter = getConnection(userId);
if (emitter == null) {
return;
}
new Thread(() -> {
try {
// 发送开始回答事件
emitter.send(SseEmitter.event()
.name("ANSWER_START")
.data("开始回答问题: " + question));
// 模拟流式回答(分段发送)
String[] answerParts = {
"这是对您问题的回答。",
"首先,我们需要理解问题的核心。",
"根据相关知识,我们可以得出以下结论。",
"综上所述,建议您采取相应措施。",
"如果您还有其他问题,请继续提问。"
};
for (int i = 0; i < answerParts.length; i++) {
// 检查连接是否还存在
if (!userEmitters.containsKey(userId)) {
break;
}
emitter.send(SseEmitter.event()
.name("ANSWER_CHUNK")
.id(String.valueOf(i))
.data(answerParts[i]));
// 模拟处理时间
Thread.sleep(1000);
}
// 发送回答结束事件
emitter.send(SseEmitter.event()
.name("ANSWER_END")
.data("回答结束"));
} catch (Exception e) {
try {
emitter.send(SseEmitter.event()
.name("ERROR")
.data("回答过程中出现错误: " + e.getMessage()));
} catch (Exception ex) {
userEmitters.remove(userId);
}
}
}).start();
}
}
- 控制器代码
import com.redxun.ssedemo.service.QaService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
@RequestMapping("/qa")
public class QaController {
@Autowired
private QaService qaService;
// 建立SSE连接
@GetMapping(path = "/connect/{userId}", produces = "text/event-stream")
public SseEmitter connect(@PathVariable String userId) {
SseEmitter emitter = new SseEmitter(); // 永不超时
qaService.addConnection(userId, emitter);
try {
emitter.send(SseEmitter.event()
.name("CONNECTED")
.data("连接已建立,用户ID: " + userId));
} catch (Exception e) {
emitter.completeWithError(e);
}
return emitter;
}
// 客户端发送问题
@PostMapping("/ask/{userId}")
public String askQuestion(@PathVariable String userId, @RequestBody QuestionRequest request) {
// 启动异步回答过程
qaService.answerQuestion(userId, request.getQuestion());
return "问题已提交,正在处理中...";
}
// 问题请求数据类
public static class QuestionRequest {
private String question;
public String getQuestion() {
return question;
}
public void setQuestion(String question) {
this.question = question;
}
}
}
3.2 前端代码
<!DOCTYPE html>
<html>
<head>
<title>SSE 问答示例</title>
<meta charset="UTF-8" />
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
#output {
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
padding: 10px;
margin: 10px 0;
background-color: #f9f9f9;
}
.message { margin: 5px 0; }
.user-message { color: blue; }
.ai-message { color: green; }
.system-message { color: gray; }
.chunk-message { color: green; }
textarea { width: 100%; height: 60px; }
button { padding: 10px 15px; margin: 5px; }
.input-area { margin: 10px 0; }
</style>
</head>
<body>
<h1>SSE 流式问答示例</h1>
<div id="output"></div>
<div class="input-area">
<textarea id="questionInput" placeholder="请输入您的问题..."></textarea>
<br>
<button onclick="askQuestion()">发送问题</button>
<button onclick="reconnect()">重新连接</button>
<button onclick="clearOutput()">清屏</button>
</div>
<script>
let eventSource;
let userId = 'user_' + Math.random().toString(36).substr(2, 9); // 生成随机用户ID
let isReceivingAnswer = false;
function connect() {
const outputDiv = document.getElementById('output');
// 创建SSE连接
eventSource = new EventSource(`/qa/connect/${userId}`);
// 连接建立
eventSource.addEventListener('CONNECTED', (event) => {
appendMessage(`[系统] ${event.data}`, 'system-message');
});
// 接收开始回答事件
eventSource.addEventListener('ANSWER_START', (event) => {
isReceivingAnswer = true;
appendMessage(`[AI] ${event.data}`, 'ai-message');
});
// 接收回答片段
eventSource.addEventListener('ANSWER_CHUNK', (event) => {
appendMessage(`[AI] ${event.data}`, 'chunk-message');
});
// 接收回答结束事件
eventSource.addEventListener('ANSWER_END', (event) => {
isReceivingAnswer = false;
appendMessage(`[AI] ${event.data}`, 'system-message');
});
// 错误处理
eventSource.onerror = (err) => {
appendMessage("[系统] 连接错误", 'system-message');
};
}
function askQuestion() {
const question = document.getElementById('questionInput').value.trim();
if (!question) {
alert("请输入问题");
return;
}
// 显示用户问题
appendMessage(`[我] ${question}`, 'user-message');
// 清空输入框
document.getElementById('questionInput').value = '';
// 发送问题到服务器
fetch(`/qa/ask/${userId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ question: question })
})
.then(response => response.text())
.then(data => {
appendMessage(`[系统] ${data}`, 'system-message');
})
.catch(error => {
appendMessage(`[错误] 发送问题失败: ${error}`, 'system-message');
});
}
function appendMessage(msg, className) {
const outputDiv = document.getElementById('output');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${className}`;
messageDiv.innerHTML = msg;
outputDiv.appendChild(messageDiv);
outputDiv.scrollTop = outputDiv.scrollHeight; // 自动滚动到底部
}
function reconnect() {
if (eventSource) {
eventSource.close();
}
connect();
}
function clearOutput() {
document.getElementById('output').innerHTML = '';
}
// 支持回车发送
document.getElementById('questionInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
askQuestion();
}
});
// 初始化连接
connect();
</script>
</body>
</html>