Compact模型Fallback
本文承接 远程Compact-V2尝试与降级,研究“模型已经发生变化”之后 compact 的选择和结果一致性。这里有两个不同的 fallback:Session 启动时 requested model 不可用,模型目录选择 provider default;以及一次 turn 切换模型后,为旧模型执行 pre-turn compact,失败时改用当前模型重试。
本文只深入第二种 compaction fallback,并用第一种作为边界对照;不覆盖 provider 服务端的摘要质量或真实网络恢复时延。读者需要先理解 ContextWindow与模型限制 的窗口约束、远程Compact请求协议 的 request metadata 和 本地Compact执行与写回 的 replacement history 提交。读完后,读者应能判断一次模型切换是否会触发 compact、fallback context 是否会被创建、错误是否允许换模型,以及成功结果由哪个模型的上下文安装。
1. 两种回退
Session 启动回退发生在模型目录解析阶段;compaction fallback 发生在已有 turn 的 pre-turn compact 阶段。两者都可能出现“requested model”和“实际使用模型”不同,但所有者、触发时机和失败后状态完全不同。
图中 E 是 Session 初始化的模型选择结果,不是 compact 结果;J 才是本文的重点。模型 fallback 只有在 attempt 已经失败且错误分类允许时才发生,不能从“模型切换”直接推断一定会重试。
2. 启动回退
Session 创建时,allow_provider_model_fallback 作为 StartThreadOptions 的字段一路传入 SessionSpawnArgs,最终交给 ModelsManager::get_default_model。这一步决定 turn 的初始 model_info,还没有 history compact。
源码位置:codex-rs/core/src/session/mod.rs :: Session::spawn_internal
let model = models_manager
.get_default_model(
&config.model,
allow_provider_model_fallback,
refresh_strategy,
config.http_client_factory(),
)
.await;
if allow_provider_model_fallback
&& let Some(requested_model) = config.model.as_ref()
&& model != *requested_model
{
info!(
model_provider = %config.model_provider_id,
requested_model,
fallback_model = %model,
"replaced unavailable requested model with provider default"
);
}这个 fallback 可能改变 model_info 的 metadata,包括 context window、reasoning preset 和 compaction hash;后续 turn 会把实际模型写入 PreviousTurnSettings。它不生成 CompactionReason::ModelDownshift,也不调用 run_auto_compact。
3. 切换快照
每个已完成 turn 会保存当前模型和 comp_hash。下一个 turn 进入 maybe_run_previous_model_inline_compact 时,先把上一 turn 的模型重新解析成 previous_model_turn_context,再与当前模型的 turn context 比较。
源码位置:codex-rs/core/src/session/turn.rs :: maybe_run_previous_model_inline_compact
let Some(previous_turn_settings) = sess.previous_turn_settings().await else {
return Ok(());
};
let should_compact_for_comp_hash_change = comp_hash_changed(
previous_turn_settings.comp_hash.as_deref(),
turn_context.model_info.comp_hash.as_deref(),
);
let previous_model = previous_turn_settings.model;
let previous_model_turn_context = Arc::new(
turn_context
.with_model(previous_model.clone(), &sess.services.models_manager)
.await,
);这段代码产生一个请求级旧模型快照,而不是修改当前 turn 的模型。compact 的第一 attempt 使用旧模型快照;如果成功,摘要仍然是按照旧模型的 compaction 兼容性和窗口边界生成的。
两个 StepContext 都是独立请求快照:旧模型快照服务第一次 compact,当前模型快照只在分类允许时消费。它们不共享可变的 model metadata,因此 fallback 不会在原 attempt 中途替换模型字段。
4. Hash门控
comp_hash_changed 只有在两个 hash 都存在且值不同才返回 true。缺失 hash 不足以证明模型的 compact 语义发生变化,因此不会仅凭一个 None 触发“旧模型压缩”。
源码位置:codex-rs/core/src/session/turn.rs :: comp_hash_changed
fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool {
previous
.zip(current)
.is_some_and(|(previous, current)| previous != current)
}hash 变化时,代码为旧模型捕获 StepContext,并把当前模型的 fallback context 一并准备好。
源码位置:codex-rs/core/src/session/turn.rs :: maybe_run_previous_model_inline_compact
if should_compact_for_comp_hash_change {
let step_context = sess
.capture_step_context(Arc::clone(&previous_model_turn_context), cancellation_token)
.await?;
let fallback_step_context = capture_current_model_fallback_step_context(
sess,
turn_context,
previous_model.as_str(),
cancellation_token,
)
.await?;
run_auto_compact(
sess,
step_context,
fallback_step_context,
client_session,
InitialContextInjection::DoNotInject,
CompactionReason::CompHashChanged,
CompactionPhase::PreTurn,
)
.await?;
return Ok(());
}DoNotInject 是因为这是 pre-turn compact;无论第一次使用旧模型还是 fallback 使用当前模型,安装后下一次普通 turn 都会重新建立当前模型所需的初始上下文。
5. 窗口门控
hash 没有变化时,模型切换仍可能因为上下文窗口变小而需要 compact。代码同时比较旧模型窗口、当前模型窗口和当前 token 使用量,并要求旧窗口确实大于新窗口。
源码位置:codex-rs/core/src/session/turn.rs :: maybe_run_previous_model_inline_compact
let active_context_tokens = sess.get_total_token_usage().await;
let previous_model_limit_reached = match turn_context
.config
.model_auto_compact_token_limit_scope
{
AutoCompactTokenLimitScope::Total => {
let new_auto_compact_limit = turn_context
.model_info
.auto_compact_token_limit()
.unwrap_or(i64::MAX);
active_context_tokens > new_auto_compact_limit
|| active_context_tokens >= new_context_window
}
AutoCompactTokenLimitScope::BodyAfterPrefix => active_context_tokens >= new_context_window,
};
let should_run = previous_model_limit_reached
&& previous_model_turn_context.model_info.slug != turn_context.model_info.slug
&& old_context_window > new_context_window;Total 会把自动 compact token limit 与完整窗口都纳入判断;BodyAfterPrefix 只比较当前窗口。这里的 token 是 session 的估算/观测状态,不是根据模型名称猜测。只要任一条件不满足,就不会为了模型切换额外执行 compact。
6. 后端门控
capture_current_model_fallback_step_context 还会检查认证后端、provider 类型和模型是否真的不同。当前实现只允许 Codex backend、OpenAI provider,且 previous model 与 current model 不相同。
源码位置:codex-rs/core/src/session/turn.rs :: capture_current_model_fallback_step_context
let uses_codex_backend = turn_context
.auth_manager
.as_deref()
.is_some_and(codex_login::AuthManager::current_auth_uses_codex_backend);
if !uses_codex_backend
|| !turn_context.provider.info().is_openai()
|| previous_model == turn_context.model_info.slug
{
return Ok(None);
}
sess.capture_step_context(Arc::clone(turn_context), cancellation_token)
.await
.map(Some)这解释了一个常见误判:即使错误属于可 fallback 类别,如果当前 provider 不是 OpenAI,或者 auth 不使用 Codex backend,上层也拿不到 fallback_step_context,最终只能返回原错误。
7. 错误分类
模型 fallback 不是对所有错误的通用重试。should_retry_with_current_model 只接受可能由模型或模型容量引起的错误:invalid request、unexpected status、context window exceeded、usage limit、server overloaded、internal server error 和 retry limit。
这个判断函数只回答“是否允许换当前模型再试”,不负责决定 fallback 模型是谁,也不负责记录 fallback 结果。 后者由调用方传入的 fallback StepContext 和 record_model_fallback telemetry 完成。V2 与 v1 都复用同一 错误分类,但各自的 attempt 类型和 CompactionImplementation 标签不同。
源码位置:codex-rs/core/src/compact_model_fallback.rs :: should_retry_with_current_model
pub(crate) fn should_retry_with_current_model(error: &CodexErr) -> bool {
matches!(
error.details(),
CodexErrorDetails::InvalidRequest(_)
| CodexErrorDetails::UnexpectedStatus(_)
| CodexErrorDetails::ContextWindowExceeded
| CodexErrorDetails::UsageLimitReached(_)
| CodexErrorDetails::ServerOverloaded
| CodexErrorDetails::InternalServerError
| CodexErrorDetails::RetryLimit(_)
)
}TurnAborted、本地构造错误和未知错误不在集合中。尤其是取消:它表达的是控制面停止当前操作,不应被改写成“旧模型不可用”,否则会在用户已经取消后额外发起一个 fallback 请求。
8. 结果一致性
旧模型 attempt 和当前模型 fallback 都调用同一个 run_remote_compact_*_attempt,区别只在 StepContext。成功后上层使用实际成功 attempt 对应的 compaction_turn_context,并据此写入 token、reference context 和完成事件。
源码位置:codex-rs/core/src/compact_remote.rs :: run_remote_compact_task_inner_impl
let (attempt, compaction_turn_context) = match attempt {
Ok(attempt) => (attempt, turn_context),
Err(error) => {
let Some(fallback_step_context) = fallback_step_context else {
return Err(error);
};
if !should_retry_with_current_model(&error) {
return Err(error);
}
let fallback_turn_context = &fallback_step_context.turn;
let fallback_result = run_remote_compact_attempt(
sess,
fallback_step_context,
turn_state,
&fallback_compaction_trace,
compaction_metadata,
analytics_details,
)
.await;
match fallback_result {
Ok(attempt) => (attempt, fallback_turn_context),
Err(_) => return Err(error),
}
}
};如果 fallback 失败,函数返回第一次错误,而不是第二次错误。这保留了原始失败的诊断语义;record_model_fallback 另行用 telemetry 记录 fallback 是否失败。
图中的“使用成功 attempt”是结果一致性的核心:不能使用旧模型的 TurnContext 去重算 fallback 模型生成的 token,也不能在第一次失败时先推进 window。真正提交发生在 attempt 选择完成之后。
9. 遥测边界
fallback 计数只记录“发生过一次 fallback 尝试”,并以 outcome 区分成功与失败;它不是模型质量评分,也不表示第二次请求一定比第一次更好。
源码位置:codex-rs/core/src/compact_model_fallback.rs :: record_model_fallback
let outcome = if fallback_error.is_none() {
"succeeded"
} else {
"failed"
};
session_telemetry.counter(
"codex.compaction.model_fallback",
/*inc*/ 1,
&[
("reason", reason_tag),
("implementation", implementation_tag),
("outcome", outcome),
],
);
warn!(
previous_model,
current_model,
?reason,
?implementation,
outcome,
?fallback_error,
"previous-model compaction failed; retried with current model"
);reason 能区分 comp_hash_changed、model_downshift 和用户/窗口触发;implementation 能区分本地 Responses、V2 和 v1 compact。读 telemetry 时必须同时看这两个标签,否则会把不同 compact 后端的 fallback 混为一类。
10. 提交边界
fallback 选择完成后,V1、V2 和本地 compact 进入各自 attempt 的成功写回路径。失败分类、fallback telemetry 和 error event 可以已经发生,但只要 replace_compacted_history 尚未调用,旧 history 仍然是 live 状态。
11. Fallback测试
| 测试或源码断言 | 输入 | 断言 | 覆盖范围 | 未覆盖边界 |
|---|---|---|---|---|
comp_hash_changed | 两个 hash、缺失 hash、相同 hash、不同 hash | 只有双方存在且不同才触发 | hash 门控 | provider 如何生成 hash |
remote_compact_v2_retries_failures_with_stream_retry_budget | 首次 500、流失败、随后成功 | 流重试后的最终摘要才进入 follow-up | attempt 输出隔离 | 模型切换 fallback |
remote_compact_v2_reuses_compaction_trigger_for_followups | V2 开启、模型请求后 compact | compaction metadata、window 和 replacement item 保持一致 | V2 写回消费 | fallback 模型质量 |
record_model_fallback | reason、implementation、成功/失败 error | telemetry 标签和 outcome 正确 | 观测记录 | 指标上报链路之外的 UI 显示 |
capture_current_model_fallback_step_context | Codex/OpenAI、其他 provider、相同模型 | 只有允许的 backend/provider/模型组合产生 fallback context | fallback 前置条件 | 真实认证刷新 |
这些测试和源码路径可以说明“何时选择 fallback”和“失败结果不提交”,不能说明 fallback 摘要与旧模型摘要语义等价,也不能说明 provider 的限流、服务过载或取消会按某个具体延迟恢复。
12. 源码练习
- 为
comp_hash_changed写四组表格测试:None/None、Some/None、相同值和不同值,说明为什么缺失 hash 不能直接代表不兼容。 - 在
capture_current_model_fallback_step_context中临时把 provider 条件去掉,运行非 OpenAI provider fixture,观察它如何改变 fallback 请求边界。 - 让旧模型返回
ContextWindowExceeded,让当前模型返回有效 compaction,检查 follow-up 使用哪个window_id、摘要 item 和 token context。 - 让 fallback 返回
TurnAborted,确认上层返回第一次模型错误还是取消错误,并检查是否出现第二个网络请求;这能帮助你区分错误保留与控制面中断。
13. 可执行验证
在本版本源码对应的 codex-rs workspace 中,先运行 compaction 的本地门控测试,再运行请求级 V2 测试。测试汇总 入口必须报告实际运行数量;显示 0 tests 只能说明过滤器没有命中。
cd codex-rs
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib session::turn::tests -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib compact_remote_v2::tests -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all remote_compact_v2_retries_failures_with_stream_retry_budget -- --nocapture第一条命令覆盖 turn 级模型切换辅助逻辑(若该模块未导出测试则以实际 target 输出为准);第二条验证 V2 输出和保留规则;第三条验证请求级 retry。它们不替代真实 provider fallback、认证刷新和取消资源回收测试。
