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(支持多轮)

    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。

相关推荐
AI原来如此12 分钟前
Claude Opus与GPT-5激战,国内API中转站如何应对2026模型迭代潮?
大数据·人工智能·gpt·ai·大模型·ai编程
weisian1511 小时前
基础篇--概念原理-25-大模型的剪枝是什么?怎么理解?——从原理到实战,一篇讲透
算法·机器学习·大模型·剪枝
CV-deeplearning18 小时前
李沐论文精读合集:67 篇深度学习经典论文逐段精读,从 AlexNet 到 Sora,B 站播放百万级的 AI 自学圣经
gpt·大模型·transformer·李沐·论文精读·ai学习路线
DogDaoDao18 小时前
【GitHub】VoxCPM2 实战全解析:原理、部署与效果对比
深度学习·大模型·github·音频·语音模型·tss·文本生成语音
AImatters20 小时前
原力灵机并购Atomix:让机器人在真实业务中长出数据飞轮
机器人·大模型·具身智能·atomix·原力灵机
Tbisnic21 小时前
AI大模型学习 第十天:让程序“指挥”大模型 —— 从对话到工具调用
人工智能·python·ai·大模型·react·cot·提示词工程
阿提说说1 天前
我的 NVIDIA 考试攻略
python·大模型·agent
大大杰哥1 天前
SSeEmitter的基本使用和介绍
java·sse·通信
刘大猫.1 天前
宇树科技回应联合英伟达开发“H2+”人形机器人,预计今年下半年正式亮相
人工智能·科技·机器学习·ai·chatgpt·机器人·大模型
蜂蜜黄油呀土豆1 天前
Agent 循环:观察、思考、行动(ReAct 入门)
python·ai·大模型·react·js