第 3 章:ContextManager:把历史变成可提示状态
阅读契约: 把本章当作 history ledger 的流向图。阅读时跟住 items 如何记录、归一化、裁剪并交给 provider,同时不破坏 replay 需要的协议形状。

第 2 章描述了 turn envelope。下一个问题在持久侧:一个 thread 累积的所有模型可见 items 该如何处理?Codex 不把历史当作不透明 transcript,而是用 ContextManager 作为 response items 的账本,按从旧到新排序,附带 token 信息、history 版本号,以及未来设置 diff 的 reference context baseline。
历史账本看似简单,实则做了不少事:只记录属于 API 历史的 items、对大型输出执行 truncation policy、保留 function-call 配对不变量、采样前剥离不支持的模态、估算 token 使用量、以及在 compaction 或 rollback 中能被替换。
读完本章,你应该把 ledger 理解为持久 thread 证据与 prompt-ready 模型输入之间的桥梁。
账本结构
核心结构很小:
| 字段 | 用途 |
|---|---|
items | 候选模型可见历史的 response items,从旧到新。 |
history_version | 历史被重写时递增的单调标记。 |
token_info | 最近一次 token 使用事实或估计。 |
reference_context_item | 注入 settings diff 时使用的 baseline turn context。 |
让人意外的字段是 reference context item。它说明历史不只是过去的对话,还是下一次 turn 决定要重新引入哪些运行时事实的 baseline。当 compaction 或 rollback 让这个 baseline 失效时,Codex 清除它,回退到完整重新注入。
这个四字段结构很容易记住,但第四个字段会改变整个对象的含义:四个字段中三个看起来普通,reference baseline 却让账本不再只是一个 vector。它是第 2 章 envelope 与第 4 章 diff fragments 之间的链接。
账本生命周期在代码里主要表现为 clone 与 mutate,但语义上很清楚:先记录足够原始的 items,再根据目标模型 normalize,再记录新证据。
记录是经过过滤的
record_items 接受按顺序到来的 items,只记录 API-message 类型。这层过滤至关重要。Rollout 里可能包含事件、UI 事实、token 计数、context checkpoint,这些不一定都属于下一次模型请求。账本只存那些应该参与 prompt history 的子集。
把 item 推入之前,manager 会按当前 truncation policy 处理。Tool output 是经典风险:它可能很大、近似二进制、或带图片。Codex 通过 truncation helper 把它们变成有界历史,避免一次命令耗尽整个上下文窗口。
模式如下:
// 伪代码 -- 说明经过过滤的账本写入。
for item in incomingItems:
if not modelHistoryItem(item):
continue
bounded = applyOutputPolicy(item, activeTruncation)
ledger.append(bounded)
这个抽象正确地把策略塑形过的证据写入历史,而不是 raw side effect。Raw side effect 仍可能在 rollout 或 UI 中存在,但模型可见账本保持有界。
Truncation policy 本身是一个由当前输出上限驱动的小型状态机。它也是后面章节能讨论”tool output 作为有预算的平面”的原因:Tool output 通过单一策略入口进入历史,而不是被每个调用点临时截断。
Normalization 保护不变量
Function call 与 function output 是配对的。删除其中一方不删另一方,会让 prompt 形状被模型 API 拒绝或误解。历史 manager 在丢弃最旧或最新 items 时把配对删除委托给 normalization helper。这是 remove_first_item 在需要时也会移除对应方的原因。
同样的思想出现在采样前。for_prompt 克隆 manager、做 normalization,再剥离不适合当前模型模态的 items。如果模型不支持图片,message 与 tool output 中的图片内容会被移除。原始账本仍能保留更丰富的历史,prompt projection 则尊重当前模型契约。
权衡很明显:Codex 选择对每个 provider 都安全的 prompt 形状,而不是最大保真度。如果某个 item 在当前模型下无法安全表示,是 projection 改变,而不是账本被破坏。
Token 估计天然粗糙
Manager 用 byte-based 启发式从 base instructions 与每条 item 估计 token。源码明确把它视为粗糙下界,而非 tokenizer 级精确计费。这是务实选择:跨 provider 与多模态做精确分词代价高且脆弱,Codex 只需要足够好的信号来驱动 compaction 阈值、UI 反馈和预算。
更好的理解是它是一个下限:从不承诺真实代价更低,只承诺不会超过其上限附近的邻居。这个性质让下游代码做出保守决策:
| 消费者 | 由估计驱动的决策 | 估计偏低的风险 |
|---|---|---|
| Compaction 阈值 | 触发采样前 compaction。 | Compaction 触发稍晚但不会过早。 |
| Skill 预算 | 决定是否截断描述。 | 略多于理想的材料溜进来。 |
| Memory write | 写入前截断 rollout payload。 | Memory 生成收到比平时更大的 payload。 |
| UI 展示 | 显示剩余上下文进度条。 | UI 报告偏少;用户感受到的余量比实际更宽。 |
模型响应报告 usage 时,精确 token 计数才到达。在那之前,估计让 runtime 不至于盲飞。
Rollback 与 Reference Baseline
Rollback 才让账本证明自己不只是 vector。删除最近 N 个用户 turn 必须保留 pre-user 材料、处理 no-op、尊重 assistant 之间的 inter-agent 边界,以及在剩余历史不再包含原 initial context bundle 时清空 reference context baseline。
最后这条行为很微妙。如果 Codex 继续 diff 一个其源文本已被移除的 baseline,未来的 turn 就可能省略关键上下文。清空 baseline 让下一次常规 turn 完整重新注入 context,而不是信任过期的 diff。
这条规则故意保守:拿不准就重新注入。它牺牲少量 token,换来不会在 baseline 已经失效时继续生成不完整 diff。
应用模式
- Prompt Ledger: 用结构化 items 存储模型可见历史;迁移时在写入时过滤非 prompt 事件;避免 UI 事件渗入模型历史。
- Normalize on Projection: 在构造 prompt view 时修复 provider-facing 不变量;迁移时先 clone 再 normalize;不要让 normalization 摧毁持久证据。
- Paired Deletion: 把 tool call 与 output 当作一个单元删除;迁移到任何请求/响应协议;避免截断后留下孤立协议帧。
- Baseline Clearing: 当 rollback 或 compaction 移除其来源时,让 diff baseline 失效;迁移时存显式 baseline 元信息;历史重写后不要继续使用旧 diff。
- Coarse Budget Signal: 用便宜估计做实时决策,可用时再用精确计数;迁移时使用保守阈值;不要把估计当作账单级真相。