Agent Runtime 不是一个库,是一个你要运维的进程

Agent Runtime 不是一个库,是一个你要运维的进程

Agent Runtime 不是一个库,是一个你要运维的进程

——聊聊 OpenCoWork 为什么把 Agent 运行时搬进了一个独立的 .NET 原生进程

项目地址:https://github.com/AIDotNet/OpenCowork —— 欢迎点个 Star,下面聊的所有细节都能在源码里对得上。


一、先看现在长什么样:三个进程,各干各的

现在一次 agent 运行,横跨三个操作系统级别的角色:

渲染进程(React 19) —— 纯 UI。它负责把用户的意图打包成一个 SidecarAgentRunRequest(sidecar-protocol.ts 里那个字段多到吓人的接口),然后把回来的事件流渲染成对话、工具卡片、审批弹窗。它不跑 agent loop,一个字节的 provider 请求都不发。

Electron 主进程(Node.js) —— 运维层,不是运行时。核心是 native-worker.ts 里的 NativeWorkerManager。它干的事一句话概括:把那个原生进程当成一个需要 7×24 照看的服务来伺候——拉起、连不上就重连、定时心跳、崩了按退避重启、系统睡眠/唤醒特殊处理、进程死了要主动把它名下的 run 判死刑。它是个 supervisor,不是 agent。

原生 Worker(.NET net10.0,AOT) —— 这才是 Agent Runtime 本体。sidecars/OpenCowork.Native.Worker/,195 个 .cs 文件,光 AgentRuntime 一个模块就三万多行。agent loop、四家 provider 的传输层(OpenAI Chat / OpenAI Responses / Anthropic Messages / Gemini + Vertex)、工具执行、子 agent、团队、上下文压缩、SQLite、MCP、SSH……全在这一个原生二进制里。

三者之间怎么说话?长度前缀的 MessagePack 帧,走 Unix domain socket / Windows named pipe。4 字节大端长度头 + msgpack body,请求带自增 id 做 req/resp,事件用 agent/stream 单向推。这不是 HTTP,不是 gRPC,就是一条你自己管生死的本地 socket。

那个 agent loop 本身,平平无奇,就该平平无奇(OpenAIChatRuntime.cs):

for (var iteration = 1; !hasIterationLimit || iteration <= requestedMaxIterations; iteration++)

一轮 provider turn,吐 iteration_start / text_delta / tool_use / iteration_end,执行工具,把结果喂回去,直到 stop reason 或者撞到 max iterations。循环的形状不值钱,值钱的是循环外面那一圈。

┌─────────────────┐   MessagePack over        ┌──────────────────────────┐
│  渲染进程 (UI)   │   IPC (invoke/send)       │   Electron 主进程 (Node)  │
│  React 19        │ ────────────────────────▶ │   NativeWorkerManager     │
│  只发起 + 订阅   │ ◀──────────────────────── │   = supervisor / router   │
└─────────────────┘   agent/stream 事件流      └────────────┬─────────────┘│ 长度前缀 msgpack│ over UDS / named pipe▼┌───────────────────────────────┐│  .NET Native Worker (sidecar)  ││  net10.0 · PublishAot          ││  ★ Agent Runtime 本体 ★        ││  loop · providers · tools ·    ││  sub-agents · SQLite · MCP     │└───────────────────────────────┘

二、几个我觉得值得拿出来讲的决定

1. 把进程边界画在"运行时",而不只画在 UI 和逻辑之间

大部分 Electron 应用的进程边界是"渲染进程管 UI,主进程管系统 API"。我们多画了一刀:agent loop 单独一个 OS 进程

好处不是玄学,是实打实的四条:

  • 崩溃隔离:worker 段错误、OOM、AOT 裁剪掉了某个反射路径直接挂了——你的窗口纹丝不动。回到开头那条 SQLite commit,现在 SQLite 通过 SQLitePCLRaw 活在 .NET worker 里,跟 Electron 的 Node ABI 一点关系都没有了。那个债,从架构上还清了。
  • 冷启动和内存:AOT 出来是一个 per-RID 的原生二进制(osx-arm64、win-x64……),没有 JIT 预热,IlcOptimizationPreference=Speed,JsonSerializerIsReflectionEnabledByDefault=false(全 source-gen,零反射)。启动快、常驻内存低。
  • 能独立升级、独立重启:worker 挂了可以悄悄换一个新进程,UI 完全无感。
  • loop 能活得比 UI 久:用户切走会话、甚至关掉窗口,run 还在 worker 里跑(这条后面单独展开,是我最喜欢的一段)。

说句得罪人的话:很多 Agent 框架的可靠性天花板,就卡在"loop 和 UI 同生共死"这一条上。 进程一刷新,任务就没了。

2. 主进程是"运维",这一层的代码才是真正的护城河

