Java 大模型应用 - 流式响应的核心概念与代码实践

大模型生成一段回答往往要几秒甚至更久,等完整结果生成后再一次性返回,用户只能对着空白页面干等。本文介绍流式响应中用到的 Mono、Flux、Sinks、Disposable、flatMap 和背压,并给出一个基于 Spring WebFlux 与 SSE 的完整示例。

为什么大模型应用需要流式响应

传统 HTTP 接口通常在业务处理完成后一次性返回结果。这个方式适合耗时短、结果固定的请求,但不适合大模型逐步生成 Token 的过程。

假设模型生成完整回答需要 10 秒:

  • 非流式响应要等待 10 秒,之后一次性显示全部内容。
  • 流式响应可以在第一个 Token 产生后立即返回,并持续把后续 Token 推送给前端。

流式响应不会让模型生成得更快,只是让用户更早看到第一个结果。对话界面里的“逐字输出”和“停止生成”,靠的就是持续传输和任务取消。

在基于 Spring WebFlux 的 Java 应用中,一条常见的数据链路如下:

大模型逐步生成 Token
        ↓
Flux<String>
        ↓
Spring WebFlux
        ↓
SSE(text/event-stream)
        ↓
浏览器逐条接收并渲染

准备 Reactor 运行环境

如果项目没有使用 Spring Boot 的依赖管理,可以直接引入 reactor-core。

<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-core</artifactId>
    <version>3.8.6</version>
</dependency>

如果项目已经引入 spring-boot-starter-webflux,Spring Boot 会通过 BOM 管理 Reactor 版本,通常不需要再单独声明 reactor-core 的版本:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Mono:表示 0 或 1 个异步结果

Mono<T> 表示一个最多产生一个元素的异步序列,其结果只有三种可能:产生一个值后完成、不产生值直接完成,或者执行失败并发出错误信号。

它适合大模型的非流式调用、数据库单条查询以及只需要完成信号的异步任务。与直接返回 String 相比,返回 Mono<String> 不是说结果已经存在,而是在描述“订阅后如何产生这个结果”。

下面将一个阻塞式模型调用包装成 Mono:

import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

public class MonoChatService {

    // 将阻塞式模型调用包装为单值异步结果
    public Mono<String> chat(String prompt) {
        return Mono.fromCallable(() -> callBlockingModel(prompt))
                .subscribeOn(Schedulers.boundedElastic());
    }

    // 模拟阻塞式大模型客户端
    private String callBlockingModel(String prompt) {
        return "模型对问题的完整回答:" + prompt;
    }
}

这里有两个容易混淆的地方:

  1. Mono.fromCallable() 只负责延迟执行并包装结果,不会自动创建后台线程。
  2. subscribeOn(Schedulers.boundedElastic()) 才把阻塞任务安排到适合阻塞工作的有界弹性线程池。

在 WebFlux 控制器中应尽量直接返回 Mono,由框架负责订阅,不要为了立即取得结果而调用 block():

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;

@RestController
public class MonoChatController {

    // 非流式聊天服务
    private final MonoChatService chatService;

    // 初始化聊天控制器
    public MonoChatController(MonoChatService chatService) {
        this.chatService = chatService;
    }

    // 返回单次完整的大模型回答
    @GetMapping("/api/chat")
    public Mono<String> chat(@RequestParam String prompt) {
        return chatService.chat(prompt);
    }
}

Mono<Void> 只表示任务完成,不携带任何返回值,适合保存会话记录、发送通知这类操作。流式接口的停止接口用的就是它:完成即可,没有业务数据要返回。

Flux:表示 0 到 N 个连续结果

Flux<T> 可以连续产生 0 到 N 个元素。大模型每生成一段文本就发出一个 String,因此 Token 流通常表示为 Flux<String>。

下面用延迟元素模拟模型逐段生成内容:

import java.time.Duration;
import reactor.core.publisher.Flux;

public class FluxChatService {

    // 模拟大模型逐段返回 Token
    public Flux<String> stream(String prompt) {
        return Flux.just("你好,", "正在回答:", prompt)
                .delayElements(Duration.ofMillis(300));
    }
}

