周毓杰

不把任务绑在窗口上: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

Runtime、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客户端当前持有的会话投影
ActionHost 发布的、有身份和顺序的状态变化
Reducer从旧状态确定性计算新状态的收敛规则
Replay / DevTools用 action history 或 snapshot 重建现场

当所有变化都必须经过可描述的 action 和纯 reducer,同一组输入就能得到同一份状态。重复事件可以去重,错误顺序可以检测,断线后可以重放,测试也不再需要启动完整 UI 才能验证状态演进。Redux 那些看似繁琐的约束,实际上是在用显式性换取可追踪、可重放和可验证。

当然,Session Host 不是一个放大版 Redux Store。Redux 解决的是确定性投影;分布式会话还必须决定 action 的权威顺序、请求是否被接受,以及并发冲突由谁裁决。这些属于 Host。客户端 reducer 的职责,是让每个接入方都能从同一组已确认事实得到同一个结果。

这里最容易被低估的是历史恢复。一项能力实时可见,并不等于它属于可靠会话:子 Agent 可能来自实时 worker event,history 却只读取主对话文本;Artifact 在直播时是结构化事件,checkpoint 重建后却只剩普通工具输出。

因此,每一种用户可见状态都必须走完四段链路:

  1. 产生:Runtime 发出真实状态变化;
  2. 收敛:Session Host 与 reducer 应用它;
  3. 恢复:Snapshot 或 history 重建它;
  4. 展示:客户端重连后正确呈现并继续交互。

只完成前两项的功能,本质上仍是“直播功能”。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_idturn_idrun_idparent_run_idclient_id 与 action 序号必须从执行开始贯穿链路,不能出问题后再从日志文本拼接。

迁移的完成标志包括删除

最危险的迁移方式,是长期保留新旧两套投影,在边缘做兼容。它短期降低切换风险,却让每个 bug 都多出一个问题:“页面现在看到的是哪条链路?”

我们采用了明确的切换边界:保留 Runtime 的 Agent loop、checkpoint 与工具能力;通过 adapter 生成稳定会话 action;让 Desktop、CLI、Automation 与远程助手依次成为同一会话的接入方;验证完成后删除旧服务、旧 normalizer、旧 projection store 和重复恢复逻辑,也不允许 UI 静默回退。

CLI 同样复用 Renderer 的类型、reducer 与连接语义,并以真实子进程、stdout、stderr、退出码及 Fake Host 接受测试。它不只是诊断入口,也能证明一个客户端启动任务并退出后,另一个客户端仍可观察正确结果。

用协议一致性验收矩阵代替“跑通了”

我们把所有接入方参加的交叉验收表称为协议一致性验收矩阵

场景DesktopCLI远程助手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 的答案是:任务属于可恢复的会话。窗口可以关闭,连接可以重建,客户端可以更换;执行事实、用户输入与最终结果不应因此失去连续性。