一个只在进程内运行的 Agent,可以把一次执行理解为一个函数调用:输入进入,模型与工具循环推进,函数返回,调用方获得结果。进入 Web 以后,这个假设很快失效。
浏览器页面可能刷新,网络连接可能断开,用户可能关闭标签页,Agent 仍可能继续执行;另一个页面随后重新打开同一个 Session,还需要恢复已经产生的消息、工具调用和当前执行状态。此时,HTTP 请求、SSE 或 WebSocket 连接都只能代表某一段通信关系,无法自然承担 Agent 的执行生命周期。
因此,Web Agent 首先要处理的并不是“如何把 token 流到浏览器”,而是几个生命周期之间的所有权关系:
Session 长期会话事实
Run 一次独立执行
Connection 一段网络连接
Browser Page 一个前端实例
只要其中任意两个对象被合并,刷新、断线、恢复或并发访问就会产生语义冲突。
从普通 Streaming 请求开始
最直接的 Web 实现通常是:
Browser
│ POST /chat
▼
Server
│ stream model output
▼
HTTP Response / SSE
│
▼
Browser UI
如果执行时间很短,这种结构足够有效。请求对象持有 AbortSignal,模型 Streaming 跟随连接,连接结束时执行也随之结束。
问题出现在执行生命周期开始长于连接生命周期之后。
例如一次 Agent 工作包含:
LLM
↓
Tool A
↓
LLM
↓
等待外部审批
↓
Tool B
↓
LLM
等待期间没有必要维持原来的 HTTP 调用栈;浏览器也可能已经刷新。若 Server 将“这次执行”直接绑定到 Response,一旦 Response 消失,Runtime 面临两个互相冲突的选择:
- 取消执行。这样网络故障会改变业务语义。
- 继续执行。这样原 Response 已经不再是执行状态的所有者。
第二种情况实际上已经要求系统引入独立于连接的执行对象。
Session 不能代替 Run
Session 适合表达长期事实:消息、分支、用户输入、模型输出、工具结果、会话元数据,以及后续构建 Context 所需要的持久状态。
一次执行还需要另一组状态:
queued
running
waiting
completed
failed
cancelled
以及:
startedAt
finishedAt
abortHandle
currentStep
waitingReason
parentRunId?
如果这些字段全部直接写进 Session,就会出现一个结构性问题:Session 的生命周期远长于单次执行,但 Run 状态具有明确的开始与终止边界。同一个 Session 还可能依次产生多个 Run,甚至存在后台 Run、SubAgent Run 或重试 Run。
因此,应用层更稳定的模型是:
Session
├── Message / Event ...
├── Run 1
├── Run 2
└── Run 3
这里的 Run 是产品与 Durable Web Runtime 层的抽象。并非所有 Agent 框架都会将它提升为核心领域对象。例如 DeepSeek Harness 当前核心更强调 Session、Turn 和 Step;Web 产品仍然可以在其上建立独立 Run 记录,用于任务状态、恢复、审计和调度。
Run 的存在解决的是“某一段执行现在处于什么状态”,Session 解决的是“这段长期交互已经发生了什么”。
Connection 更不应该拥有 Run
连接的生命周期具有偶然性。
Connection #1
│
├── event 10
├── event 11
└── disconnect
Run 仍在继续
│
├── event 12
├── event 13
└── event 14
Connection #2
│
└── resume from 11
这里最重要的关系是:
Run 1 ────────────────┐
│
Connection #1 ───X │
│
Connection #2 ─────────┘
连接只是某个客户端观察 Run 和 Session 的通道。
如果网络断开后 Server 自动取消 Run,那么 Connection 获得了业务生命周期控制权;如果刷新页面产生新的 Run,则 Browser Page 又获得了业务生命周期控制权。两者都会导致“通信故障”与“业务取消”混为一谈。
更清晰的控制方式是将两件事分开:
transport disconnect
→ 结束当前连接
cancel run
→ 显式调用 Runtime cancellation
用户点击 Stop 属于后者。Wi-Fi 断开属于前者。
Vercel AI SDK 提供了哪些边界
Vercel AI SDK 的 useChat 很适合观察 Web Agent 的 UI 与 Transport 层。当前 API 中,useChat 管理 UIMessage[]、status、错误以及发送、停止和恢复操作;Transport 可以替换为自定义 HTTP、WebSocket 或直接调用 Agent 的实现。[1][2]
这说明 useChat 的主要职责位于:
UI State
+
Transport
而不是替应用定义完整的 Durable Session 模型。
AI SDK 的持久化文档也明确把消息持久化交给应用。UIMessage 面向前端展示,和发送给模型的 ModelMessage 并非同一种结构。[3]
这一点对应一个重要分层:
Frontend UIMessage[]
│
│ Transport payload
▼
Server Canonical Session
│
│ Context Builder
▼
ModelMessage[]
前端拥有显示所需的 UI State,Server 仍应拥有会话事实的最终解释权。
如果应用规模很小,可以直接持久化 UIMessage[]。当 Agent 引入 Event Log、Tool State、Approval、SubAgent 或 Compaction 后,Server Canonical State 通常会比 UIMessage[] 更丰富,UI Message 成为其中一个投影。
Stream Resume 仍然需要持久状态
AI SDK 当前支持 useChat 的 stream resume,但官方文档同时明确指出:恢复机制需要应用自己持久化 Message 与 Active Stream,并维护 Chat 与 Stream ID 的关系。[4]
恢复的实际问题可以写成:
客户端已经看到 seq = N
│
X connection lost
│
Server 继续产生 N+1 ... M
│
新连接建立
│
如何补齐 N+1 ... M?
只有 Streaming Transport 本身无法回答这个问题。
需要至少有一种可恢复事实源:
Durable Event Tail
或
Resumable Stream Store
或
Server Projection + Durable Delta
然后客户端才能执行:
load baseline at N
+
replay durable events > N
+
attach live stream
若系统声称支持严格恢复,就必须处理“历史读取完成”和“实时订阅建立”之间的 gap。常见做法是在 Server 端让 replay 与 live attach 共享一个连续 sequence 空间,或者建立 checkpoint 后再订阅。
Abort 与 Resume 之间存在真实冲突
AI SDK 当前文档专门指出,resume: true 的 resumable stream 与 abort/stop 机制存在冲突:刷新或关闭页面会触发 AbortSignal,可能破坏 stream resumption。[4][5]
这不是某个库的偶然限制,它揭示了一个更一般的问题:
Connection Abort
与
Execution Cancel
在简单 Streaming 架构中经常共用同一个 AbortSignal。
一旦要求 Run 独立于连接继续存在,两种取消必须分离:
connection.abort()
只关闭客户端消费通道
run.cancel()
终止 Agent Runtime
这是 Web Agent 从“流式 HTTP 请求”演化成 Runtime 的一个明确边界。
Browser Page 只拥有视图实例
页面刷新意味着 JavaScript 内存全部丢失。如果页面对象拥有 Session 的唯一副本,则刷新等价于丢失 Session;如果页面对象拥有 Run 的唯一控制状态,则刷新会丢失当前执行。
更稳定的前端关系是:
Browser Page
│
├── Client Runtime
│ ├── Session mirror
│ ├── connection state
│ └── projection cache
│
└── React / UI
页面可以销毁整个 Client Runtime。重新加载时,新 Runtime 从 Server Canonical State 重建。
Client Runtime 可以缓存 lastSeq、当前 Session ID 或 UI preference,但这些数据用于提高恢复效率,不应成为业务事实的唯一来源。
DeepSeek Harness 的 Client Runtime
DeepSeek Harness 当前浏览器侧已经明确采用了这种分层。其客户端约束文档把数据对象层写成:
ConnectionController
↓
SessionManager
↓
Session
这一层禁止 React 依赖;Session 自己维护事件窗口、Streaming accumulation、Reconnect 修复和可观察 Snapshot。React 绑定放在独立的 web-react 层。[6]
ConnectionController 自己管理 connection generation 和 exponential backoff。新的 generation 建立后触发 onConnected;SessionManager.handleConnected() 刷新 Session 列表,并让已经实例化的 Session 执行 resync()。[7][8]
Session 的 resync() 会提高 openGeneration,废弃旧连接上的 in-flight open,清空窗口后重新读取历史;实时 Event 在 open 或 gap repair 期间进入 liveBuffer。历史落地后,再根据 seq 将 buffer 接到窗口尾部。[9]
这个实现说明 Web Runtime 的恢复对象不是 React Component,也不是 Socket,而是一个有明确状态与序列规则的 Client Session。
Server State、Client State 和 Run State
将这些关系收敛后,可以得到一套较稳定的所有权模型:
Server
│
├── Session State
│ durable facts
│
├── Run State
│ active execution
│
└── Event Stream
transport projection
│ network
▼
Client Runtime
│
├── Session Mirror
├── Projection Cache
├── Connection State
└── lastSeq / resync state
│ observable snapshot
▼
UI
├── local interaction state
└── rendered view
几个边界因此可以明确下来:
- Session 的事实不由浏览器连接拥有。
- Run 的终止不由网络断开隐式决定。
- Event Stream 负责传输变化,不承担唯一状态源。
- Client Runtime 负责恢复和投影,不把这类业务状态塞进 React Component。
- UI 负责展示和局部交互状态。
一个设计判断
Web Agent 的核心问题并不是选择 SSE 还是 WebSocket。
Transport 可以替换。更稳定的设计问题是:执行、事实、连接和视图分别由谁拥有,以及一个对象消失以后其余对象是否还能保持正确语义。
当这四种生命周期被分开以后,断线恢复的结构就自然形成:
Session / Run 继续存在
↓
Connection 可以反复建立
↓
Client Runtime 根据 checkpoint 重建
↓
UI 只是当前投影
下一篇将继续处理 Client Runtime 内部最关键的问题:Server 发送的是 Event,React 最终需要的是 View State,中间的 Projection 应该放在哪里,以及历史 Replay 与实时 Append 如何使用同一套计算逻辑。
参考资料
[1] Vercel AI SDK useChat: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat
[2] Vercel AI SDK Transport: https://ai-sdk.dev/docs/ai-sdk-ui/transport
[3] Vercel AI SDK Chatbot Message Persistence: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence
[4] Vercel AI SDK Chatbot Resume Streams: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams
[5] Vercel AI SDK Abort breaks resumable streams: https://ai-sdk.dev/docs/troubleshooting/abort-breaks-resumable-streams
[6] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md
[7] DeepSeek Harness ConnectionController: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/src/client/connection.ts
[8] DeepSeek Harness SessionManager: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/manager.ts
[9] DeepSeek Harness Client Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/session.ts