🔥 Spring AI 流式输出深度实战:SSE + 停止按钮 + JSON 事件,一文全搞定

导读:AI 生成回答让用户干等?流式输出让文字像打字机一样实时呈现!本文手把手教你用 Spring AI 实现 SSE 流式推送、停止生成控制、结构化 JSON 事件------代码全给你,直接抄进项目!


🎯 前言:流式输出是用户体验的分水岭

用过 ChatGPT 的人都知道,它的回答是一个字一个字"蹦"出来的,而不是等全部生成完才显示。这就是**流式(Streaming)**模式。在 Spring AI 里启用流式,用户等待的焦虑感直接减半,感知延迟从"秒级"降到"毫秒级"

本章把流式聊天的完整技术链路彻底讲透:从底层接口到 Web 应用实战,再到结构化事件设计。建议先收藏,跟着代码一步步敲!


📌 核心要点抢先看

  • ✅ 流式需要两个前提:模型服务支持 + Spring AI 实现 StreamingChatModel 接口
  • ChatClient.stream() 返回 Flux<ChatResponse>,配合 SSE 推送到浏览器
  • 停止生成Sinks.One<Boolean> + takeUntilOther 优雅实现
  • switchMap 确保新输入自动切换响应流,避免"消息串台"
  • 结构化 JSON 事件让前端不止收到文字,还能拿到 Token 用量、开始/结束信号

🧱 第一节:StreamingChatModel 接口------流式的入口

1.1 为什么返回类型是 Flux?

普通调用 call() 是阻塞式的:发请求 → 等模型生成完 → 收到完整响应。流式调用 stream() 则是:发请求 → 模型每生成一部分就推一部分 → 你实时收到片段。

Spring AI 用 Project Reactor 的 Flux 来表示这种"持续产生的数据流":

csharp 复制代码
public interface StreamingModel<TReq extends ModelRequest<?>,
                                 TResChunk extends ModelResponse<?>> {

    Flux<TResChunk> stream(TReq request);
}

Model.call() 的区别

维度 call() stream()
返回类型 单个 TRes Flux<TResChunk>
获取时机 全部生成完 实时片段
用户体验 白屏等待 即时反馈
适用场景 后台任务、短文本 聊天对话、长文本生成

1.2 StreamingChatModel 的便捷方法

StreamingChatModel 继承自 StreamingModel<Prompt, ChatResponse>,还提供了两个默认方法

arduino 复制代码
Flux<String> stream(String message);
Flux<String> stream(Message... messages);

如果你只关心纯文本内容,不需要构造 Prompt 对象,直接用这俩,省事!

1.3 ChatClient 的流式 API

scss 复制代码
chatClient.prompt().user("你好").stream()
    .chatClientResponse()  // Flux<ChatClientResponse> --- 最全,含上下文
    .chatResponse();       // Flux<ChatResponse> --- 含元数据(Token用量等)
    .content();            // Flux<String> --- 纯文本,最简单

💡 选型建议 :需要监控 Token 用量 → 用 chatResponse();只展示文字 → 用 content()


🌐 第二节:实战!流式 Web 应用完整代码

2.1 我们要做什么?

一个简单的网页聊天界面:

  • 用户输入文字 → POST 到后端
  • 后端调 AI 模型流式生成 → 通过 SSE(Server-Sent Events) 实时推送到浏览器
  • 用户可以随时点 "停止" 按钮终止生成

🔑 SSE 是什么? 浏览器原生支持的服务器单向推送协议,基于 HTTP,比 WebSocket 轻量,专门适合这种"服务器持续推文字"的场景。

2.2 核心难题:怎么优雅地停止流?

流一旦启动,模型会持续生成 Token。如果用户点了"停止",我们需要取消订阅、停止接收后续数据 。这里用 Reactor 的 Sinks.One<Boolean> 作为信号量。

ChatResponseHolder:停止信号封装

csharp 复制代码
public record ChatResponseHolder(Flux<ChatResponse> response,
                                 Sinks.One<Boolean> stopSignal) {

    public ChatResponseHolder(Flux<ChatResponse> response) {
        this(response, Sinks.one());
    }

    /**
     * 关键:takeUntilOther 持续从流中取元素,
     * 直到 stopSignal 发射一个值,立即停止!
     */
    public Flux<ChatResponse> chatResponse() {
        return response.takeUntilOther(stopSignal.asMono());
    }

    public void stop() {
        stopSignal.tryEmitValue(true);
    }
}

