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 事件模型设计:让每个事件都自带上下文
流式推送的事件不能只是一段文本,它需要携带足够的上下文,让前端知道“这是什么类型的事件”“属于哪个会话”“顺序是多少”。我设计的事件模型大概长这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| eventId | String | 事件唯一 ID,用于断点续传 |
| sessionId | String | 会话 ID,关联一次 Agent 执行 |
| type | Enum | 事件类型:THINKING、TOOL_CALL、TOOL_RESULT、ANSWER、DONE、ERROR |
| sequence | long | 事件序号,保证顺序 |
| payload | Object | 事件内容,不同类型结构不同 |
| timestamp | long | 事件产生时间 |
事件类型的设计很关键。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: 3000msmax-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 做解耦和缓冲、事件模型做上下文。把这三点做扎实,剩下的就是细节打磨。如果你也在做类似的东西,希望这些经验能帮你少走点弯路。