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。

相关推荐
GPUStack4 小时前
Day 0 实测|在 GPUStack 上部署 Inkling-BF16:8 卡 H20-141G 推理性能测试
ai·大模型·llm·gpu·vllm·gpu集群·sglang·gpustack
Tbisnic6 小时前
28.NLP 三大预训练模型 ELMo/BERT/GPT + HuggingFace Transformers
gpt·自然语言处理·大模型·nlp·bert·预训练模型
hhzz7 小时前
CNN图像分类入门:基于TensorFlow+Keras的CIFAR-10数据集全流程实战
图像处理·计算机视觉·ai·cnn·大模型
视***间7 小时前
端侧20B级推理标杆:视程空间Pandora,让GPT-OSS 20B在边缘原生落地
人工智能·gpt·大模型·本地部署·大模型本地部署·视程空间
冷小鱼1 天前
Agent 大模型的后训练、微调、评估与迭代优化:从理论到工程实践
大模型·微调·优化·迭代·评估
程序员-李俞1 天前
向量引擎接入自研 API 中转网关:鉴权、限流、熔断和审计日志复盘
服务器·人工智能·大模型·api·ai编程·ai api
山顶夕景2 天前
【Audio】Audio encoder相关Benchmark
大模型·音视频·多模态·audio·检索·全模态
一个天蝎座 白勺 程序猿2 天前
从电网改造踩坑说起:深度拆解时序大模型TimechoAI的自主可控与安全合规底气
大数据·运维·服务器·大模型·timechoai
不能只会打代码2 天前
Day 006 — Multi-Agent + MCP/A2A + 安全 + 可观测性
agent·token·sse·multi-agent·mcp·a2a
春波petal2 天前
大模型底层逻辑:优势局限与天生短板
人工智能·自然语言处理·chatgpt·大模型·多模态·语音大模型