原理图解

scss 复制代码
Flux<ChatResponse> 持续产生 token ──→ takeUntilOther ──→ 前端实时显示
                                         ↑
                                  stopSignal.asMono()
                                         ↑
                              用户点击"停止" → tryEmitValue(true)

2.3 StreamingChatService:业务核心

csharp 复制代码
public class StreamingChatService {
    private final AtomicReference<ChatResponseHolder> chatResponseHolder 
        = new AtomicReference<>();
    private final Flux<ChatResponse> chatResponses;
    private FluxSink<String> fluxSink;

    public StreamingChatService(ChatClient chatClient) {
        // Flux.create 创建一个可外部推送元素的流
        Flux<String> chatInputs = Flux.create(sink -> fluxSink = sink);

        // switchMap:新输入到达时,自动切换到新的响应流,丢弃旧的!
        chatResponses = chatInputs.switchMap(input -> {
            var holder = new ChatResponseHolder(
                chatClient.prompt().user(input).stream().chatResponse()
            );
            chatResponseHolder.set(holder);
            return holder.chatResponse();
        });
    }

    // 用户发送新消息 → 推入 FluxSink 触发流式生成
    public void chat(String input) {
        fluxSink.next(input);
    }

    // 停止当前生成
    public void stopGeneration() {
        if (chatResponseHolder.get() != null) {
            chatResponseHolder.get().stop();
        }
    }

    // SSE 端点订阅这个流
    public Flux<ChatResponse> getChatResponses() {
        return chatResponses;
    }
}

🎯 为什么必须用 switchMap

假设用户连续快速发了两条消息:

  • 没有 switchMap:两条消息的响应会混在一起,前端看到文字乱串
  • switchMap:第二条消息到达时,自动取消第一条的订阅,只展示第二条的结果

这就是 switchMap 的魔力------自动丢弃旧流,只保留最新

2.4 StreamingChatController:SSE 端点暴露

kotlin 复制代码
@RestController
@RequestMapping("/streaming")
public class StreamingChatController {
    private final StreamingChatService chatService;

    public StreamingChatController(StreamingChatService chatService) {
        this.chatService = chatService;
    }

    // 接收用户输入,触发流式生成
    @PostMapping("/chat")
    public ResponseEntity<Void> chatInput(@RequestBody ChatRequest request) {
        chatService.chat(request.input());
        return ResponseEntity.noContent().build();
    }

    // 停止当前生成
    @PostMapping("/stop")
    public ResponseEntity<Void> stopGeneration() {
        chatService.stopGeneration();
        return ResponseEntity.noContent().build();
    }

    /**
     * 核心 SSE 端点:
     * produces = TEXT_EVENT_STREAM_VALUE 告诉浏览器这是事件流
     */
    @GetMapping(value = "/chatResponse", 
                produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> streamingChat() {
        return chatService.getChatResponses()
            .map(response -> ServerSentEvent.<String>builder()
                .event("message")
                .data(response.getResult().getOutput().getText())
                .build());
    }
}

2.5 前端 HTML:EventSource 接收流

xml 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Spring AI Streaming Chat Client</title>
    <style>
        #messages { height: 500px; border: 1px solid grey; overflow-y: scroll; }
        #controls { margin: 10px 0; }
    </style>
    <script src="https://unpkg.com/axios@1.6.7/dist/axios.min.js"></script>
    <script>
        // EventSource 自动建立 SSE 连接,持续监听服务器推送
        var eventSource = new EventSource("/streaming/chatResponse");
        eventSource.addEventListener("message", (e) => {
            var messages = document.getElementById("messages");
            var message = document.createElement("p");
            message.textContent = e.data;
            messages.appendChild(message);
        });

        function chat() {
            var input = document.getElementById("chatInput");
            if (input.value) {
                axios.post("/streaming/chat", { input: input.value });
            }
        }

