
如果你还在用轮询的方式去拉 Agent 的思考进度我劝你快停手。最近我在打磨一个基于 Spring Boot 3 的 Agent 流式交互中枢把原本的短轮询全部换成了 SSE Redis 的方案终于不用再看着前端每隔几百毫秒发一次请求了。这篇就把完整的设计思路、核心代码和一路踩过的坑都掰开聊聊特别是流式思考过程、工具调用状态同步以及断线续传这些问题都是生产环境里真正绕不开的关卡。先说清楚这篇适合谁。如果你正在做 Agent 应用或者想把大模型输出从“等结果一起返回”改成“边思考边输出”的体验又或者你已经搭过 SSE 但总觉得不稳固、一上线就断流那这篇很值得读完。我会尽量把方案选型的逻辑、生产化的细节、以及一些常规文档里不会写的坑也一起讲透。1. 为什么放弃轮询Agent 流式交互的现状与痛点1.1 轮询方案为什么撑不起 Agent 场景很多团队刚做 Agent 应用时第一反应就是轮询。客户端每隔一两秒调一次接口问“任务好了吗”服务端查任务状态返回“还在跑”。这个方案在小流量、短耗时任务里还能凑合但放到 Agent 场景里几乎是一场灾难。Agent 的一次完整执行往往要经历多轮大模型推理、工具选择、工具参数生成、工具结果回传、再推理。慢的时候一个任务能跑几十秒甚至几分钟。如果客户端每 1 秒轮询一次几十秒下来就是几十个请求。这些请求大部分都是无效的因为状态没变化但服务端还是得花资源去查 Redis、查数据库一路把空结果返回回去。到了并发量稍微高一点系统响应变慢又反过来拉长任务执行时间形成恶性循环。更难受的是轮询没法去体现“流式思考”的过程。Agent 中间会输出推理片段、会中途调用工具、会修正计划这些过程如果只是在任务结束时一次性返回用户根本不知道当前卡在哪一步。体验上就是一个黑盒用户只能干等。哪怕你给轮询接口加上了 step、progress 这种字段也只是把状态粒度强行拉高本质上还是客户端主动去“猜测”状态变化的时机不仅实现别扭前端代码也会变得非常难维护。1.2 SSE 与 WebSocket 的取舍以及 Redis 的角色既然要推流常见的选择无非两个SSE 和 WebSocket。我最终选的是 SSE也就是 Server-Sent Events。核心原因很简单Agent 场景下大部分数据流向都是单向的就是服务端主动把事件推给客户端。大模型在逐步吐 token工具调用状态在逐步变化客户端在这个过程中几乎不需要向服务端发消息最多就是被动接收。SSE 是建立在 HTTP 之上的轻量协议浏览器端原生支持EventSource会自动做重连服务端实现也非常简单。相比之下WebSocket 虽然支持双向通信但握手、协议、反向代理、防火墙穿透都要多一重配置和调试成本。如果只是为了单向推送WebSocket 有点杀鸡用牛刀。再加上 SSE 本身有Last-Event-ID这种断线续传机制配合 Redis 缓存事件可以让客户端重连后立即补拉缺失的消息这正好命中 Agent 场景里“思考过程不能丢”的诉求。那 Redis 在里面的角色又是什么简单说SSE 只解决了“连接怎么推”的问题但生产环境里 Agent 服务通常不止一个实例。用户连到了 A 实例任务却在 B 实例上跑那 B 实例生成的事件怎么推到用户的长连接上这就需要一个跨实例的消息通道。Redis Pub/Sub 在这里扮演了轻量级消息总线的角色所有携带着任务进度、思考片段、工具调用事件的业务消息都发布到同一个频道上各节点订阅后识别出哪些事件属于自己节点上的会话再通过对应 SSE 连接推给客户端。再加上 Redis 本身还能存任务状态、会话映射、消息幂等标识一套组合下来既不引入额外中间件又能解决多实例推送的基本问题。2. 生产级架构设计从单机长连到多实例消息中枢2.1 整体数据流一次请求如何变成一段流式响应在设计这套中枢之前我先明确了一个原则任何 Agent 执行过程都不能和某台机器的本地变量绑定。任务状态、事件消息、会话映射全部放在 Redis 里SSE 连接只是“可移动的出口”。这样任何一个实例挂了任务还可以被其他实例接管客户端重连后也能拿到完整上下文。整体数据流大致是这样的。客户端发起一次 Agent 调用通过 REST 接口或网关进入 Web 层Web 层立即返回一个sessionId同时建立 SSE 连接连接请求里带上这个sessionId。请求参数被封装成任务消息写入 Redis 队列或直接发布到 Redis Channel 中。某个空闲的 Agent worker 实例消费到任务消息开始执行大模型推理和工具调用每产生一段思考文本、一个工具调用请求、一个工具执行结果都会组装成统一的事件结构发布到 Redis 的一个全局频道中。同时所有 Spring Boot 实例都订阅了这个频道收到事件后判断sessionId是否落在自己节点的连接表里是则通过 SseEmitter 推给客户端。这个模型的关键在于Agent worker 不需要知道客户端长连接在哪Web 层也不需要关心任务执行细节。两者之间靠 Redis 解耦靠事件协议对齐。这样还带来一个额外好处Web 层可以独立扩展Agent worker 也可以独立扩展没有单点瓶颈。实测下来单是去掉轮询整体请求量就下降了 80% 以上系统压力小了很多。2.2 Redis 在流式中枢里的三类核心职责聊清楚 Redis 的作用我习惯把它拆成三层消息通道、状态存储、会话路由。第一层是消息通道。我主要用的是 Redis Pub/Sub全局频道agent:events发布各类事件。为什么选 Pub/Sub 而不是 Streams 或者普通队列因为它足够轻语义上就是“广播给所有订阅者”非常适合多实例同时监听并做本连接路由。但要注意Redis Pub/Sub 的消息不持久化如果订阅端短暂断连消息会丢。这个问题后面第 5 章会再展开如果对可靠性要求很高可以把事件同时写入 StreamsSSE 只是加了一个快速分发通道。第二层是状态存储。我用Hash结构存每个任务的状态比如status: running / tool_calling / done以及record: 已经发送过的事件偏移量或事件 ID 列表。客户端断线重连后Web 层从 Redis 里查到这个任务的当前状态再决定是从头补发还是只补最后几条。第三层是会话路由。我用Hash结构存sessionId到nodeId的映射节点启动时注册自己节点下线时清理过期映射。当一个实例收到 Redis 广播的事件后只需要查本地连接表不需要每次都查 Redis。但如果事件来自另一个实例本机查不到连接就直接忽略不产生额外开销。这个小设计在高并发下非常关键避免了每个实例都拿着全局关系表到处找连接。2.3 Spring Boot 3 异步模型选择SseEmitter 还是响应式流Spring Boot 3 中实现 SSE 主流有两种姿势一种是 Spring MVC 的SseEmitter另一种是 Spring WebFlux 的FluxServerSentEvent。如果你是新项目且愿意全部切到 WebFlux响应式流在数据拉取和背压上有天然优势但代价是整套技术栈都要转向 Reactor数据库访问、Redis 客户端也要跟着换改造成本不小。我的方案是继续用 Spring MVC SseEmitter因为原来的业务代码大量依赖同步的接口和工具类强行换 WebFlux 会让复杂度爆炸。SseEmitter本质上就是 Servlet 异步线程模型下提供的 SSE 封装使用起来非常直接接口收到请求后立即返回线程从 Tomcat 线程池中释放连接由异步线程和底层 Netty/容器配合维护。随后在另一个线程里往 SseEmitter 写事件即可。不过这里有一个极其重要的变化Spring Boot 3.2 之后对虚拟线程支持更好了默认的 Tomcat 线程池可以被替换成虚拟线程。起初我以为把所有普通接口改成虚拟线程就行了后来才发现 SseEmitter 的异步写和虚拟线程池之间如果配合不当很容易把连接池任务队列撑爆。这个坑在第 5 章会详细说。3. 核心模块实现SSE 控制器、Redis 订阅与协议设计3.1 用 SseEmitter 搭一个可落地的 SSE 端点先看最基础的控制层实现。定义一个连接端点客户端传入sessionId服务端为这个会话创建一个SseEmitter设置超时时间然后存入本地连接表。之后所有针对该会话的事件都会通过这个 SseEmitter 推送到客户端。RestController RequestMapping(/api/agent) public class AgentSseController { private final ConcurrentHashMapString, SseEmitter liveEmitters new ConcurrentHashMap(); GetMapping(value /stream/{sessionId}, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(PathVariable String sessionId) { SseEmitter emitter new SseEmitter(0L); // 0L 表示不超时需根据场景调整 liveEmitters.put(sessionId, emitter); emitter.onCompletion(() - liveEmitters.remove(sessionId)); emitter.onTimeout(() - liveEmitters.remove(sessionId)); emitter.onError(e - liveEmitters.remove(sessionId)); return emitter; } }这里注意几点。第一SseEmitter本身是 Spring MVC 提供的异步返回对象控制器返回它之后Tomcat 不会立刻关闭连接而是脱离当前请求线程进入异步模式。第二new SseEmitter(0L)意味着永不超时但生产环境最好别直接用 0而是设置一个合理的超时时间比如半小时避免客户端一直挂住不释放。第三liveEmitters是本地缓存只代表当前节点上的连接节点重启后必然丢失所以会话级恢复逻辑必须依赖 Redis而不是这里。3.2 把 Redis Pub/Sub 事件桥接到 SSE 推送通道连接建立好了接下来的关键是把 Redis 广播事件桥接给对应的 SseEmitter。我的实现是定义 Redis 消息监听容器监听agent:events频道收到消息后解析事件体根据事件里的sessionId判断当前节点有没有对应的 SseEmitter有就发往客户端。Component public class RedisEventBridge implements MessageListener { private final ObjectMapper objectMapper; private final AgentSseController sseController; Override public void onMessage(Message message, byte[] pattern) { String payload new String(message.getBody(), StandardCharsets.UTF_8); AgentEvent event objectMapper.readValue(payload, AgentEvent.class); sseController.push(event.sessionId(), event); } }而在AgentSseController里需要补一个push方法负责把事件序列化成ServerSentEvent格式写出去。这里有个小技巧每次写入前先发送一条注释行作为心跳保持连接活跃同时避免代理节点因为“长时间没有数据而超时断开”。具体会在第 4 章讲。public void push(String sessionId, AgentEvent event) { SseEmitter emitter liveEmitters.get(sessionId); if (emitter null) return; try { ServerSentEventAgentEvent sseEvent ServerSentEvent.builder(event) .id(String.valueOf(event.eventId())) .event(event.type()) .build(); emitter.send(sseEvent); } catch (Exception e) { liveEmitters.remove(sessionId); } }在配置 Redis 监听容器时要注意线程池配置。默认的 RedisMessageListenerContainer 在事件量大的时候如果只用一个 listener 线程很容易成为瓶颈。我建议在容器初始化时设置多个执行线程同时给不同的 Agent 事件类型拆到不同频道避免单一频道的消费压力。3.3 流式思考与工具调用的事件协议设计要想让前端流畅展示 Agent 的思考过程事件协议必须提前设计好不能事到临头再拼。我的协议里事件类型一共有 5 种thinking、tool_call、tool_result、final_answer、error。每种事件结构上都包含sessionId、eventId、type、data、timestamp。eventId用来做幂等和断线续传data是业务载荷。{ sessionId: xxxx-xxxx, eventId: 12, type: tool_call, data: { toolName: web_search, arguments: {\keyword\:\spring boot sse\}, callId: call_01 }, timestamp: 1712345678901 }事件粒度也很重要。thinking事件可以按 token 片段推也可以按完整句子推。推得太细Redis 和网络开销成倍增长推得太粗前端动画会卡。我的经验是大模型每生成 30-50 个字推送一次完整片段同时附带一个delta标记由前端自己决定是替换还是追加。工具调用事件则必须一次推完整结构包含工具名、参数和调用 ID方便前端展示工具运行状态。这里尤其要设计好final_answer的data。它既要包含最终答案文本也要包含这次 Agent 执行过程中所有工具调用的摘要列表。这样客户端即使在重连后没有收到历史事件也能通过final_answer感知整体执行结果。4. 生产级优化断线续传、心跳保活与多实例水平扩展4.1 会话状态映射客户端重连后如何续上断线续传是生产级 SSE 方案里最容易被忽略的一块。客户端网络抖动、代理重连、页面刷新任何一个场景都会导致原有 SSE 连接断开。如果重连后直接新建一条连接就会丢失中间事件用户发现“思考过程突然断了几步”体验非常割裂。解决思路是给每个会话维护一个“已推送事件 ID”的水位线。Redis 里存一份sessionId - 已推送事件ID列表客户端重连时在请求头或查询参数里带上Last-Event-IDWeb 层拿到这个 ID 后从 Redis 里把应用户缺失的事件全部补推一次。实际落地时我会把事件同时写入 Redis 的一个 Stream 结构以sessionId作为 key 维度每条事件都有自己的 ID用于增量读取。这样即使事件已经通过 Pub/Sub 广播过了也不会在断线续传时丢失。补推的优先级要控制好先补状态类事件再补增量文本事件最后补终态事件避免前端因为乱序渲染出错。测试下来这个续传机制对用户体感改善非常明显至少不会再出现白屏或半个回答卡在页面上的情况。4.2 心跳与代理缓冲让长连接不再无端断开SSE 长连接在生产环境第一个遇到的敌人往往是网络链路中的各种代理层。Nginx、网关、防火墙都有空闲超时如果一段时间内没有任何数据帧通过底层就会把连接判定为僵尸连接并断开。要避免这个问题只能在应用层定期发送心跳。最简单的心跳方式是定时往 SseEmitter 发送一个注释行格式是: ping\n\n。这种注释事件浏览器和 EventSource 会忽略但能维持连接活跃。我用一个全局的ScheduledExecutorService每隔 15 秒遍历所有活跃连接发送心跳。但要注意不能只发心跳不发业务事件否则虽然连接没断用户也可能因为长时间没有真实内容而以为页面卡死。所以心跳间隔最好比代理超时时间短一半以上。另一个非常常见的坑是 Nginx 默认开启proxy_buffering导致 SSE 数据被缓冲到一定大小才返回。表现就是前端事件不是实时展示而是一段一段地蹦出来。解决办法是在响应头里加上X-Accel-Buffering: no同时关闭代理缓冲。Spring 里可以直接写过滤器统一设置这个头下面这个配置可以参考。Component public class SseHeaderFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (request instanceof HttpServletRequest httpRequest httpRequest.getRequestURI().startsWith(/api/agent/stream)) { HttpServletResponse httpResponse (HttpServletResponse) response; httpResponse.setHeader(X-Accel-Buffering, no); httpResponse.setHeader(Cache-Control, no-cache); httpResponse.setHeader(Connection, keep-alive); } chain.doFilter(request, response); } }4.3 断流排查速查常见根因和解决顺序做了这套中枢之后我总结了一张断流排查表。每次线上报“SSE 断流”我会按这个顺序查基本能快速定位到根因。表现根因解决方案连接建立后无数据1 分钟左右断开代理/网关空闲超时缺少心跳加 15 秒心跳注释行调大代理超时事件不是实时而是一段段到达Nginx 或网关开启缓冲设置 X-Accel-Buffering: no关闭 proxy_buffering客户端重连后丢中间事件事件只走 Pub/Sub没有持久化使用 Redis Streams 或 DB 存储事件配合 Last-Event-ID 补推高并发下 SseEmitter 发送报 IOException连接已被远端关闭但本地未及时清理增加连接清理定时任务在 push 失败时立即移除断线重连后事件顺序错乱补推和实时推送并发执行按 sessionId 加锁或串行化推送队列这张表里每一条都是我真实踩过的。尤其是第一条如果 Nginx 和 Spring Boot 两端的超时配置不一致SSE 很容易在刚建立连接没多久就被掐断。所以生产部署前先统一网关层与应用层的心跳时机能少踩一大半坑。5. 常见问题与避坑实录5.1 虚拟线程和 SseEmitter 一起用我差点踩坏连接池Spring Boot 3.2 开始支持虚拟线程很多朋友直接spring.threads.virtual.enabledtrue把 Tomcat 线程换掉感觉很爽。但虚拟线程不是万能的特别是它和 SseEmitter 的写操作配合不当容易导致瞬间创建大量虚拟线程把底层线程工厂都打满。我的教训是不要把所有接口都无脑放在虚拟线程上。SseEmitter 一旦建立请求线程已经释放后续的推送发生在业务处理线程中这里的线程模型和 Tomcat 的请求线程无关。对于真正的 Agent 执行任务我用的是一个普通的ThreadPoolTaskExecutor核心线程数和最大线程数都设了上限避免突发大量任务时资源失控。虚拟线程在我的场景里主要用来跑那些本来会阻塞 IO 的普通同步接口比如查询历史记录、获取工具列表。如果你非要在 SseEmitter 的 push 路径上使用虚拟线程一定要限制并发的推送任务数量否则一个上游慢接口就能瞬间创建几千个虚拟线程内存和调度开销都会爆。推荐做法是维护一个信号量控制在途推送任务数。5.2 Redis Pub/Sub 的丢消息问题替代方案是什么Redis Pub/Sub 的语义是 fire-and-forget发布时不关心有没有订阅者在听订阅者掉线期间的消息直接丢失。这在大多数 Agent 场景下其实是可以接受的因为丢的只是实时增量重连后可以通过持久化事件补发。但如果你的业务要求“每个工具调用结果都不能丢”那 Pub/Sub 就不够用了。我最终的方案是双写业务事件先写入 Redis Stream然后同时在 Pub/Sub 频道发一条广播让在线连接尽快收到。如果某个节点掉线它重新启动后可以通过 Redis Stream 从上次的消费偏移量补拉事件再推给已经重连上的客户端。这样做的好处是实时性和可靠性都有了保障缺点是实现复杂度增加不少。对于刚开始做 SSE 的方案我建议先只用 Pub/Sub把完整流程跑通再按需升级为 Streams。这里再提一句不要把 Redis Streams 和 Kafka 混为一谈。Redis Streams 更适合中小规模的消息持久化需求操作简单不引入额外中间件但海量吞吐和复杂分区还是得交给真正的消息队列。5.3 客户端重连时的消息幂等控制SSE 自带自动重连浏览器 EventSource 在连接断开后会自动重新发起请求并带上Last-Event-ID头。但很多自研前端工具不会自动带这个头导致补推逻辑失效。所以服务端需要兼容两种重连方式一种是通过Last-Event-ID头另一种是通过 URL 参数里的lastEventId。防重复也一样重要。客户端收到事件后需要根据eventId做去重。前端的事件处理函数里可以维护一个已处理 ID 的 Set超过一定数量后只保留最近 N 条。服务端推送时也要注意如果一个事件既被实时推送过又因为重连被补推了一次前端拿到两个相同eventId的事件时不能二次渲染工具调用卡片。我在协议里给每个事件加了类型和顺序标识前端按eventId排序后只处理递增事件能有效避免重复和乱序。6. 从这套方案里学到的坐标定位与扩展方向6.1 稳定运行后的个人体会这套 SSE Redis 的中枢结构我先是用了几个月把验证服务和核心闭环都跑通然后逐步完善成生产级方案。最深的体会是SSE 选型本身没问题但真正决定体验的是细节比如心跳、续传、幂等、代理配置。任何一个环节没做扎实用户都会用“又断流了”“又没动静了”来反馈。在实际部署中我建议你先把单个链路跑稳再考虑水平扩展。不要让多实例路由、Redis Streams 这些高级特性一开始就叠加进来。先用单实例 Pub/Sub 把整个流程打通观察运行 2 周后有没有断流和消息丢失问题之后再逐步加节点、补全持久化。很多团队一上来就追求完美架构结果排查问题时被多实例和消息补偿搞得焦头烂额。6.2 下一步可以怎么演进如果后续想继续往生产级方向走我比较推荐两个扩展点。第一把 Redis Pub/Sub 升级为 Redis Streams 消费组这样多个 Agent worker 可以共同消费同一批任务消息而不是简单广播任务调度层面的可靠性会大幅提升。第二SSE 毕竟只解决了服务端到客户端单向推送如果未来产品需要客户端取消、人工介入、动态调整工具调用参数就要引入一条反向通道这时再考虑 WebSocket 或者建立一个单向的反向 HTTP 接口也不迟。对我来说这套方案带来的最大价值是彻底把 Agent 的黑盒执行过程变成了可观察、可追溯的透明流程。只要把事件协议持续维护好后面无论是接监控、做审计还是给用户回放历史执行过程都有现成的数据基础。个人感觉这比单纯去追新框架新特性要实用得多。