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(variteration1;!hasIterationLimit||iterationrequestedMaxIterations;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 预热,IlcOptimizationPreferenceSpeed,JsonSerializerIsReflectionEnabledByDefaultfalse(全 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()拉起,退避只管后台自愈的节奏。睡眠/唤醒:powerMonitor一suspend就停心跳——因为系统睡着的时候定时器根本不 fire,不停的话醒来会数出一堆幻觉丢失。一resume立刻主动探一次,而不是傻等最坏 35s。死了要发讣告:worker 意外退出,disconnected事件一广播,上层就把它名下所有 run 判死刑并 fail 掉。更狠的是主进程会给每个 active run合成errorloop_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 }反向请求,主进程的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 的区别。4. 工具系统:渲染进程只发菜单,厨房在 worker 里顺着上面说。渲染进程里那个ToolRegistry,根本不是执行引擎,而是一个纯目录 / 元数据层。每个工具的execute实现体只是一句 stub——比如fs-tool.ts里直接返回Tool 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 个)。交互态是动态目录,后台态是白名单——运行时要为不同信任边界提供不同的能力面,这件事被明确地设计了进去,而不是一套工具走天下。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 直接吐槽了上一版实现:老版本挨个 idupdateMessage,窗口没加载到那条就静默丢消息——这个坑他们踩过并写进了注释,现在改成缺失的 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.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#。写 OpenCoWork 这套东西,最深的一个体感是:做一个能跑起来的 agent,一个下午;做一个用户三个月不重装、跨睡眠跨崩溃跨升级都还在的 agent,是另一门手艺。前者是算法,后者是工程。而运行时,就是那门工程活儿凝结的地方。