一个越来越常见的场景:桌面应用(Tauri / Electron)要在用户本机拉起长时运行的 Agent 任务——跑一个 Codex / Claude Code 式的编码代理,一跑几十分钟。然后用户合上笔记本、应用崩了、或者自动更新重启了。任务怎么办?
传统桌面应用没有这个问题,因为传统桌面操作是短的:点一下,几百毫秒内完成,UI 活着操作就活着。Agent 任务打破了这个假设——任务的生命周期第一次显著长于 UI 进程的生命周期,而桌面环境里没有 Kubernetes 帮你重新调度,没有消息队列帮你重投递。你要么自己把服务端那套可靠性机制搬到本地,要么接受”应用一关任务就死”。
于是问题变成:本地 Agent 运行时应该放在哪里?我最近在做这个决策,认真比较了三条路径。如果你只带走一句话,就带走这个:
进程边界是部署决策,不是架构决策。真正的架构决策是内核干不干净——内核干净了,“要不要独立进程”可以推迟到需要的那天再回答;内核不干净,独立进程也救不了你,只是把泥球搬进了 daemon。
下面先诚实地摆出三条路,再解释为什么这么排序。
三条路径
路径 C:Web/TS 主导派发,宿主进程只负责 spawn
先说最容易滑进去的一条,因为它是重力方向。
控制包解析、任务状态机、事件处理、恢复逻辑——全部放在前端 TypeScript 里;Tauri(或 Electron 主进程)退化成一个 spawn() 代理:前端说”帮我拉起这个进程”,它照做,把 stdout 管回来。
React/TS(编排 + 状态 + 恢复)
│ invoke("spawn", …)
Tauri Command(只是 spawn 代理)
│
本地 Codex 进程
优点:改动最快。前端工程师不用碰 Rust,迭代全在自己熟悉的领地里。
代价:业务编排长在了 renderer 里,而 renderer 是整个系统里生命周期最短、最不可靠的进程——用户切个路由、刷新一下、窗口关掉,编排状态就没了。恢复逻辑无处安放:应用重启后,“哪些任务还在跑、哪些要重连、哪些要补偿”这个问题需要有人在 UI 加载之前回答,但你的回答者本身要等 UI 加载。于是恢复变成”尽力而为”,状态散落在 localStorage、Zustand store 和后端进程的实际状态之间,三者随时可能互相矛盾。
这条路不是不能走——原型期、任务短、丢了无所谓的场景它完全够用。但如果目标是”干净、本地高可用”,它在结构上就给不了。
路径 B:独立 Local Runtime Daemon
直觉上”最正确”的一条:运行时做成独立守护进程,随系统启动,UI 只是它的一个客户端。Tauri 通过 IPC 提交任务、订阅事件、查询状态。UI 关了任务照跑,UI 崩了 daemon 不受影响。
Tauri UI ──IPC──▶ Runtime Daemon ──▶ 本地 Codex / 存储
(薄客户端) (独立生命周期)
优点:进程边界是真实的,解耦是物理的。这是服务端工程师最舒服的形态——它就是把微服务模式搬回了本机。
代价是一张很长的隐藏账单,每一项单看都不大,加起来是一个新的工程领域:
| 成本项 | 具体内容 |
|---|---|
| 安装与升级 | daemon 和 UI 版本不再原子一致,要处理版本偏斜、双向兼容、升级时机 |
| 生命周期管理 | 谁拉起 daemon?崩了谁重启?macOS launchd / Linux systemd / Windows Service 三套注册方式 |
| IPC 鉴权 | 本机 socket 也要防别的进程冒充客户端,token 怎么分发轮换 |
| 端口/socket 发现 | UI 怎么找到 daemon?固定端口冲突、unix socket 路径约定、握手协议 |
| 可观测性 | 日志不再在一个进程里,调试要跨进程对时间线 |
| 卸载残留 | 用户删了 App,daemon 还在跑——这是用户信任问题,不只是技术问题 |
关键的反思是:daemon 给你的是进程隔离,但你真正要的是两样别的东西——任务与 UI 生命周期解耦,以及崩溃后可恢复。 这两样都不需要独立进程才能拿到:任务本身(Codex 子进程)可以脱离父进程存活,状态可以持久化,重启后可以重连或接管。进程隔离只在一种情况下是刚需:任务必须在 UI 完全退出后继续被监督(不只是继续跑,还要有人看着它、处理它的事件)。多数桌面 Agent 场景没有这么强的要求——用户重新打开应用时恢复现场,就已经是很好的体验。
为一个还没出现的需求,提前支付整张运维账单,不划算。
路径 A:内嵌 Clean Runtime Kernel(推荐)
在宿主进程内部,建一个纯净的分层运行时:
Tauri Commands ← 传输层:只做参数解码和响应编码
→ Application Use Cases ← 用例层:submit_run / query_state / cancel …
→ Domain Runtime Kernel ← 领域核心:状态机、调度、恢复决策
→ Ports ← 接口(trait):ProcessPort / JournalPort / EventPort …
→ Adapters ← 实现:本地 Codex / 本地存储 / 事件回灌
规则只有一条,但必须是铁的:核心层(Use Cases + Kernel)不依赖 Tauri、不依赖 Codex、不依赖文件系统、不依赖 HTTP。 它只依赖自己定义的 Ports。Codex 是 ProcessPort 的一个实现,SQLite 是 JournalPort 的一个实现,Tauri event 是 EventPort 的一个实现——全部可替换,全部可以在测试里换成内存假件。
这就是六边形架构(Ports & Adapters)的标准做法,没有任何新发明。新的部分在于:在这个干净内核之上,把服务端的可靠性模式搬到本地单机,用四个机制提供”本地高可用”。
四个机制:桌面里的分布式系统
这四个机制没有一个是新的——它们分别对应 WAL、Transactional Outbox、Lease、Crash Recovery,全是分布式系统的老模式。有意思的是它们在单机桌面场景下的重新组合。
1. Run Journal(运行账本)。 每个任务的生命周期事件——创建、启动、子进程 PID、阶段变更、完成、失败——先写持久化账本,再执行动作。这是 Write-Ahead Log 的思路:账本是事实源,内存状态只是账本的缓存视图。任何时刻进程崩溃,账本里都有足够信息回答”这个任务当时进行到哪了”。
2. Event Outbox(事件发件箱)。 任务产生的事件(工具调用、输出、状态变更)不直接发给 UI,而是先落盘到 outbox 表,再由投递器发出并标记已投递。UI 不在线?事件在 outbox 里等着。UI 重连?从上次确认的位置续传。这是 Transactional Outbox 模式——服务端用它保证”数据库写入和消息发送的原子性”,本地用它保证”任务进展和 UI 展示最终一致,中间断连不丢事件”。
3. Lease(租约)。 每个运行中的任务由某个 kernel 实例持有租约,租约要续期。为什么单机也需要租约?因为单机也会出现”两个监督者”:应用升级时新旧进程短暂共存、崩溃后重启的进程遇到还活着的孤儿子进程。租约回答”现在谁拥有这个 run”——新进程启动后发现某个 run 的租约已过期,才有资格接管;租约还有效,就说明有别人在管,不要碰。
4. Startup Recovery(启动恢复)。 进程启动时的固定仪式:扫描 Journal,找出所有账面上”运行中”的任务,逐个核对现实——子进程还活着(PID 存在且校验通过)就重新接上事件流继续监督;死了就按策略标记失败或重试;账本和现实对不上的,以现实为准修正账本。恢复不是异常路径,是每次启动的常规路径。
这四件事合起来,任务就获得了独立于 UI 进程的生命:UI 关掉,Codex 子进程照跑;UI 重开,kernel 恢复现场、续传事件。没有 daemon,但拿到了 daemon 承诺的大部分东西。
为什么 A 优于 B:可推迟性是架构质量的试金石
现在可以展开开头那句话了。
比较 A 和 B,会发现真正难的工作——Journal、Outbox、租约、恢复——两条路都要做,一行都省不掉。daemon 不会因为自己是独立进程就免于崩溃,它同样需要账本和恢复。B 相对 A 的增量不是可靠性,而是那张运维账单:安装、升级、鉴权、发现、跨平台服务注册。
反过来,A 相对 B 缺的只有一样:进程边界。而如果内核是干净的,这个边界随时可以补——把 Kernel 提取到独立进程,Tauri 侧的 Use Cases 换成 IPC 客户端 adapter,Ports 合同原封不动。从 A 到 B 是一次搬家,不是一次重写。
这里有个通用的判断方法:一个架构决策如果可以被推迟,而且推迟的代价不随时间增长,就应该推迟。 进程边界恰好是这种决策——前提是内核干净。所以真正的架构投资应该花在”内核干净”上,而不是花在”进程边界”上。先做 B 而内核不干净的团队,最后得到的是一个带着 IPC 复杂度的分布式泥球,比单体泥球更难调试。
路径 C 的问题也可以用同一个透镜看:它不是”错在没有 daemon”,而是错在编排逻辑长在了最短命的进程里,且没有账本——它把两个该分离的东西(编排与 UI)焊死了,把一个该存在的东西(持久化事实源)省略了。之后想迁移到 A 或 B,几乎等于重写。
决策框架
| 你的情况 | 选择 |
|---|---|
| 原型期,任务短,丢了重跑无所谓 | C,别过度设计 |
| 任务长时运行,要求崩溃可恢复、重启可重连,团队规模有限 | A |
| 任务必须在 UI 完全退出后继续被监督(无人值守调度、多客户端共享一个运行时) | B,且先按 A 的分层把内核做干净再拆 |
| 已经在 C,想升级 | 直接奔 A:先立 Journal 和恢复,再把编排从前端搬进内核 |
一个隐含的顺序值得强调:B 的正确打开方式是先做 A。 干净内核 + 四个机制在进程内先跑起来、稳定下来,等”UI 退出后仍需监督”的需求真的出现,再把内核搬出去。届时你搬的是一个有账本、有恢复、有清晰合同的成熟内核——而不是一边发明daemon 运维、一边发明可靠性机制。
结语
桌面 Agent 运行时这个问题,表面上是”进程放哪”,实际上是老问题的新皮肤:领域核心与基础设施的分离(六边形架构,2005 年就写下了)加上单机场景下的可靠性模式(WAL、Outbox、Lease,分布式系统教科书内容)。新场景没有要求新发明,它只是要求你在一个以前不需要这些的地方——用户的笔记本上——认真地应用它们。
Agent 把长时运行、可中断、需恢复的工作负载带进了桌面进程,桌面开发也就继承了服务端的可靠性义务。好消息是模式都是现成的;坏消息是没有捷径——账本要写,恢复要做,租约要管。唯一真正可以省的,是那个你可能永远不需要的守护进程。