概述
系列定位:本文是“多 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 的决策矩阵
| 维度 | SSE | WebSocket | WebTransport |
|---|---|---|---|
| 通信模式 | 单向(服务端→客户端) | 全双工 | 全双工 + 多路复用 |
| 传输基础 | 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):承载 双向控制消息。前端发送UserMessage和Interrupt;Agent 推送THOUGHT、TOOL_CALL、TOOL_RESULT、ERROR、ANSWER_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) 逐元素分解:
- WebSocket 主控通道:承担全部双向控制消息,是交互的核心路径,所有非流式 Agent 状态推送均经由此路。
- SSE 流式通道:仅负责逐 Token 的文本输出,简化前端打字机实现,利用 HTTP 代理兼容性保障到达率。
- HTTP 降级分支:当 WebSocket 升级失败(如企业防火墙拦截)时,客户端自动回退到 HTTP 消息发送 + SSE 流接收,保证服务可用。
-
c) 设计原理映射:
- 策略模式:SSE、WebSocket、HTTP 三者构成可替换的传输策略族,前端根据
WebSocket可用性动态选择,符合开闭原则。 - 观察者模式:Agent 作为被观察者,通过 WebSocket 和 SSE 两个事件通道通知前端观察者,不同类型消息触发不同的 UI 更新。
- 策略模式:SSE、WebSocket、HTTP 三者构成可替换的传输策略族,前端根据
-
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 推送:通过
StreamingResponseHandler的onNext(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 消息。服务端处理流程:
- 设置 Redis 中断标志:
SETEX interrupt:sessionId 5 "1",5 秒过期,避免持久标记。 - 取消 LLM 调用:
StreamingChatLanguageModel的generate()返回Future<Response>, 调用future.cancel(true)中断底层 HTTP 请求。 - 关闭 SSE 流:
sseEmitter.completeWithError(new InterruptedException())让前端 EventSource 触发 error 事件。 - 清理待发送队列:清空
BlockingQueue<AnswerChunkMessage>,防止旧消息残留。 - 回复中断确认:通过 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) 逐元素分解:
- ReAct 阶段:THOUGHT、TOOL_CALL/RESULT 均通过 WebSocket 以事件形式推送,使前端能够精准展示 Agent 当前行为。
- 流式回复:ANSWER_CHUNK 单独走 SSE,在时序上与其他控制消息并行,互不阻塞。
- 中断分支:用户可随时发送 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) 逐元素分解:
- Java 记录类:利用 record 不可变性确保消息在传输中不被篡改,
@JsonTypeName指定多态类型。 - Jackson 序列化:统一使用 Jackson,确保时间戳格式、枚举序列化一致。
- 双通道传输:WebSocket 用于控制消息,SSE 用于流式 Token,均以 JSON 文本帧携带。
- Java 记录类:利用 record 不可变性确保消息在传输中不被篡改,
-
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) 逐元素分解:
- 重连恢复:从 Redis 中加载记忆和离线消息,尽可能无缝衔接。
- 心跳机制:定期 Ping/Pong 维持连接,同时刷新 Redis 中的活跃状态。
- 超时清理: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) 逐元素分解:
- 中断可从任何活跃状态回到 IDLE:确保用户始终可以打断。
- TOOL_EXECUTING 与 THINKING 可交替:Agent 可能多次思考和调用工具。
- 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.reconnect 会 leftPop 所有消息并推送。
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) 逐元素分解:
- 分流判断:
SessionState标志决定消息去向。 - Redis List:充当离线消息缓冲区,重连后一次性消费。
- 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) 逐元素分解:
- 查询阶段:THOUGHT 和 TOOL_* 消息让用户感知后台进度。
- 流式回复:SSE 逐字推送回复,用户可在中途打断。
- 打断转人工: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 的可观测性与调试》:本文的
THOUGHT、TOOL_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_CALL 和 TOOL_RESULT 消息。
详细解释:使用 @Around 切面,在方法执行前构造 ToolCallMessage 发送,执行结束后发送 ToolResultMessage,若异常则发送 ErrorMessage。需从 SessionContextHolder 获取当前会话的 WebSocket 会话。
追问:如果工具调用超时(如外部 API 卡住),如何通知前端?
加分:设置 CompletableFuture.orTimeout(),超时后取消任务并向 WebSocket 发送 ErrorMessage("TOOL_TIMEOUT"),允许前端重试或跳过。
7. 前端打字机效果卡顿,如何优化?
答:控制 requestAnimationFrame 合并 DOM 更新,并合并连续 token 批量更新状态,而不是每 token 重新渲染。
详细解释:React 中利用 startTransition 或 useDeferredValue 降低流式更新优先级,同时使用虚拟列表优化长对话性能。
追问:如果用户网络延迟导致 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) 逐元素分解:
- 接入层:GeoDNS 根据用户 DNS 出口 IP 返回最近 Region 的负载均衡 IP,Anycast 进一步优化路由。
- 网关层:Netty 驱动的 WebSocket 网关负责连接管理、心跳、编解码、消息路由,每个网关节点可以维持 10 万+ 连接。
- Agent 层:无状态 Agent 服务,通过 Redis 获取会话上下文,与 LangChain4j 流式模型交互,推送消息到网关层。
- 数据层: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) 逐元素分解:
- 迁移发起:由客户端智能探测触发,减轻服务端复杂度和错误判断。
- 状态快照:原网关冻结推送,将
ChatMemory、AgentState、未消费的离线消息列表打包成快照,通过 Kafka 异步传送。 - 恢复与重放:新网关消费快照后重建 Agent 上下文,并按序重放缓存消息,保证用户消息连续性。
- 清理:原会话标记为迁移完成,保留短时 TTL 以防止网络延迟导致的消息迷途。
-
c) 设计原理:
- 快照模式 + 事件溯源:状态通过一次性快照 + 后续事件中继实现,避免实时代价高昂的全量同步。
- 两阶段迁移:先冻结再转移,保证迁移期间状态一致。
-
d) 工程联系与关键结论:
迁移期间的“消息迷途”问题:原网关在快照后仍可能收到少量 Agent 消息(已经在途),若不处理会导致丢失。解决方案是快照时记录最后处理的消息sequenceId,原网关继续将后续消息中转到 Kafka,由新网关顺序消费,直到新网关发送MIGRATE_COMPLETE后原网关停止转发。实际故障:某次跨 Region 网络分区,Kafka 延迟超过 30 秒,导致用户长时间停留在迁移中状态,增加了客户端超时重试逻辑和迁移状态提示 UI 后解决。
14.3 Redis Cluster 宕机降级方案
当某 Region 的 Redis Cluster 完全不可用时(如电源故障),该 Region 的 Agent 服务面临丢失会话状态的风险。我们设计三层降级:
- 本地内存缓存 (L1):每个 Agent 节点维护一个 Caffeine Cache(最大 10000 条目,TTL 5 分钟),存储近期活跃的会话状态。Redis 宕机时,Agent 优先从本地缓存读取
ChatMemory和AgentState,可支撑 80% 活跃会话。 - 跨 Region 备份恢复 (L2):通过 Kafka 异步复制到其他 Region 的会话快照,在新请求路由到备用 Region 时,从备份 Redis 拉取最近快照。消息可靠性由 Kafka 的持久化和异地副本保证。
- 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 请求次数,并通过 Etag 和 If-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 协作、安全合规等高级主题。