我们搞定了 Prompt Cache,流水线反而更慢了:Codex App-Server 的四个反直觉真相

你跟同事聊 Codex 的性能优化时,有没有听过这句话?
「你每次都 spawn 一个新的 codex exec 干嘛?直接跑个 app-server 复用线程啊,光 prompt cache 就值回票价了。」
听起来太对了,对到没人会去质疑。开一个常驻服务进程,预热几条线程,所有请求走同一个会话,cache 命中率拉满,token 消耗直接腰斩,对不对?
我们团队也是这样想的。然后我们测了四次。
结果是:prompt cache 最终打到了 86% 的命中率,但流水线比最朴素的基线更慢,原始 token 消耗反而多了 39%。
这篇文章讲的就是为什么,以及 app-server 到底是为谁设计的。
背景:两种运行模式#
Codex CLI 有两种运行模式,很多人分不清它们的适用场景:
codex exec:每次调用启动一个独立进程,执行完就退出。没有状态,没有记忆,每次都是「从头来过」。codex app-server --stdio:启动一个常驻服务进程,通过 JSON-RPC 协议维持长连接。可以创建线程、复用上下文、支持中断和恢复。
从表面上看,app-server 显然是更「高级」的选择。进程复用省启动时间,线程上下文省 token,还有一套完整的会话管理协议(thread/list、thread/resume、thread/fork、turn/interrupt)。
但表面正确不等于实际正确。
实验设计:公平对比#
我们的实验设计刻意保持了公平性。不搞连接池,不搞常驻守护进程。app-server 为每个 formation 私有启动、跑完所有 turn 后立即销毁,生命周期跟 codex exec 子进程一模一样。
这样对比的只有一件事:复用同一个线程到底能不能省钱省时间?
在正式测量之前,我们先写了一个探针程序去直连真实的 app-server,验证了八个关键行为,顺手挖出了三个文档里没写但会坑死你的细节:
cwd和sandboxPolicy是粘性参数。如果你不每个 turn 显式传一遍,turn 3 会悄悄继承 turn 2 的写权限。- token 用量上报在
thread/tokenUsage/updated事件上,不在turn/completed。等 turn 结束再去读,永远晚一步。 - 空线程不会被物化。一个从未跑过任何 turn 的线程,你无法恢复它。线程池预热是个陷阱。
然后探针跑了一个简单的两轮对话,turn 2 报告了 15,104 个缓存的输入 token。Cache 管用!赶紧上线!
那个 15,104 的数字让我们多跑了三轮测试才敢不相信它。
四轮测试:数据不会说谎#
同一个仓库,同一个需求,每次跑一个四阶段的真实 formation:
| 指标 | A: codex exec(基线) | B: app-server 天真版 | C: +统一 schema | D: +瘦身 prompt |
|---|---|---|---|---|
| 总耗时 | 204.4s | 137.5s | 205.1s | 218.0s |
| 进程数 | 4 | 1 | 1 | 1 |
| 原始总 token | 79,640 | 111,855 | 115,062 | 110,385 |
| 缓存命中率 | 0% | 0% | 66.4% | 69.7%(turn 2-4 达 85.8%) |
| 输出质量 | 通过 | 通过 | 通过 | 通过 |
B 轮:天真移植的惊喜与失落#
一个服务进程,一个线程,四个 turn。比基线快了 33%。但探针承诺的那个 cache 呢?每一轮都是 0%。
这个现象太反直觉了,我们专门做了「cache 尸检」。结论是:结构化输出的 JSON schema 被渲染到模型上下文中,位置在系统消息之前,它属于可缓存前缀的一部分。我们的四个阶段各自传了不同的 schema,所以每一个 turn 都让整个前缀失效。
对照实验证实了这一点:把所有 schema 改成同一个,cache 命中率立刻飙到 90.8%。探针的两轮对话能命中 cache,只是因为碰巧用了同一个 schema。真实 formation 永远做不到。
C 轮:cache 活了,速度死了#
我们设计了一个统一的信封 schema,四个阶段共用。每个阶段的校验逻辑移到客户端。Cache 终于活了:整体命中率 66%。
但耗时回到基线水平。更糟糕的是,原始 token 比基线多了 44%。因为线程历史会累积前面每个 turn 的全部内容,而我们的 prompt 里又嵌入了一遍前阶段的产出结果。同一份上下文,付了两遍钱。
D 轮:终极瘦身,还是输了#
把 prompt 里嵌入的前阶段结果删掉,让线程历史自己承载上下文。这是所有人想象中「复用线程」的终极形态。
token 输入量减少了 4.7%。就这么点。因为线程历史里本来就什么都有,你删掉的那份不过是副本。cache 命中率在 turn 2-4 阶段打到了 85.8%,但总耗时是四轮里最慢的。
一个残酷的结论浮出水面:cache 和速度在这套系统里从来没有同时出现过。 唯一快的那轮是零 cache。唯一便宜的那轮是最朴素的基线。
App-server 到底是做什么的?#
回头看 app-server 协议提供的那些能力:thread/list、thread/resume、thread/fork、turn/interrupt、细粒度的 delta 通知、实时的 token 用量更新。
这些不是批量加速器的接口。它们是交互式会话的后端:IDE 扩展、聊天面板。一个人看着流式输出,随时按停止、分支对话、打开昨天的会话继续聊。
模型推理的延迟两边一样(我们测出来 3.58s vs 3.64s per turn,没有实质差异)。常驻进程帮 headless 流水线省下的,只有大约 0.17 秒的进程启动时间。而它那个看起来最诱人的能力,那个记住一切的线程,恰恰是一个可审计的多智能体系统必须拒绝的:我们的溯源依据是 git commit 和结构化配置记录,不是某个模型对话记忆里的隐藏状态。一段会影响输出却不被版本控制的隐式上下文,不是 cache,是未记账状态。
对于 headless 的一次性任务,codex exec 不是「朴素」选项,它是为这个场景设计的选项。
源码阅读的意外收获#
整个折腾下来,最值回票价的反而是读源码时挖出来的几个坑:
exec resume会从当前调用的参数重建配置。 恢复一个 session 时如果忘了加--sandbox workspace-write,你的 coder 就会以只读模式悄悄干活,直到写操作开始报错。--ephemeral会话永远不注册到线程存储。 恢复一个 ephemeral 会话不是「偶尔会失败」,是设计上就不可能。这不是 bug,但文档不说。- 结构化输出的 schema 要求
required列出properties中的每一个 key,递归到所有层级。 漏一个可选属性,真实调用 HTTP 400,mock 测试全绿。Provider 侧的约束应该转成本地的红灯测试。 - 重连时的 chatter 会以 JSONL
error事件出现在 stdout。 如果你的存活监控把它们当心跳计数,一个断连的上游配一个健谈的客户端就永生不死。诊断信息和存活信号不是一回事。
总结#
四次测试,一个结论:在迁移到任何「看起来更高级」的方案之前,先做基准测试。当一个特性的核心卖点恰好是你架构的反模式时,没有任何基准测试能拯救它。基准测试的价值,是让你停止幻想它。
对于 headless 流水线里的 Codex 调用,codex exec 是对的。App-server 是给有屏幕前面的那个人用的。别搞反了。
本文参考自 We Got the Prompt Cache Working. Our Pipeline Got Slower.(terum, dev.to, 2026-07-25),经原创中文重写并补充了架构分析。