SSE流式输出

用过 ChatGPT 的同学一定对那个"一个字一个字往外蹦"的效果印象深刻。很多人以为这是前端做的动画,其实它来自模型 API 本身的流式响应能力。如果你还在用同步调用让用户干等 10 秒才看到完整回复,那这篇文章就是为你准备的。

本文将带你完整实现一个生产可用的流式对话系统:从 Spring AI 的 Flux<String> 基础,到 SSE 推送到前端,再到多轮记忆、异常处理和性能选型。全程有代码、有测试命令、有前端示例,跟着做就能直接搬进你的项目。




一、同步 vs 流式:体验天差地别

传统同步调用:

复制代码
String content = chatClient.prompt()
        .user("写一首关于春天的诗")
        .call()
        .content();   // 等到完整结果才返回

用户要等模型生成完所有文字(比如 10 秒)才一次性看到结果。而流式调用:

复制代码
Flux<String> stream = chatClient.prompt()
        .user("写一首关于春天的诗")
        .stream()
        .content();   // 返回一个热流,每生成几个字就推一次

即使总耗时依然是 10 秒,用户在第 0.5 秒就能看到第一个字,体验完全不在一个档次。Spring AI 基于 Reactor 的 Flux 让这件事变得异常简单。

二、Server-Sent Events(SSE):最适合 Web 的流式方案

后端有了 Flux,前端怎么接收?SSE (Server-Sent Events)是最佳选择:浏览器原生支持(EventSource API),无需 WebSocket 或额外库,数据格式就是普通的 HTTP 流。

2.1 Spring MVC 下的 SSE 接口

复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/api/stream")
public class StreamController {

    private final ChatClient chatClient;

    public StreamController(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("你是一个 Java 技术助手,回答详细且有条理。")
                .build();
    }

    @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content();
    }
}

produces = MediaType.TEXT_EVENT_STREAM_VALUE 是关键,Spring 会自动将 Flux 的每个元素以 SSE 格式写入响应流。

💡 测试命令
curl "http://localhost:8080/api/stream/chat?message=解释一下JVM内存模型"

你会看到文字一句一句地出现在终端。

三、流式 + 多轮对话:带 conversationId 的流式多轮对话

流式输出不影响多轮对话。Spring AI 的 MessageChatMemoryAdvisor 搭配 MessageWindowChatMemory 可以轻松实现带会话 ID 的流式记忆

复制代码
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;

@RestController
@RequestMapping("/api/stream/conversation")
public class StreamConversationController {

    private final ChatClient chatClient;
    private final MessageWindowChatMemory chatMemory;

    public StreamConversationController(ChatClient.Builder builder) {
        this.chatMemory = MessageWindowChatMemory.builder().maxMessages(10).build();
        this.chatClient = builder.defaultSystem("你是一个 Java 技术助手").build();
    }

    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestParam String message,
                                   @RequestParam(defaultValue = "default") String conversationId) {
        return chatClient.prompt()
                .user(message)
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(conversationId)
                        .build())
                .stream()
                .content();
    }
}

测试

复制代码
# 第一轮:告诉AI名字
curl "http://localhost:8080/api/stream/conversation?message=我叫大王&conversationId=user001"

# 第二轮:询问记忆(同一个conversationId)
curl "http://localhost:8080/api/stream/conversation?message=你还记得我叫什么吗&conversationId=user001"

AI 会记得之前说过的话,而且所有回复都是流式的。

四、WebFlux 版本:更高性能的响应式写法

如果你的项目已经用了 Spring WebFlux,代码会更加简洁(框架对 Flux 是原生支持)。只需将 spring-boot-starter-web 替换为 spring-boot-starter-webflux

复制代码
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/api/stream")
public class WebFluxStreamController {

    private final ChatClient chatClient;

    public WebFluxStreamController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content()
                .doOnNext(chunk -> System.out.print(chunk))
                .doOnComplete(() -> System.out.println("\n[完成]"));
    }
}

WebFlux 下 Flux 直接作为响应体,框架自动处理背压和 SSE 格式转换。

五、流式输出时拿完整响应

很多场景下,我们既希望实时流式推送给用户,又希望在后端把完整的模型回复存下来(比如记录到数据库)。这俩需求完全不冲突------利用 doOnNextdoOnComplete 即可:

复制代码
@RestController
@RequestMapping("/api/stream/save")
public class StreamSaveController {

    private static final Logger log = LoggerFactory.getLogger(StreamSaveController.class);
    private final ChatClient chatClient;

    public StreamSaveController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatAndSave(@RequestParam String message,
                                    @RequestParam(defaultValue = "default") String conversationId) {
        StringBuilder fullResponse = new StringBuilder();

        return chatClient.prompt()
                .user(message)
                .stream()
                .content()
                .doOnNext(fullResponse::append)      // 累积每一个chunk
                .doOnComplete(() -> saveToDatabase(conversationId, message, fullResponse.toString()));
    }

    private void saveToDatabase(String conversationId, String question, String answer) {
        log.info("保存对话 {} - 问题: {} - 回复长度: {}", conversationId, question, answer.length());
        // 这里调用你的数据库服务
    }
}

测试:

复制代码
curl "http://localhost:8080/api/stream/save?message=用三句话介绍Spring AI&conversationId=session001"

你会看到流式输出打印在前端,同时后端日志里也会输出保存记录。

六、错误处理:别让异常破坏流式体验

流式调用有个容易踩坑的地方:一旦开始推送数据,就无法再返回 HTTP 4xx/5xx 状态码。正确的做法是把错误信息也作为一条流数据推给前端,然后优雅结束流。

复制代码
@RestController
@RequestMapping("/api/stream/safe")
public class StreamSafeController {

    private final ChatClient chatClient;

    public StreamSafeController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content()
                .timeout(Duration.ofSeconds(30))   // 30秒超时
                .onErrorResume(TimeoutException.class,
                        e -> Flux.just("[响应超时,请重试]"))
                .onErrorResume(e -> {
                    log.error("流式调用出错: {}", e.getMessage());
                    return Flux.just("[抱歉,生成过程中出现错误,请稍后重试]");
                });
    }
}

这样,无论超时还是模型异常,前端都能收到明确的错误文本,流也会正常关闭。

七、前端完整示例(带输入框 + 停止按钮)

光有后端还不够,前端用 EventSource 几行代码就能对接。下面是一个功能完整的 HTML 示例,支持:

  • 流式追加显示

  • 自动滚动到底部

  • 停止按钮中断流

  • 自动生成会话 ID(支持多轮)

    <!DOCTYPE html> <html> <head> <title>AI 流式对话</title> <style> body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; } #output { white-space: pre-wrap; border: 1px solid #ddd; padding: 15px; min-height: 200px; border-radius: 8px; background: #f9f9f9; margin-top: 10px; } .controls { display: flex; gap: 10px; margin-top: 10px; } input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; } button { padding: 8px 16px; border-radius: 4px; cursor: pointer; border: none; } #sendBtn { background: #4CAF50; color: white; } #stopBtn { background: #f44336; color: white; display: none; } </style> </head> <body>

    Spring AI 流式对话

    等待输入...
    <button id="sendBtn" onclick="sendMessage()">发送</button> <button id="stopBtn" onclick="stopStream()">停止</button>
    <script> let eventSource = null; const conversationId = 'user_' + Date.now();

    function sendMessage() {
    const input = document.getElementById('input');
    const output = document.getElementById('output');
    const message = input.value.trim();
    if (!message) return;

    复制代码
      if (eventSource) eventSource.close();
    
      output.textContent = '';
      document.getElementById('sendBtn').disabled = true;
      document.getElementById('stopBtn').style.display = 'inline-block';
    
      const url = `/api/stream/chat?message=${encodeURIComponent(message)}&conversationId=${conversationId}`;
      eventSource = new EventSource(url);
    
      eventSource.onmessage = (event) => {
          output.textContent += event.data;
          output.scrollTop = output.scrollHeight;
      };
    
      eventSource.onerror = () => onStreamEnd();
      input.value = '';

    }

    function stopStream() {
    if (eventSource) {
    eventSource.close();
    eventSource = null;
    }
    onStreamEnd();
    }

    function onStreamEnd() {
    document.getElementById('sendBtn').disabled = false;
    document.getElementById('stopBtn').style.display = 'none';
    eventSource = null;
    }
    </script>

    </body> </html>

将上述 HTML 放在 src/main/resources/static/index.html,启动 Spring Boot 后访问 http://localhost:8080 即可体验。

八、Spring MVC vs WebFlux:到底怎么选?

场景 推荐
已有 Spring MVC 项目,只是加 AI 功能 Spring MVC + Flux 返回值(本文主要演示的)
新项目,全栈响应式编程 Spring WebFlux
需要高并发流式连接(数千个同时) Spring WebFlux
简单 CRUD + AI 功能 Spring MVC 完全够用

注意:Spring MVC 也可以返回 Flux,框架会自动处理流式写入,不一定要全栈切到 WebFlux。

相关推荐
Maiko Star6 小时前
Function Calling:让大模型拥有“动手能力”
function·springai
Flying pigs~~17 小时前
Agent 完整面试指南:原理、框架、架构模式
大模型·prompt·agent·rag·agent架构·人工只能
xixixi7777720 小时前
三重筑基:5G-A超级上行提速千兆,电联低频共享扫平盲点,800V HVDC算电协同破局
人工智能·5g·ai·大模型·算力·通信·信通院
Resistance丶未来21 小时前
GPT-5.5 深度评测:性能边界与实战价值全解析
gpt·大模型·api·claude·gemini·api key·gpt5.5
ACCELERATOR_LLC1 天前
【DataWhale组队学习】DIY-LLM Task6 评估与基准测试
人工智能·深度学习·大模型·模型评估
CoderJia程序员甲1 天前
GitHub 热榜项目 - 日榜(2026-04-29)
ai·大模型·github·ai教程
IT届小白1 天前
Medical-Qwen3-14B基于Ollama内网私有化部署方案
人工智能·大模型
GoAI1 天前
《深入浅出Agent》:项目深度解析Autoresearch
人工智能·深度学习·大模型·llm·agent
OpenCSG1 天前
Kimi K2.6:月之暗面发布的原生多模态智能体模型
人工智能·开源·大模型·ai技术·kimi k2.6