聊聊 OpenCoWork 为什么把 Agent 运行时搬进了一个独立的 .NET 原生进程 先看现在长什么样:三个进程,各干各的现在一次 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 │└───────────────────────────────┘二、几个我觉得值得拿出来讲的决定把进程边界画在运行时,而不只画在 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 预热,IlcOptimizationPreferenceSpeed,JsonSerializerIsReflectionEnabledByDefaultfalse(全 source-gen,零反射)。启动快、常驻内存低。能独立升级、独立重启:worker 挂了可以悄悄换一个新进程,UI 完全无感。loop 能活得比 UI 久:用户切走会话、甚至关掉窗口,run 还在 worker 里跑(这条后面单独展开,是我最喜欢的一段)。说句得罪人的话:很多 Agent 框架的可靠性天花板,就卡在loop 和 UI 同生共死这一条上。 进程一刷新,任务就没了。主进程是运维,这一层的代码才是真正的护城河native-worker.ts 是我最想让人读的一个文件,因为它一点都不性感,全是脏活,而这些脏活恰恰是 demo 和产品的分水岭。随手挑几个:心跳,但要聪明:每 15s 一次 worker/ping,5s 超时,连丢 2 次就回收进程。但是——有请求在飞的时候不 ping。因为在途请求本身就是最好的存活证明,这时候去探反而可能误杀一个正忙的健康 worker。重启带退避 抖动:base 300ms,封顶 30s,backoff backoff0.25random()。一个彻底坏掉的 worker(比如二进制过期了、需要重新 native:publish)不会把你 CPU 打满,而一个真实用户请求进来仍然会立刻 ensureStarted() 拉起,退避只管后台自愈的节奏。睡眠/唤醒:powerMonitor 一 suspend 就停心跳——因为系统睡着的时候定时器根本不 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% 在这种地方。反向请求:worker 不只是被调用,它还能反过来调宿主这是整套架构里我觉得最漂亮的一招,也是工具到底在哪跑这个问题的正确答案。先纠正一个容易想当然的说法:“工具全在 worker 里跑”——大方向对,但更精确的真相是:工具在谁拥有这个能力,就在谁那一层执行。机制是这样的:worker 需要一个它自己没有的能力时,发一个 agent/reverse-request { id, method, params } 反向请求,主进程的 handleReverseRequest 按 method 分诊:要操作系统 / 插件能力的(plugin:exec、notify:desktop、fs/request-system-access、cron/*)→ 路由回主进程执行;要UI能力的(browser/tool-request、ask-user/request、plan/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 的区别。工具系统:渲染进程只发菜单,厨房在 worker 里顺着上面说。渲染进程里那个 ToolRegistry,根本不是执行引擎,而是一个纯目录 / 元数据层。每个工具的 execute 实现体只是一句 stub——比如 fs-tool.ts 里直接返回 “ execution has migrated to .NET Native Worker.”。它在渲染进程只干三件事:给请求提供 ToolDefinition[](名字、描述、JSON schema);驱动系统提示词生成;声明每个工具的审批策略(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 个)。交互态是动态目录,后台态是白名单——运行时要为不同信任边界提供不同的能力面,这件事被明确地设计了进去,而不是一套工具走天下。会话不属于窗口这是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 不是靠特判关窗口才活下来的——它从一开始就没绑在窗口上。一套事件协议,喂给所有入口不管是前台交互、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,底层是同一条心跳。运行时和 UI 之间,背压是一等公民流式事件如果原样透传给渲染进程,一个多工具并发的 run 能把 IPC 打爆。主进程做自适应批处理:按 runId 把帧攒进 pendingStreamBatches,遇到终止事件、超过字节上限、或者攒够一小段时间就 flush。前台大约 33ms 一 flush(≈30fps,肉眼觉得实时),后台压到 150ms,还会合并可聚合的事件(比如连续的 text delta)。前台丝滑,后台不吵——一个正在后台默默跑的会话,不该抢着刷新你正看着的这个。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.cs 的 Main 干净到只有几行。顺带一个我很喜欢的细节:子 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 产品,我觉得可以抄的几点不吹了,给点能带走的:把进程边界画在运行时上。 让 loop 住在一个能比 UI 活得久、能被单独监管的地方。这一条几乎决定了你的可靠性天花板。把 agent loop 当长跑服务运维。 心跳、退避、崩溃日志、睡眠唤醒、崩溃时给在途 run 合成终止事件——你现在偷的每一个懒,都是第二个月线上要还的债。这部分代码不酷,但它才是护城河。用反向请求解耦能力,别把工具在每一层各抄一遍。 让运行时够到宿主的能力,而不是把宿主能力塞进运行时。审批、UI 工具、OS 操作,都能走同一条反向通道。会话别绑在窗口上。 把 run 锚在一个进程里、用 runId 寻址,UI 只是可拆卸的视图。切走 换水槽,不是停。一套事件词汇,喂所有入口。 交互、定时、插件、团队,别给每个界面 fork 一份 loop。一份协议,N 个消费者。运行时到 UI 之间的背压,别当事后优化。 前台 30fps、后台压到 150ms,源头节流 渲染端缓冲,一开始就该设计进去。能力协商 provider 分支。 让调用方问而不是猜,不支持就优雅降级。运行时的技术选型,按运维属性选,别按你熟不熟选。 对一个桌面产品,一个 AOT 原生 sidecar 换来的 ABI 解耦、崩溃隔离、快冷启动,是非常划算的一笔买卖——哪怕这意味着你团队里得有人写 C