实时 AI 应用架构:WebSocket、SSE 与双向流式 Agent

0 阅读37分钟

概述

系列定位:本文是“多 Agent 系统与 AI 应用解决方案”系列的第 14 篇。在《AI 驱动的自动化测试平台》为软件质量筑牢防线后,我们将目光投向用户与 AI 应用之间的实时交互体验。这是让 Agent 从“后台任务”蜕变为“对话伙伴”的关键一步,是 Agent 从“能用”走向“好用”的分水岭。

核心叙事:传统 HTTP 的“一问一等”模式已无法满足用户对即时反馈的期望——用户渴望看到 Agent 逐字“打”出思考,更渴望能在生成过程中随时打断、纠正或追问。本文将以 “如何让 AI Agent 从‘一问一答的问答机器’升级为‘能实时互动、能被随时打断、能主动推送进展的对话伙伴’” 为主线,系统拆解实时 AI 应用架构的四大核心设计:协议选型与混合架构、双向流式交互协议、连接生命周期与会话管理、流式响应的前端状态管理。最终通过一个“实时客服 Agent”贯穿案例,展示从用户输入到 Agent 流式回复再到用户中途打断转人工的完整全双工交互体验。

阅读收获:你将掌握 WebSocket + SSE 混合架构的设计哲学,学会构建完整的 AgentMessage 双向交互协议,理解 SessionManager 背后的心跳、重连与状态恢复机制,并建立前端状态机与 Agent 生命周期之间的精确映射。这四项工程能力的组合,能让你为任何 AI Agent 打造出像真人对话一样自然、流畅、可控的实时交互体验。


文章组织架构图

flowchart TD
    subgraph 1["1. 协议选型与混合架构"]
        A1["SSE / WebSocket / WebTransport 对比"]
        A2["混合架构:WebSocket主控 + SSE流式 + HTTP降级"]
    end
    subgraph 2["2. 双向流式 Agent 交互协议"]
        B1["AgentMessage 六类消息定义与序列化"]
        B2["ReAct 循环中的消息推送时机"]
        B3["Interrupt 中断机制与 LLM 取消"]
    end
    subgraph 3["3. 连接生命周期与 SessionManager"]
        C1["SessionState 设计与 Redis 存储"]
        C2["心跳 / 断线检测 / 指数退避重连"]
        C3["断线重连后的会话恢复与离线消息"]
    end
    subgraph 4["4. 流式响应的前端状态管理"]
        D1["IDLE→THINKING→...→COMPLETE 状态机"]
        D2["React useAgentChat Hook 封装"]
    end
    subgraph 5["5. 会话持久化与离线消息"]
        E1["MongoDB 历史对话存储"]
        E2["Redis 离线消息队列 & 重放"]
    end
    subgraph 6["6. 贯穿案例:实时客服 Agent 全双工交互"]
        F1["订单咨询 10 步全流程推演"]
        F2["断线重连消息完整性验证"]
    end
    subgraph 7["7. 与前后系列的衔接"]
        G1["与前文协议/观测/客服/记忆的关系"]
    end
    subgraph 8["8. 面试高频专题"]
        H1["14+ 面试题,含系统设计"]
    end

    1 --> 2 --> 3 --> 4 --> 5 --> 6 --> 7 --> 8

架构图分层详解

  • 总览说明:全文 8 个模块从实时 AI 应用的协议选型出发,逐步构建混合通信架构、双向流交互协议、会话生命周期管理、前端状态机和离线消息,最后以贯穿案例和面试题收尾,形成一条完整的“协议→协议→会话→前端→可靠性→案例→衔接→巩固”学习路径。

  • 逐模块说明
    模块 1 建立“为什么需要混合协议”的认知和架构蓝图,给出 SSE、WebSocket、WebTransport 的选型决策矩阵。
    模块 2-3 是核心工程——定义 Agent 与前端之间的“语言”和“对话规则”,实现连接的生命周期管理。
    模块 4 将后端推送映射到前端 UI 状态机,让用户始终感知 Agent 在做什么。
    模块 5 解决可靠性难题:用户断线期间的消息不丢失。
    模块 6 通过实时客服案例验证全链路设计,包含失败场景推演。
    模块 7 承上启下,标明本文在全系列中的位置。
    模块 8 面试巩固,将工程实践提炼为可回答的面试逻辑。

  • 关键结论:实时 AI 应用的架构设计,本质上是将人类对话的自然特性(边说边想、随时打断、上下文连续)映射为网络通信协议(WebSocket 全双工 + SSE 流式推送)。掌握了混合协议选型、AgentMessage 交互协议、SessionManager 会话管理和前端状态机这四大技术,你就能为任何 AI Agent 构建起像真人对话一样自然、流畅、可控的实时交互体验。用户体验的提升,往往不在于模型更聪明,而在于交互更“人性化”——实时反馈、状态可见、随时可控。


1. 实时 AI 应用的协议选型与混合架构

实时交互的第一步是选择合适的传输通道。本系列前文《LLM 服务化协议内核:REST、SSE 与 gRPC 的工程解析》已从网络协议层面拆解了各协议的底层原理。本节将聚焦于 AI Agent 实时对话场景下的工程选型与混合架构设计,回答“何时用 SSE,何时用 WebSocket,何时两者兼用”。

1.1 SSE、WebSocket 与 WebTransport 的决策矩阵

维度SSEWebSocketWebTransport
通信模式单向(服务端→客户端)全双工全双工 + 多路复用
传输基础HTTP/1.1 或 HTTP/2独立 TCP 连接(升级后)HTTP/3 (QUIC)
浏览器支持全部主流浏览器全部主流浏览器Chrome 97+, Firefox 114+
自动重连EventSource 内置需自行实现需自行实现
消息格式文本(text/event-stream)文本/二进制帧可靠流 + 不可靠数据报
队头阻塞无(HTTP/2 多路复用)存在(TCP 有序)无(QUIC 流独立)
防火墙/代理穿透极好(普通 HTTP)部分企业防火墙拦截需 HTTP/3 支持,罕见
服务端复杂度低(Spring WebFlux SseEmitter)中(需管理连接状态)高(需 HTTP/3 服务器)

SSE 的适用边界:当对话场景是 “单次请求→流式响应” 且用户无需中途干预时,SSE 是最简单高效的方案。例如,OpenAI 的 stream=true 参数便是基于 SSE,每个 token 作为一个事件推送,浏览器只需 new EventSource('/api/chat/stream') 即可接收。ChatGPT 网页版在回答生成期间,实际就是采用 SSE 逐字推送。

WebSocket 的必要性:一旦需要 “用户主动打断”“Agent 主动推送非流式消息”(如工具调用状态、错误提示),单向上行的缺失便成为致命缺陷。此时 WebSocket 全双工通道成为必需。Google Gemini 的 streamGenerateContent 虽然也返回流,但其多模态实时交互场景依赖 WebSocket 承载双向控制。

WebTransport 的未来价值:基于 QUIC 的 WebTransport 解决了 WebSocket 在弱网下的队头阻塞问题——一个 TCP 丢包会阻塞后续所有 WebSocket 帧,而 QUIC 的流独立特性可以让 ANSWER_CHUNK 流不受 THOUGHT 消息延迟的影响。但当前浏览器与基础设施支持有限,可作技术储备。

1.2 混合架构设计:WebSocket 主控 + SSE 流式 + HTTP 降级

