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 格式转换。

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

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

复制代码
@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(支持多轮)

    AI 流式对话

    Spring AI 流式对话

    等待输入...

将上述 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。

相关推荐
猿界零零七2 小时前
Agent 开发面试题--Day002【Agent和Workflow的有什么区别】
人工智能·大模型
大鹏的NLP博客3 小时前
从格赖斯合作原则看大语言模型与提示词工程
人工智能·自然语言处理·大模型·agent
寻道码路3 小时前
AI Agent 工程化开发实战(五):持久化与断点恢复——Checkpointer 与 Store
人工智能·大模型·checkpoint·持久化·ai agent·langgraph·ai工程化
虎鲸不是鱼3 小时前
AI 自动化逆向(三十六)先找不加密的部分
人工智能·自动化·大模型·逆向·ai逆向
学IT的周星星5 小时前
本地部署与开源模型:不想花钱怎么办
人工智能·开源·大模型·llm·agent
重生之我在番茄自学网安拯救世界15 小时前
AI 开发者日报 第1期(本周精选)| 插件市场投毒、智能体搞出“官僚饥荒村”,十年前的 CNN 打赢大模型
人工智能·网络安全·大模型·ai编程·智能体
遇码18 小时前
2026-11-01-daw-retry-banner-body
人工智能·ai·rust·数据分析·大模型·agent
猿界零零七21 小时前
Agent 开发面试题--day001
人工智能·大模型
Flynn Liang1 天前
Spring AI 多模态实战(下):文生图 + TTS 语音合成(含 2.0 流式音频分片)
人工智能·spring·文生图·音视频·语音合成·tts·springai
增量星球1 天前
Spring AI 2.0 Tool Calling 到底怎么跑起来的:从 @Tool 到两次模型调用
java·agent·springai·ai coding