Spring AI企业级应用实战(3):流式输出、SSE、背压、取消与异常恢复

文章摘要

上一篇完成了ChatClient、Advisor与Prompt Template统一调用层。本篇继续构建生产级流式能力:使用Spring WebFlux和Spring AI返回结构化SSE事件,实现首Token统计、Token合并、背压保护、用户取消、心跳、超时、部分结果保存、错误事件和Nginx部署配置。文章给出完整的请求模型、任务状态、Service、Controller与前端读取代码,解决“本地能流式、上线后一次性返回”的常见问题。

一、本篇目标

最终调用链:

浏览器
→ POST流式请求
→ Spring WebFlux Controller
→ StreamingAiService
→ Enterprise ChatClient
→ Spring AI ChatModel
→ SSE事件
→ 浏览器逐段渲染

需要支持:

  • 结构化SSE;
  • 任务ID;
  • 开始、增量、错误和结束事件;
  • 首Token耗时;
  • 用户取消;
  • 慢客户端保护;
  • 空闲心跳;
  • 总超时;
  • 部分结果保存;
  • Nginx无缓冲部署。

二、为什么不直接返回Flux

最小代码:

return chatClient.prompt()
        .user(message)
        .stream()
        .content();

适合Demo,但前端无法区分:

普通文本
工具事件
错误
Usage
任务结束

生产环境建议事件协议:

{
  "taskId": "task-123",
  "sequence": 12,
  "type": "delta",
  "data": {
    "content": "正在"
  }
}

三、项目依赖

            org.springframework.ai
            spring-ai-bom
            2.0.0
            pom
            import






        org.springframework.ai
        spring-ai-starter-model-deepseek



        org.springframework.boot
        spring-boot-starter-webflux



        org.springframework.boot
        spring-boot-starter-actuator



        org.springframework.boot
        spring-boot-starter-validation

Spring WebFlux基于Reactive Streams,支持非阻塞处理和背压。

四、定义请求

public record AiStreamRequest(
        @NotBlank
        String message,
        String conversationId
) {
}

请求上下文:

public record AiRequestContext(
        String requestId,
        String taskId,
        String userId,
        String tenantId
) {
}

生产环境从认证上下文读取用户和租户,不直接信任客户端请求字段。

五、定义事件

public record AiStreamEvent(
        String taskId,
        long sequence,
        String type,
        Map data,
        long timestamp
) {
}

事件类型:

public final class StreamEventType {

    public static final String START = "start";
    public static final String DELTA = "delta";
    public static final String HEARTBEAT = "heartbeat";
    public static final String USAGE = "usage";
    public static final String ERROR = "error";
    public static final String DONE = "done";

    private StreamEventType() {
    }
}

六、任务状态

public enum StreamTaskStatus {
    CREATED,
    STREAMING,
    CANCEL_REQUESTED,
    CANCELLED,
    COMPLETED,
    FAILED,
    TIMED_OUT
}

任务对象:

public class StreamTask {

    private final String taskId;
    private final String userId;
    private final String tenantId;
    private final Instant createdAt;
    private final AtomicReference
            status;

    public StreamTask(
            String taskId,
            String userId,
            String tenantId
    ) {
        this.taskId = taskId;
        this.userId = userId;
        this.tenantId = tenantId;
        this.createdAt = Instant.now();
        this.status = new AtomicReference(
                StreamTaskStatus.CREATED
        );
    }

    public void markStreaming() {
        status.set(StreamTaskStatus.STREAMING);
    }

    public void requestCancel() {
        status.set(
                StreamTaskStatus.CANCEL_REQUESTED
        );
    }

    public boolean isCancelRequested() {
        return status.get()
                == StreamTaskStatus.CANCEL_REQUESTED;
    }
}

七、任务仓库

演示使用内存:

@Component
public class InMemoryStreamTaskRepository {

    private final ConcurrentMap
            tasks = new ConcurrentHashMap();

    public StreamTask create(
            String userId,
            String tenantId
    ) {
        String taskId = UUID.randomUUID()
                .toString();

        StreamTask task = new StreamTask(
                taskId,
                userId,
                tenantId
        );

        tasks.put(taskId, task);
        return task;
    }

    public StreamTask require(String taskId) {
        StreamTask task = tasks.get(taskId);

        if (task == null) {
            throw new IllegalArgumentException(
                    "任务不存在"
            );
        }

        return task;
    }
}

多实例生产部署应使用Redis或数据库保存可共享状态。

八、事件工厂

@Component
public class AiStreamEventFactory {

    public AiStreamEvent create(
            String taskId,
            AtomicLong sequence,
            String type,
            Map data
    ) {
        return new AiStreamEvent(
                taskId,
                sequence.incrementAndGet(),
                type,
                data,
                System.currentTimeMillis()
        );
    }
}

九、核心Streaming Service

@Service
public class StreamingAiService {

