☰
Spring Boot 3 + SSE + Redis:构建生产级 AI Agent 流式输出中枢
2026/10/26 14:57:29 网站建设 项目流程

1. 为什么流式输出是 Agent 体验的分水岭

做过 AI Agent 应用的人大概都有过这种体验:用户在前端输入一个问题,后端调用大模型,然后……页面就卡住了。十几秒甚至几十秒之后,答案“啪”地一下全部蹦出来。用户在这段时间里完全不知道系统是在思考、在调用工具、还是已经挂了。这种体验放在 2023 年还能忍,放到现在,用户早就关掉页面去用别家了。

流式输出解决的正是这个问题。它让模型的思考过程、工具调用过程、最终回答像打字机一样逐步呈现,用户能实时看到“Agent 正在思考”“正在调用搜索工具”“正在整理结果”。这不仅仅是视觉上的爽感,更关键的是它把黑盒变成了白盒,用户对系统的信任度和耐心都会显著提升。

但流式输出这件事,说起来简单,做起来坑特别多。我见过太多项目一开始用轮询(前端每隔一秒问一次后端“好了没”),结果要么延迟高、要么请求量大、要么断流后状态全丢。也见过用 WebSocket 的,功能是能跑,但连接管理、心跳保活、集群下的会话同步,每一样都是额外的运维负担。

所以这篇文章我想聊的,是一套我实际在用的方案:Spring Boot 3 + SSE + Redis,把 Agent 的流式思考和工具调用做成一个生产级的中枢。SSE 负责服务端到客户端的单向推送,Redis 负责跨实例的事件缓冲和状态共享,Spring Boot 3 负责把整条链路串起来。这套组合不追求花哨,但足够稳、足够简单、足够好维护。

如果你正在做 AI Agent 应用,或者任何需要“长任务 + 实时反馈”的场景,比如报告生成、代码分析、数据处理流水线,这套思路都能直接抄。下面我会从整体设计讲到具体实现,再到踩过的坑,尽量把每个决策背后的“为什么”说清楚。

2. 整体架构设计与技术选型逻辑

2.1 为什么是 SSE 而不是 WebSocket 或轮询

先把这个最关键的选择说清楚。很多人一提到实时推送,第一反应就是 WebSocket。但 WebSocket 是双向通信,而 Agent 流式输出本质上是一个单向推送场景:客户端发起一次请求,服务端持续推送事件,客户端只需要接收。用 WebSocket 相当于为了喝杯水建了个双向管道,多出来的那一半能力用不上,却要承担连接管理、心跳、重连、集群会话同步的全部成本。

轮询的问题更明显。假设 Agent 一次思考需要 20 秒,前端每秒轮询一次,那就是 20 次 HTTP 请求。每次请求都要经过负载均衡、鉴权、查状态,服务端压力大不说,用户看到的还是“一秒一跳”的离散更新,不是真正的流式。而且轮询天然有延迟,你永远不知道下一次轮询前状态有没有变。

SSE 的优势在于:它基于标准 HTTP,浏览器原生支持EventSource,服务端用text/event-stream响应类型持续写入即可。连接是长连接,但方向单一,服务端推送、客户端接收,模型非常简单。断线后浏览器会自动重连,配合Last-Event-ID还能做断点续传。对于 Agent 这种“一次请求、持续输出、最终结束”的场景,SSE 几乎是量身定做的。

当然 SSE 也有它的限制。比如它是单向的,客户端不能通过同一条连接发消息;比如在 HTTP/1.1 下浏览器对同域名连接数有限制。但这些在 Agent 场景里基本不是问题,因为客户端本来就不需要在这条连接上发消息,而连接数限制可以通过 HTTP/2 或者子域名规避。

2.2 Redis 在架构里到底扮演什么角色

很多人会问:SSE 直接推不就行了,为什么还要 Redis?答案在“生产级”三个字里。

单实例部署时,SSE 连接和 Agent 执行在同一个 JVM 里,确实不需要 Redis。但生产环境一定是多实例的。用户请求可能打到实例 A,建立 SSE 连接;但 Agent 的实际执行可能因为负载均衡跑到实例 B。这时候实例 B 产生的事件,怎么推给实例 A 上挂着的那个连接?答案就是 Redis 的发布订阅。

具体来说,Agent 执行器把事件写入 Redis 的 Pub/Sub 频道,每个实例订阅这个频道,收到事件后检查自己本地有没有对应的 SSE 连接,有就推给客户端。这样实例之间就解耦了,谁执行、谁推送互不关心。