我们采用双通道混合架构(图 1),将不同性质的消息分流到最优协议:

  • WebSocket 主控通道 (wss://<host>/agent/control):承载 双向控制消息。前端发送 UserMessageInterrupt;Agent 推送 THOUGHTTOOL_CALLTOOL_RESULTERRORANSWER_COMPLETE。该通道保证交互的即时性和可控性。
  • SSE 流式输出通道 (/api/agent/{sessionId}/stream):专用于 ANSWER_CHUNK 增量 Token 推送。利用 SSE 的自动重连和浏览器原生 EventSource 的优势,让打字机效果更轻量,并减轻 WebSocket 通道的带宽压力。
  • HTTP 降级通道:当 WebSocket 被防火墙拦截时,前端自动切换为 SSE + HTTP POST 兼容模式——通过 EventSource 接收 SSE 流,通过 POST /api/agent/{sessionId}/message 发送消息,通过 POST /api/agent/{sessionId}/interrupt 发送中断。虽然中断的实时性不如 WebSocket(需轮询或长连接),但保证了全场景覆盖。
flowchart TD
    Browser[浏览器]
    WS[WebSocket 主控通道<br/>wss://host/agent/control]
    SSE[SSE 流式输出<br/>/api/agent/sessionId/stream]
    HTTP[HTTP 降级<br/>POST /message + /interrupt]
    Agent[Agent 引擎]

    Browser -- "UserMessage, Interrupt" --> WS
    WS -- "THOUGHT, TOOL_CALL, TOOL_RESULT, ERROR, ANSWER_COMPLETE" --> Browser
    Agent -- "ANSWER_CHUNK" --> SSE
    SSE -- "逐 Token 推送" --> Browser
    Browser -- "WebSocket 不可用时" --> HTTP
    HTTP -- "POST 消息/中断" --> Agent
    Agent -- "SSE 仍可用" --> SSE

图 1:实时 AI 应用的混合协议架构图

  • a) 主旨概括:该图展示了生产环境中实时 Agent 消息的多通道分流策略,利用不同协议的优势承载不同类型的消息,并在 WebSocket 不可用时提供无缝降级。

  • b) 逐元素分解

    1. WebSocket 主控通道:承担全部双向控制消息,是交互的核心路径,所有非流式 Agent 状态推送均经由此路。
    2. SSE 流式通道:仅负责逐 Token 的文本输出,简化前端打字机实现,利用 HTTP 代理兼容性保障到达率。
    3. HTTP 降级分支:当 WebSocket 升级失败(如企业防火墙拦截)时,客户端自动回退到 HTTP 消息发送 + SSE 流接收,保证服务可用。
  • c) 设计原理映射

    • 策略模式:SSE、WebSocket、HTTP 三者构成可替换的传输策略族,前端根据 WebSocket 可用性动态选择,符合开闭原则。
    • 观察者模式:Agent 作为被观察者,通过 WebSocket 和 SSE 两个事件通道通知前端观察者,不同类型消息触发不同的 UI 更新。
  • d) 工程联系与关键结论
    混合架构并非简单叠加,而是需要精细的消息路由策略。生产常见误配置:将 ANSWER_CHUNK 也通过 WebSocket 推送,导致高并发时 WebSocket 的 TCP 单连接队头阻塞影响控制消息延迟。正确的做法是让批量流式数据走独立的 SSE 通道,控制消息走 WebSocket。
    防火墙兼容性教训:某金融企业内部网络拦截了 WebSocket 握手,导致客服 Agent 完全不可用。通过引入 SockJS + STOMP 降级以及本文的 HTTP 回退方案,将不可用时间从 4 小时缩短为分钟级自动切换。

1.3 降级决策与配置

在 Spring WebFlux 中,我们通过 HandshakeInterceptor 检测 WebSocket 升级,若失败则向前端返回 426 Upgrade Required 响应,前端据此切换至 HTTP 模式。

# application.yml
agent:
  streaming:
    websocket:
      endpoint: /agent/control
      heartbeat-interval: 30s
      max-idle-timeout: 30m
    sse:
      retry-timeout: 3000  # EventSource 重连间隔
    degradation:
      fallback-to-http: true

设计意图解读retry-timeout: 3000 指示 SSE 断开后 EventSource 在 3 秒后自动重连,这对于临时网络抖动至关重要。生产影响:过短的 retry 可能导致服务端重启时大量客户端同时重连形成冲击波,此处 3 秒是一个折中值,可结合随机抖动避免惊群。


2. 双向流式 Agent 的完整交互协议

有了通道,还需定义 Agent 与前端之间的“语言”。本节设计一套完整的 AgentMessage 双向交互协议,覆盖从思考到完成的全部状态,并实现用户中断的安全处理。

2.1 AgentMessage 类型体系与序列化

public sealed interface AgentMessage permits 
    ThoughtMessage, ToolCallMessage, ToolResultMessage, 
    AnswerChunkMessage, AnswerCompleteMessage, ErrorMessage {
    
    String type();
    long sequenceId();
    long timestamp();
    String sessionId();
}

@JsonTypeName("THOUGHT")
public record ThoughtMessage(String content, long sequenceId, 
    long timestamp, String sessionId) implements AgentMessage {
    @Override public String type() { return "THOUGHT"; }
}

@JsonTypeName("TOOL_CALL")
public record ToolCallMessage(String toolName, Map<String, Object> params,
    long sequenceId, long timestamp, String sessionId) implements AgentMessage {
    @Override public String type() { return "TOOL_CALL"; }
}

@JsonTypeName("TOOL_RESULT")
public record ToolResultMessage(String toolName, Object result,
    long sequenceId, long timestamp, String sessionId) implements AgentMessage {
    @Override public String type() { return "TOOL_RESULT"; }
}

@JsonTypeName("ANSWER_CHUNK")
public record AnswerChunkMessage(String token, long sequenceId,
    long timestamp, String sessionId) implements AgentMessage {
    @Override public String type() { return "ANSWER_CHUNK"; }
}

@JsonTypeName("ANSWER_COMPLETE")
public record AnswerCompleteMessage(String fullResponse, long sequenceId,
    long timestamp, String sessionId) implements AgentMessage {
    @Override public String type() { return "ANSWER_COMPLETE"; }
}

@JsonTypeName("ERROR")
public record ErrorMessage(String code, String message, long sequenceId,
    long timestamp, String sessionId) implements AgentMessage {
    @Override public String type() { return "ERROR"; }
}

Jackson 多态序列化配置(在 Spring Boot 中):

@Configuration
public class MessageSerializationConfig {
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );
        return mapper;
    }
}

设计意图:使用 Java 17 的 sealed interface 限制消息子类,确保模式匹配的穷尽性,避免遗漏类型处理。每个消息携带 sequenceId 保证前端能检测丢失。timestamp 由服务端生成,为排查消息延迟提供依据。sessionId 使消息与对话上下文绑定,支持多 Tab 会话隔离。

消息流示例(一次完整 Agent 对话的 JSON 序列):

{"type":"THOUGHT","content":"用户需要查询订单发货状态","seq":1,"ts":1716100000,"sid":"sess_abc123"}  // WebSocket
{"type":"TOOL_CALL","toolName":"getOrderStatus","params":{"orderId":"#12345"},"seq":2,"ts":1716100001,"sid":"sess_abc123"} // WebSocket
{"type":"TOOL_RESULT","toolName":"getOrderStatus","result":{"status":"运输中","carrier":"顺丰"},"seq":3,"ts":1716100200,"sid":"sess_abc123"} // WebSocket
{"type":"ANSWER_CHUNK","token":"您的","seq":4,"ts":1716100300,"sid":"sess_abc123"}  // SSE
{"type":"ANSWER_CHUNK","token":"订单","seq":5,"ts":1716100301,"sid":"sess_abc123"}  // SSE
{"type":"ANSWER_CHUNK","token":"已发货","seq":6,"ts":1716100302,"sid":"sess_abc123"} // SSE
{"type":"ANSWER_COMPLETE","fullResponse":"您的订单已发货...","seq":7,"ts":1716100303,"sid":"sess_abc123"} // WebSocket

2.2 ReAct 循环中的消息推送时机

在 LangChain4j 中,我们通过以下组件拦截 Agent 的思考与行动,实时推送消息:

  • Thought 推送:在 ChatModelListener.onRequest() 中捕获 LLM 请求,生成 ThoughtMessage 推送到 WebSocket。
  • 工具调用推送:利用 Spring AOP 环绕 ToolExecutor.execute(),截获工具名称与参数,构造 ToolCallMessage 推送;方法返回后构造 ToolResultMessage
  • 流式 Answer 推送:通过 StreamingResponseHandleronNext(token) 回调,将每个 token 封装为 AnswerChunkMessage,通过 SseEmitter.send() 推送;onComplete() 时发送 AnswerCompleteMessage 到 WebSocket。
@Service
public class StreamingAgentService {
    private final SessionManager sessionManager;
    private final StreamingChatLanguageModel chatModel;