    private final ChatClient chatClient;
    private final InMemoryStreamTaskRepository
            taskRepository;
    private final AiStreamEventFactory eventFactory;

    public StreamingAiService(
            @Qualifier("enterpriseChatClient")
            ChatClient chatClient,
            InMemoryStreamTaskRepository taskRepository,
            AiStreamEventFactory eventFactory
    ) {
        this.chatClient = chatClient;
        this.taskRepository = taskRepository;
        this.eventFactory = eventFactory;
    }

    public StreamResult stream(
            AiStreamRequest request,
            AuthenticatedUser user
    ) {
        StreamTask task = taskRepository.create(
                user.userId(),
                user.tenantId()
        );

        AtomicLong sequence = new AtomicLong();
        AtomicBoolean firstToken =
                new AtomicBoolean(true);
        long startedAt = System.nanoTime();
        StringBuilder answerBuffer =
                new StringBuilder();

        AiStreamEvent startEvent =
                eventFactory.create(
                        task.taskId(),
                        sequence,
                        StreamEventType.START,
                        Map.of(
                                "requestId",
                                UUID.randomUUID()
                                        .toString()
                        )
                );

        Flux modelEvents =
                chatClient.prompt()
                        .advisors(spec -> spec
                                .param(
                                        "tenantId",
                                        user.tenantId()
                                )
                                .param(
                                        "conversationId",
                                        request.conversationId()
                                )
                        )
                        .user(request.message())
                        .stream()
                        .content()
                        .takeUntilOther(
                            cancelSignal(task)
                        )
                        .bufferTimeout(
                                12,
                                Duration.ofMillis(40)
                        )
                        .filter(parts ->
                                !parts.isEmpty()
                        )
                        .map(parts ->
                                String.join("", parts)
                        )
                        .map(content -> {
                            if (
                                firstToken.compareAndSet(
                                        true,
                                        false
                                )
                            ) {
                                long firstTokenMs =
                                        (System.nanoTime()
                                         - startedAt)
                                        / 1_000_000;

                                metrics.recordFirstToken(
                                        firstTokenMs
                                );
                            }

                            answerBuffer.append(content);

                            return eventFactory.create(
                                    task.taskId(),
                                    sequence,
                                    StreamEventType.DELTA,
                                    Map.of(
                                            "content",
                                            content
                                    )
                            );
                        });

        Flux stream =
                Flux.concat(
                        Mono.just(startEvent),
                        modelEvents
                )
                .timeout(Duration.ofMinutes(5))
                .onErrorResume(
                        TimeoutException.class,
                        error -> Flux.just(
                            errorEvent(
                                    task,
                                    sequence,
                                    "TASK_TIMEOUT",
                                    "生成超时"
                            )
                        )
                )
                .onErrorResume(error -> Flux.just(
                    errorEvent(
                            task,
                            sequence,
                            "STREAM_FAILED",
                            "生成过程中发生错误"
                    )
                ))
                .concatWith(
                        Mono.fromSupplier(() ->
                            doneEvent(
                                    task,
                                    sequence
                            )
                        )
                )
                .doOnSubscribe(subscription ->
                        task.markStreaming()
                )
                .doOnCancel(() ->
                        savePartial(
                                task,
                                answerBuffer.toString()
                        )
                )
                .doOnComplete(() ->
                        saveCompleted(
                                task,
                                answerBuffer.toString()
                        )
                )
                .doFinally(signal ->
                        recordFinalSignal(
                                task,
                                signal
                        )
                );

        return new StreamResult(
                task.taskId(),
                stream
        );
    }
}

示例中使用了:

bufferTimeout

把极小Token合并后发送,减少网络和DOM更新开销。

十、取消信号

private Publisher cancelSignal(
        StreamTask task
) {
    return Flux.interval(
            Duration.ofMillis(100)
        )
        .filter(index ->
                task.isCancelRequested()
        )
        .next();
}

这是便于理解的演示实现。

生产环境更适合:

  • Reactor Sink;
  • Redis Pub/Sub;
  • 消息总线;
  • Provider显式取消接口。

使用轮询会增加不必要的调度。

十一、取消接口

@PostMapping("/tasks/{taskId}/cancel")
public ResponseEntity cancel(
        @AuthenticationPrincipal
        AuthenticatedUser user,
        @PathVariable String taskId
) {
    StreamTask task =
            taskRepository.require(taskId);

    if (!task.userId().equals(user.userId())) {
        throw new AccessDeniedException(
                "无权取消该任务"
        );
    }

    task.requestCancel();

    return ResponseEntity.accepted().build();
}

取消必须校验任务所有者和租户。

十二、Controller返回SSE

