本地Compact执行与写回
本地 compact 在模型返回摘要之后,还没有真正结束。Codex 必须把摘要结果变成新的 live history,推进 context window 身份,保存可用于 resume/fork 的 CompactedItem,重建 world-state 与 turn-context 基线,再按新历史估算 token。只有这些状态变化完成,后续模型请求才能稳定地看到压缩后的上下文。
本文面向已经读过 本地Compact提示词构造 的读者。前文讲 compact 请求和 replacement history 怎样构造;本文从 new_history 已经生成的位置继续,重点分析写回顺序、状态 所有者、持久化格式、事件可见性、hook 时机和恢复消费。远程 endpoint 如何产生 replacement history 不在本文范围。 读完后,读者应能判断一次 compact 到底停在“摘要生成”“内存替换”“rollout 落盘”还是“客户端完成事件”,并能 从恢复后的模型请求反查检查点是否被正确消费。
1. 提交对象
写回不是修改一个字段,而是同时更新五类状态。先区分它们的所有者和消费者:
| 状态 | 所有者 | 写入内容 | 主要消费者 |
|---|---|---|---|
| live history | SessionState.history | replacement history、reference context、world-state baseline | 下一次 Prompt |
| window state | AutoCompactWindow | number、UUID 链、一次性标志 | 请求 header、触发算法 |
| token state | ContextManager 的 TokenUsageInfo | 新 history 的估算 token | 窗口判断、UI token event |
| rollout | LiveThread | CompactedItem、WorldStateItem、TurnContextItem | resume、fork、分页恢复 |
| protocol event | Session.tx_event | item lifecycle、warning、兼容事件 | TUI、App Server、SDK |
这些状态不由一个数据库事务统一提交。Session 通过短时间持有 state 锁来替换内存状态,然后异步追加 rollout, 最后发送事件。因而理解调用顺序比只看 replace_compacted_history 的函数名更重要。
这张图表达的是依赖方向:恢复只能消费已经持久化的检查点,token 重算只能读取已经替换的 live history,完成事件 则位于两者之后。它不表示这些写入具有跨内存和文件系统的原子性。
2. 写回主线
本地执行函数先给摘要 item 补 turn id,再推进窗口;随后根据注入策略准备初始上下文与 reference context,调用 replace_compacted_history。替换返回后才重算 token、完成 ContextCompactionItem 并发送 warning。
源码位置:codex-rs/core/src/compact.rs :: run_compact_task_inner_impl
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
if let Some(summary_item) = new_history.last_mut() {
summary_item.set_turn_id_if_missing(&turn_context.sub_id);
}
let (window_number, window_ids) = sess.advance_auto_compact_window().await;
let (initial_context, world_state_baseline) =
build_compaction_initial_context(sess.as_ref(), &initial_context_injection).await;
if !initial_context.is_empty() {
new_history =
insert_initial_context_before_last_real_user_or_summary(new_history, initial_context);
}
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage { .. } => {
Some(turn_context.to_turn_context_item())
}
};
sess.replace_compacted_history(
new_history,
reference_context_item,
world_state_baseline,
CompactedHistoryMetadata {
message: summary_text,
window_number,
window_ids,
},
)
.await;
sess.recompute_token_usage(&turn_context).await;摘要 item 的 turn id 在进入 replace_compacted_history 前设置,是因为这条 replacement history 没有经过普通的 record_conversation_items。其余缺失 item id 则由 replace_compacted_history 集中分配,使 live history 和 persisted replacement history 使用相同标识。
图中的 replace_compacted_history 是语义提交边界:从它更新内存 history 开始,下一次请求已经可能看到新窗口。 rollout 追加发生在锁外,因此不能把整个时序理解为一个不可分割事务。
3. 窗口推进
advance_auto_compact_window 在拿到新 history 后、替换前执行。AutoCompactWindow::advance 单调增加 window_number,把当前 UUID 移到 previous_window_id,生成新的 UUIDv7,并清除一次性请求、reminder 和 fallback 状态。
源码位置:codex-rs/core/src/state/auto_compact_window.rs :: AutoCompactWindow::advance
pub(super) fn advance(&mut self) -> (u64, AutoCompactWindowIds) {
self.window_number = self.window_number.saturating_add(1);
self.ids.previous_window_id = Some(self.ids.window_id);
self.ids.window_id = Uuid::now_v7();
self.new_context_window_requested = false;
self.token_budget_reminder_delivered = false;
self.auto_compact_fallback_delivered = false;
(self.window_number, self.ids)
}first_window_id 不变,所以它标识整条 thread window 链;window_id 标识当前窗口; previous_window_id 让 persisted checkpoint 可以表达相邻关系。请求 header 使用的是 thread_id:window_number,而 CompactedItem 还保存 UUID 链,两者服务不同消费者。
这里存在一个重要失败边界:窗口先推进,后执行 history 替换。当前实现中的 replace_compacted_history 不返回 错误,rollout append 失败也只写日志,因此窗口推进不会因为磁盘持久化失败自动回滚。
4. 历史替换
replace_compacted_history 先为所有缺失 ID 的 ResponseItem 分配稳定 ID,再用同一个 items 同时构造 CompactedItem.replacement_history 和 live history。先分配、再 clone 是保证恢复后模型视图一致的关键。
源码位置:codex-rs/core/src/session/mod.rs :: Session::replace_compacted_history
let items = Self::assign_missing_response_item_ids(Cow::Owned(items)).into_owned();
let compacted_item = CompactedItem {
message: metadata.message,
replacement_history: Some(items.clone()),
window_number: Some(metadata.window_number),
first_window_id: Some(metadata.window_ids.first_window_id.to_string()),
previous_window_id: metadata
.window_ids
.previous_window_id
.map(|id| id.to_string()),
window_id: Some(metadata.window_ids.window_id.to_string()),
};真正替换发生在 SessionState 锁内。ContextManager::replace_annotated 更换完整 envelope 向量、增加 history_version 并清空旧 world-state baseline;SessionState::replace_annotated_history 继续写入新的 reference context, 同时清除旧窗口 prefill。
源码位置:codex-rs/core/src/context_manager/history.rs :: ContextManager::replace_annotated
pub(crate) fn replace_annotated(&mut self, items: Vec<ResponseItemEnvelope>) {
self.items = Arc::new(items);
self.history_version = self.history_version.saturating_add(1);
self.world_state_baseline = None;
}源码位置:codex-rs/core/src/state/session.rs :: SessionState::replace_annotated_history
pub(crate) fn replace_annotated_history(
&mut self,
items: Vec<ResponseItemEnvelope>,
reference_context_item: Option<TurnContextItem>,
) {
self.history.replace_annotated(items);
self.history
.set_reference_context_item(reference_context_item);
self.auto_compact_window.clear_prefill();
}清空 prefill 后,recompute_token_usage 会为新历史写入 estimated prefill;随后第一份服务器 usage 还可以用更准确的 server-observed input tokens 覆盖估算值。这避免旧窗口的 prefix baseline 泄漏到新窗口。
5. 基线提交
mid-turn compact 可能携带 world_state_baseline 和 reference_context_item。代码在同一次 state 锁内替换 history 并写 world-state snapshot,保证 live history 中的初始上下文与用于后续 diff 的 baseline 来自同一个捕获状态。
源码位置:codex-rs/core/src/session/mod.rs :: live baseline 安装
let mut world_state_item = None;
{
let mut state = self.state.lock().await;
state.replace_annotated_history(items, reference_context_item.clone());
if let Some(world_state) = world_state_baseline {
let snapshot = world_state.snapshot();
world_state_item = Some(WorldStateItem::full(snapshot.clone().into_value()));
state.history.set_world_state_baseline(snapshot);
}
}pre-turn/manual 使用 DoNotInject,所以这两个 baseline 都为空;下一次普通 turn 会重新建立完整上下文。mid-turn 则 立即安装 baseline,后续 continuation 可以基于它计算环境、权限等动态状态的增量。
这两条路径最终都能得到完整上下文,但生效时机不同:mid-turn 必须在同一 continuation 前准备好;pre-turn/manual 等待下一次正常 turn。这就是 reference_context_item 不能无条件保留旧值的原因。
6. Rollout顺序
内存锁释放后,Session 按固定顺序追加 rollout:先 CompactedItem,再可选的 full WorldStateItem,最后可选的 TurnContextItem。顺序本身带有恢复语义:replacement history 先建立新窗口,后续 baseline 记录解释这个窗口。
源码位置:codex-rs/core/src/session/mod.rs :: rollout 持久化顺序
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
.await;
if let Some(world_state_item) = world_state_item {
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
.await;
}
if let Some(turn_context_item) = reference_context_item {
self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)])
.await;
}
{
let mut state = self.state.lock().await;
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
}CompactedItem 不是只有摘要文本。新格式把完整 replacement history、窗口序号和 UUID 链一起保存;恢复逻辑可以 直接从最新 surviving checkpoint 开始,而不必重放它之前所有模型 item。
这张图把同一个检查点的两类消费者分开:replacement history 决定模型输入,window 元数据决定窗口身份和预算; 恢复扫描器再把 checkpoint 之后的新 item 接回前缀。摘要文本 message 主要供兼容和诊断使用,不替代 replacement_history。
源码位置:codex-rs/protocol/src/protocol.rs :: CompactedItem
pub struct CompactedItem {
pub message: String,
pub replacement_history: Option<Vec<ResponseItemEnvelope>>,
pub mcp_resource_origins: Option<McpResourceOriginCheckpoint>,
pub window_number: Option<u64>,
pub first_window_id: Option<String>,
pub previous_window_id: Option<String>,
pub window_id: Option<String>,
}persist_rollout_items 的错误策略也必须看清:append_items 失败只记录 error!,函数不向调用方返回错误。于是 “live history 已替换但 checkpoint 未成功持久化”在接口语义上是可能的;当前调用链不会因此回滚内存状态或把本次 compact 改判为失败。
源码位置:codex-rs/core/src/session/mod.rs :: Session::persist_rollout_items
pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) {
if let Some(live_thread) = self.live_thread()
&& let Err(e) = live_thread.append_items(items).await
{
error!("failed to record rollout items: {e:#}");
}
}7. 恢复消费
resume/fork 的重建器从 rollout 尾部向前扫描。看到带 replacement_history 的 CompactedItem 时,它把该历史记为 候选 base,并把更老的 rollout 排除在 suffix 之外;随后只正向重放 checkpoint 之后仍然存活的 item。
源码位置:codex-rs/core/src/session/rollout_reconstruction.rs :: reconstruct_history_from_rollout
RolloutItem::Compacted(compacted) => {
let active_segment =
active_segment.get_or_insert_with(ActiveReplaySegment::default);
active_segment.world_state_replay.push(item);
if active_segment.window.is_none()
&& let Some(window_number) = compacted.window_number
{
active_segment.window = Some(ReconstructedWindow {
number: window_number,
first_id: compacted.first_window_id.as_deref().and_then(parse_uuid_v7),
previous_id: compacted
.previous_window_id
.as_deref()
.and_then(parse_uuid_v7),
id: compacted.window_id.as_deref().and_then(parse_uuid_v7),
});
}
if active_segment.base_replacement_history.is_none()
&& let Some(replacement_history) = &compacted.replacement_history
{
active_segment.base_replacement_history = Some(replacement_history);
rollout_suffix = &rollout_items[index + 1..];
}
}正向物化时,base 先进入新的 ContextManager,然后才追加 suffix。这正是 compact 后、resume 后模型请求应共享同一 历史前缀的原因。
源码位置:codex-rs/core/src/session/rollout_reconstruction.rs :: replacement history 物化
let mut history = ContextManager::new();
if let Some(base_replacement_history) = base_replacement_history {
history.replace(base_replacement_history.to_vec());
}
for item in rollout_suffix {
match item {
RolloutItem::ResponseItem(response_item) => {
history.record_items(
std::iter::once(response_item),
turn_context.model_info.truncation_policy.into(),
);
}
// ...
}
}旧 rollout 可能没有 replacement_history 或 window_number。这种 legacy checkpoint 不能作为有界扫描的可靠截断 点,重建器会继续扫描到开头,并用摘要文本走兼容重建路径。新格式的性能和精确性不能外推到这些旧记录。
8. Token重算
compact 模型响应中的 token usage 描述的是“生成摘要这次请求”,不是新 history 的 active context 大小。因此 history 替换后必须重新估算。recompute_token_usage 使用 base instructions 加 replacement history 的近似 token 数,把它写入 last_token_usage.total_tokens,更新模型窗口,再建立新窗口的 estimated prefill。
源码位置:codex-rs/core/src/session/mod.rs :: Session::recompute_token_usage
let history = self.clone_history().await;
let base_instructions = self.get_base_instructions().await;
let Some(estimated_total_tokens) =
history.estimate_token_count_with_base_instructions(&base_instructions)
else {
return;
};
{
let mut state = self.state.lock().await;
let mut info = state.token_info().unwrap_or(TokenUsageInfo {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
model_context_window: None,
});
info.last_token_usage = TokenUsage {
input_tokens: 0,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: estimated_total_tokens.max(0),
codex_rollout_budget_units: None,
};
state.set_token_info(Some(info));
}重算最后会发送 token-count event。这里的 total_tokens 是 byte-based heuristic 得到的粗略下界,不是 provider tokenizer 的精确值;它用于新窗口初始判断,后续 server usage 才能提供观测值。
9. 事件顺序
ContextCompactionItem 在模型请求前发出 started,在 history 替换、rollout 追加和 token 重算后才发出 completed。started/completed 共用同一个 item id,TurnTimingState 用它关联时间戳。
源码位置:codex-rs/core/src/session/mod.rs :: emit_turn_item_started, emit_turn_item_completed
self.send_event(
turn_context,
EventMsg::ItemStarted(ItemStartedEvent {
thread_id: self.thread_id,
turn_id: turn_context.sub_id.clone(),
item: item.clone(),
started_at_ms,
}),
)
.await;源码位置:codex-rs/core/src/compact.rs :: 完成与 warning
sess.emit_turn_item_completed(&turn_context, compaction_item)
.await;
let warning = EventMsg::Warning(WarningEvent {
message: "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.".to_string(),
});
sess.send_event(&turn_context, warning).await;canonical completed event 还会通过 HasLegacyEvent 转换出 ContextCompacted,供尚未迁移到 TurnItem 的兼容 消费者使用。它不是第二次 compact,也不是另一个 state commit。
源码位置:codex-rs/protocol/src/legacy_events.rs :: ContextCompactionItem::as_legacy_event
impl ContextCompactionItem {
pub fn as_legacy_event(&self) -> EventMsg {
EventMsg::ContextCompacted(ContextCompactedEvent {})
}
}10. Hook边界
PreCompact 位于模型请求和任何写回之前;如果它返回支持的 stop,compact 以 TurnAborted 结束,不推进窗口。 PostCompact 则在 run_compact_task_inner_impl 已经完成后执行,此时 history、rollout、token、item completed 和 warning 都已经发生。
源码位置:codex-rs/core/src/compact.rs :: run_compact_task_inner
let result = run_compact_task_inner_impl(
Arc::clone(&sess),
Arc::clone(&turn_context),
input,
initial_context_injection,
compaction_metadata,
)
.await;
let status = compaction_status_from_result(&result);
let codex_error = result.as_ref().err();
if result.is_ok() {
let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await;
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
attempt
.track(sess.as_ref(), status, codex_error, CompactionAnalyticsDetails::default())
.await;
return Err(CodexErr::TurnAborted);
}
}因此 post-hook stop 的语义是阻止后续 continuation,不是撤销 compact。代码甚至使用内层 result 预先计算的 Completed status 写 compaction analytics,然后向外返回 TurnAborted。调试时若只看 task 终态,可能误以为 history 没有写回;应同时检查 CompactedItem 和 item lifecycle。
11. 写回测试
manual_compact_emits_context_compaction_items 构造普通 turn 和手动 compact 两个响应,持续读取事件直到同时看到 started、completed、legacy ContextCompacted 和 turn complete,并断言 started/completed 的 item id 相同。它 证明协议生命周期与兼容事件存在,不证明 rollout 文件已经可恢复。
源码位置:codex-rs/core/tests/suite/compact.rs :: manual_compact_emits_context_compaction_items
match event.msg {
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::ContextCompaction(item),
..
}) => started_item = Some(item),
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::ContextCompaction(item),
..
}) => completed_item = Some(item),
EventMsg::ContextCompacted(_) => legacy_event = true,
EventMsg::TurnComplete(_) => saw_turn_complete = true,
_ => {}
}
assert_eq!(started_item.id, completed_item.id);compact_resume_and_fork_preserve_model_history_view 依次执行 compact、普通 turn、shutdown、resume、fork,再比较每次 provider request 的 input。关键断言是 compact 后 input 同时成为 resume/fork input 的前缀,并逐项检查摘要和用户 消息顺序。它证明 persisted checkpoint 能恢复模型可见历史,不证明磁盘 append 失败时存在回滚。
window_id_advances_after_compact_persists_on_resume_and_resets_on_fork 检查请求 header:compact 请求仍在 generation 0,compact 后请求变为 1,resume 保持 1,fork 换 thread id 并回到 generation 0。该测试把窗口推进、持久化恢复 和 fork 重置三个边界放在同一条可观察链上。
12. 故障定位
遇到“compact 显示完成,但恢复后历史不对”时,按提交层次检查,而不是只搜索 summary 文本:
- 事件流是否有同 ID 的
ContextCompactionItemstarted/completed;没有 completed,先查模型请求或写回前错误。 - live history 是否已经出现带
SUMMARY_PREFIX的末尾 user message;有则内存替换已经发生。 - rollout 是否出现带
replacement_history、window_number和 UUID 链的CompactedItem;没有则检查failed to record rollout items日志。 - mid-turn 是否紧跟 full
WorldStateItem和TurnContextItem;缺失会影响恢复后的 diff baseline。 - 恢复请求是否以 replacement history 为前缀;若 live 正常而 resume 异常,入口应转向 rollout reconstruction。
可以运行以下测试分别验证事件、恢复历史和窗口身份:
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all \
manual_compact_emits_context_compaction_items
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all \
compact_resume_and_fork_preserve_model_history_view
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all \
window_id_advances_after_compact_persists_on_resume_and_resets_on_fork修改写回代码时,至少应重新回答两个不变量:live history 与 CompactedItem.replacement_history 是否仍共享同一组 item id;checkpoint、world-state baseline 和 turn-context baseline 的顺序是否仍允许恢复器从最新 compact 截断旧 历史。只让当前 session 继续运行并不足以证明实现正确,resume 和 fork 才是写回链的最终消费者。
