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>
posted on 2025-09-05 15:18  自由港  阅读(42)  评论(0)    收藏  举报