@PostMapping(
    value = "/stream",
    produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public ResponseEntity>
> stream(
        @AuthenticationPrincipal
        AuthenticatedUser user,
        @Valid @RequestBody
        AiStreamRequest request
) {
    StreamResult result =
            streamingAiService.stream(
                    request,
                    user
            );

    Flux> body =
            result.events()
                    .map(event ->
                        ServerSentEvent
                                .builder()
                                .id(
                                    Long.toString(
                                        event.sequence()
                                    )
                                )
                                .event(event.type())
                                .data(event)
                                .build()
                    );

    return ResponseEntity.ok()
            .header(
                    HttpHeaders.CACHE_CONTROL,
                    "no-cache, no-transform"
            )
            .header(
                    "X-Accel-Buffering",
                    "no"
            )
            .header(
                    "X-AI-Task-Id",
                    result.taskId()
            )
            .body(body);
}

十三、心跳

模型调用工具时可能较长时间无Token。

Flux heartbeat =
        Flux.interval(Duration.ofSeconds(15))
                .map(index ->
                    eventFactory.create(
                            task.taskId(),
                            sequence,
                            StreamEventType.HEARTBEAT,
                            Map.of()
                    )
                );

将心跳与业务流合并时,要在任务完成后停止心跳:

Flux.merge(
    businessStream,
    heartbeat.takeUntilOther(
        businessStream.ignoreElements()
    )
)

实际实现应避免对冷流重复订阅,可使用publish或共享完成信号。

十四、背压与缓冲

WebFlux支持Reactive Streams背压,但模型SDK、网络和前端消费速度不一定完全一致。

保护:

.onBackpressureBuffer(
        256,
        dropped -> log.warn(
                "stream overflow taskId={}",
                task.taskId()
        ),
        BufferOverflowStrategy.ERROR
)

不要设置无限缓冲。

文本场景可使用:

小Token合并
+有限缓冲
+慢客户端超时

十五、部分结果保存

正常完成:

status = COMPLETED
partial = false

用户取消:

status = CANCELLED
partial = true

异常中断:

status = FAILED
partial = true

部分结果不能进入完整答案缓存,也不能作为后续Memory中的权威回答。

十六、前端读取

const controller = new AbortController();

async function startChat(message) {
  const response = await fetch("/api/ai/stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      message,
      conversationId
    }),
    signal: controller.signal
  });

  const taskId = response.headers.get(
    "X-AI-Task-Id"
  );

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, {
      stream: true
    });

    buffer = parseSseFrames(
      buffer,
      event => handleEvent(event)
    );
  }

  return taskId;
}

停止:

async function stop(taskId) {
  controller.abort();

  await fetch(
    `/api/ai/tasks/${taskId}/cancel`,
    { method: "POST" }
  );
}

十七、Nginx配置

location /api/ai/stream {
    proxy_pass http://spring_ai_backend;
    proxy_http_version 1.1;

    proxy_buffering off;
    proxy_cache off;
    gzip off;

    proxy_read_timeout 600s;
    proxy_send_timeout 600s;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    add_header X-Accel-Buffering no;
}

如果前面还有CDN或API Gateway,也要确认其支持SSE和长连接。

十八、错误事件

private AiStreamEvent errorEvent(
        StreamTask task,
        AtomicLong sequence,
        String code,
        String message
) {
    return eventFactory.create(
            task.taskId(),
            sequence,
            StreamEventType.ERROR,
            Map.of(
                    "code",
                    code,
                    "message",
                    message
            )
    );
}

不要把Provider异常堆栈直接返回用户。

十九、关键监控指标

stream_requests_total
stream_active_connections
first_token_latency_ms
stream_duration_ms
stream_cancel_rate
stream_timeout_rate
stream_error_rate
buffer_overflow_count
partial_answer_count
provider_tokens_after_cancel

重点关注:

  • P95首Token耗时;
  • 用户取消率;
  • 取消传播耗时;
  • 超时率;
  • 代理层断连率。

二十、常见错误

collectList后返回
在map中使用JDBC
Nginx开启buffering
前端使用response.text()
取消只隐藏页面
无限onBackpressureBuffer
每个Token写数据库
心跳无法停止
错误后仍发送done成功事件

二十一、完整生产链路

用户提交请求
→ 创建taskId
→ 权限与配额校验
→ ChatClient流式调用
→ Token合并
→ SSE事件
→ 首Token与耗时监控
→ 用户取消或正常完成
→ 保存完整或部分结果
→ 记录Token、费用与Trace

总结

生产级Spring AI流式接口,不只是把:

.stream().content()

返回给Controller。

还必须处理:

事件协议
背压
取消
心跳
超时
部分结果
代理缓冲
错误恢复
监控

完成这些能力后,流式输出才能从演示功能变成稳定的企业AI交互基础设施。

下一篇将继续实现:

Spring AI企业级应用实战(4):Chat Memory、多租户会话与长期记忆治理。

延伸阅读

如果你正在关注企业级 AI 应用、Agent、RAG、MCP 与大模型工程化落地,欢迎访问 智元界

https://www.zyentor.com/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。