Flux 在创建时通常不会立即执行。只有出现订阅者后,数据源才开始产生数据:

// 流式聊天服务
FluxChatService chatService = new FluxChatService();

// 模型返回的 Token 流
Flux<String> tokenStream = chatService.stream("什么是流式响应?");

// 订阅 Token、错误和完成信号
tokenStream.subscribe(
        token -> System.out.print(token),
        error -> System.err.println("生成失败:" + error.getMessage()),
        () -> System.out.println("\n生成完成")
);

一个响应式流不只传递数据,还会传递错误和完成信号:onNext 表示收到一个 Token,onError 表示执行失败并终止,onComplete 表示所有 Token 已经发送完成。

在完整的流式接口中,Flux<String> 会被控制器包装成 SSE 事件流并作为响应返回,浏览器每收到一个事件就渲染一段内容。

Flux.concat 与 concatWith:按顺序拼接多个流

Flux 之间需要首尾相接时,可以使用 Flux.concat 或 concatWith。它们都会等前一个流正常完成后,才订阅下一个流,因此拼接后的元素顺序与各流的先后顺序一致。

Flux.concat 是静态方法,一次接收多个 Publisher:

import reactor.core.publisher.Flux;

public class ConcatExample {

    // 依次输出提示语、模型回答和结束标记
    public Flux<String> assemble() {
        // 首部提示流
        Flux<String> header = Flux.just("开始回答:");

        // 模型回答流
        Flux<String> answer = Flux.just("流式响应", "的核心概念");

        // 尾部结束流
        Flux<String> footer = Flux.just("[END]");

        return Flux.concat(header, answer, footer);
    }
}

concatWith 是实例方法,每次把另一个流拼接到当前流之后,上面的示例可以等价写成:

// 依次拼接提示流、回答流和结束流
Flux<String> combined = header.concatWith(answer).concatWith(footer);

这两种写法等价。

concat 与 flatMap 合并流的方式不同:

方式 订阅时机 元素顺序 总耗时
concat 前一个流完成后才订阅下一个 严格按流的先后顺序 各流耗时之和
flatMap 按并发度同时订阅多个内部流 不保证顺序 接近最慢内部流耗时

大模型应用里,concat 适合顺序明确的拼接,比如在回答流前面加角色提示、结尾追加结束标记;需要并行执行时才用 flatMap。另外 concat 遇到前一个流出错会直接终止,后面的流不会再被订阅。

Sinks:由业务代码主动向流中发送数据

有些数据源本身就返回 Flux,直接把流交给 WebFlux 就行。但智能体系统里,Token 还可能来自回调、消息队列或多个执行阶段,得由业务代码自己往流里写数据,Sinks 就是 Reactor 提供的手动写入入口。

下面创建一个只允许单个订阅者消费的 Token 通道:

import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
import reactor.util.concurrent.Queues;

public class TokenSink {

    // 单订阅者 Token 通道,最多暂存 256 个尚未处理的数据
    private final Sinks.Many<String> sink =
            Sinks.many().unicast().onBackpressureBuffer(
                    Queues.<String>get(256).get()
            );

    // 向通道发送一个 Token
    public void emit(String token) {
        // 本次发送的结果
        Sinks.EmitResult emitResult = sink.tryEmitNext(token);
        if (emitResult.isFailure()) {
            throw new IllegalStateException("Token 发送失败:" + emitResult);
        }
    }

    // 正常结束 Token 流
    public void complete() {
        sink.emitComplete(Sinks.EmitFailureHandler.FAIL_FAST);
    }

    // 将可写通道暴露为只读数据流
    public Flux<String> asFlux() {
        return sink.asFlux();
    }
}

三者在数据链路中的职责不同:

角色 作用
Sinks.Many<String> 由业务代码调用 tryEmitNext() 或 emitNext() 写入 Token
Flux<String> 向下游暴露只能订阅的数据流
Subscriber 消费 Token,并接收错误或完成信号