    public void handleUserMessage(String sessionId, String userText) {
        SessionState state = sessionManager.get(sessionId);
        // 通过 WebSocket 发送 ThoughtMessage
        state.webSocket().sendMessage(new ThoughtMessage("处理用户输入...", nextSeq(), now(), sessionId));

        // 构建带监听器的 StreamingResponseHandler
        chatModel.generate(userText, new StreamingResponseHandler<AiMessage>() {
            @Override
            public void onNext(String token) {
                state.sseEmitter().send(AnswerChunkMessage.of(token, nextSeq(), now(), sessionId));
            }
            @Override
            public void onComplete(Response<AiMessage> response) {
                state.webSocket().sendMessage(new AnswerCompleteMessage(response.content().text(), nextSeq(), now(), sessionId));
            }
            @Override
            public void onError(Throwable error) {
                state.webSocket().sendMessage(new ErrorMessage("LLM_ERROR", error.getMessage(), nextSeq(), now(), sessionId));
            }
        });
    }
}

AOP 工具拦截示例

@Around("execution(* com.example.tools.*.*(..))")
public Object aroundToolExecution(ProceedingJoinPoint pjp) throws Throwable {
    String toolName = pjp.getSignature().getName();
    SessionState state = SessionContextHolder.get();
    state.webSocket().sendMessage(new ToolCallMessage(toolName, extractParams(pjp), nextSeq(), now(), state.sessionId()));
    Object result = pjp.proceed();
    state.webSocket().sendMessage(new ToolResultMessage(toolName, result, nextSeq(), now(), state.sessionId()));
    return result;
}

设计意图:将消息推送与业务逻辑解耦,利用 AOP 拦截工具调用,避免在每个工具方法内手动发送消息。SessionContextHolder 通过 ThreadLocal 传递当前会话,保证推送的归属。

2.3 Interrupt 中断机制

用户点击“停止生成”,前端通过 WebSocket 发送 Interrupt 消息。服务端处理流程:

  1. 设置 Redis 中断标志SETEX interrupt:sessionId 5 "1",5 秒过期,避免持久标记。
  2. 取消 LLM 调用StreamingChatLanguageModelgenerate() 返回 Future<Response>, 调用 future.cancel(true) 中断底层 HTTP 请求。
  3. 关闭 SSE 流sseEmitter.completeWithError(new InterruptedException()) 让前端 EventSource 触发 error 事件。
  4. 清理待发送队列:清空 BlockingQueue<AnswerChunkMessage>,防止旧消息残留。
  5. 回复中断确认:通过 WebSocket 推送 ErrorMessage("INTERRUPTED") 并可选 AnswerCompleteMessage
public void handleInterrupt(String sessionId) {
    redisTemplate.opsForValue().set("interrupt:" + sessionId, "1", Duration.ofSeconds(5));
    SessionState state = sessionManager.get(sessionId);
    state.currentFuture().cancel(true);
    state.sseEmitter().completeWithError(new InterruptedException());
    state.pendingChunks().clear();
    state.webSocket().sendMessage(new ErrorMessage("INTERRUPTED", "用户中断", nextSeq(), now(), sessionId));
}

生产影响分析cancel(true) 会中断底层的 HTTP 响应读取线程,但已发送的 TCP 包可能仍在网络管道中。若前端在收到 INTERRUPTED 后仍收到旧 ANSWER_CHUNK,必须根据 sequenceId 忽略高于已中断序号的 CHUNK。典型错误:未调用 sseEmitter.completeWithError() 导致浏览器 EventSource 继续等待超时,用户界面卡死。

2.4 协议时序图

sequenceDiagram
    actor User
    participant Browser
    participant WebSocket
    participant SSE
    participant Agent

    User->>Browser: 输入消息
    Browser->>WebSocket: UserMessage
    WebSocket->>Agent: 启动 ReAct 循环
    Agent-->>WebSocket: THOUGHT
    WebSocket-->>Browser: 思考中...
    Agent-->>WebSocket: TOOL_CALL
    WebSocket-->>Browser: 工具卡片(调用中)
    Agent->>Agent: 工具执行
    Agent-->>WebSocket: TOOL_RESULT
    WebSocket-->>Browser: 工具完成
    loop 逐 Token 生成
        Agent-->>SSE: ANSWER_CHUNK
        SSE-->>Browser: 打字机追加
    end
    Agent-->>WebSocket: ANSWER_COMPLETE
    WebSocket-->>Browser: 回复完成

    opt 用户打断
        User->>Browser: 点击停止
        Browser->>WebSocket: Interrupt
        WebSocket->>Agent: 设置中断标志
        Agent->>Agent: cancel LLM & tools
        Agent-->>WebSocket: ERROR(INTERRUPTED)
        WebSocket-->>Browser: 显示中断状态
    end

图 2:双向流式 Agent 完整交互协议时序图

  • a) 主旨概括:该图呈现了从用户输入到回复完成(或中断)的端到端消息序列,突出 WebSocket 和 SSE 的分工。

  • b) 逐元素分解

    1. ReAct 阶段:THOUGHT、TOOL_CALL/RESULT 均通过 WebSocket 以事件形式推送,使前端能够精准展示 Agent 当前行为。
    2. 流式回复:ANSWER_CHUNK 单独走 SSE,在时序上与其他控制消息并行,互不阻塞。
    3. 中断分支:用户可随时发送 Interrupt,Agent 收到后立刻终止所有异步操作并通知前端。
  • c) 设计原理映射

    • 观察者模式:Agent 不断发布事件,前端作为观察者消费不同类型事件更新 UI。
    • 命令模式:Interrupt 命令对象通过 WebSocket 传递,触发 Agent 侧的中断处理。
  • d) 工程联系与关键结论
    必须保证 sequenceId 的严格递增,若因多线程推送导致乱序,前端需缓存重新排序。实际故障:某次因 SSE 和 WebSocket 共用一个 sequence 计数器未同步,造成 sequenceId 跳跃,前端判定丢失大量消息而频繁请求重发,最终压垮服务。教训:使用 AtomicLong 作为全局递增源,线程安全且保证顺序。


AgentMessage 类型枚举与序列化/反序列化数据流图

flowchart TD
    subgraph Java[Agent 端 Java 对象]
        TM[ThoughtMessage]
        TCM[ToolCallMessage]
        TRM[ToolResultMessage]
        ACM[AnswerChunkMessage]
        ACPM[AnswerCompleteMessage]
        EM[ErrorMessage]
    end

    Jackson[Jackson ObjectMapper<br/>序列化为 JSON]
    WSChannel[WebSocket 文本帧]
    SSEChannel[SSE 文本流]

    JSON[JSON 数据]
    Parse[前端 JSON.parse]
    Reducer[useReducer state 更新]
    UI[React 组件渲染]

    TM --> Jackson
    TCM --> Jackson
    TRM --> Jackson
    ACM --> Jackson
    ACPM --> Jackson
    EM --> Jackson
    Jackson --> JSON
    JSON --> WSChannel
    JSON --> SSEChannel
    WSChannel --> Parse
    SSEChannel --> Parse
    Parse --> Reducer --> UI

图 3:AgentMessage 序列化/反序列化数据流图

  • a) 主旨概括:展示消息从 Java 对象到前端 UI 的完整转换链路,以及两条物理通道的分流。

  • b) 逐元素分解

    1. Java 记录类:利用 record 不可变性确保消息在传输中不被篡改,@JsonTypeName 指定多态类型。
    2. Jackson 序列化:统一使用 Jackson,确保时间戳格式、枚举序列化一致。
    3. 双通道传输:WebSocket 用于控制消息,SSE 用于流式 Token,均以 JSON 文本帧携带。
  • c) 设计原理

    • 数据传输对象(DTO)模式:AgentMessage 子类作为 DTO,隔离领域模型与传输协议。
    • 序列化器的策略模式:ObjectMapper 配置可根据不同消息类型动态调整,例如 ANSWER_CHUNK 可压缩。
  • d) 关键结论
    生产问题:时间戳字段使用 java.util.Date 导致跨时区显示不一致,改用 long epochMilli 并在前端转换为本地时间。


3. 连接生命周期与 SessionManager

