如果你只带走一句话:慢的不是模型,是你把三种生命周期焊死在了一起。 环境的天然生命周期是天,运行时是小时,一次对话轮次是分钟——而
codex exec --ephemeral把三者全部压缩到一次 turn 的生命周期里,每个任务都从绝对零度开始加热。
上一篇我把 codex exec 的参数表整理成了五个控制面,结论是它是一个认真为自动化设计的非交互入口。这一篇讲它的反面:当你把这个批处理入口当成高频交互后端时,会发生什么。
素材来自一个真实系统:一个 Tauri 桌面应用,把本地 Codex 作为 AI 执行引擎。每次用户触发任务,链路是:
Tauri (Rust)
→ 准备目录 / 日志 / prompt / output schema / MCP 配置
→ 生成 run-scoped CODEX_HOME(只写 MCP + hook,不继承全局 model 配置)
→ spawn Node harness
→ 物化执行 workspace(git clone + 依赖预热)
→ spawn codex exec --json --ephemeral --output-schema ...
→ 冷启动 1~3 个 MCP server(各是一个 Node 进程)
→ 每次工具调用前后各触发一个进程级 hook
→ 解析最终 schema 输出
用户的体感是”Codex 好慢”。但逐层测过之后你会发现,模型推理在总耗时里可能连一半都占不到。
一、固定成本解剖
把链路上每个环节的量级摆出来(本机实测 + 典型量级,非精确基准):
| 环节 | 量级 | 性质 |
|---|---|---|
| Node harness 进程启动 | ~100ms | 每 turn |
| codex 进程冷启动(读配置、初始化) | 300ms~1s | 每 turn |
| MCP server 冷启动 × 3(Node 进程,超时预算 30s/个) | 0.5s~3s/个 | 每 turn |
| 进程级 hook(PreToolUse + PostToolUse) | 50~150ms × 2 × 工具调用次数 | 每次工具调用 |
| git clone —depth 1(workspace 物化) | 秒级~分钟级 | 每个新 workspace |
| pnpm install —frozen-lockfile(依赖预热,同步阻塞) | 10s~120s | 每个新 workspace |
最后一行是大头,而且它藏着一个专门值得展开的反模式(见第三节)。
先记住这个结构性事实:这些成本全部发生在第一个模型 token 之前。 用户按下按钮到看见第一个事件(TTFE, time to first event),中间隔着 6 层与模型无关的固定开销。交互式产品的体感由 TTFE 决定,而这条链的 TTFE 下限就是几秒、上限是几分钟。
二、根因:三条生命周期
为什么会这样?因为这条链在设计时只考虑了一个目标——强隔离、可审计、可复现。run-scoped CODEX_HOME 保证配置不受污染,--ephemeral 保证会话不残留,workspace 每次物化保证环境干净。每一个选择单独看都是对的。
但它们合在一起,把三种天然生命周期完全不同的东西,焊死在了同一个进程生命周期上:
| 层 | 里面有什么 | 天然生命周期 | 被焊死后的生命周期 |
|---|---|---|---|
| 环境 | repo checkout、node_modules、MCP server 二进制 | 天/周(按内容哈希失效) | 一次 turn |
| 运行时 | codex 进程、auth、model 配置、MCP 连接 | 小时(应用存活期) | 一次 turn |
| 轮次 | 一个 prompt → 一个结构化输出 | 秒/分钟 | 一次 turn ✓ |
只有第三层的生命周期是配对的。前两层每个 turn 都在重付。
这个模式在基础设施史上反复出现过,解法也早就收敛了:
- CI 的进化:每次构建起一台冷 VM → 热池(warm pool)→ 常驻 runner。
- Serverless 的进化:冷启动 Lambda → provisioned concurrency → Firecracker 快照恢复。
- 数据库连接的进化:每个请求握手建连 → 连接池。
codex exec --ephemeral 就是那台”每次构建都重新装系统的 VM”。而 Codex 官方提供的 app-server(JSON-RPC 驱动,thread/turn 模型,进程常驻)就是它的 provisioned concurrency。这不是需要发明的架构,是需要认领的架构。
三、反模式一:per-run store 打败了内容寻址缓存
依赖预热那 10~120 秒里,有一个细节值得单独拎出来,因为它太典型了。
harness 为了隔离,把 pnpm 的 store 目录指到了每次执行的 workspace 里面:
npmCacheDir = path.join(executionWorkspace, '.npm-cache')
pnpmStoreDir = path.join(executionWorkspace, '.pnpm-store')
意图是好的:包管理器的副作用不要逃逸出执行沙箱。但 pnpm 的 store 本身就是一个全局内容寻址存储(CAS)——同一个包版本在磁盘上只存一份,install 时 hardlink 进项目。把 store 塞进 per-run workspace,等于每个新 workspace 都拿着一个空缓存去 registry 重新下载全部依赖。
隔离作用错了层。 需要隔离的是”写入项目的文件”(hardlink 本来就在 workspace 内),不是”内容寻址的只读缓存”。CAS 的键是内容哈希,天然免疫污染——隔离它只有成本没有收益。
这个错误的普遍形式是:为了隔离,把一个按内容寻址的共享缓存复制进了每个沙箱。 Docker layer cache、Nix store、Bazel action cache、git 的 --reference clone,全都是同一个道理:内容寻址的东西可以放心共享,隔离应该作用在可变状态上。
修复是一行配置:store 指回共享目录,install 从分钟级掉到秒级(纯 hardlink)。这是整条链上性价比最高的一个改动。
四、反模式二:双重观测
链路给 codex 挂了 5 个生命周期 hook:SessionStart、UserPromptSubmit、PreToolUse、PostToolUse、Stop。每个 hook 是一条 shell 命令——spawn 一个 Node 进程,把事件转发给桌面端,超时预算 5 秒。
也就是说,每次工具调用要额外付两次进程冷启动。一个 20 次工具调用的任务,光 hook 就要 spawn 40 个进程。
讽刺的是,同一个 harness 里已经有另一条观测通道了:它逐行解析 codex exec --json 的 stdout 事件流——用来检测”agent 起了一个不受管理的长进程”这类情况。工具调用的开始、结束、命令内容,事件流里全都有。
同样的信息,走了两条通道,其中一条按次付进程费。
这里的设计原则是把遥测和策略分开:
- 遥测(telemetry):只读、不阻断、允许滞后。应该搭事件流的便车——解析已经在流出的 JSONL,零额外进程。
- 策略(policy):需要”在动作发生前阻止它”的语义。这是 hook 进程唯一正当的用途——而且只有 PreToolUse 真正需要它。
把 4 个只做遥测的 hook 改成事件流消费,只保留需要阻断语义的那一个,长任务的累积开销直接消失。这个改动同样不需要等任何架构迁移。
五、深层障碍:审计性从哪里来
到这里,短期优化讲完了。但要迁移到常驻 runtime,先要拆掉一个心理障碍——当初选 --ephemeral 的理由是审计与可复现,daemon 化会不会丢掉这些?
不会。因为审计性的来源被搞错了。
事件溯源(event sourcing)领域有一个成熟结论:系统的可审计性来自 append-only 的事件日志,而不是来自执行者的短命。 银行不会为了让账目可信而在每笔交易后杀掉会计——它靠的是每笔交易都进不可篡改的流水。
映射到 agent 执行链:
- 每个 turn 的完整事件流(模型输出、工具调用、文件变更、退出状态)被捕获进执行档案 → 这就是审计线索,和执行进程活多久无关。
- 隔离应该作用在 workspace(文件系统)层——每个任务仍然有自己的执行目录、自己的 diff 边界。
- 进程短命唯一额外保证的是”配置不残留”——而这靠 thread 级别的显式生命周期管理同样能做到(一个任务一个 thread,绝不跨任务复用)。
想通这一点,常驻 daemon 就没有原则性障碍了:envelope(事件档案)不变、workspace 隔离不变、变的只是 transport。
六、目标架构:合约稳定,传输可插拔
于是长期架构自然就是六边形(ports & adapters)的标准形状——把”执行合约”和”执行传输”分开:
RequirementExecutionPackage / phaseRun / envelope ← 合约层,一行不动
│
ExecutorDriver (port)
│
┌───────────────┼───────────────────┐
ExecDriver AppServerDriver CloudDriver
(codex exec) (常驻 app-server, (云端长任务 /
CI、离线、 JSON-RPC 驱动 异步批处理)
降级路径) thread/turn)
- AppServerDriver 是本地交互的默认路径:进程常驻、MCP 连接复用、auth 复用、事件原生流式。TTFE 从”秒到分钟”掉到”百毫秒级”。
- ExecDriver 降级为 batch 路径:CI、离线调试、一次性脚本——正好回到上一篇讲的那个控制面的本职。
- 合约层完全不动:上层的编排、审批、质量门、档案全部照旧。这是整个迁移里最重要的纪律——如果迁移 transport 的同时还想”顺便”改合约,两件事的失败会互相污染。
Daemon 化真正难的 20%
常驻进程不是免费的,这五个问题必须在设计期回答,而不是上线后补:
- 状态泄漏:一个任务一个 thread,用完即弃。任何”复用 thread 省 token”的诱惑都是在拿隔离换小钱。
- 崩溃恢复:daemon 死在 turn 中间怎么办?turn ID 必须幂等,档案要能标记 interrupted,客户端重连后能判断”重发还是放弃”。
- 版本偏斜:桌面应用升级了 CLI,daemon 还持有旧二进制。连接时握手校验版本,不匹配就 drain + 重启。
- 并发与背压:多个任务并行时,thread 池要有上限和排队语义,否则 daemon 会把机器打满。
- 凭证刷新:短命进程每次读新鲜的 auth,长命进程必须处理 token 过期。
七、行动顺序
如果你的系统也长这样,我建议的顺序是——先测量,再摘低垂的果子,最后动架构:
- 埋点:在 spawn、workspace ready、MCP ready、首个模型事件、最终输出五个边界打点。定义两个 SLO:TTFE(首事件)和 TTFP(最终产物)。没有这两个数,后面所有优化都无法验收。
- 共享内容寻址缓存(第三节):一行配置,install 从分钟到秒。
- 遥测搬回事件流(第四节):删掉 4 个进程 hook,长任务累积开销归零。
- 配置继承:run-scoped 配置显式带上 model / reasoning 等全局设定,避免每次 run 用默认值跑出不一致的行为。
- POC 迁移一条链路:选最短的交互链(比如”生成待确认计划”)切到 app-server driver,对比埋点数据。数据好看再迁全量。
前四步不动架构,一两天的工作量,能把固定成本砍掉一半以上;第五步才是那个一劳永逸的答案。
结语
这个案例里没有任何一个决策是蠢的:强隔离是对的,可审计是对的,结构化输出是对的,连选 codex exec 本身在项目初期都是对的——它让团队用最少的代码拿到了一个能跑的执行链。
问题只在于没有随着调用频率的变化重新审视生命周期的绑定。batch 形态在低频时是简单性优势,在高频时变成按次付费的税。而识别的信号其实很清晰:当你发现自己在”每次执行”的路径上做”本该缓存”的事情——装依赖、克隆仓库、启动同一批 server、握同一次手——就是生命周期焊错了的时候。
判断归模型,物理归代码,而生命周期归架构。