tryEmitNext() 不保证发送成功,它会返回 EmitResult。没有订阅者、流已经终止、并发发送冲突或下游没有需求时,都可能得到失败结果,生产代码不能完全忽略它。

Sinks.many() 还需要根据订阅模型选择具体类型:

  • unicast():只允许一个订阅者,适合一次聊天请求对应一个前端连接。
  • multicast():多个当前订阅者接收后续数据,适合实时广播。
  • replay():新订阅者还可以收到部分或全部历史数据,适合需要回放的场景。

流式接口里,模型 SDK 的回调或事件先写进 Sinks.Many,再由它转发给当前 SSE 请求,SDK 和 Web 层之间的数据传递就靠这一层中转。

Disposable:取消已经建立的订阅

调用 Lambda 形式的 subscribe() 后会得到一个 Disposable。它是本次订阅的取消句柄,可以用来实现“停止生成”。

下面使用会话 ID 保存和取消任务:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import reactor.core.Disposable;

public class AgentTaskManager {

    // 会话 ID 与流式任务之间的映射
    private final Map<String, Disposable> tasks = new ConcurrentHashMap<>();

    // 注册正在执行的流式任务
    public void register(String conversationId, Disposable task) {
        // 同一会话此前注册的任务
        Disposable previousTask = tasks.put(conversationId, task);
        if (previousTask != null) {
            previousTask.dispose();
        }
    }

    // 停止并移除指定会话的任务
    public boolean stop(String conversationId) {
        // 当前需要停止的任务
        Disposable task = tasks.remove(conversationId);
        if (task == null) {
            return false;
        }
        task.dispose();
        return true;
    }
}

启动模型流并注册任务时,可以这样使用:

// 当前会话的唯一标识
String conversationId = "conversation-1001";

// 模型返回的 Token 流
Flux<String> tokenStream = chatService.stream("介绍 Project Reactor");

// 订阅后得到的取消句柄
Disposable task = tokenStream.subscribe(token -> System.out.print(token));

// 保存任务,供停止接口使用
taskManager.register(conversationId, task);

当用户点击停止按钮时,服务端调用:

// 取消指定会话的订阅
boolean stopped = taskManager.stop("conversation-1001");

dispose() 可以取消 Reactor 订阅并向上游传播取消信号,但“取消订阅”不一定等于“远端大模型已经停止推理”。只有 HTTP 客户端、模型 SDK 和服务端都正确处理取消信号时,底层网络请求和推理任务才会真正结束。如果上游忽略取消,应用只能停止接收结果,不能保证远端计算立即终止。

flatMap:把元素转换为异步任务并合并结果

map 适合一对一的同步转换,例如把 Token 转为大写字符串;如果转换函数返回的仍然是 Mono 或 Flux,继续使用 map 会得到嵌套类型 Flux<Mono<T>>。flatMap 会订阅这些内部异步流,再把结果合并为一条流。

下面并发调用多个异步模型任务:

import java.time.Duration;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public class FlatMapExample {

    // 并发处理多个问题,最多同时执行两个内部任务
    public Flux<String> answerAll() {
        // 等待处理的问题流
        Flux<String> prompts = Flux.just("问题一", "问题二", "问题三");

        // 合并各个异步模型调用的结果
        Flux<String> answers = prompts.flatMap(this::callModel, 2);
        return answers;
    }

    // 模拟一次异步模型调用
    private Mono<String> callModel(String prompt) {
        return Mono.just("回答:" + prompt)
                .delayElement(Duration.ofMillis(300));
    }
}

第二个参数 2 是并发度,表示最多同时订阅两个内部任务。限制并发度可以避免一次性向模型服务或外部工具发起过多请求。

flatMap 不保证结果与输入顺序一致。如果业务依赖原始顺序,可以选择 concatMap 按顺序逐个执行,或者选择 flatMapSequential 并发启动后按原输入顺序发送结果。

在完整智能体中,一次回答往往需要同时调用检索、代码执行等多个异步工具,flatMap 可以让这些子任务并发执行,再合并各工具的返回结果继续生成。

Backpressure:让生产速度服从消费能力