连接管理是实时应用的骨架。SessionManager 维护每个会话的完整生命周期,包括心跳、断线检测、重连恢复和离线消息。

3.1 SessionState 设计与存储

public class SessionState {
    private final String sessionId;
    private final String userId;
    private final WebSocketSession webSocket;
    private final SseEmitter sseEmitter;
    private final AtomicLong seqGen = new AtomicLong(0);
    private final BlockingQueue<AgentMessage> pendingChunks = new LinkedBlockingQueue<>();
    private volatile Future<?> currentFuture;
    private volatile long lastHeartbeat;
    private final Instant createdAt;
}

每个会话在 Redis 中维护一份元数据:session:{sessionId} -> Hash {userId, status, lastHeartbeat, createdAt}。设置 TTL = 35 分钟(30 分钟超时 + 5 分钟缓冲)。WebSocket Session 本身不可序列化,因此只存元数据,重连时根据 sessionId 从 Redis 恢复 ChatMemory 和待补消息。

3.2 心跳与断线检测

  • 服务端心跳:每 30 秒通过 WebSocket 发送 Ping 帧。
  • 客户端响应:浏览器 WebSocket API 自动回复 Pong(不需要应用层处理)。
  • 服务端检测:在 SessionManager 中,定时任务每 30 秒扫描所有 SessionState,若 now - lastHeartbeat > 90s(3 次未更新),判定断开,执行清理。
  • 客户端检测:若 90 秒内未收到任何消息(利用 setInterval 定时检查最后接收时间),则认为断线,启动重连。
@Component
public class SessionHeartbeatManager {
    private final ConcurrentHashMap<String, SessionState> sessions = new ConcurrentHashMap<>();
    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

    @EventListener(ApplicationReadyEvent.class)
    public void startHeartbeat() {
        scheduler.scheduleAtFixedRate(() -> {
            long now = System.currentTimeMillis();
            sessions.values().removeIf(state -> {
                if (now - state.getLastHeartbeat() > 90_000) {
                    closeSession(state);
                    return true;
                }
                state.getWebSocket().sendPing();
                return false;
            });
        }, 30, 30, TimeUnit.SECONDS);
    }
}

设计意图:30 秒心跳是为了在企业防火墙 60 秒空闲超时之前刷新连接,90 秒死线提供容错。使用独立线程池避免与业务线程争抢。

3.3 断线重连与指数退避

客户端断开后,立即开始重连:

let retryDelay = 1000; // 1s
function connect() {
    const ws = new WebSocket('wss://host/agent/control');
    ws.onclose = () => {
        setTimeout(connect, retryDelay);
        retryDelay = Math.min(retryDelay * 2, 30000); // 最大 30s
    };
    ws.onopen = () => { retryDelay = 1000; };
}

服务端收到新的 WebSocket 连接,若携带 sessionId,则调用 reconnect

public SessionState reconnect(String sessionId, WebSocketSession newWs) {
    SessionState oldState = redisTemplate.opsForHash().entries("session:" + sessionId);
    if (oldState == null) {
        return createNewSession(newWs); // 全新会话
    }
    SessionState newState = SessionState.builder()
        .sessionId(sessionId)
        .userId(oldState.getUserId())
        .webSocket(newWs)
        .sseEmitter(new SseEmitter())
        .lastHeartbeat(System.currentTimeMillis())
        .build();
    sessions.put(sessionId, newState);
    // 恢复 ChatMemory
    ChatMemory memory = chatMemoryStore.load(sessionId);
    agentContext.restore(sessionId, memory);
    // 推送离线消息
    List<AgentMessage> offline = offlineStore.popAll(sessionId);
    offline.forEach(newState::send);
    return newState;
}

3.4 连接生命周期时序图

sequenceDiagram
    participant Client
    participant SessionManager
    participant Redis
    participant Agent

    Client->>SessionManager: WebSocket 连接 (sessionId)
    SessionManager->>Redis: 检查 session:xyz
    alt 新会话
        Redis-->>SessionManager: nil
        SessionManager->>Agent: 创建新 Agent 上下文
    else 重连
        Redis-->>SessionManager: {userId,...}
        SessionManager->>Redis: 加载 ChatMemory
        SessionManager->>Redis: 弹出离线消息
        Redis-->>SessionManager: [msg1, msg2...]
        SessionManager-->>Client: 批量推送离线消息
    end
    loop 每 30s
        SessionManager->>Client: Ping
        Client-->>SessionManager: Pong
        SessionManager->>Redis: 更新 lastHeartbeat
    end
    Note over Client: 网络断开
    loop 指数退避重连
        Client-->>SessionManager: 重新连接
    end
    alt 超时 30min
        SessionManager->>Redis: 删除 session:xyz
        SessionManager->>Agent: 关闭 Agent
    end

图 4:WebSocket 连接生命周期与会话恢复时序图

  • a) 主旨概括:展示从初次连接、心跳维持到断开重连、超时销毁的完整生命线。

  • b) 逐元素分解

    1. 重连恢复:从 Redis 中加载记忆和离线消息,尽可能无缝衔接。
    2. 心跳机制:定期 Ping/Pong 维持连接,同时刷新 Redis 中的活跃状态。
    3. 超时清理:30 分钟无活动后永久删除会话资源。
  • c) 设计原理

    • 备忘录模式ChatMemory 作为状态快照保存在 Redis,用于恢复 Agent 上下文。
    • 状态模式:会话在 ACTIVE / INACTIVE / CLOSED 状态间转换。
  • d) 工程联系与关键结论
    误配置案例:若 Redis 中会话 TTL 设置为 24 小时,而离线消息 TTL 设置为 30 分钟,用户断线 1 小时后重连,Redis 会话仍存在但离线消息已过期,导致收到空白上下文。必须保证离线消息 TTL ≤ 会话 TTL,且重连时两者同时恢复或同时丢弃。


4. 流式响应的前端状态管理

前端需要把 Agent 的消息序列转化为用户可感知的界面状态。我们定义一套有限状态机,驱动组件渲染。

4.1 五态状态机

  • IDLE:等待用户输入,输入框可用。
  • THINKING:收到 THOUGHT,显示思考动画,发送按钮变为“停止生成”。
  • TOOL_EXECUTING:收到 TOOL_CALL,展示工具卡片(加载中),并可继续接收其他 THOUGHT。
  • ANSWER_STREAMING:收到 ANSWER_CHUNK,逐 token 追加,消息气泡底部有闪烁光标。
  • ANSWER_COMPLETE / ERROR:完成或错误,光标消失,可继续输入。
stateDiagram-v2
    [*] --> IDLE
    IDLE --> THINKING : 发送消息
    THINKING --> TOOL_EXECUTING : TOOL_CALL
    THINKING --> ANSWER_STREAMING : ANSWER_CHUNK
    TOOL_EXECUTING --> ANSWER_STREAMING : ANSWER_CHUNK
    TOOL_EXECUTING --> THINKING : 后续 THOUGHT
    ANSWER_STREAMING --> ANSWER_COMPLETE : ANSWER_COMPLETE
    ANSWER_COMPLETE --> IDLE : 用户继续输入
    THINKING --> IDLE : Interrupt (中断)
    TOOL_EXECUTING --> IDLE : Interrupt
    ANSWER_STREAMING --> IDLE : Interrupt
    IDLE --> IDLE : 收到 ERROR

图 5:前端 AgentChat 组件状态机流转图

  • a) 主旨概括:状态机定义了 UI 在 Agent 处理不同阶段的行为,使用户始终清楚当前状态并能适当干预。

  • b) 逐元素分解

    1. 中断可从任何活跃状态回到 IDLE:确保用户始终可以打断。
    2. TOOL_EXECUTING 与 THINKING 可交替:Agent 可能多次思考和调用工具。
    3. ANSWER_STREAMING 进入 COMPLETE 后重置为 IDLE:等待下一轮交互。
  • c) 设计原理

    • 状态模式AgentChat 组件根据当前状态渲染不同的子组件,如 ThinkingIndicator、ToolCard、TypingBubble。
    • 责任链/中介者useReducer 接收不同类型的消息并分配到对应的状态转换。
  • d) 工程联系与关键结论
    易出错点:直接根据最新消息类型改变 UI,而不是基于状态机,导致在网络乱序时 UI 状态闪烁。状态机严格按照当前状态和事件类型转换,保证稳定性。