native-worker.ts 是我最想让人读的一个文件,因为它一点都不性感,全是脏活,而这些脏活恰恰是 demo 和产品的分水岭。随手挑几个:

  • 心跳,但要聪明:每 15s 一次 worker/ping,5s 超时,连丢 2 次就回收进程。但是——有请求在飞的时候不 ping。因为在途请求本身就是最好的存活证明,这时候去探反而可能误杀一个正忙的健康 worker。
  • 重启带退避 + 抖动:base 300ms,封顶 30s,backoff + backoff*0.25*random()。一个彻底坏掉的 worker(比如二进制过期了、需要重新 native:publish)不会把你 CPU 打满,而一个真实用户请求进来仍然会立刻 ensureStarted() 拉起,退避只管后台自愈的节奏。
  • 睡眠/唤醒:powerMonitorsuspend 就停心跳——因为系统睡着的时候定时器根本不 fire,不停的话醒来会数出一堆"幻觉丢失"。一 resume 立刻主动探一次,而不是傻等最坏 35s。
  • 死了要发讣告:worker 意外退出,disconnected 事件一广播,上层就把它名下所有 run 判死刑并 fail 掉。更狠的是主进程会给每个 active run 合成 error + loop_end{reason:'error'} 事件——宁可给用户报个错,也绝不让 UI 挂在一条永远不会再有数据的流上。重连了则发 reconnected,让上层重跑 initialize 握手,因为新进程是一张白纸。
  • stop 和 recycle 是两回事:软 sidecar:stop 只是发个关闭 RPC,一个卡死在 native 代码里的进程根本不理你;sidecar:recycle 是真的杀掉 OS 进程再拉一个新的。初始化握手失败时走的就是 recycle。
  • 还有一堆你想不到的坑:hard-kill 的主进程在 /tmp 里留下的僵尸 socket 文件,靠 PID 存活探测来清扫;SIGTERM 发出去 3s 不死就升级 SIGKILL;socket 分片只在攒够一个完整帧后才拼接,避免每来一个 chunk 就 concat 整个 backlog 的 O(n²);msgpack 把省略的字段编码成 nil→null,直接绕过 JS 的默认参数——一个 setTimeout(cb, null) 会在 ~1ms 就触发,把请求提前打死。

这些代码没有一行是"AI 能力",但少了任何一行,产品用到第二个月就会在某个奇怪的边界上崩给用户看。我一直觉得,一个 agent 产品的真实工程量,80% 在这种地方。

3. 反向请求:worker 不只是被调用,它还能反过来调宿主

这是整套架构里我觉得最漂亮的一招,也是"工具到底在哪跑"这个问题的正确答案。

先纠正一个容易想当然的说法:"工具全在 worker 里跑"——大方向对,但更精确的真相是:工具在"谁拥有这个能力,就在谁那一层"执行。

