SSE 流式输出的暂停与中断
AI 流式对话中的暂停/中断与取消:区分 SSE transport 断开、可恢复的 Agent interrupt,以及显式 CancelRun 业务终止。
[!info] related notes
- 前置概念: SSE
- 并列概念: AI 对话与流式界面
- 取消语义: Cancellation, Abort Generation
- 恢复: Durable SSE Recovery with after_seq
- HITL: Agent Interrupt / Resume Lifecycle
SSE 流式输出的暂停与中断
一句话定义
在 production Agent streaming 中,“SSE 断了”“Agent 暂停等待用户”“用户明确停止生成”是三种不同状态:
Transport disconnect
Agent interrupt / waiting_user
Business cancellation
不能只靠一个 AbortController 或 socket close 把它们混成同一个 cancelled。
三种情况先分清
1. Transport disconnect
Wi-Fi lost / proxy timeout / reader abort / tab reload
→ SSE transport unavailable
它只说明客户端暂时收不到 live event。若服务端 Run 是 durable/detached 的,它可以继续运行;客户端后续应该恢复/catch up,而不是自动取消。
2. Agent interrupt / waiting_user
例如 ask_user:
Run reaches interaction.required
→ durable pending interaction
→ execution pauses
→ user answers later
→ resume same logical Agent thread/checkpoint
这是正常业务流程,不是错误,也不是取消。
3. Explicit cancellation
用户点击真正的“停止生成”:
CancelRun(run_id)
→ server validates current state
→ durable run becomes cancelled
→ propagate cancellation to live execution
→ run.cancelled terminal event/state
这是不可恢复继续的终态语义,除非产品显式定义另一种重新开始机制。
前端状态机
更合理的 UI 状态可以表达为:
streaming
├─ transport lost
│ → recovering/reconnecting
│ → streaming | terminal
│
├─ interaction.required
│ → interrupted/waiting_user
│ → run.resumed
│ → streaming
│
├─ explicit stop
│ → cancelling
│ → cancelled
│
├─ run.completed → completed
└─ run.failed → failed
这样 UI 不会把“网络暂时断了”错误显示成“用户已取消”。
AbortController 的正确边界
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort();
它负责停止浏览器这一侧的 fetch/ReadableStream 工作。
它不自动等于:
CancelRun accepted
run.cancelled persisted
LLM/tool actually stopped
因此:
AbortController
= transport/client async cancellation primitive
CancelRun
= business command
见 AbortController 与异步取消 与 Cancellation。
为什么不能“后端检测连接关闭就自动取消 Run”
网络关闭的原因不包含可靠的用户意图:
user clicked Stop
和
Wi-Fi dropped
在服务器看来都可能表现为 transport close/request cancellation。
如果把它直接映射成 Run cancel:
临时网络故障
→ Agent 被误杀
→ durable result 无法继续产生
所以 durable Agent architecture 通常让 execution lifetime 独立于单条 SSE request lifetime,再通过显式 CancelRun 处理真正的用户取消。
断线恢复
如果客户端已经消费:
seq 1..5
网络断开,而服务端继续产生:
6..10
客户端恢复时应从 durable event source 请求:
after_seq=5
→ 6..10
并按 (run_id, seq) 做 ordering/dedup。详见 Durable SSE Recovery with after_seq。
HITL Resume 与 SSE Reconnect 不是一回事
SSE reconnect
= 换一条 transport,追赶同一 Run 的事件
Agent resume
= 用户给 interrupted execution 新业务输入,让未完成的 logical thread 继续计算
BodySense 当前 ask_user Resume 会保持同一个 Conversation / logical checkpoint thread,但创建新的 public Run,因此新 Run 拥有新的 run-local sequence domain,可以从 seq=1 开始。
Partial Message
取消、断线和 interrupt 对 partial content 的策略也不同:
Disconnect
→ partial UI projection 可以保留,等待 replay 继续补齐
Interrupt
→ partial state + pending interaction 都应可恢复
Cancel
→ 是否保留 partial result 由 durable product/domain contract 决定
不要仅因为 reader 结束就清空或固化最终消息。
BodySense 当前实现对应
Live SSE
→ fetch + ReadableStream
→ StreamEvent validation
→ ActiveTurn reducer
Transport disconnect
→ durable Runtime Event Log / after_seq recovery
Explicit Stop
→ POST /consultation-runs/:id/cancel
→ durable run.cancelled
ask_user
→ interaction.required
→ user answer persisted
→ new public Run resumes same logical Agent thread
主要代码边界:
apps/web/src/features/consultation/hooks/useSSEProcessor.ts
apps/web/src/features/consultation/runtime/durableRunRecovery.ts
apps/web/src/features/consultation/runtime/activeTurnReducer.ts
apps/api/internal/consultation/runtime.go
常见误解
EventSource.close()/AbortController.abort()= 服务端 Run 已取消。- SSE EOF =
run.completed。 interaction.required= error/cancel。- reconnect 和 resume 是同一种操作。
- cancel 后能自动回滚已经发生的外部 tool side effect。
最短记忆
SSE断了 → recover transport/events
Agent暂停 → wait for input, then resume execution
用户点停止 → explicit CancelRun → durable cancelled