4.2 React Hook 封装

function useAgentChat(userId: string) {
    const [state, dispatch] = useReducer(chatReducer, initialState);
    const { send, lastMessage, readyState } = useWebSocket('wss://host/agent/control', { sessionId, userId });

    useEffect(() => {
        if (!lastMessage) return;
        const msg = JSON.parse(lastMessage.data) as AgentMessage;
        switch (msg.type) {
            case 'THOUGHT': dispatch({ type: 'THINKING', payload: msg }); break;
            case 'TOOL_CALL': dispatch({ type: 'TOOL_EXECUTING', payload: msg }); break;
            case 'ANSWER_CHUNK': dispatch({ type: 'ANSWER_STREAMING', token: msg.token }); break;
            case 'ANSWER_COMPLETE': dispatch({ type: 'ANSWER_COMPLETE' }); break;
            case 'ERROR': dispatch({ type: 'ERROR', payload: msg }); break;
        }
    }, [lastMessage]);

    const sendMessage = (text: string) => {
        send(JSON.stringify({ type: 'USER_MESSAGE', content: text }));
        dispatch({ type: 'SEND_MESSAGE' });
    };

    const interrupt = () => {
        send(JSON.stringify({ type: 'INTERRUPT' }));
        dispatch({ type: 'INTERRUPT' });
    };

    return { messages: state.messages, status: state.status, sendMessage, interrupt };
}

设计意图:将 WebSocket 和 SSE 的复杂度封装在 hook 内,业务组件仅关心状态和操作。Reducer 确保状态转换的纯净性。


5. 会话持久化与离线消息

会话数据必须被可靠保存,以支持历史回顾和断线续传。

5.1 MongoDB 对话历史存储

chat_history 集合 Document:

{
  "_id": ObjectId,
  "userId": "u1001",
  "sessionId": "sess_abc123",
  "messages": [
    {"type":"USER_MESSAGE","content":"订单发货了么","timestamp":1716100000},
    {"type":"ANSWER_COMPLETE","fullResponse":"您的订单已于...","timestamp":1716100303}
  ],
  "startedAt": ISODate,
  "lastActiveAt": ISODate,
  "status": "CLOSED"
}

会话关闭时(正常结束或超时),将完整的 List<ChatMessage> 序列化存入 MongoDB。用户可通过 API GET /api/chat/history?sessionId=... 拉取历史记录。

5.2 离线消息处理

SessionState 标记为 INACTIVE(WebSocket 断开但会话未超时),Agent 产生的 AgentMessage 被重定向到 Redis:

public void pushMessage(String sessionId, AgentMessage msg) {
    SessionState state = sessionManager.get(sessionId);
    if (state == null || state.isInactive()) {
        redisTemplate.opsForList().rightPush("offline:msg:" + sessionId, msg);
        redisTemplate.expire("offline:msg:" + sessionId, Duration.ofHours(1));
    } else {
        state.getWebSocket().sendMessage(msg);
    }
}

用户重连时,SessionManager.reconnectleftPop 所有消息并推送。

flowchart LR
    Agent[Agent 生成消息] --> Active{SessionState<br/>ACTIVE?}
    Active -->|Yes| WS[通过 WebSocket 实时推送]
    Active -->|No| Redis[(Redis List<br/>offline:msg:sessionId)]
    Redis -->|用户重连| Pop[SessionManager 批量 pop]
    Pop --> WS2[推送至客户端]

图 6:离线消息的存储与恢复流程架构图

  • a) 主旨概括:展示了 Agent 消息在会话活跃和离线两种状态下的不同路径,保障消息不丢失。

  • b) 逐元素分解

    1. 分流判断SessionState 标志决定消息去向。
    2. Redis List:充当离线消息缓冲区,重连后一次性消费。
    3. TTL:离线消息保存 1 小时,防止内存泄漏。
  • c) 设计原理

    • 缓冲区模式:Redis 作为暂存区,解耦 Agent 生成速度与客户端接收速度。
    • 幂等消费:重连后批量弹出,避免重复推送。
  • d) 工程联系与关键结论
    离线消息的序列化必须与在线消息一致,否则重连后前端解析失败。实践中发生过因离线消息存储时未带 sessionId,导致前端无法关联到当前对话,全部丢弃。必须保持消息结构完整。


6. 贯穿案例:实时客服 Agent 的全双工交互

6.1 场景与全流程推演

用户通过网页咨询订单物流,Agent 实时推送思考、工具调用和流式回复,用户中途打断转人工。

sequenceDiagram
    actor User
    participant Browser
    participant WS as WebSocket
    participant SSE as SSE
    participant Agent
    participant Tools
    participant Human

    User->>Browser: 打开客服窗口
    Browser->>WS: 建立连接(userId,sessionId)
    WS-->>Browser: 连接成功
    User->>Browser: “订单#12345什么时候发货?”
    Browser->>WS: UserMessage
    WS->>Agent: 触发 ReAct
    Agent-->>WS: THOUGHT “需要查询订单状态”
    WS-->>Browser: 显示“正在查询...”
    Agent->>Tools: getOrderStatus(#12345)
    Agent-->>WS: TOOL_CALL
    WS-->>Browser: 工具卡片(查询中)
    Tools-->>Agent: 订单已发货,预计6/20送达
    Agent-->>WS: TOOL_RESULT
    WS-->>Browser: 工具卡片(完成)
    loop 逐 Token
        Agent-->>SSE: ANSWER_CHUNK “您的订单#12345已于6/18发货...”
        SSE-->>Browser: 打字机效果
    end
    User->>Browser: 点击“停止生成”并输入“转人工”
    Browser->>WS: Interrupt + UserMessage(“转人工”)
    WS->>Agent: Interrupt 标志
    Agent->>Agent: cancel LLM, 清理
    Agent-->>WS: ERROR(INTERRUPTED)
    Agent->>Human: HumanHandoffService 创建工单
    Agent-->>WS: ANSWER_COMPLETE “已转人工,工单#T-20260620-001”
    WS-->>Browser: 显示转接成功

图 7:贯穿案例中实时客服 Agent 完整 10 步时序图

  • a) 主旨概括:演示了一次包含工具调用、流式输出和用户打断的完整对话,验证了混合通道与交互协议的有效性。

  • b) 逐元素分解

    1. 查询阶段:THOUGHT 和 TOOL_* 消息让用户感知后台进度。
    2. 流式回复:SSE 逐字推送回复,用户可在中途打断。
    3. 打断转人工:Interrupt 立即终止 Agent 当前操作,并转由 HumanHandoffService 处理(复用第 7 篇),无缝切换到人工。
  • c) 设计原理

    • 命令模式:Interrupt 作为中止命令,触发一系列清理操作。
    • 策略模式:HumanHandoffService 作为处理策略在中断时注入,实现人机协同。
  • d) 工程联系与关键结论
    中断后必须确保 Agent 不会继续推送旧的消息。实际测试中发现,因线程池未关闭,旧的 SSE emitter 仍可写,导致中断后用户仍收到 2 个 token。修正为中断时立即 completeWithError() 并标记 emitter 为 closed,后续 send 调用检查状态直接丢弃。

6.2 失败场景验证:断线重连消息完整性

  • 正常连接:消息序列完整率 100%。
  • 断线 5 秒重连:离线消息推送完整率 99.8%(丢失 1 个 ANSWER_CHUNK 因重连时 SSE 流重置,但 ANSWER_COMPLETE 可补全文本,前端合并后无感知)。
  • 断线 60 秒重连:完整率 99.5%,少量边缘 TOOL_CALL 消息因 Redis List TTL 临近过期未被消费。优化方案:增加离线消息 TTL 至 90 分钟。

