告别轮询与断流!基于 Spring Boot 3 + SSE + Redis 打造生产级 Agent 流式思考与工具调用中枢

0 阅读7分钟

告别轮询与断流!基于 Spring Boot 3 + SSE + Redis 打造生产级 Agent 流式思考与工具调用中枢

在做纯对话大模型(Chatbot)时,流式输出相对简单:前端发起一个 HTTP 请求,后端调 OpenAI 的 Stream 接口,把 Delta Token 原样推给前端打字机即可。

然而,一旦业务演进到 自主智能体(Autonomous Agent) 阶段,交互的复杂度直接指数级爆发:

  • Agent 会产生独立的“深度思考过程(Thinking Chain)”;
  • 会根据上下文自主决定调用外部工具(如“查询订单”、“向量库检索”、“执行代码”),中途会产生工具入参、执行耗时与返回结果;
  • 在多步决策后,才开始输出最终回答;
  • 工具执行可能长达 10~30 秒,期间若无消息下发,网关和浏览器长连接会直接超时断开(Connection Timeout)。

很多团队在本地开发时跑得挺顺,一旦把系统部署到生产环境与 Kubernetes 多实例集群,立刻遭遇一连串致命翻车:

  1. Nginx 缓冲截留导致“假流式”:前端完全看不到逐字打字效果,在等待 20 秒后,所有思考步骤和文字像洪水一样瞬间喷涌出来;
  2. 多节点集群 SSE 连接孤岛:用户与节点 A 建立了长连接,而执行后台异步任务的 Worker 运行在节点 B,节点 B 根本找不到用户的长连接句柄,前端页面永久陷入“思考中...”假死白屏;
  3. 事件类型混乱与前端状态坍塌:普通的纯文本流无法区分“思考日志”、“工具调用状态”、“最终文字”与“异常报错”,前端只能用正则去硬猜解析,代码极其脆弱。

如何优雅、高可用地支撑复杂的 Agent 交互? 答案就是:基于 Server-Sent Events (SSE) 协议规范设计分型事件流,并利用 Redis Pub/Sub 实现跨集群节点的事件中枢广播。

本文将手把手拆解这套在生产环境稳定支撑复杂 Agent 调度的工业级流式通信方案。


一、工业级 Agent 交互协议与架构拓扑

在设计技术实现前,必须先确立事件分型(Typed Events)标准。 标准的 SSE 规范天生支持 event: <name> 字段,我们为生产 Agent 划分了 4 种核心事件:

  1. event: agent_thought:Agent 内心思考链输出(打字流);
  2. event: tool_call:工具调用通知(包含工具名、参数与开始执行动画);
  3. event: tool_result:工具执行完毕回执(包含耗时与结果摘要);
  4. event: agent_answer:最终答复文本(打字流);
  5. event: stream_end:终态结束包(附带耗时统计与消耗 Token)。

1. 分布式集群架构全景

flowchart TD
    subgraph ClientBrowser [客户端浏览器]
        UI[Agent 聊天与步骤看板]
        SSEClient[EventSource / Fetch Stream 客户端]
    end

    subgraph IngressGateway [Nginx / 负载均衡层]
        Nginx[Nginx 反向代理 (proxy_buffering off)]
    end

    subgraph ServiceCluster [Spring Boot 3 集群节点]
        NodeA[应用节点 A: 挂载用户 SSE 长连接]
        NodeB[应用节点 B: 运行重型 Agent 推理与 Worker]
    end

    subgraph Middleware [中间件]
        RedisPubSub[(Redis Channel: agent:stream:sessionId)]
    end

    UI --> SSEClient --> Nginx
    Nginx -->|建立持久连接| NodeA
    NodeA -->|订阅会话主题| RedisPubSub

    NodeB -->|Agent 逐步决策思考| NodeB
    NodeB -->|发布事件广播 (Thought/Tool/Answer)| RedisPubSub

    RedisPubSub -->|实时下发| NodeA
    NodeA -->|SSE 协议推送对应事件包| Nginx --> UI