背压解决的是生产者速度超过消费者处理能力的问题。响应式流中的下游会通过 Subscription.request(n) 表达自己还能接收多少个元素,上游根据需求发送数据,而不是不受限制地持续推送。

下面的订阅者每次只请求两个元素,处理完一批后再请求下一批:

import org.reactivestreams.Subscription;
import reactor.core.publisher.BaseSubscriber;
import reactor.core.publisher.Flux;

public class BackpressureExample {

    // 演示下游分批请求数据
    public void consumeInBatches() {
        // 等待消费的数据流
        Flux<Integer> source = Flux.range(1, 10);

        source.subscribe(new BaseSubscriber<Integer>() {

            // 当前批次已经处理的数量
            private int consumedInBatch;

            // 建立订阅后请求第一批数据
            @Override
            protected void hookOnSubscribe(Subscription subscription) {
                request(2);
            }

            // 消费一个元素,并在一批完成后继续请求
            @Override
            protected void hookOnNext(Integer value) {
                System.out.println("消费数据:" + value);
                consumedInBatch++;
                if (consumedInBatch == 2) {
                    consumedInBatch = 0;
                    request(2);
                }
            }
        });
    }
}

并非所有数据源都能减慢生产速度。例如外部回调可能不关心下游需求,仍然持续发送数据。此时需要明确选择溢出策略:

// 无法根据下游需求减速的上游数据流
Flux<String> fastSource = getFastTokenSource();

// 缓存最多 100 个暂时来不及处理的 Token
Flux<String> buffered = fastSource.onBackpressureBuffer(100);

// 丢弃下游暂时无法接收的 Token
Flux<String> dropped = fastSource.onBackpressureDrop(
        token -> System.out.println("丢弃 Token:" + token)
);

// 消费跟不上时只保留最新的 Token
Flux<String> latest = fastSource.onBackpressureLatest();

常见策略的取舍如下:

策略 适用情况 风险
Buffer 每个元素都不能丢失,并且流量峰值可控 无界缓冲可能持续占用内存;有界缓冲需要处理溢出
Drop 允许舍弃部分高频状态或监控数据 被丢弃的数据无法恢复
Latest 只关心最新状态,例如进度百分比 不适合正文 Token,会导致回答缺字
Error 消费跟不上时立即失败 需要上层提供重试或错误提示

大模型回答中的正文 Token 通常不能丢弃,因此更常使用有界 Buffer,同时限制模型调用并发度、监控队列长度并处理客户端断开。onBackpressureBuffer() 的无参形式使用无界缓冲,不能因为写法简单就默认适合生产环境。

使用 Spring WebFlux 与 SSE 实现完整流式接口

SSE 使用普通 HTTP 长连接,并通过 text/event-stream 持续发送事件,适合服务端单向推送大模型 Token。下面把前面的概念组合成一个可以停止生成的流式聊天服务。

首先实现任务管理器和模型流桥接服务:

import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
import reactor.util.concurrent.Queues;

@Service
public class StreamingChatService {

    // 会话与完整流式任务之间的映射
    private final Map<String, ChatTask> tasks = new ConcurrentHashMap<>();

    // 启动一次模型生成并返回 Token 流
    public Flux<String> stream(String conversationId, String prompt) {
        // 当前请求独占的有界 Token 通道
        Sinks.Many<String> sink =
                Sinks.many().unicast().onBackpressureBuffer(
                        Queues.<String>get(256).get()
                );

        // 模型 SDK 返回的原始 Token 流
        Flux<String> modelStream = callModelStream(prompt);

        // 将模型事件转发到当前请求的 Sink
        Disposable sourceTask = modelStream.subscribe(
                token -> sink.emitNext(token, Sinks.EmitFailureHandler.FAIL_FAST),
                error -> sink.emitError(error, Sinks.EmitFailureHandler.FAIL_FAST),
                () -> sink.emitComplete(Sinks.EmitFailureHandler.FAIL_FAST)
        );

        // 当前请求对应的完整流式任务
        ChatTask currentTask = new ChatTask(sourceTask, sink);

        // 同一会话此前尚未结束的任务
        ChatTask previousTask = tasks.put(conversationId, currentTask);
        if (previousTask != null) {
            previousTask.stop();
        }

        return sink.asFlux()
                .doFinally(signalType -> {
                    tasks.remove(conversationId, currentTask);
                    currentTask.disposeSource();
                });
    }