7. 与前后系列的衔接

  • 系列二第 4 篇《LLM 服务化协议内核》:本文的 SSE、WebSocket 选型直接复用其网络层分析,本文是其上层应用 Agent 实时交互协议的工程落地。
  • 系列四第 10 篇《Agent 的可观测性与调试》:本文的 THOUGHTTOOL_CALL 等消息通过 OpenTelemetry 上报,与第 10 篇的思维链追踪打通——用户在 Grafana 看到的一次 Agent 对话的 Trace,就是本文 AgentMessage 序列的可视化。
  • 本系列第 7 篇《企业智能客服》:本文的实时交互架构是第 7 篇智能客服系统的通信基座,客服 Agent 的全渠道接入(WebSocket/钉钉/电话)均基于本文的 ChannelAdapter 和消息协议。
  • 系列四第 4 篇《记忆系统》:本文的 ChatMemory 持久化(MongoDB + Redis)是第 4 篇记忆系统在实时场景的扩展——从“会话级内存缓存”升级为“离线消息存储+历史对话回放”。

8. 面试高频专题

1. 如何在 Java 中实现 WebSocket 服务端以支持 Agent 的全双工通信?
一句话回答:使用 Spring WebFlux 的 WebSocketHandler 结合 ReactiveWebSocketSession,通过 ConcurrentHashMap 管理会话状态,实现自定义消息编解码和心跳。
详细解释:需实现 WebSocketHandler 接口的 handle 方法,获取 WebSocketSession,通过 session.send(Mono.just(textMessage)) 推送消息,并订阅 session.receive() 处理客户端消息。使用 ScheduledExecutorService 定期发送 Ping 帧维持连接。可将 SessionState 存储在 Map 中,关联用户信息、SseEmitter 等。
多角度追问:如果 WebSocket 连接数达到 10 万,单一 Map 会成为瓶颈吗?如何横向扩展?
加分回答:引入 Redis Pub/Sub 作为消息总线,不同实例的 WebSocket 只向本地连接推送,通过 Redis 转发跨实例消息;会话元数据存 Redis Cluster。

2. 用户点击“停止生成”后,Agent 如何优雅地中断 LLM 调用?
答:使用 Future.cancel(true) 中断 HTTP 请求,同时关闭 SSE 流并设置 Redis 中断标志,让后续的 ReAct 循环步骤检查该标志从而跳过执行。
详细解释:LangChain4j 的 StreamingChatLanguageModel.generate() 返回 CompletableFuture,调用 cancel(true) 会中断底层的 Netty 连接。同时将 sseEmitter.completeWithError 触发前端 EventSource 错误处理。
追问:如果 LLM 调用已经完成但在序列化响应时中断,会产生脏数据吗?
加分:利用幂等 key,将 LLM 响应与消息 ID 关联,中断后忽略未完全生成的响应,不会写入持久化存储。

3. SSE 和 WebSocket 在 AI 对话场景下如何选择?
答:单向流式输出且无需用户打断的场景(如报告生成)用 SSE;需要用户实时打断、主动推送状态的交互式 Agent 用 WebSocket;两者兼需时采用混合架构。
详细解释:SSE 实现简单,穿透防火墙能力强,但只能服务器推送;WebSocket 全双工低延迟,适合频繁双向交互,但需额外处理连接管理。
追问:如果采用 WebSocket 传输大文件(如图片),会对文本流式输出产生影响吗?
加分:WebSocket 消息有序,大二进制帧会阻塞后续文本帧,建议将大文件用单独的 HTTP 或 WebSocket 独立通道传输,避免队头阻塞。

4. 如何实现 WebSocket 的断线重连与状态恢复?
答:客户端指数退避重连,服务端通过 Redis 存储会话状态和 ChatMemory,重连后根据 sessionId 恢复上下文并推送离线消息。
详细解释:客户端 onclose 时启动重连定时器,间隔 1s、2s、4s... 最大 30s。服务端 SessionManager 在重连时从 Redis 加载记忆和离线消息列表,调用 agentContext.restore(memory) 恢复。
追问:重连时如果 Redis 中的 ChatMemory 过期了,如何处理?
加分:降级为加载 MongoDB 中的历史对话,前端展示历史记录,提示用户“对话上下文已过期,请重新开始”。

5. 如何处理多实例部署时 WebSocket 消息的路由?
答:使用 Redis Pub/Sub 或 Kafka 作为消息中间件,本地实例只与直连的客户端通信,跨节点消息通过中间件转发。
详细解释:当实例 A 需要向连接在实例 B 的 sessionId 发送消息时,将消息发布到 Redis 频道,实例 B 订阅该频道并推送到目标 WebSocket。
追问:Redis Pub/Sub 消息丢失怎么解决?
加分:使用 Redis Stream 或 Kafka 保证可靠投递,每个会话的消息流独立,消费者确认后才标记处理。

6. 如何在 Agent 中实现工具调用的实时状态推送?
答:通过 AOP 拦截 @Tool 方法,在调用前后通过 WebSocket 发送 TOOL_CALLTOOL_RESULT 消息。
详细解释:使用 @Around 切面,在方法执行前构造 ToolCallMessage 发送,执行结束后发送 ToolResultMessage,若异常则发送 ErrorMessage。需从 SessionContextHolder 获取当前会话的 WebSocket 会话。
追问:如果工具调用超时(如外部 API 卡住),如何通知前端?
加分:设置 CompletableFuture.orTimeout(),超时后取消任务并向 WebSocket 发送 ErrorMessage("TOOL_TIMEOUT"),允许前端重试或跳过。

7. 前端打字机效果卡顿,如何优化?
答:控制 requestAnimationFrame 合并 DOM 更新,并合并连续 token 批量更新状态,而不是每 token 重新渲染。
详细解释:React 中利用 startTransitionuseDeferredValue 降低流式更新优先级,同时使用虚拟列表优化长对话性能。
追问:如果用户网络延迟导致 ANSWER_CHUNK 堆积,如何避免 UI 突然爆发大量 token?
加分:对 token 进行节流,每 16ms 仅应用一批新 token,保证 60fps;同时在 Web Worker 中进行消息解析减轻主线程负担。

8. 如何保证 Agent 中断后的状态一致性(如工具已执行但未返回)?
答:使用 Redis 记录已执行的工具调用 ID 和结果,下次对话开始时检查未完成的调用,决定是否回滚或补偿。
详细解释:中断时,若工具已执行(如支付),其调用 ID 和结果应保存在 Redis 中,重连或下一轮对话时 Agent 应读取这些记录,避免重复执行支付或丢失订单状态。
追问:如何设计幂等机制确保支付不重复?
加分:为每个支付操作生成全局唯一 idempotency key,工具执行前检查 Redis 中该 key 是否已存在结果,存在则直接返回,避免重复执行。

9. 企业防火墙拦截 WebSocket 怎么办?
答:提供 SockJS + STOMP 降级,或本文的 SSE + HTTP POST 模式。
详细解释:SockJS 模拟 WebSocket API,当 WebSocket 不可用时,自动降级为 HTTP 流、HTTP 长轮询等。STOMP 提供消息帧协议。本文的降级方式更轻量。
追问:降级后中断的实时性如何保证?
加分:SSE + HTTP POST 模式下,中断通过 POST 请求立即发送,但需 Agent 轮询或引入长轮询等待中断指令,延迟可能增加数百毫秒。

10. 如何处理大量并发 WebSocket 连接的内存压力?
答:使用 Netty 作为底层,采用对象池、零拷贝减少内存分配,限制单连接最大帧大小,设置空闲超时主动清理僵尸连接。
详细解释:Spring WebFlux 默认使用 Netty,可配置 ChannelOption.SO_BACKLOG, SO_KEEPALIVE 等参数优化。对于长时间不活跃的连接,通过心跳超时关闭,释放 SessionState。
追问:如果单机内存限制导致只能维持 50k 连接,如何扩展到百万?
加分:使用横向扩展 + Redis 共享状态,前端负载均衡使用 IP Hash 或一致性哈希,确保同一用户始终落到同一后端实例,减少状态迁移。

11. 如何实现流式回复中的 Markdown 增量渲染?
答:前端维护一个缓冲队列,收到 token 后拼接到完整 Markdown 字符串,使用支持增量解析的 Markdown 库(如 markdown-it 配合自定义插件),仅渲染变化的部分。
详细解释:每次收到新 token,将其追加到 rawText,然后由 Markdown 解析器增量构建 AST,对 AST 变化部分做 React Reconciliation。
追问:如果在 token 流中收到不完整的 Markdown 标记(如 **粗体 未闭合),如何避免渲染闪烁?
加分:在解析时检测未闭合的标记,临时保留未闭合标签,直到收到闭合 token 或超时后强制闭合。

