🔥 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、结合知识库回答! 🚀

相关推荐
alex2751 小时前
深入 Spring AI 聊天补全:ChatClient、PromptTemplate、Advisor 一网打尽!
人工智能
IVEN_1 小时前
Hermes Agent 接入 Kimi Coding 套餐:修复 Vision 图像分析功能
人工智能
Bode_20021 小时前
AI时代制造企业创新的需要的关键技术
人工智能
Arvid1 小时前
Transformer 隐藏的另一半:Attention 之后,大模型靠什么变聪明?
人工智能
极客老王说Agent1 小时前
实在Agent委外加工智能化管控方案与落地案例:重构2026制造业协同新范式
人工智能·ai·chatgpt
璞华Purvar1 小时前
VC PE投资管理系统选型的核心考量因素有哪些?(2026选型指南)
大数据·运维·人工智能
图特摩斯科技1 小时前
不用OWL/RDF!Function 和 Action 在本体智能平台中的重要性体现
人工智能·知识图谱·本体论·palantir·ontology
飞哥数智坊1 小时前
“AI 做事,人做主”,值得好好琢磨
人工智能·ai编程