本文引用的文件
本项目使用两种基于 Server-Sent Events 的实时通信机制:
| 场景 | 技术路径 | 主要用途 |
|---|---|---|
| Agent 流式对话 | Spring WebFlux Flux<ServerSentEvent<String>>,控制器直接返回事件流 |
工具调用、思考过程、增量文本、完成与错误帧 |
| 清洗/治理等后台任务进度 | Spring MVC SseEmitter + @Scheduled 定时心跳 |
数据清洗、数据治理、AI 清洗状态快照、日志推送 |
两者虽然都叫“SSE”,但实现层不同:Agent 对话是响应式流;清洗治理进度是传统阻塞式 SseEmitter。本文重点说明两者的心跳机制、断连重连策略、消息乱序处理,以及长连接对服务器资源的实际影响。
SSE 相关代码分布在三个层次:
graph TB
subgraph "前端"
FE_SSE["sse.ts<br/>通用 SSE 解析器"]
FE_Store["chatStream.ts<br/>对话流式状态机"]
end
subgraph "网关"
GW_App["application.yaml<br/>连接超时、鉴权、路由"]
end
subgraph "后端服务"
Controller["AgentChatController.java<br/>/chat/stream"]
Service["AgentChatServiceImpl.java<br/>Reactor SSE 流"]
EmitterSvc["SseService.java<br/>SseEmitter 心跳"]
DTO["SseDTO.java<br/>清洗/治理消息模型"]
end
FE_SSE --> Controller
FE_Store --> FE_SSE
Controller --> Service
Service --> Controller
EmitterSvc --> DTO
图表来源
章节来源
Agent 聊天接口由控制器暴露 /chat/stream,内容类型为 text/event-stream。服务层将底层 Agent 的事件转换为一系列 SSE 事件帧:
| 事件类型 | 含义 | 是否可重复 |
|---|---|---|
intent |
意图识别结果 | 每轮一次 |
suggestions |
下一步引导建议 | 每轮末尾可能追加 |
tool_call |
工具调用开始 | 每次工具调用一次 |
tool_input |
工具入参完整 | 每次工具调用一次 |
tool_result |
工具结果 | 每次工具调用一次 |
graph_data |
全量关系图数据 | 仅 get_case_graph 触发 |
thinking |
模型思考过程 start/delta/end | 一轮多次 |
token |
增量回复文本 | 大量帧 |
heartbeat |
服务端心跳 | 每 10 秒一次 |
done |
正常结束 | 每轮一次 |
error |
异常结束 | 每轮最多一次 |
章节来源
SseService 维护用户到 SseEmitter 的映射,承担三类职责:
SseEmitter,注册完成、超时、异常回调,并立即发送一条“连接成功”初始消息。该服务已改为按用户隔离连接:同一用户重复连接会断开旧连接,避免多标签页或多端同时登录时覆盖对方进度。
章节来源
sse.ts 提供通用解析器 createParser,负责:
\n\n / \r\n\r\n 分隔;data:;onToken、onDone、onError、onHeartbeat 等回调;flush(),避免最后一帧丢失。streamSse 使用原生 fetch + ReadableStream 读取流,因为普通 axios 无法消费增量数据。它还会判断 Content-Type 是否为 text/event-stream,以区分未登录时后端返回的普通 JSON 错误响应。
章节来源
chatStream.ts 是 AI 对话的流式状态管理中心,负责:
章节来源
sequenceDiagram
participant User as "用户"
participant Frontend as "前端 chatStream.ts"
participant Parser as "前端 sse.ts"
participant Gateway as "网关 application.yaml"
participant Controller as "AgentChatController"
participant Service as "AgentChatServiceImpl"
participant Heartbeat as "心跳 Flux"
User->>Frontend: 发送对话消息
Frontend->>Parser: streamSse(url, params, handlers)
Parser->>Gateway: POST /js/a/chat/stream
Gateway->>Controller: 转发请求
Controller->>Service: stream(req)
Service->>Service: 会话校验、并发控制、构建 Agent
Service->>Service: 订阅 Agent 事件
Service->>Parser: 依次发送 tool_call/tool_result/token/thinking
Service->>Heartbeat: 启动 10 秒心跳
Heartbeat-->>Parser: event: heartbeat
Service->>Parser: event: done
Parser-->>Frontend: onDone()
Frontend->>Frontend: 停止流式态、刷新会话元信息
图表来源
Agent 对话的核心流程如下:
flowchart TD
Start["收到 /chat/stream 请求"] --> Validate["校验消息与会话"]
Validate --> AcquireGate{"会话级 Semaphore 抢占成功?"}
AcquireGate -->|否| ReturnError["返回 error 帧:会话正在回复中"]
AcquireGate -->|是| BuildAgent["构建 RuntimeContext 与 Agent"]
BuildAgent --> Subscribe["订阅 Agent 事件流"]
Subscribe --> Dispatch["按事件类型转换 SSE 帧"]
Dispatch --> ToolCall["tool_call → tool_input → tool_result"]
Dispatch --> Thinking["thinking start → delta → end"]
Dispatch --> Token["token 增量文本"]
Dispatch --> Graph["graph_data(可选)"]
Dispatch --> Done["AgentEndEvent → done"]
Dispatch --> Error["异常 → error"]
Dispatch --> Heartbeat["独立心跳:每 10 秒"]
Heartbeat --> Merge["merge(body, heartbeats)"]
Token --> Merge
ToolCall --> Merge
Thinking --> Merge
Graph --> Merge
Done --> End["释放 Semaphore、持久化结果"]
Error --> End
图表来源
关键点:
sessionId 对应一个 Semaphore(1),防止同一会话同时发起两条流式对话。trace 数组记录思考与工具调用的到达顺序。Flux.interval(Duration.ofSeconds(10)) 与业务事件 Flux.merge 合并,不参与正文累积。AgentResultEvent,会异步保存已累积的文本和工具事件。章节来源
本项目有两套心跳:
| 心跳 | 位置 | 间隔 | 作用 |
|---|---|---|---|
| Agent 对话心跳 | AgentChatServiceImpl 中的 Flux.interval |
10 秒 | 让前端区分“模型仍在处理”和“连接已断” |
| 清洗/治理心跳 | SseService.fixedSee 定时任务 |
5 秒 | 防止代理/Nginx/浏览器断开空闲 SSE 连接 |
flowchart TD
Scheduled["@Scheduled(fixedRate = 5000)"] --> CheckEmpty{"emitters 为空?"}
CheckEmpty -->|是| Return["直接返回"]
CheckEmpty -->|否| Iterate["遍历 userId → emitter"]
Iterate --> Send["发送 SseDTO(cmd=999, message=心跳)"]
Send --> CatchErr{"发送异常?"}
CatchErr -->|是| Remove["移除失效连接并记录日志"]
CatchErr -->|否| Next["下一个连接"]
Remove --> Next
Next --> Done["本轮心跳结束"]
图表来源
注意:调度线程池默认只有一根线程,因此配置中将其放大为 4 线程,避免 SSE 心跳、数据源回收、清洗看门狗、附件清理等定时任务互相阻塞。
章节来源
前端通过两个时间戳区分两类状态:
lastActivityAt:最近一次模型或工具事件时间;lastHeartbeatAt:最近一次心跳时间。如果长时间没有 token、tool_result、thinking 等业务活动,但有心跳,说明连接仍存活,只是模型在处理;如果心跳也停止,则判定连接可能已断。
当连接关闭但未收到 done 时,前端不会把半截内容伪装成完成,而是标记为“连接已结束但未收到完成信号”。
当前 sse.ts 与 chatStream.ts 的实现是:单次流式请求失败后不自动重连。重连责任交给上层 UI 逻辑:用户看到错误提示后重新发送消息。这样做避免了无界重试导致的状态混乱。
SSE 本身不保证跨网络重连后的严格顺序,因此前端采用以下策略:
type,例如 token、tool_call、tool_result、done。toolCallId 关联:tool_call、tool_input、tool_result、graph_data 共享同一个 toolCallId。toolInput 和 toolResult 由前端按字符串累积,后端也对超长结果做了截断。trace 数组按事件到达顺序记录“思考节点”和“工具节点”,渲染时优先使用这个顺序,而不是猜测思考与工具的对应关系。脏读防护:清洗/治理消息在发送前补齐 caseId,前端据此丢弃其他案件的进度帧,避免上一个案件还在跑时误渲染到新案件。
flowchart TD
Frame["收到 SSE 帧"] --> Parse["解析事件类型"]
Parse --> IsTool{"是否工具事件?"}
IsTool -->|是| MatchId["按 toolCallId 匹配已有步骤"]
MatchId --> UpdateStep["更新 input/result/status/graphPayload"]
IsTool -->|否| IsHeartbeat{"是否心跳?"}
IsHeartbeat -->|是| UpdateHB["更新 lastHeartbeatAt"]
IsHeartbeat -->|否| AppendText["token 追加 content"]
AppendText --> Throttle["节流重扫富内容块"]
UpdateStep --> Trace["追加 trace 节点"]
UpdateHB --> Trace
Throttle --> Trace
Trace --> State["更新本地 ChatMessage 状态"]
图表来源
章节来源
本项目选择 SSE 而非 WebSocket,主要基于以下实际场景:
| 维度 | 本项目现状 | 说明 |
|---|---|---|
| 方向 | 主要是服务端单向推送到前端 | Agent 对话、清洗治理进度都是服务端输出 |
| 协议复杂度 | 希望保持简单 | SSE 基于 HTTP,天然支持鉴权、缓存、代理、Nginx 透传 |
| 双向命令 | 不需要复杂的双向二进制协议 | 控制面走普通 REST,数据面走 SSE |
| 实现成本 | 两套后端技术栈并存 | Reactor SSE 用于 Agent;SseEmitter 用于传统任务进度 |
| 浏览器兼容 | Electron + 现代浏览器 | 原生 EventSource 或 fetch + ReadableStream 均可 |
WebSocket 更适合需要频繁双向小消息、低延迟指令通道的场景,例如实时多人协作编辑、游戏同步。而本项目的 Agent 对话本质上是“服务端逐步输出结果”,清洗治理进度也是“服务端周期性推送”,SSE 更贴合需求。
章节来源
classDiagram
class AgentChatController {
+stream(req) Flux
+sendMessage(dto) MessageVO
+listMessages(sessionId) List
}
class AgentChatServiceImpl {
-streamLocks : ConcurrentHashMap
-stream(req) Flux
-doStream(...) Flux
-doStreamLocked(...) Flux
+stream(ChatRequestDTO) Flux
}
class SseService {
-emitters : Map
+see() SseEmitter
+sendSee(dto) void
+fixedSee() void
+closeSee(userId) void
}
class SseDTO {
+cmd : int
+process : int
+message : String
+seeData : SeeDataDTO
+caseId : Long
}
class ChatStreamStore {
+send(content) Promise
+stop() void
}
class SseParser {
+push(chunk) void
+flush() void
}
AgentChatController --> AgentChatServiceImpl : "调用"
AgentChatServiceImpl --> AgentChatController : "返回 ServerSentEvent"
SseService --> SseDTO : "序列化消息"
ChatStreamStore --> SseParser : "使用"
图表来源
| 风险 | 现状 | 建议 |
|---|---|---|
| 连接数量增长 | SseService.emitters 按用户保存 SseEmitter |
增加连接监控指标,设置单用户连接上限 |
| 心跳 CPU 消耗 | 每 5 秒遍历所有连接 | 连接数很大时改用批量或分段广播 |
| 调度线程阻塞 | 默认单线程调度器承载多个定时任务 | 已配置 4 线程,继续观察调度队列时长 |
| SSE 响应内存 | 后端聚合工具结果、图谱、思考文本 | 保持超长结果截断,避免单帧过大 |
| 前端渲染压力 | token 增量高频触发富内容扫描 | 已使用 100ms 节流,必要时可调整阈值 |
| 网关超时 | 网关后端连接超时 3 秒,注释指出不设全局响应超时 | 确保 Nginx/反向代理的 SSE 超时远大于 Agent 最长推理时间 |
当前 SseService 使用 ConcurrentHashMap<Long, SseEmitter> 直接保存连接,没有连接池或大小限制。建议:
SseEmitter 默认超时 6 小时;Flux 生命周期自动终止。Flux,天然具备背压能力;当前实现偏向“错误即终止”:
done → 标记中断,不自动重连;改进方向:
章节来源
关注以下日志关键字:
| 日志关键词 | 含义 | 排查方向 |
|---|---|---|
sse 连接已关闭 |
SSE 连接正常关闭 | 确认是否为用户主动关闭或页面卸载 |
sse 连接超时 |
SseEmitter 达到 6 小时超时 |
检查是否存在僵尸连接 |
sse 连接异常 |
发送或连接生命周期出现异常 | 检查网络、代理、防火墙 |
sse 初始消息发送失败 |
刚建立连接就发送失败 | 检查客户端是否立刻关闭连接 |
sse 发送失败,关闭连接 |
业务消息发送失败 | 检查连接是否已经半关闭 |
sse 心跳失败 |
定时心跳写入失败 | 检查连接是否已失效 |
Chat stream error |
Agent 流式对话异常 | 结合 sessionKey 查看下游模型或工具错误 |
章节来源
lastHeartbeatAt;lastActivityAt。如果前端长时间显示“连接正常但无输出”,应检查是否有 heartbeat 事件;如果没有,说明底层连接已被代理或网关断开。
章节来源
SSE 要求反向代理:
Content-Type: text/event-stream;event: / data: 帧;connect-timeout 不应被误解为 SSE 响应超时。当前网关配置中已明确注释:网关后端连接超时为 3 秒,但没有设置全局响应超时,原因是 SSE 研判会长时间保持连接。部署时应特别注意 Nginx 的 proxy_read_timeout 和负载均衡器的连接超时。
章节来源
| 现象 | 可能原因 | 建议操作 |
|---|---|---|
| 前端一直等待,无 token 也无心跳 | 连接在代理层被提前关闭 | 检查 Nginx、防火墙、负载均衡超时 |
| 有心跳但无业务输出 | 模型或工具仍在处理 | 等待;检查后端日志中是否仍有工具调用 |
| 偶尔丢最后一段文本 | 流结束帧未冲刷 | 确认前端调用了 flush() |
| 工具结果渲染异常 | JSON 被外层包裹或过长 | 检查归一化和截断逻辑 |
| 切换案件后看到上一个案件的进度 | 缺少 caseId 过滤 | 检查 withCaseId 补发逻辑 |
| 同一账号多标签页进度互相覆盖 | 同用户复用连接键 | 这是设计行为,与“同账号不允许多端登录”一致 |
章节来源
本项目的 SSE 心跳与长连接保活分为两层:
toolCallId、trace 时序链维持消息语义一致性。SseEmitter,心跳间隔 5 秒,主要用于防止中间网络设备和浏览器断开空闲连接。当前实现的优点是简单可靠、前后端职责清晰;缺点是连接管理偏手动、没有显式连接池、重连策略较弱。后续优化应优先考虑连接监控、连接上限、指数退避重连、应用层超时和背压指标采集。对于生产环境,尤其要验证反向代理和负载均衡器对 text/event-stream 的支持,否则即使后端心跳正常,前端也可能因中间层超时而断连。