        function stop() {
            axios.post("/streaming/stop");
        }
    </script>
</head>
<body>
    <h1>Spring AI Streaming Chat Client</h1>
    <div id="messages"></div>
    <div id="controls">
        <input id="chatInput">
        <button onclick="chat()">Send</button>
        <button onclick="stop()">Stop</button>
    </div>
</body>
</html>

运行效果 :访问 http://localhost:8080/streaming.html,输入 "how to cook fish",AI 的文字会像打字一样一行一行出现在页面上。点击 Stop 按钮,生成立刻停止!


📡 第三章:Streaming JSON Events------不止返回文字

3.1 为什么需要结构化事件?

前面示例里,SSE 只推送了 AI 生成的文本。但生产环境中,前端往往还需要:

  • Token 用量:这次对话花了多少 Token?
  • 开始/结束信号:方便前端做加载动画、滚动到底部
  • 会话 ID:用于多轮对话上下文关联

如果把这些信息全塞进纯文本里,前端解析起来很痛苦。更好的方案是:每个 SSE 事件都是一个结构化 JSON,用 event 字段区分类型

3.2 事件类型设计:Sealed Interface + Java Record

typescript 复制代码
import com.fasterxml.jackson.annotation.JsonIgnore;

public sealed interface StreamingEvent {

    @JsonIgnore
    String getEventType();

    // ── DeltaEvent:AI 生成的内容片段 ──
    record DeltaEvent(String v) implements StreamingEvent {
        @Override
        public String getEventType() {
            return "delta";
        }
    }

    // ── MessageEvent:元数据类事件 ──
    sealed interface MessageEvent extends StreamingEvent {
        @Override
        default String getEventType() {
            return "message";
        }
        String getType();
    }

    // Token 用量统计
    record TokenUsageEvent(String conversationId, int totalTokens) 
        implements MessageEvent {
        @Override
        public String getType() {
            return "token_usage";
        }
    }

    // 流式传输开始
    record MessageStreamingStartEvent(String conversationId) 
        implements MessageEvent {
        @Override
        public String getType() {
            return "message_streaming_start";
        }
    }

    // 流式传输完成
    record MessageStreamingCompleteEvent(String conversationId) 
        implements MessageEvent {
        @Override
        public String getType() {
            return "message_streaming_complete";
        }
    }
}

设计亮点

  • sealed interface 限制实现类,编译器帮你检查有没有漏处理的事件类型
  • JsonIgnore 让 Jackson 序列化时忽略 getEventType() 方法,避免循环
  • event 字段用 getEventType() 区分,data 字段用 getType() 再细分

3.3 控制器:多源拼接流

less 复制代码
@RestController
@RequestMapping("/streaming_app")
public class StreamingAppChatController {
    private final ChatClient chatClient;

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

    @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<StreamingEvent>> streamingChat(
            @RequestBody ChatRequest request) {
        var conversationId = UUID.randomUUID().toString();
        var totalTokens = new AtomicInteger(0);

        return Flux.concat(
            // 1. 先发"开始"事件,前端可以展示加载态
            Mono.just(new MessageStreamingStartEvent(conversationId)),

            // 2. AI 生成的内容流,同时累加 Token 用量
            chatClient.prompt().user(request.input()).stream()
                .chatResponse()
                .doOnNext(response -> {
                    var usage = response.getMetadata().getUsage();
                    if (usage != null) {
                        totalTokens.addAndGet(usage.getTotalTokens());
                    }
                })
                .map(response -> new DeltaEvent(
                    response.getResult().getOutput().getText())),

            // 3. 生成结束后发 Token 统计(Mono.defer 延迟求值)
            Mono.defer(() -> Mono.just(
                new TokenUsageEvent(conversationId, totalTokens.get()))),

            // 4. 最后发"完成"事件,前端可以收起加载态
            Mono.just(new MessageStreamingCompleteEvent(conversationId))

        ).map(event -> ServerSentEvent.<StreamingEvent>builder()
            .event(event.getEventType())   // SSE 的 event 字段
            .data(event)                   // SSE 的 data 字段(JSON)
            .build());
    }
}

🔥 关键技术点