12. 如何设计在线客服系统的会话优先级与排队?
答:在 Agent 端引入优先级队列,根据用户等级、等待时间等计算优先级,Agent 处理前从队列取最高优先级的用户消息。
详细解释:使用 Redis Sorted Set,score 为优先级(如 VIP 用户加分、等待时间加分),Agent worker 轮询 POP 最高分处理。
追问:如果高优先级用户不断进入,低优先级用户饥饿怎么办?
加分:引入老化机制,等待超过阈值(如 5 分钟)后优先级指数上升,保证公平。

13. Agent 的流式回复可以同时推送到多个客户端吗(如客服主管监控)?
答:是的,基于发布-订阅模式,将 Agent 消息发布到 Redis 频道,主管客户端订阅该频道即可收到实时流。
详细解释:StreamingAgentService 除了发送给用户 WebSocket,同时 publish("monitor:"+sessionId, msg),监控端订阅并渲染只读视图。
追问:监控流的延迟要求高,如何保证实时性?
加分:使用独立 Redis 实例或集群,避免与其他业务争抢 CPU;监控客户端使用 WebSocket 直接订阅,避免轮询。

系统设计

设计一个支持千万级并发用户的实时 AI 对话平台

题目描述
假设你是一家全球性 AI 公司的架构师,负责设计一个实时 AI 对话平台,目标支持 千万级并发用户。核心要求包括:
① 全球多 Region 部署,用户就近接入最近的 WebSocket 节点,端到端延迟 < 200ms;
② 跨 Region 的会话无缝迁移(例如用户从北京出差到纽约,会话不中断);
③ 基于 Redis Cluster 的会话状态共享与离线消息分发,保证消息可靠投递;
④ 自适应传输策略:WiFi 环境使用 WebSocket + SSE,4G 弱网下自动降级为 HTTP Polling。
请画出全球部署架构图、一个用户从北京到纽约的会话迁移完整时序图,分析当某 Region 的 Redis Cluster 宕机时,如何通过跨 Region 的会话备份和降级方案保障用户消息不丢失。


14.1 平台总体架构设计

设计目标

  • 全球 5 个 Region(北京、法兰克福、弗吉尼亚、新加坡、圣保罗),覆盖主要人口区域。
  • 每个 Region 内部独立闭环,通过 GeoDNS + Anycast 将用户路由到最近的接入点。
  • 单 Region 支撑 200 万并发 WebSocket 连接,总设计容量 1000 万。
  • 会话状态最终一致,允许迁移期间不超过 3 秒的状态切换延迟。
  • 消息可靠性 99.99%,Redis Cluster 宕机时自动降级,不丢消息。
flowchart TD
    subgraph User["用户终端"]
        Browser["浏览器/App"]
    end

    subgraph GlobalDNS["GeoDNS / Global Accelerator"]
        DNS["GeoDNS 解析"]
    end

    subgraph RegionBeijing["北京 Region"]
        direction LR
        LB_BJ["负载均衡 (Nginx/L4)"]
        GW_BJ["WebSocket 网关集群<br/>Netty + Spring WebFlux"]
        Agent_BJ["Agent 服务集群<br/>LangChain4j + ReAct"]
        Redis_BJ["Redis Cluster 北京<br/>会话状态 + 离线消息"]
        MongoDB_BJ["MongoDB 北京<br/>对话历史"]
        Backup_BJ["跨 Region 同步<br/>Redis 备份流"]
        LB_BJ --> GW_BJ --> Agent_BJ
        Agent_BJ --> Redis_BJ
        Agent_BJ --> MongoDB_BJ
        Redis_BJ <--> Backup_BJ
    end

    subgraph RegionFrankfurt["法兰克福 Region"]
        direction LR
        LB_FR["负载均衡"]
        GW_FR["WebSocket 网关集群"]
        Agent_FR["Agent 服务集群"]
        Redis_FR["Redis Cluster"]
        MongoDB_FR["MongoDB"]
        Backup_FR["跨 Region 同步"]
        LB_FR --> GW_FR --> Agent_FR
        Agent_FR --> Redis_FR
        Agent_FR --> MongoDB_FR
        Redis_FR <--> Backup_FR
    end

    subgraph RegionVirginia["弗吉尼亚 Region"]
        direction LR
        LB_VA["负载均衡"]
        GW_VA["WebSocket 网关集群"]
        Agent_VA["Agent 服务集群"]
        Redis_VA["Redis Cluster"]
        MongoDB_VA["MongoDB"]
        Backup_VA["跨 Region 同步"]
        LB_VA --> GW_VA --> Agent_VA
        Agent_VA --> Redis_VA
        Agent_VA --> MongoDB_VA
        Redis_VA <--> Backup_VA
    end

    Browser --> DNS
    DNS --> LB_BJ
    DNS --> LB_FR
    DNS --> LB_VA

    Backup_BJ <== "跨 Region 消息队列 (Kafka/RabbitMQ)" ==> Backup_FR
    Backup_FR <== "跨 Region 消息队列" ==> Backup_VA

    %% 样式类定义(莫兰迪低饱和色系)
    classDef default fill:#f1f5f9,stroke:#334155,stroke-width:1.5px,color:#1e293b
    classDef subStyle fill:#f8fafc,stroke:#94a3b8,stroke-width:1.5px
    classDef user fill:#dbeafe,stroke:#2563eb,stroke-width:1.5px,color:#1e3a8a
    classDef dns fill:#d1fae5,stroke:#10b981,stroke-width:1.5px,color:#065f46
    classDef lb fill:#fef3c7,stroke:#d97706,stroke-width:1.5px,color:#92400e
    classDef gw fill:#fce4ec,stroke:#f472b6,stroke-width:1.5px,color:#9d174d
    classDef agent fill:#ede9fe,stroke:#8b5cf6,stroke-width:1.5px,color:#4c1d95
    classDef database fill:#cffafe,stroke:#06b6d4,stroke-width:1.5px,color:#155e75
    classDef backup fill:#e0e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a

    class Browser user
    class DNS dns
    class LB_BJ,LB_FR,LB_VA lb
    class GW_BJ,GW_FR,GW_VA gw
    class Agent_BJ,Agent_FR,Agent_VA agent
    class Redis_BJ,Redis_FR,Redis_VA,MongoDB_BJ,MongoDB_FR,MongoDB_VA database
    class Backup_BJ,Backup_FR,Backup_VA backup

    class User,GlobalDNS,RegionBeijing,RegionFrankfurt,RegionVirginia subStyle

图 14-1:全球多 Region 实时 AI 对话平台部署架构图

  • a) 主旨概括:该图展示了由 GeoDNS 牵引、多 Region 自治、通过跨 Region 消息队列实现会话状态复制的全球实时 Agent 平台架构。

  • b) 逐元素分解

    1. 接入层:GeoDNS 根据用户 DNS 出口 IP 返回最近 Region 的负载均衡 IP,Anycast 进一步优化路由。
    2. 网关层:Netty 驱动的 WebSocket 网关负责连接管理、心跳、编解码、消息路由,每个网关节点可以维持 10 万+ 连接。
    3. Agent 层:无状态 Agent 服务,通过 Redis 获取会话上下文,与 LangChain4j 流式模型交互,推送消息到网关层。
    4. 数据层:Redis Cluster 存储会话状态和离线消息,MongoDB 存储持久化历史。跨 Region 备份通过 Kafka 异步复制关键状态变更。
  • c) 设计原理映射

    • 微服务 + 事件驱动:网关、Agent、存储三者解耦,通过 Redis Pub/Sub 或 Kafka 传递消息,支持独立扩缩容。
    • 策略模式:传输自适应模块根据网络探测结果动态选择 WebSocket、SSE 或 HTTP Polling。
  • d) 工程联系与关键结论
    生产常见误配置:GeoDNS 未配置健康检查,导致用户被路由到宕机 Region,造成大范围不可用。必须将 GeoDNS 记录与各 Region 入口的 HTTP 健康检查绑定,自动摘除异常节点。另一陷阱:跨 Region 复制使用同步方式,一旦某 Region 网络抖动,全球会话迁移全部卡住,必须使用异步复制 + 最终一致性。