机制是这样的:worker 需要一个它自己没有的能力时,发一个 agent/reverse-request { id, method, params } 反向请求,主进程的 handleReverseRequestmethod 分诊:

  • 操作系统 / 插件能力的(plugin:execnotify:desktopfs/request-system-accesscron/*)→ 路由回主进程执行;
  • UI能力的(browser/tool-requestask-user/requestplan/ui-update、审批 approval/request)→ 转发回渲染进程;
  • 其余的默认留在 worker 里跑。

一条反向通道,让原生 loop 能"够到"任意一层的能力,而不用把工具逻辑在三层里各抄一遍。审批就是这条通道上的一个特殊 method:worker 暂停工具、发 approval/request,主进程先跑一遍权限策略钩子(能自动放行就不烦用户),否则才弹 UI 给人拍板,{ approved, reason } 再顺着反向响应回到 worker,那个被 await 挂起的工具才继续。

有一个特别见功力的顺序保证:每次发反向请求前,先 flushAllStreamBatches() 因为审批弹窗绝对不能抢在"用户正在读的那段文字"前面冒出来——注释原话是 "Reverse requests must not overtake stream events that were emitted before them." 这种对"人的阅读顺序"的照顾,才是产品和 demo 的区别。

4. 工具系统:渲染进程只发菜单,厨房在 worker 里

顺着上面说。渲染进程里那个 ToolRegistry,根本不是执行引擎,而是一个纯目录 / 元数据层。每个工具的 execute 实现体只是一句 stub——比如 fs-tool.ts 里直接返回 "<Tool> execution has migrated to .NET Native Worker."。它在渲染进程只干三件事:

  1. 给请求提供 ToolDefinition[](名字、描述、JSON schema);
  2. 驱动系统提示词生成;
  3. 声明每个工具的审批策略(requiresApproval)。

真正的执行,全在 worker 里(或者按上一节说的,反向路由到该去的那一层)。

工具注册是分阶段的:核心工具(Read/Write/Bash/Task/Plan/Memory…)同步注册,技能 / 子 agent / 扩展这类用户可编辑的目录异步从磁盘刷新,WebSearch / Browser / 插件工具则按设置动态挂载卸载。审批策略也很讲究——Read/LS 永远免批,Write/Edit 永远要批,而 Bash看上下文的:ctx.channelPermissions ? !allowShell : true,即在一个消息渠道会话里,要不要批 shell 跟着渠道的权限走。

一个能当反例讲的细节:cron / 后台路径不复用这个动态注册表,而是维护了一份冻结的、按安全收窄的静态白名单(SUPPORTED_BACKGROUND_TOOLS,只放 Read/Write/Edit/Bash/Notify 等 11 个)。交互态是动态目录,后台态是白名单——运行时要为不同信任边界提供不同的能力面,这件事被明确地设计了进去,而不是一套工具走天下。

5. 会话不属于窗口

这是"loop 活得比 UI 久"的完整证据链,也是我个人最得意的一段。

一次前台 run,它的生命根本不在 React 组件里,而在 worker 进程里,以一个 runId 为锚。所以"切走会话 / 关窗口"这些动作,改的只是事件流往哪个水槽灌,而不是"停不停":

  • 切到别的会话 → 该会话变"后台"而非"停止"。session-runtime-router.ts 是那个岔路口:每个 delta 先问 isSessionForeground(sessionId)。前台灌进 chat-store(并塞进一个 requestAnimationFrame 里,让 React 一帧只渲染一次);后台则灌进独立的 background-session-store 缓冲区,连未读角标都从 ~30 次/秒 降频防抖到 ~2 次/秒。
  • 切回来flushBackgroundSessionToForeground 把缓冲区原子地排干、按插入顺序合并回 chat-store,画面"追帧"补上你不在时发生的一切。(这个函数的 docblock 直接吐槽了上一版实现:老版本挨个 id updateMessage,窗口没加载到那条就静默丢消息——这个坑他们踩过并写进了注释,现在改成缺失的 id 直接当新消息插入,绝不丢。)
  • 弹成独立窗口 / 多窗口 → 每个 mutation 还会 emitSessionRuntimeSync 广播,主进程 routeRuntimeSync 把事件扇出给其他正在看这个会话的窗口。一个会话被拖到独立窗口后,哪怕主窗口藏起来,它照样在流。
  • 可见性还会往下压到运行时 → detached 页面会 notifySessionVisibility 一路传到 worker 的自适应批处理器,让 worker 在源头就给后台会话粗化批处理。所以是"源头节流 + 渲染端缓冲"双保险。

一句能钉进脑子的总结:渲染进程只是一个可拆卸、可重新挂载的"视图",挂在一个主进程 run 上;导航、弹窗、关窗,只是切换同一条 runId 流的落点而已。 前台 run 不是靠"特判关窗口"才活下来的——它从一开始就没绑在窗口上。

6. 一套事件协议,喂给所有入口

不管是前台交互、cron 定时任务、还是消息平台的插件自动回复,它们说的是同一种事件语言:agent-loop-types.ts 里那个 InteractiveAgentEvent 联合类型,和 { v, runId, sessionId, seq, events[] } 的信封。v 是协议版本,seq 单调递增保证顺序、能检测丢帧,events 是数组好批量发。

这件事的价值在于:我们没有为每个界面 fork 一份 loop。 前台会话和后台 cron 都是调 worker 的同一个 agent/run,只是消费事件的姿势不同——前台把事件灌进 UI store,后台把它落进 SQLite。飞书群里被 @ 的那个 bot,和你屏幕上正在打字的那个 agent,底层是同一条心跳。

7. 运行时和 UI 之间,背压是一等公民

流式事件如果原样透传给渲染进程,一个多工具并发的 run 能把 IPC 打爆。主进程做自适应批处理:按 runId 把帧攒进 pendingStreamBatches,遇到终止事件、超过字节上限、或者攒够一小段时间就 flush。前台大约 33ms 一 flush(≈30fps,肉眼觉得"实时"),后台压到 150ms,还会合并可聚合的事件(比如连续的 text delta)。前台丝滑,后台不吵——一个正在后台默默跑的会话,不该抢着刷新你正看着的这个。

8. Provider 无关,是靠"能力协商",不是靠 if (provider === ...)

renderer 不假设 worker 一定支持某个 provider,它:canSidecarHandle('agent.run')canSidecarHandle('provider.anthropic'),底层是一个 capabilities/check 的 RPC。不支持就优雅降级、给个明确的错(${provider.type} requires the .NET Native Worker for execution.),而不是抛异常炸给用户。worker 侧则是个干净的模块注册表——WorkerModuleCatalog 列了 20 个模块,WorkerHostBuilder 遍历着把它们注册进 WorkerDispatcher,新增一个能力就是加一个 module,Program.csMain 干净到只有几行。

顺带一个我很喜欢的细节:子 agent(Task 工具)默认路由到配置里的 fast model(subAgentProvider,取自 getFastProviderConfig()),让被委派出去的活儿用更便宜更快的模型,没配就退回主模型。这种"省钱"是刻在运行时里的,不是让用户在 prompt 里自己念叨。

关于重试,补一句

整套系统有两条独立的重试链,别搞混:

  • 原生 loop 自己对 provider 请求的重试,通过 request_retry { attempt, maxAttempts, delayMs, statusCode, reason } 事件流回 UI,用户能看到"正在第几次重试";
  • renderer 侧那些仍走 JS 的 provider,HTTP 重试在 api-proxy.ts:最多 10 次,只在 429 / ≥500 时重试,指数退避 + 25% 抖动,认 Retry-After 头,还顺手解析配额头推给 UI。

没有经典的 circuit breaker——韧性是靠"进程监管 + 分层重试 + 事件级降级"这几层叠出来的,不是靠一个熔断器对象。


三、那它跟一个 Agent Framework 到底差在哪?

这是我最想掰扯清楚的一段,因为现在"Agent 框架"这词被用烂了。

框架是你 import 进自己进程的一个库;运行时是你运维的一个服务。 这句是核心,剩下都是它的推论:

Agent Framework(LangChain / AutoGPT / 各种 SDK) OpenCoWork 的 Runtime
存在形式 一个库,活在你的进程里,跟你同生共死 一个独立 OS 进程,你的 UI 挂了它照跑
优化目标 表达力、可组合性——"怎么把一个 agent 拼出来" 可运维性、隔离性、生命周期——"怎么让一堆 agent 跨越睡眠/崩溃/升级稳定跑几个月"
loop 归属 in-process,进程一刷新就没 独立进程,能扛过 UI 导航、关窗、渲染进程 reload
工具执行 在你的进程里直接调 默认在 worker,按能力反向路由到主进程 / 渲染进程
provider 抽象 类型层面的接口抽象 传输 / 能力层面的协商 + 优雅降级
崩溃语义 它崩 = 你崩 它崩,supervisor 收尸、重启、给在途 run 报丧
产品性关注点 大多留给你自己接 审批、权限策略、前后台一致、多渠道、团队、子 agent、上下文压缩,全是运行时原语

再具体一点,framework 通常给你的是"一个会调工具的循环";而作为一个真实产品,我们需要的是:工具执行前的人工审批握手、按会话快照下发的权限策略、cron 和交互同源的执行路径、上下文压缩作为运行时内建的一等能力(而不是你在 userland 里塞个 callback)、跨窗口的会话同步……这些东西你在框架里都得自己缝,而且缝得对不对,要等线上崩了才知道。

一句话:框架帮你把 agent 想出来,运行时帮你把 agent 养活。


四、如果你也在造 Agent 产品,我觉得可以抄的几点

不吹了,给点能带走的:

  1. 把进程边界画在运行时上。 让 loop 住在一个能比 UI 活得久、能被单独监管的地方。这一条几乎决定了你的可靠性天花板。
  2. 把 agent loop 当长跑服务运维。 心跳、退避、崩溃日志、睡眠唤醒、崩溃时给在途 run 合成终止事件——你现在偷的每一个懒,都是第二个月线上要还的债。这部分代码不酷,但它才是护城河。
  3. 用反向请求解耦能力,别把工具在每一层各抄一遍。 让运行时"够到"宿主的能力,而不是把宿主能力塞进运行时。审批、UI 工具、OS 操作,都能走同一条反向通道。
  4. 会话别绑在窗口上。 把 run 锚在一个进程里、用 runId 寻址,UI 只是可拆卸的视图。切走 = 换水槽,不是停。
  5. 一套事件词汇,喂所有入口。 交互、定时、插件、团队,别给每个界面 fork 一份 loop。一份协议,N 个消费者。
  6. 运行时到 UI 之间的背压,别当事后优化。 前台 30fps、后台压到 150ms,源头节流 + 渲染端缓冲,一开始就该设计进去。
  7. 能力协商 > provider 分支。 让调用方"问"而不是"猜",不支持就优雅降级。
  8. 运行时的技术选型,按运维属性选,别按你熟不熟选。 对一个桌面产品,一个 AOT 原生 sidecar 换来的 ABI 解耦、崩溃隔离、快冷启动,是非常划算的一笔买卖——哪怕这意味着你团队里得有人写 C#。

写 OpenCoWork 这套东西,最深的一个体感是:做一个能跑起来的 agent,一个下午;做一个用户三个月不重装、跨睡眠跨崩溃跨升级都还在的 agent,是另一门手艺。 前者是算法,后者是工程。而运行时,就是那门工程活儿凝结的地方。