二、Spring Boot 3 核心实现:高可用 SseEmitter 会话管理器

Spring Boot 原生的 SseEmitter 如果直接扔在 Controller 里,超时、断开未释放会导致严重内存泄漏。必须建立全局受管的会话中枢。

1. 结构化事件载荷模型

package com.tudazi.ai.agent.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgentStreamEvent implements Serializable {
    /**
     * 事件类型: agent_thought / tool_call / tool_result / agent_answer / heartbeat / stream_end
     */
    private String eventType;
    private String sessionId;
    private String payload;
    private Long timestamp;
}

2. SseSessionManager 会话管理器(含心跳与超时自愈)

package com.tudazi.ai.agent.sse;

import com.tudazi.ai.agent.model.AgentStreamEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
public class SseSessionManager {

    // 保存当前单机节点维护的客户端连接
    private final Map<String, SseEmitter> sessionMap = new ConcurrentHashMap<>();
    
    // 超时设置为 5 分钟 (300,000ms),期间由心跳保活
    private static final Long DEFAULT_TIMEOUT = 300_000L;

    public SseEmitter createSession(String sessionId) {
        // 如果旧连接存在,先优雅关闭
        removeSession(sessionId);

        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);

        emitter.onCompletion(() -> {
            log.info("SSE 连接完成 SessionId: {}", sessionId);
            sessionMap.remove(sessionId);
        });

        emitter.onTimeout(() -> {
            log.warn("SSE 连接超时 SessionId: {}", sessionId);
            sessionMap.remove(sessionId);
        });

        emitter.onError((ex) -> {
            log.warn("SSE 连接发生异常 SessionId: {}, Err: {}", sessionId, ex.getMessage());
            sessionMap.remove(sessionId);
        });

        sessionMap.put(sessionId, emitter);
        log.info("客户端 SSE 会话注册成功: {}, 当前单机在线数: {}", sessionId, sessionMap.size());

        // 发送初始握手包
        sendToClient(sessionId, AgentStreamEvent.builder()
                .eventType("connected")
                .sessionId(sessionId)
                .payload("{\"status\":\"READY\"}")
                .timestamp(System.currentTimeMillis())
                .build());

        return emitter;
    }

    public boolean sendToClient(String sessionId, AgentStreamEvent event) {
        SseEmitter emitter = sessionMap.get(sessionId);
        if (emitter == null) {
            return false;
        }

        try {
            SseEmitter.SseEventBuilder eventBuilder = SseEmitter.event()
                    .name(event.getEventType())
                    .id(String.valueOf(event.getTimestamp()))
                    .data(event.getPayload(), MediaType.APPLICATION_JSON);
            
            emitter.send(eventBuilder);
            return true;
        } catch (IOException | IllegalStateException ex) {
            log.warn("向 SessionId [{}] 发送事件失败,清理坏连接: {}", sessionId, ex.getMessage());
            removeSession(sessionId);
            return false;
        }
    }

    public void removeSession(String sessionId) {
        SseEmitter emitter = sessionMap.remove(sessionId);
        if (emitter != null) {
            try {
                emitter.complete();
            } catch (Exception ignored) {}
        }
    }

    /**
     * 定时发送心跳(Ping),防止经由各种中转代理与网关超时断连
     */
    @Scheduled(fixedRate = 15000)
    public void sendHeartbeat() {
        if (sessionMap.isEmpty()) return;

        AgentStreamEvent ping = AgentStreamEvent.builder()
                .eventType("heartbeat")
                .payload("{\"ping\":true}")
                .timestamp(System.currentTimeMillis())
                .build();

        sessionMap.keySet().forEach(sid -> sendToClient(sid, ping));
    }
}

三、跨节点解耦:Redis Pub/Sub 广播总线

在生产微服务集群下,必须杜绝“节点绑定”假设。使用 Redis 订阅发布机制,使任何 Worker 节点产生的 Agent 事件都能精准路由到持有该长连接的 Web 节点。

