不把任务绑在窗口上:Open Nua 如何重构桌面智能体会话架构
Open Nua Engineering
一个桌面 Agent 开始执行任务后,如果用户关闭窗口、网络短暂中断,或者从另一台设备打开同一个会话,任务应该发生什么?
这表面上像“断线重连”,真正落到工程上,却牵出一组更根本的问题:谁拥有会话状态?两个客户端同时回答确认请求时哪个生效?工具调用、TODO、子 Agent、Artifact 和 token 消耗,实时流结束后还能不能恢复?一个慢客户端会不会拖停 Agent?
Open Nua Desktop 最终没有继续修补“窗口连接执行”的旧模型,而是重新划分执行、会话与客户端的边界:
Agent 的执行属于会话,不属于某个窗口;窗口只是会话的观察者和操作者之一。
本文不依赖某个特定协议,讨论的是长时间运行、多客户端参与的 Agent 产品都会遇到的系统设计问题。
问题不在断线,而在事实所有权
最初的桌面 Agent 往往是一条直接链路:Renderer 发起请求,主进程交给 Agent runtime,runtime 持续返回文本和工具事件,Renderer 一边接收一边更新页面。
短对话时,这很直接。加入长任务、工具调用、人工确认、子 Agent、自动化和远程助手之后,Renderer 却逐渐承担了本不属于 UI 的职责:判断 turn 是否仍在运行、拼装工具状态、缓存流、恢复刷新前的页面,以及处理不同入口各自略有差异的事件。
同一个任务于是出现多份“事实”:runtime 有 checkpoint,主进程有 execution journal,Renderer 有实时消息,远程助手有进度卡片,自动化调度器又有自己的运行状态。
它们在线时看似一致,断线、重启、重复事件和并发操作一来,问题便一起出现:
- Agent 已完成,页面仍显示思考中;
- 工具调用实时可见,重新打开后却消失;
- 子 Agent 只剩任务卡,子消息和结果无法恢复;
- 确认已经提交,另一个客户端仍允许再次提交;
- 最终回答、工具结果和子 Agent 卡片顺序改变;
- token 区域只显示最后一次模型调用;
- 慢客户端反过来阻塞执行。
这些不是七个独立的 UI bug,而是同一个架构问题:系统没有唯一、可恢复、可供多个客户端观察的会话事实源。
拆开 Runtime、Session Host 与 Client
新的架构引入了一个独立角色:Session Host。
三层各自只拥有一类事实:
Agent Runtime 拥有执行事实。 它负责模型调用、工具执行、checkpoint、上下文、子 Agent 和恢复执行。它不需要知道某个按钮是否显示,也不应因为 Renderer 断开就结束任务。
Session Host 拥有客户端可见事实。 它把 runtime 事件投影成稳定的 Session、Turn、Message、Tool Call、Input Request、Artifact 和 Worker 状态,维护有序 action、快照、订阅与冲突处理。
Client 观察状态并表达意图。 Desktop、CLI、远程助手和 Automation 可以订阅同一个会话,也可以提交消息、取消、确认或补充输入;但客户端不独自决定会话事实。
边界一旦明确,客户端完全断开时 Agent 仍能继续运行。重新连接的一端无需找回每个内存事件,只需从某个序号继续重放,或获取一份新的权威快照。
会话不是文本流,而是可归并的状态
一次 Turn 不只有用户消息与模型回复,还可能同时包含 reasoning、工具生命周期、HITL、TODO、子 Agent、用户指定的文件或图片、Artifact、文件变更、usage 与 trace。
这些状态不能靠“收到什么就往页面追加什么”来维护。Session Host 使用有序 action 更新纯状态,客户端共享同一套 reducer:
previous state + ordered action = next state
Action 可以重复传输,但必须具有稳定身份和顺序。客户端做乐观更新时,如果 Host 拒绝操作,还要回滚或以权威状态覆盖。多客户端一致性因此不再依赖“大家碰巧按相同顺序收到相同消息”。
这次改造让我真正理解了 Redux
我以前一直把 Redux 理解成一种“把全局变量集中起来”的前端状态管理方案。这样看,它确实显得笨重:为了改一个字段,要定义 action、调用 dispatch,再写 reducer;很多页面用局部 state 明明更简单。
直到需要让实时事件、历史恢复、多个客户端和乐观更新收敛到同一个会话,我才理解 Redux 真正解决的不是“变量放在哪里”,而是:系统允许状态以什么方式发生变化。
| Redux 概念 | 会话架构中的对应物 |
|---|---|
| Store | 客户端当前持有的会话投影 |
| Action | Host 发布的、有身份和顺序的状态变化 |
| Reducer | 从旧状态确定性计算新状态的收敛规则 |
| Replay / DevTools | 用 action history 或 snapshot 重建现场 |
当所有变化都必须经过可描述的 action 和纯 reducer,同一组输入就能得到同一份状态。重复事件可以去重,错误顺序可以检测,断线后可以重放,测试也不再需要启动完整 UI 才能验证状态演进。Redux 那些看似繁琐的约束,实际上是在用显式性换取可追踪、可重放和可验证。
当然,Session Host 不是一个放大版 Redux Store。Redux 解决的是确定性投影;分布式会话还必须决定 action 的权威顺序、请求是否被接受,以及并发冲突由谁裁决。这些属于 Host。客户端 reducer 的职责,是让每个接入方都能从同一组已确认事实得到同一个结果。
这里最容易被低估的是历史恢复。一项能力实时可见,并不等于它属于可靠会话:子 Agent 可能来自实时 worker event,history 却只读取主对话文本;Artifact 在直播时是结构化事件,checkpoint 重建后却只剩普通工具输出。
因此,每一种用户可见状态都必须走完四段链路:
- 产生:Runtime 发出真实状态变化;
- 收敛:Session Host 与 reducer 应用它;
- 恢复:Snapshot 或 history 重建它;
- 展示:客户端重连后正确呈现并继续交互。
只完成前两项的功能,本质上仍是“直播功能”。History 也不再只是 assistant 文本列表,而要保留足以重建 turn identity、工具配对、子 Agent 关联、输入请求、Artifact reference、usage 和必要元数据的结构。
HITL 是并发协议,不是一个表单
同一条确认请求可能同时出现在 Desktop 与远程助手中,两个客户端都可能回答。系统必须定义请求是否有效、哪个答案生效、后提交的一端得到什么结果,以及断开和重连是否会制造第二个逻辑请求。
Open Nua 将 Input Request 作为 Session Host 拥有的资源,采用 first-writer-wins。客户端提交的是“完成这个 request”的 action,而不是直接调用 runtime resume。Host 验证 request、turn 与 tool call 仍然匹配后,才把一次有效回答交给 runtime:
invoke → interrupt → publish request → accept one answer
→ resume → record tool result → terminal state
这条生命周期必须整体测试。仅证明“点击按钮调用了 resume”,无法说明重复提交、断线恢复与工具副作用是安全的。
外部副作用需要明确的接受点
Agent 通过远程助手发送消息或更新卡片时,存在两个不同事实:Host 是否接受这次能力调用,以及外部平台是否成功投递。如果两者混在一起,网络超时会让 Agent 不知道该不该重试,重复事件也可能制造两条外部消息。
新的实现使用稳定的 interrupt/tool identity 去重,并缓存同一次调用的在途 promise 与最终结果。Host 接受后,Agent 侧不会因为客户端重连再次执行;外部投递失败则进入独立的 delivery 状态与历史,由投递层决定重试或暴露错误。
这等于为副作用建立清晰的提交点,而不是寄希望于“某段异步函数大概只运行一次”。
背压保护连接,而不是控制 Agent
背压仍然需要,但它应该保护连接与内存,不能把某个客户端的消费速度变成 Agent 的执行速度。
Session Host 为每个客户端维护有界的发送与重放状态。客户端跟不上时,可以放弃增量追赶并请求新快照;完全断开时,Host 释放连接资源,runtime 继续运行。大型工具输出和 Artifact 使用 reference 按需读取,不在每次更新里重复广播全文。
关键不是队列长度,而是承认:客户端状态可以丢弃并重建,Agent 执行状态不可以。
Usage 与 Trace 描述整个 Turn
复杂任务通常包含多次模型调用:主 Agent 规划、工具后继续推理、上下文整理,以及一个或多个子 Agent。把最后一条 assistant message 的 usage 当成本轮 usage,会系统性低估成本,也会让 cache 命中率失真。
Usage 因此按 Turn 聚合全部模型调用,分别保留 input、output、cache read/creation、reasoning、model identity,并把子 Agent 计入父 Turn。
Trace 采用相同原则。子 Agent 不是另一棵无关 trace,而是父 Turn 下的子执行。session_id、turn_id、run_id、parent_run_id、client_id 与 action 序号必须从执行开始贯穿链路,不能出问题后再从日志文本拼接。
迁移的完成标志包括删除
最危险的迁移方式,是长期保留新旧两套投影,在边缘做兼容。它短期降低切换风险,却让每个 bug 都多出一个问题:“页面现在看到的是哪条链路?”
我们采用了明确的切换边界:保留 Runtime 的 Agent loop、checkpoint 与工具能力;通过 adapter 生成稳定会话 action;让 Desktop、CLI、Automation 与远程助手依次成为同一会话的接入方;验证完成后删除旧服务、旧 normalizer、旧 projection store 和重复恢复逻辑,也不允许 UI 静默回退。
CLI 同样复用 Renderer 的类型、reducer 与连接语义,并以真实子进程、stdout、stderr、退出码及 Fake Host 接受测试。它不只是诊断入口,也能证明一个客户端启动任务并退出后,另一个客户端仍可观察正确结果。
用协议一致性验收矩阵代替“跑通了”
我们把所有接入方参加的交叉验收表称为协议一致性验收矩阵:
| 场景 | Desktop | CLI | 远程助手 | Automation | 核心断言 |
|---|---|---|---|---|---|
| 工具与流式输出 | 展示 | 可诊断 | 增量更新 | 可观察 | 有序、无重复 |
| HITL | 可回答 | 可回答 | 可回答 | 可恢复 | first-writer-wins |
| 断开与重连 | 恢复 | 可退出 | 可离线 | 不受影响 | Agent 继续执行 |
| TODO / 子 Agent | 完整展示 | 可检查 | 按需展示 | 可观察 | 状态与关联完整 |
| Artifact | 可打开 | 可定位 | 可发送引用 | 可定位 | 重启后仍可解析 |
| Usage / trace | 整轮展示 | 可检查 | 不必展示 | 可审计 | 包含子 Agent |
| 慢或并发客户端 | 状态收敛 | 状态收敛 | 状态收敛 | 状态收敛 | 有界、冲突明确 |
最关键的测试不是“发送一句话并收到回复”,而是启动一个包含工具、HITL、子 Agent 与 Artifact 的长任务;客户端完全退出;任务继续;另一端连接并回答 HITL;再次重启;最后确认消息顺序、TODO、子 Agent、Artifact、整轮 usage 与 trace 仍然完整。
结语:任务属于可恢复的会话
这次改造留下了几条不会再妥协的原则:
- 协议迁移首先是事实所有权迁移;
- 每种状态都要验证 live、disconnect、restart 与 history;
- 副作用必须验收从 interrupt 到 terminal 的完整生命周期;
- 父子 run、整轮 usage 与 action 顺序从第一天就要可观测;
- 所有客户端使用同一张协议一致性验收矩阵;
- 旧事实源与旧恢复路径不退出,迁移就没有完成。
长时间运行的 Agent 产品最终都要回答同一个问题:当所有界面暂时离开时,任务还属于谁?
Open Nua 的答案是:任务属于可恢复的会话。窗口可以关闭,连接可以重建,客户端可以更换;执行事实、用户输入与最终结果不应因此失去连续性。