除了 Pub/Sub,Redis 还承担另外两个职责。一是事件缓冲:用 List 或 Stream 结构暂存最近的事件,客户端断线重连时可以根据Last-Event-ID把漏掉的事件补上。二是状态共享:Agent 的执行状态(进行中、已完成、已失败)存在 Redis 里,任何实例都能查到,避免状态只存在单个 JVM 内存里导致的不一致。

这里有个细节要注意:Pub/Sub 是“发后即忘”的,订阅者掉线期间的消息会丢。所以事件缓冲不能只靠 Pub/Sub,必须配合 List 或 Stream 做持久化暂存。我的做法是双写:事件既发到 Pub/Sub 做实时推送,又追加到 Redis Stream 做缓冲,重连时从 Stream 里按 ID 补。

2.3 Spring Boot 3 带来的关键能力

Spring Boot 3 在这个方案里不是简单的“版本升级”,它有几个实打实的帮助。首先是虚拟线程(需要 JDK 21),SSE 连接是长连接,每个连接占一个线程,传统平台线程模型下几百个连接就把线程池耗光了。虚拟线程让每个连接的开销降到极低,几万连接也不在话下。其次是WebFlux 的响应式支持,用Flux<ServerSentEvent>返回流式数据,背压和资源管理都由框架处理,比手写HttpServletResponse的PrintWriter优雅得多。

当然如果你不想引入 WebFlux,用 Spring MVC 的SseEmitter也完全可以。SseEmitter是 Spring 对 SSE 的封装,用起来更直观,适合团队里响应式经验不足的情况。我下面会以SseEmitter为主讲,因为它更贴近大多数人的技术栈,WebFlux 版本会在关键处提一下差异。

3. 核心模块拆解与关键实现细节

3.1 SSE 连接管理:别让连接变成孤儿

SSE 连接管理是整套方案里最容易出问题的地方。一个连接建立后,如果客户端异常关闭、网络中断、或者服务端重启,连接对象如果没被正确清理,就会变成“孤儿连接”,占着内存和线程不放。时间一长,服务端就被拖垮了。

我的做法是维护一个本地的连接注册表,用ConcurrentHashMap<String, SseEmitter>存sessionId到 emitter 的映射。连接建立时注册,完成、超时、出错时都要在回调里移除。SseEmitter提供了onCompletion、onTimeout、onError三个回调,这三个回调里必须做清理,一个都不能漏。

public SseEmitter createConnection(String sessionId) { SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); // 30分钟超时 emitter.onCompletion(() -> registry.remove(sessionId)); emitter.onTimeout(() -> { registry.remove(sessionId); emitter.complete(); }); emitter.onError(e -> registry.remove(sessionId)); registry.put(sessionId, emitter); return emitter; }

超时时间要设得合理。Agent 执行可能很久,但也不能无限挂着。我一般设 30 分钟,同时在前端做心跳检测,如果长时间没有事件推送,前端主动断开重连。服务端也可以定期发一个注释行(: heartbeat)作为心跳,保持连接活跃,防止中间的代理或负载均衡器因为空闲超时把连接掐掉。

注意:很多负载均衡器默认 60 秒空闲就断连接,SSE 如果长时间没数据推送,连接会被静默切断。所以心跳不是可选项,是必选项。我一般 15 到 30 秒发一次心跳。

3.2 事件模型设计:让每个事件都自带上下文

流式推送的事件不能只是一段文本,它需要携带足够的上下文,让前端知道“这是什么类型的事件”“属于哪个会话”“顺序是多少”。我设计的事件模型大概长这样:

字段类型说明
eventIdString事件唯一 ID,用于断点续传
sessionIdString会话 ID,关联一次 Agent 执行
typeEnum事件类型:THINKING、TOOL_CALL、TOOL_RESULT、ANSWER、DONE、ERROR
sequencelong事件序号,保证顺序
payloadObject事件内容,不同类型结构不同
timestamplong事件产生时间

事件类型的设计很关键。Agent 的流式输出不是单一文本流,它包含多个阶段:模型在思考(可能输出思考过程)、决定调用工具(输出工具名和参数)、工具返回结果、模型基于结果生成最终回答。如果前端只收到一堆文本,根本没法区分这些阶段,也就没法做差异化的 UI 展示。

有了类型区分,前端就能做很丰富的交互:思考阶段显示“正在思考”的动画,工具调用阶段显示工具名和参数,工具结果阶段显示返回摘要,最终回答阶段才逐字渲染。这种分层展示对用户体验的提升是巨大的。

3.3 Redis 事件总线:Pub/Sub 加 Stream 双保险