  • Flux.concat:按顺序拼接多个流,确保事件顺序严格为 开始 → 内容 → 统计 → 完成
  • Mono.defer延迟创建 Mono ,确保执行到这里时 totalTokens 已经累加完毕
  • 如果不写 deferMono.just 会立即求值,此时 totalTokens 还是 0!

3.4 前端收到的原始 SSE 流

输入 "hi" 后,浏览器收到的原始事件流长这样:

vbnet 复制代码
event:message
data:{"conversationId":"c5b37454-c08b-4753-bc65-544b2feb2d79","type":"message_streaming_start"}

event:delta
data:{"v":"Hello"}

event:delta
data:{"v":"!"}

event:delta
data:{"v":" How"}

event:delta
data:{"v":" can"}

event:delta
data:{"v":" I"}

event:delta
data:{"v":" assist"}

event:delta
data:{"v":" you"}

event:delta
data:{"v":" today"}

event:delta
data:{"v":"?"}

event:delta
data:{"v":""}

event:message
data:{"conversationId":"c5b37454-c08b-4753-bc65-544b2feb2d79","totalTokens":19,"type":"token_usage"}

event:message
data:{"conversationId":"c5b37454-c08b-4753-bc65-544b2feb2d79","type":"message_streaming_complete"}

前端处理逻辑

ini 复制代码
eventSource.addEventListener("delta", (e) => {
    const data = JSON.parse(e.data);
    appendText(data.v);  // 逐字追加到聊天框
});

eventSource.addEventListener("message", (e) => {
    const data = JSON.parse(e.data);
    if (data.type === "token_usage") {
        showTokenCount(data.totalTokens);  // 显示 Token 用量
    } else if (data.type === "message_streaming_complete") {
        hideLoading();  // 隐藏加载动画
    }
});

🧠 总结:流式输出技术全景图

scss 复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        Spring AI 流式输出架构                         │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│   用户输入 ──POST /chat──→ StreamingChatService.chat(input)         │
│                              ↓                                      │
│                        FluxSink.next(input)                         │
│                              ↓                                      │
│   switchMap ──→ ChatClient.prompt().user(input).stream()            │
│                              ↓                                      │
│                        Flux<ChatResponse>                           │
│                              ↓                                      │
│   ChatResponseHolder ──→ takeUntilOther(stopSignal)                 │
│                              ↓                                      │
│   Controller ──→ Flux<ServerSentEvent<String|StreamingEvent>>       │
│                              ↓                                      │
│                        SSE (TEXT_EVENT_STREAM_VALUE)                │
│                              ↓                                      │
│                        浏览器 EventSource                           │
│                                                                     │
│   停止按钮 ──POST /stop──→ StreamingChatService.stopGeneration()    │
│                              ↓                                      │
│                        Sinks.One.tryEmitValue(true)                 │
│                              ↓                                      │
│                        takeUntilOther 终止取流                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

💬 互动时间

你实现流式输出时踩过哪些坑?

  • AswitchMap 没加,消息串台了
  • B:停止逻辑不会写,直接关连接太粗暴
  • C:SSE 和 WebSocket 选哪个纠结很久
  • D:结构化事件设计太复杂,直接用纯文本了
  • E:其他问题,评论区聊聊

这篇文章把流式聊天的完整链路都盘清楚了,代码可以直接复制到项目里跑!觉得有用的话,点赞👍、收藏⭐、转发给队友~ 下章预告:结构化输出转换器 + RAG 检索增强,让 AI 不止会聊天,还会乖乖返回 JSON、结合知识库回答! 🚀

相关推荐
奔跑中的小象15 分钟前
统信UOS + 天数AI卡部署SGLang服务手册
人工智能·uos·sglang·天数智芯
DevSecOps选型指南18 分钟前
中国版Mythos,为何是悬镜安全灵脉CodeAI?
人工智能·安全
Shockang28 分钟前
LangGraph 状态机实战
人工智能
刹那芳华199240 分钟前
循环神经网络的从零开始实现(RNN)
人工智能·rnn·深度学习
阿童木写作1 小时前
跨境图片翻译工具多合一,批量图片视频字幕翻译加智能抠图
人工智能·python·音视频·语音识别
科技之门1 小时前
AI3D从建模到贴图、绑骨和动画的完整流程怎么做?V2Fun完整工作流指南
人工智能·3d·贴图
宇宙第一小趴菜1 小时前
二、机器学习的应用领域和发展史
人工智能·机器学习
前端开发江鸟1 小时前
我写过 MCP Server,却一直以为 MCP 只有 Tool
人工智能
天国梦1 小时前
自习室智能化升级避坑指南:天学网AI智习室方案实测与选型建议
大数据·人工智能
阿里云云原生2 小时前
可用性从 99.9% 跃升至 99.995%:畅捷通如何用 AI 重塑运维底座?
运维·网络·人工智能