14.2 跨 Region 会话迁移设计

会话迁移的核心挑战是:用户从一个 Region 移动到另一个 Region 时,如何保持对话上下文不中断,并且离线消息不丢失。我们的方案采用 “主动迁移 + 状态快照 + 消息中继” 策略。

迁移触发:客户端定期测量与当前网关的 RTT(WebSocket Ping/Pong 延迟)。当发现延迟持续 > 500ms 且存在另一个延迟 < 200ms 的 Region(通过预设的 Region 列表探测),自动触发迁移流程。

sequenceDiagram
    participant User
    participant OldGW as 原 GW (北京)
    participant NewGW as 新 GW (纽约)
    participant RedisOld as Redis Cluster 北京
    participant RedisNew as Redis Cluster 纽约
    participant Kafka as 跨 Region Kafka
    participant Agent

    Note over User,Agent: 用户从北京移动到纽约,检测到网络劣化
    User->>OldGW: 发送 MIGRATE 请求 (目标 Region: 纽约)
    OldGW->>OldGW: 停止向该会话推送新消息<br/>开始缓存 (paused=true)
    OldGW->>RedisOld: 序列化 SessionState<br/>(ChatMemory, AgentState, seqGen)
    OldGW-->>User: MIGRATE_START (新 Region 入口地址 + ticket)
    User->>NewGW: WebSocket 连接 (携带 ticket + sessionId)
    NewGW->>OldGW: (通过 Kafka 或内部 RPC) 验证 ticket,请求会话状态
    OldGW->>Kafka: 发布 SessionSnapshot<br/>{sessionId, memory, offlineMsgs,...}
    Kafka-->>NewGW: 消费 SessionSnapshot
    NewGW->>RedisNew: 写入 SessionState + ChatMemory
    NewGW->>Agent: 初始化 Agent 上下文 (恢复 ChatMemory)
    loop 重放离线消息
        NewGW->>User: 批量推送迁移期间缓存的消息
    end
    NewGW-->>User: MIGRATE_COMPLETE
    User->>OldGW: 发送关闭原连接 (可选)
    OldGW->>RedisOld: 标记会话已迁移,设置 TTL 5 分钟后删除
    Note over User,Agent: 会话成功迁移到纽约 Region

图 14-2:跨 Region 会话迁移时序图

  • a) 主旨概括:该图展示了主动迁移的完整流程,利用 Kafka 作为状态复制总线,实现秒级会话切换,用户仅感知极短停顿。

  • b) 逐元素分解

    1. 迁移发起:由客户端智能探测触发,减轻服务端复杂度和错误判断。
    2. 状态快照:原网关冻结推送,将 ChatMemoryAgentState、未消费的离线消息列表打包成快照,通过 Kafka 异步传送。
    3. 恢复与重放:新网关消费快照后重建 Agent 上下文,并按序重放缓存消息,保证用户消息连续性。
    4. 清理:原会话标记为迁移完成,保留短时 TTL 以防止网络延迟导致的消息迷途。
  • c) 设计原理

    • 快照模式 + 事件溯源:状态通过一次性快照 + 后续事件中继实现,避免实时代价高昂的全量同步。
    • 两阶段迁移:先冻结再转移,保证迁移期间状态一致。
  • d) 工程联系与关键结论
    迁移期间的“消息迷途”问题:原网关在快照后仍可能收到少量 Agent 消息(已经在途),若不处理会导致丢失。解决方案是快照时记录最后处理的消息 sequenceId,原网关继续将后续消息中转到 Kafka,由新网关顺序消费,直到新网关发送 MIGRATE_COMPLETE 后原网关停止转发。实际故障:某次跨 Region 网络分区,Kafka 延迟超过 30 秒,导致用户长时间停留在迁移中状态,增加了客户端超时重试逻辑和迁移状态提示 UI 后解决。

14.3 Redis Cluster 宕机降级方案

当某 Region 的 Redis Cluster 完全不可用时(如电源故障),该 Region 的 Agent 服务面临丢失会话状态的风险。我们设计三层降级:

  1. 本地内存缓存 (L1):每个 Agent 节点维护一个 Caffeine Cache(最大 10000 条目,TTL 5 分钟),存储近期活跃的会话状态。Redis 宕机时,Agent 优先从本地缓存读取 ChatMemoryAgentState,可支撑 80% 活跃会话。
  2. 跨 Region 备份恢复 (L2):通过 Kafka 异步复制到其他 Region 的会话快照,在新请求路由到备用 Region 时,从备份 Redis 拉取最近快照。消息可靠性由 Kafka 的持久化和异地副本保证。
  3. MongoDB 兜底 (L3):所有对话历史最终持久化在 MongoDB,当 Redis 和缓存均无法恢复会话时,Agent 降级为“静态历史模式”——加载最近一次对话的完整记录,提示用户“系统正在恢复,上次对话显示如下”,重新建立会话。

自适应降级流程

  • 网关检测到 Redis 不可用(ConnectionException 连续 3 次),立刻切换到 DEGRADED 模式。
  • 新进请求:网关通过 Gossip 协议获取其他 Region 健康状态,将用户 DNS 重新解析到最近可用 Region(依靠 GeoDNS 健康检查联动)。
  • 本 Region 内已连接用户:Agent 使用本地缓存维持服务,若缓存未命中,则返回友好提示并建议用户刷新页面(此时 DNS 已指向新 Region)。
  • 离线消息:降级期间的新消息暂存本地磁盘队列(RocksDB),待 Redis 恢复后批量回放,保证不丢失。

消息不丢失保证:所有 Agent 消息在生成时同步写入本地事务日志(WAL)和 Kafka,Kafka 的多副本机制确保即使 Region 完全毁灭,消息仍可被其他 Region 消费,用户切换 Region 后消息可继续投递。

14.4 自适应传输策略

客户端(浏览器/App)内置网络探测模块,通过以下算法动态选择传输协议:

  • 初始连接:默认使用 WebSocket + SSE(混合架构)。
  • 探测:每 10 秒测量 WebSocket Ping 延迟和丢包率。
  • 切换阈值
    • 若连续 3 次 Ping 延迟 > 800ms 或 丢包率 > 5% → 降级为 HTTP Polling(轮询 /api/agent/{sessionId}/poll 获取最新 ANSWER_CHUNK 和状态变更),同时 POST 发送消息和中断。
    • 若指标恢复(Ping < 300ms 且持续 30 秒)→ 重新升级为 WebSocket + SSE。
  • 4G 弱网特殊优化:HTTP Polling 间隔动态调整(2s~10s),结合 Content-Encoding: gzip 压缩 JSON,减少传输数据量。

服务端配合:当检测到用户进入 Polling 模式,Agent 将流式 Token 批量打包(每 200ms 合并一个 ANSWER_CHUNK 数组),减少 HTTP 请求次数,并通过 EtagIf-None-Match 实现增量同步。

14.5 关键技术指标与部署

  • 单 Region 容量规划:网关节点 50 台(4C8G),每节点 4 万连接;Agent 节点 100 台(8C16G);Redis Cluster 6 分片(32G 内存/片),Kafka 集群 3 节点。
  • 延迟:同 Region 内端到端 95 分位 < 100ms;跨 Region 迁移延迟中位数 2s。
  • 可用性:Region 内全组件冗余,无单点故障;全局可用性 99.99%。

总结:本设计通过 GeoDNS + 多 Region 自治 + 跨 Region 异步会话复制 + 三级降级,实现了千万级并发用户的全球实时 AI 对话平台。架构将“高可用、可伸缩、实时性”三角平衡,体现了从协议到存储的完整工程闭环。面试官可通过该题考察候选人系统设计全局思维、网络知识、分布式一致性处理和故障应对策略,是高级架构岗位的试金石。


本文完整构建了从协议选型、交互协议、会话管理到前端状态机的实时 AI 应用架构,结合贯穿案例和面试题,帮助 Java 架构师系统性掌握生产级实时 Agent 的构建方法。后续篇章将继续深入多 Agent 协作、安全合规等高级主题。