前面提到 Pub/Sub 会丢消息,所以我的方案是双写。Agent 执行器每产生一个事件,做两件事:一是XADD到 Redis Stream,二是PUBLISH到 Pub/Sub 频道。

public void publish(AgentEvent event) { String streamKey = "agent:stream:" + event.getSessionId(); // 写入 Stream 做缓冲,设置过期时间防止无限增长 redisTemplate.opsForStream().add(streamKey, event.toMap()); redisTemplate.expire(streamKey, Duration.ofHours(2)); // 发布到 Pub/Sub 做实时推送 redisTemplate.convertAndSend("agent:events", event); }

订阅端收到 Pub/Sub 消息后,根据sessionId查本地连接注册表,有连接就推送。如果本地没有这个连接(说明连接在别的实例上),就忽略,因为那个实例也会收到同一条 Pub/Sub 消息。

断线重连时,客户端带上Last-Event-ID,服务端从 Stream 里读取该 ID 之后的所有事件,先补发历史,再继续接收实时推送。这样就实现了断点续传,用户不会因为网络抖动丢失中间过程。

Stream 的过期时间要设,不然会话多了会撑爆内存。我一般设 2 小时,足够覆盖一次 Agent 执行加用户可能的重连窗口。如果业务需要更长的回溯,可以调大,但要配合定期清理策略。

3.4 工具调用的流式封装:把阻塞调用变成事件流

Agent 调用工具(比如搜索、查数据库、调 API)通常是阻塞的,一个工具调用可能几秒到几十秒。如果等工具返回再推送,用户在这段时间里又看不到东西了。所以工具调用也要流式化:调用开始时推一个TOOL_CALL事件,调用过程中如果有中间状态就推TOOL_PROGRESS,返回后推TOOL_RESULT。

实现上,工具执行器要接受一个事件回调,在关键节点主动发事件。比如搜索工具,可以在“开始搜索”“收到 N 条结果”“正在筛选”这些节点各推一个事件。这样用户能看到工具在干活,而不是干等。

public ToolResult execute(Tool tool, Map<String, Object> params, EventCallback callback) { callback.emit(AgentEvent.toolCall(tool.getName(), params)); try { ToolResult result = tool.invoke(params); callback.emit(AgentEvent.toolResult(tool.getName(), result.summary())); return result; } catch (Exception e) { callback.emit(AgentEvent.error("工具执行失败: " + e.getMessage())); throw e; } }

这里有个经验:工具结果的summary不要推全量数据,只推摘要。全量结果可能很大,推给前端既浪费带宽又没意义。前端需要详情时再单独请求。摘要可以是“找到 5 条相关结果”“查询返回 120 行”这种。

4. 完整实操流程与关键配置

4.1 环境准备与依赖配置

先把依赖理清楚。Spring Boot 3 的项目,核心依赖是spring-boot-starter-web(用 SseEmitter)或spring-boot-starter-webflux(用 Flux),加上spring-boot-starter-data-redis。如果要用虚拟线程,JDK 必须是 21,并在配置里开启。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>

Redis 连接池一定要配,默认的 Lettuce 连接是共享的,但 Pub/Sub 需要独立连接,因为订阅连接会一直阻塞。Spring Data Redis 会自动为 Pub/Sub 创建独立连接,但连接池参数还是要调。

spring: data: redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3000ms

max-active别设太小,SSE 场景下并发连接多,每个连接可能对应一个 Redis 操作。16 是个保守值,压测后按需调大。

4.2 SSE 接口的完整实现

接口层要做的事情:接收客户端请求,创建 SseEmitter,注册到连接表,触发 Agent 执行,返回 emitter。注意 Agent 执行要异步,不能阻塞接口线程,否则连接建立后接口不返回,SSE 流也起不来。

@GetMapping(value = "/agent/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(@RequestParam String query, @RequestParam(required = false) String lastEventId) { String sessionId = UUID.randomUUID().toString(); SseEmitter emitter = connectionManager.create(sessionId); // 异步执行 Agent,不阻塞接口返回 CompletableFuture.runAsync(() -> { try { // 如果有 lastEventId,先补发历史事件 if (lastEventId != null) { replayEvents(sessionId, lastEventId, emitter); } agentExecutor.execute(sessionId, query); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }

这里有个坑:SseEmitter的send方法在连接已关闭时会抛IOException。如果 Agent 执行过程中客户端断开了,后续的send都会失败。所以每次send都要 try-catch,捕获到异常就停止推送并清理连接。

public void send(String sessionId, AgentEvent event) { SseEmitter emitter = registry.get(sessionId); if (emitter == null) return; try { emitter.send(SseEmitter.event() .id(event.getEventId()) .name(event.getType().name()) .data(event, MediaType.APPLICATION_JSON)); } catch (IOException e) { registry.remove(sessionId); emitter.completeWithError(e); } }

4.3 Redis 订阅者的注册与消息分发

订阅者要在应用启动时注册,监听 Pub/Sub 频道。收到消息后反序列化成事件对象,查本地连接表,有连接就推送。

@Configuration public class RedisSubscriberConfig { @Bean public RedisMessageListenerContainer listenerContainer( RedisConnectionFactory factory, EventDispatcher dispatcher) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(factory); container.addMessageListener((message, pattern) -> { AgentEvent event = deserialize(message.getBody()); dispatcher.dispatch(event); }, new ChannelTopic("agent:events")); return container; } }

EventDispatcher的dispatch方法就是查本地连接表然后send。这里要注意反序列化,Pub/Sub 传的是字节数组,要用和发布端一致的序列化方式。我一般用 Jackson 的ObjectMapper手动序列化成 JSON 字符串再发,避免 Redis 默认 JDK 序列化的兼容性问题。

4.4 断点续传的完整逻辑

断点续传分两步:客户端重连时带上Last-Event-ID,服务端从 Stream 里读该 ID 之后的事件补发,然后继续实时推送。

public void replayEvents(String sessionId, String lastEventId, SseEmitter emitter) { String streamKey = "agent:stream:" + sessionId; List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream() .range(streamKey, lastEventId.equals("0") ? Range.unbounded() : Range.rightOpen(lastEventId, "+"), Limit.limit().count(1000)); for (MapRecord<String, Object, Object> record : records) { AgentEvent event = AgentEvent.fromMap(record.getValue()); sendToEmitter(emitter, event); } }

Range.rightOpen表示不包含起始 ID,只取之后的。Limit限制一次补发的数量,防止事件太多把内存撑爆。如果超过 1000 条,可以分批补发,或者提示客户端“历史事件过多,已跳过部分”。

提示:Stream 的 ID 是 Redis 自动生成的,格式是“时间戳-序号”,天然有序。客户端保存的Last-Event-ID直接用这个值就行,不需要自己生成。

5. 常见问题排查与避坑经验

5.1 连接建立后收不到任何事件

这是最常见的问题,排查顺序如下。先确认 Agent 执行有没有真正触发,看日志里有没有“开始执行”的记录。如果执行了但没事件,检查事件发布有没有走到 Redis,用redis-cli MONITOR看有没有PUBLISH命令。如果有PUBLISH但客户端没收到,检查订阅者有没有注册成功,看启动日志里RedisMessageListenerContainer有没有报错。

还有一个隐蔽的坑:Spring MVC 的异步请求默认超时时间可能比 SSE 超时短。如果spring.mvc.async.request-timeout没配,默认可能是 30 秒,SSE 连接 30 秒后就被容器关了。要么把这个值调大,要么在SseEmitter构造时传超时时间,两者取较大值。

5.2 多实例下事件重复推送

如果客户端收到重复事件,大概率是 Pub/Sub 消息被多个实例都处理了。检查EventDispatcher的逻辑:收到消息后应该先查本地连接表,只有本地有这个sessionId的连接才推送。如果本地没有,直接忽略。如果每个实例都推,说明查连接表的逻辑有问题,或者连接表被错误地共享了。

还有一种可能是客户端重连时补发了历史事件,而实时推送也在进行,导致同一条事件被推了两次。解决办法是客户端根据eventId去重,或者服务端在补发时记录已补发的最大 ID,实时推送时跳过小于等于该 ID 的事件。

5.3 Redis 内存增长过快

Stream 如果不设过期,会一直增长。每个会话一个 Stream,会话多了内存就爆了。必须设expire,而且要在每次XADD后刷新过期时间,保证活跃会话的 Stream 不会中途过期。

另外 Pub/Sub 本身不占内存,但如果有大量订阅者,Redis 的客户端缓冲区会增长。如果某个订阅者消费慢,缓冲区会堆积。可以在 Redis 配置里设client-output-buffer-limit pubsub限制,超过就断开慢消费者。

5.4 工具调用阻塞导致心跳中断

如果 Agent 执行过程中有一个长时间阻塞的工具调用,而心跳是在同一个线程里发的,心跳就会中断,连接可能被负载均衡器切断。解决办法是把心跳放在独立线程或定时任务里,不依赖 Agent 执行线程。

@Scheduled(fixedRate = 20000) public void heartbeat() { registry.forEach((sessionId, emitter) -> { try { emitter.send(SseEmitter.event().comment("heartbeat")); } catch (IOException e) { registry.remove(sessionId); } }); }

注释行(comment)不会触发前端的onmessage,但能保持连接活跃,是心跳的理想形式。

5.5 常见问题速查表

现象可能原因排查方法解决
连接建立后无事件Agent 未触发/事件未发布/订阅未注册查日志、MONITOR、查订阅日志逐段确认
30 秒后连接断开MVC 异步超时查 async.request-timeout调大超时
事件重复多实例重复推送/补发重叠查 Dispatcher 逻辑本地连接判断+去重
Redis 内存涨Stream 未过期INFO memory设 expire
心跳中断阻塞调用占线程查线程栈心跳独立线程
断线重连丢事件未用 Last-Event-ID查请求头实现补发逻辑

6. 性能优化与生产环境加固

6.1 虚拟线程的开启与收益

JDK 21 的虚拟线程对 SSE 场景提升巨大。传统平台线程下,每个 SSE 连接占一个线程,线程栈默认 1MB,1000 个连接就是 1GB 内存。虚拟线程的栈是动态的,初始只有几 KB,1000 个连接可能只占几十 MB。

开启方式很简单,Spring Boot 3.2 以上配置一个TomcatProtocolHandlerCustomizer就行:

@Bean public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() { return protocolHandler -> protocolHandler.setExecutor( Executors.newVirtualThreadPerTaskExecutor()); }

实测下来,开启虚拟线程后,单实例支撑的 SSE 连接数从几百提升到几千,而且 CPU 和内存都很平稳。这是 Spring Boot 3 + JDK 21 组合最值得用的特性之一。

6.2 Redis 连接池与序列化调优

Pub/Sub 的订阅连接是独占的,不参与连接池。但发布和 Stream 操作走连接池。max-active要按并发量调,一般并发连接数 / 10起步。max-wait别设太大,设 3 秒左右,拿不到连接快速失败比一直等好。

序列化方面,别用默认的 JDK 序列化,用StringRedisSerializer或GenericJackson2JsonRedisSerializer。JDK 序列化体积大、跨语言差、还有安全风险。JSON 序列化体积小、可读、跨语言,是生产环境的标准选择。

@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; }