    // 停止指定会话的模型生成任务
    public boolean stop(String conversationId) {
        // 当前需要取消的完整流式任务
        ChatTask task = tasks.remove(conversationId);
        if (task == null) {
            return false;
        }
        task.stop();
        return true;
    }

    // 模拟模型 SDK 的流式输出,实际项目中替换为真实调用
    private Flux<String> callModelStream(String prompt) {
        return Flux.just("收到问题:", prompt, ",正在生成回答。")
                .delayElements(Duration.ofMillis(300));
    }

    // 同时管理上游订阅和下游 Token 通道
    private static class ChatTask {

        // 模型流的取消句柄
        private final Disposable sourceTask;

        // 向 SSE 客户端发送数据的 Token 通道
        private final Sinks.Many<String> sink;

        // 初始化完整流式任务
        private ChatTask(Disposable sourceTask, Sinks.Many<String> sink) {
            this.sourceTask = sourceTask;
            this.sink = sink;
        }

        // 取消上游订阅并结束下游 Token 流
        private void stop() {
            sourceTask.dispose();
            sink.tryEmitComplete();
        }

        // 仅释放上游订阅
        private void disposeSource() {
            sourceTask.dispose();
        }
    }
}

doFinally() 会在流正常完成、发生错误或被取消后执行,因此浏览器断开 SSE 连接时也能清理任务。这里的 Sinks.Many 只服务于一次请求,使用 unicast() 与单个 SSE 客户端对应。

然后提供 SSE 接口和停止接口:

import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
public class StreamingChatController {

    // 流式聊天服务
    private final StreamingChatService chatService;

    // 初始化流式聊天控制器
    public StreamingChatController(StreamingChatService chatService) {
        this.chatService = chatService;
    }

    // 以 SSE 形式持续返回模型 Token
    @GetMapping(
            value = "/api/conversations/{conversationId}/stream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE
    )
    public Flux<ServerSentEvent<String>> stream(
            @PathVariable String conversationId,
            @RequestParam String prompt
    ) {
        return chatService.stream(conversationId, prompt)
                .map(token -> ServerSentEvent.builder(token)
                        .event("token")
                        .build());
    }

    // 停止指定会话正在执行的生成任务
    @DeleteMapping("/api/conversations/{conversationId}/stream")
    public Mono<Void> stop(@PathVariable String conversationId) {
        chatService.stop(conversationId);
        return Mono.empty();
    }
}

浏览器可以使用 EventSource 接收 SSE:

// 当前会话的唯一标识
const conversationId = "conversation-1001";

// 经过 URL 编码的用户问题
const prompt = encodeURIComponent("什么是流式响应?");

// 与服务端建立 SSE 长连接
const eventSource = new EventSource(
  `/api/conversations/${conversationId}/stream?prompt=${prompt}`
);

// 将收到的 Token 追加到页面
eventSource.addEventListener("token", (event) => {
  document.querySelector("#answer").textContent += event.data;
});

// 连接异常时关闭客户端连接
eventSource.onerror = () => {
  eventSource.close();
};

// 请求服务端停止生成并关闭 SSE 连接
async function stopGeneration() {
  await fetch(`/api/conversations/${conversationId}/stream`, {
    method: "DELETE"
  });
  eventSource.close();
}

这条链路中各个核心概念分别承担下面的职责:

概念 在示例中的作用
Mono 停止接口只表示异步完成,不返回业务数据
Flux 表示模型连续产生的 Token 序列,并作为 SSE 响应返回
Sinks 把模型 SDK 的事件主动转发给当前 SSE 请求
Disposable 保存模型流订阅,用于停止生成和断开后的资源清理
flatMap 完整智能体中可用于并发执行多个异步工具或子任务
Backpressure 协调 Token 生产与网络发送速度,防止数据无限积压