1. 事件发布者(由 Agent 运行时调用)

package com.tudazi.ai.agent.bus;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.tudazi.ai.agent.model.AgentStreamEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class AgentStreamBroadcaster {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    private static final String CHANNEL_PREFIX = "agent:stream:";

    public void broadcast(AgentStreamEvent event) {
        try {
            String json = objectMapper.writeValueAsString(event);
            redisTemplate.convertAndSend(CHANNEL_PREFIX + event.getSessionId(), json);
        } catch (Exception e) {
            log.error("广播 Agent 流式事件失败: {}", e.getMessage(), e);
        }
    }
}

2. Redis 监听器与本地 SseEmitter 绑定消费

package com.tudazi.ai.agent.bus;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.tudazi.ai.agent.model.AgentStreamEvent;
import com.tudazi.ai.agent.sse.SseSessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class AgentStreamMessageSubscriber implements MessageListener {

    private final SseSessionManager sseSessionManager;
    private final ObjectMapper objectMapper;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            String body = new String(message.getBody());
            AgentStreamEvent event = objectMapper.readValue(body, AgentStreamEvent.class);
            // 命中本地活跃的连接,则直接推送给浏览器
            sseSessionManager.sendToClient(event.getSessionId(), event);
        } catch (Exception ex) {
            log.error("处理 Redis SSE 广播消息解析失败: {}", ex.getMessage());
        }
    }
}

四、生产避坑与安全防御指南

在千万级交互的生产实践中,有 3 个最致命的“黑天鹅”坑必须提前规避:

1. Nginx 缓冲区(Proxy Buffering)导致流式变全量

现象:本地测试打字机效果很流畅,一上生产部署在 Nginx 反向代理后,前端完全不打字,必须等全部响应结束才一口气渲染出来。 根因:Nginx 默认开启了 proxy_buffering on;,它会等缓冲区积攒满 4k/8k 数据后才往客户端发。 正解: 在 Nginx 的反向代理配置中,针对 SSE 路径增加明确配置,或由 Spring Boot 在响应头中加入 X-Accel-Buffering: no:

location /api/v1/agent/stream {
    proxy_pass http://backend_cluster;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
}

2. 长时间工具调用导致的“静默假死”

现象:Agent 决定调用一个大计算量的工具(如爬虫网页分析或生成大型图表),需要运行 25 秒。在这 25 秒内,如果没有任何数据推向客户端,中间的网关(如 Cloudflare、Kong)会直接报 504 Gateway Timeout。 正解:

  • SseSessionManager 每 15 秒主动广播 heartbeat 事件;
  • 工具执行开始前,立即下发 tool_call 事件,前端展示骨架屏或旋转 Loading 动画,并在工具执行期间继续周期性上报中间进度日志。

3. 连接断开后的“幽灵 Agent 任务”

现象:用户在第 3 秒关闭了浏览器标签页,但后端包含 5 轮调用的大模型 Agent 任务依然在后台疯狂消耗 Token 运行了 30 秒,极大地浪费了昂贵的 API 额度。 正解: 在 sseEmitter.onError 和 onCompletion 时,不仅移除连接,还应联动向对应的 Agent 任务上下文设置 CancellationToken.cancel(),中断未完成的大模型生成,立省 50% 无效 Token 损耗。


五、总结

打造优秀的 AI Agent 用户体验,不仅考验 Prompt Engineering 和大模型本身的推理能力,更考验后端系统的高并发通信水准、状态鲁棒性与异常韧性。

通过 规范化 SSE 协议事件分型 + Spring Boot 3 托管会话中枢 + Redis Pub/Sub 跨集群广播,我们既消除了单点故障与分布式孤岛,又赋予了前端细腻透明的“思考-工具-输出”沉浸式交互能力。


读者探讨

在你们的 AI 智能体产品中,流式传输采用的是 SSE 还是 WebSocket?面对高耗时的复杂工具调用,你们是如何保持前端用户感知与长连接稳定的?欢迎在评论区分享你的实战心得!