6.3 监控与告警要点

生产环境必须监控几个关键指标。SSE 连接数,突然下降说明有异常断开;事件推送延迟,从事件产生到推送的时间差,超过阈值说明有瓶颈;Redis Stream 长度,持续增长说明消费跟不上;Pub/Sub 订阅者数量,和实例数对不上说明有实例订阅失败。

这些指标可以用 Micrometer 暴露到 Prometheus,配 Grafana 看板。告警阈值我一般设:连接数 5 分钟内下降超过 30% 告警,推送延迟 P99 超过 2 秒告警,Stream 长度超过 10000 告警。

7. 我踩过的几个真实坑

第一个坑是SseEmitter的complete调用时机。我一开始在 Agent 执行完就调complete,结果发现最后几个事件偶尔丢失。原因是complete会立即关闭连接,而send是异步的,可能还有事件在缓冲区没发出去。解决办法是在最后一个事件发送后,稍微延迟一下再complete,或者用emitter.complete()前先flush。更稳妥的做法是发一个DONE类型的事件,前端收到后主动关闭连接,服务端在onCompletion回调里清理。

第二个坑是 Redis Pub/Sub 的消息顺序。Pub/Sub 不保证顺序,虽然实践中大部分情况是有序的,但高并发下可能乱序。所以我在事件里加了sequence字段,前端按序号排序后再渲染。Stream 本身是有序的,补发时不会有这个问题,但实时推送走 Pub/Sub 就可能乱序。

第三个坑是工具调用的超时。有些工具调用可能卡死,如果不设超时,Agent 会一直挂着,SSE 连接也一直不结束。我给每个工具调用都包了一层超时控制,用CompletableFuture.orTimeout或者Future.get(timeout),超时后推一个ERROR事件并结束会话。

第四个坑是前端EventSource的重连。浏览器默认在连接断开后 3 秒重连,但如果服务端返回的不是 200,或者响应类型不对,浏览器会一直重连失败。调试时可以在浏览器 Network 面板看 EventStream 的响应,确认Content-Type是text/event-stream,且没有Content-Length头(SSE 不能有固定长度)。

这套方案我在几个项目里跑了大半年,从单实例到多实例、从几十连接到几千连接,整体很稳。核心就是三点:SSE 做推送、Redis 做解耦和缓冲、事件模型做上下文。把这三点做扎实,剩下的就是细节打磨。如果你也在做类似的东西,希望这些经验能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询