ContextWindow与模型限制
Codex 判断“上下文还装得下吗”时不会只读取一个 context_window。模型元数据可能同时提供 context_window 和 max_context_window;本地配置可以覆盖前者,但不能超过后者;Turn 再按 effective_context_window_percent 折算出实际使用的硬上限;自动压缩还有独立水位,默认是原始窗口的 90%。当模型切换时,新 Turn 会立即采用新模型的有效窗口,必要时还会在正式采样前用旧模型压缩历史。
本文面向已经理解 ContextHistory读写 和 Runtime提醒体系 的读者。本文只解决窗口大小的来源、覆盖、 保留空间、作用域和模型切换,不展开 token 用量怎样累计,也不展开 compaction 摘要怎样生成;这些分别由 后续预算和压缩文章负责。读完后应能从模型 catalog 或配置值算出一个 Turn 的有效硬上限与自动压缩水位, 并能解释模型降档、未知窗口和服务端超限时为什么走不同路径。
1. 四层窗口
先区分四个经常被统称为“上下文窗口”的数字。
| 层级 | 典型字段/函数 | 含义 | 主要消费者 |
|---|---|---|---|
| 模型声明 | context_window、max_context_window | catalog 提供的当前值与覆盖上限 | ModelsManager |
| 解析窗口 | resolved_context_window() | 优先当前值,缺失时回退最大值 | Turn、压缩水位 |
| 有效硬上限 | TurnContext::model_context_window() | 解析窗口乘有效百分比 | 请求状态、硬 cap、事件 |
| 自动压缩水位 | auto_compact_token_limit() | 显式值与原始窗口 90% 的较小值 | pre/mid-turn compaction |
“有效硬上限”和“自动压缩水位”不能互换。前者保护模型请求绝不超过可用窗口,后者让系统更早开始 压缩,留下输出、工具和 fallback 所需空间。
2. 模型元数据
ModelInfo 保存窗口、最大覆盖值、压缩水位和有效百分比。context_window 可以理解为当前模型配置声明的 窗口;max_context_window 是允许本地覆盖到的上界,不是每个 Turn 默认都使用的值。
源码位置:codex-rs/protocol/src/openai_models.rs :: ModelInfo 窗口字段。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<i64>,
/// Maximum context window allowed for config overrides.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_context_window: Option<i64>,
/// Token threshold for automatic compaction. When omitted, core derives it
/// from `context_window` (90%). When provided, core clamps it to 90% of the
/// context window when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_compact_token_limit: Option<i64>,
/// Opaque identifier for compaction-compatible model configurations.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comp_hash: Option<String>,
/// Percentage of the context window considered usable for inputs, after
/// reserving headroom for system prompts, tool overhead, and model output.
#[serde(default = "default_effective_context_window_percent")]
pub effective_context_window_percent: i64,字段默认反序列化值是 95,而不是 100。
源码位置:codex-rs/protocol/src/openai_models.rs :: default_effective_context_window_percent。
const fn default_effective_context_window_percent() -> i64 {
95
}这 5% 是模型输入可用空间之外的保留区。源码注释给出的用途包括 system prompt、工具开销和模型输出; 它不是精确的服务端 tokenizer 证明,而是模型元数据控制的工程保留比例。
3. 配置覆盖
本地 model_context_window 覆盖在 ModelsManager 解析模型信息时生效。如果模型声明了 max_context_window,覆盖值会被 min 截断;没有最大值时,配置值直接成为新的 context_window。
源码位置:codex-rs/models-manager/src/model_info.rs :: with_config_overrides。
pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) -> ModelInfo {
if let Some(context_window) = config.model_context_window {
model.context_window = Some(
model
.max_context_window
.map_or(context_window, |max_context_window| {
context_window.min(max_context_window)
}),
);
}
if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit {
model.auto_compact_token_limit = Some(auto_compact_token_limit);
}
if let Some(token_limit) = config.tool_output_token_limit {
model.truncation_policy = match model.truncation_policy.mode {
TruncationMode::Bytes => {
let byte_limit =
i64::try_from(approx_bytes_for_tokens(token_limit)).unwrap_or(i64::MAX);
TruncationPolicyConfig::bytes(byte_limit)
}
TruncationMode::Tokens => {
let limit = i64::try_from(token_limit).unwrap_or(i64::MAX);
TruncationPolicyConfig::tokens(limit)
}
};
}窗口覆盖与工具输出截断同在模型解析层,但它们不是同一限制:前者约束整个模型上下文,后者只决定单个 工具输出进入历史前怎样截断。不能用 tool_output_token_limit 推断模型窗口。
例如 catalog 给出 context_window=273000、max_context_window=400000,配置请求 500000,最终解析窗口是 400000;随后有效百分比若为 95,Turn 硬上限是 380000,而不是 400000 或 500000。
4. 解析顺序
没有配置覆盖时,resolved_context_window 优先使用 context_window。只有当前值缺失时才回退 max_context_window。
源码位置:codex-rs/protocol/src/openai_models.rs :: resolved_context_window、auto_compact_token_limit。
impl ModelInfo {
pub fn resolved_context_window(&self) -> Option<i64> {
self.context_window.or(self.max_context_window)
}
pub fn auto_compact_token_limit(&self) -> Option<i64> {
let context_limit = self
.resolved_context_window()
.map(|context_window| (context_window * 9) / 10);
let config_limit = self.auto_compact_token_limit;
if let Some(context_limit) = context_limit {
return Some(
config_limit.map_or(context_limit, |limit| std::cmp::min(limit, context_limit)),
);
}
config_limit
}这里形成三种情况:
| 解析窗口 | 显式压缩水位 | auto_compact_token_limit() |
|---|---|---|
| 100000 | 无 | 90000 |
| 100000 | 70000 | 70000 |
| 100000 | 120000 | 90000 |
| 未知 | 70000 | 70000 |
| 未知 | 无 | None |
当窗口未知但配置了显式压缩水位时,系统仍能按 70000 触发自动压缩;但没有 full context hard cap,不能 声称模型真实窗口就是 70000。
5. 有效上限
TurnContext 持有已经解析并应用配置的 ModelInfo。它把原始解析窗口乘以 effective_context_window_percent,得到本 Turn 对外报告和硬限制使用的窗口。
源码位置:codex-rs/core/src/session/turn_context.rs :: model_context_window。
pub(crate) fn model_context_window(&self) -> Option<i64> {
let effective_context_window_percent = self.model_info.effective_context_window_percent;
self.model_info
.resolved_context_window()
.map(|context_window| {
context_window.saturating_mul(effective_context_window_percent) / 100
})
}使用 saturating_mul 避免异常大值乘法溢出,整数除法向下取整。这个函数不读取原始 Config 再覆盖一次, 因为覆盖已经发生在 ModelsManager 产生 model_info 时。
窗口字段的所有者和消费者可以画成一条更窄的责任链:catalog 不负责触发压缩,ModelsManager 不负责计算 Turn 的有效百分比,ContextWindowTokenStatus 也不重新解析配置。每一层只消费上一层已经确定的值。
6. 作用域选择
自动压缩并不总是把全部 active context 计入水位。AutoCompactTokenLimitScope::Total 直接使用完整 active context;BodyAfterPrefix 则从当前 compaction window 的 prefill baseline 中扣除已携带前缀,只计算 窗口建立后新增的增长。
相关源码:
codex-rs/protocol/src/config_types.rs :: AutoCompactTokenLimitScopecodex-rs/core/src/session/context_window.rs :: context_window_token_status
pub enum AutoCompactTokenLimitScope {
/// Count the full active context against the limit.
#[default]
Total,
/// Count sampled output and later growth after the carried window prefix.
BodyAfterPrefix,
}源码位置:codex-rs/core/src/session/context_window.rs :: scope calculation。
let (auto_compact_scope_tokens, auto_compact_scope_limit, auto_compact_window_prefill_tokens) =
match turn_context.config.model_auto_compact_token_limit_scope {
AutoCompactTokenLimitScope::Total => (
active_context_tokens,
turn_context.model_info.auto_compact_token_limit(),
None,
),
AutoCompactTokenLimitScope::BodyAfterPrefix => {
let window = sess.auto_compact_window_snapshot().await;
let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens);
let scope_limit = turn_context
.config
.model_auto_compact_token_limit
.or_else(|| turn_context.model_info.auto_compact_token_limit());
(
active_context_tokens.saturating_sub(baseline),
scope_limit,
window.prefill_input_tokens,
)
}
};BodyAfterPrefix 的 baseline 不是“本次增长”,而是当前窗口的绝对 input-token 基线。它可以来自服务端 usage 的第一条 input token,也可以来自估算值;服务端观测值会替换估算 baseline。这样 compaction 后重新 计算时,前缀不会被重复计入,但新生成的 output 和后续历史增长仍会计入。
当前窗口身份由 Session::current_window_id 组合为 thread_id:window_number,而 Responses metadata 还携带 first_window_id、previous_window_id 和 UUID window_id。窗口编号推进与历史 replacement 同步发生在 start_new_context_window,不是由模型请求临时计算;因此提醒、metadata、compact 和恢复可以引用同一窗口 身份。
源码位置:codex-rs/core/src/session/mod.rs :: current_window_id / advance_auto_compact_window
let window_number = state.auto_compact_window_number();
let context_window_id = state.auto_compact_window_ids().window_id;
(format!("{thread_id}:{window_number}"), context_window_id)7. 硬顶与软水位
窗口状态同时计算剩余 token、自动压缩水位和 full context hard cap。fallback buffer 只有在配置了 fallback prompt 时才会加入自动压缩水位;full context hard cap 不受这个 buffer 影响。
源码位置:codex-rs/core/src/session/context_window.rs :: ContextWindowTokenStatus。
let full_context_window_limit = turn_context.model_context_window();
let base_window_tokens_remaining = [
tokens_remaining(auto_compact_scope_limit, auto_compact_scope_tokens),
tokens_remaining(full_context_window_limit, active_context_tokens),
]
.into_iter()
.flatten()
.min();
let auto_compact_fallback_buffer_tokens = turn_context
.config
.token_budget
.as_ref()
.map_or(0, crate::config::TokenBudgetConfig::fallback_buffer_tokens);
let buffered_auto_compact_limit = auto_compact_scope_limit
.map(|limit| limit.saturating_add(auto_compact_fallback_buffer_tokens));
let full_context_window_limit_reached =
full_context_window_limit.is_some_and(|limit| active_context_tokens >= limit);
let token_limit_reached = buffered_auto_compact_limit
.is_some_and(|limit| auto_compact_scope_tokens >= limit)
|| full_context_window_limit_reached;base_window_tokens_remaining 取 scope 水位和 full hard cap 剩余量中的较小值,所以 TokenBudget reminder 不会因为选择了 BodyAfterPrefix 就忽略真实窗口顶。token_limit_reached 则是两个条件的或:软水位达到, 或者 active context 到达有效硬上限。
8. 采样后决策
窗口限制是在一次模型采样完成后重新计算的。run_turn 先收集 pending input 和 token status,再决定是否 进入下一步或执行 mid-turn compaction。
源码位置:codex-rs/core/src/session/turn.rs :: post-sampling token decision。
let (has_pending_input, token_status) = async {
let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await;
let token_status = super::context_window::context_window_token_status(
sess.as_ref(),
turn_context.as_ref(),
)
.await;
(has_pending_input, token_status)
}
.await;
let needs_follow_up = model_needs_follow_up || has_pending_input;
let token_limit_reached = token_status.token_limit_reached;
let should_roll_over =
needs_follow_up && (sess.take_new_context_window_request().await || token_limit_reached);
let allow_auto_compact_fallback = !should_roll_over && !token_limit_reached;
super::token_budget::maybe_record(
sess.as_ref(),
turn_context.as_ref(),
token_status.base_window_tokens_remaining,
allow_auto_compact_fallback,
)
.await;
if should_roll_over {
run_auto_compact(
&sess,
Arc::clone(&step_context),
/*fallback_step_context*/ None,
&mut client_session,
InitialContextInjection::BeforeLastUserMessage {
world_state: Arc::clone(&world_state),
step_context: Arc::clone(&step_context),
},
CompactionReason::ContextLimit,
CompactionPhase::MidTurn,
)
.await?;
}这段判断发生在一次采样已经结束之后,因此“达到限制”与“立刻压缩”不是同义词。模型仍需要 follow-up 或队列中有输入时才会 rollover;已结束的 Turn 把处理留给下一次 pre-turn。
关键条件是 needs_follow_up。如果模型已经结束 Turn,哪怕 token limit reached,也不会在当前 Turn 再强行 启动 mid-turn compaction;下一次真正需要采样时由 pre-turn 路径处理。反过来,模型要求继续或队列有输入时, 达到限制才会 rollover。
9. 模型降档
模型切换会改变 TurnContext::model_info,但旧历史不会自动适配更小窗口。正式采样前,Codex 先比较旧模型 与新模型的 compaction hash 和窗口。如果 hash 同时存在且变化,直接按 CompHashChanged 压缩;如果窗口 变小且当前使用量超过新模型限制,则按 ModelDownshift 压缩。
源码位置:codex-rs/core/src/session/turn.rs :: maybe_run_previous_model_inline_compact。
fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool {
previous
.zip(current)
.is_some_and(|(previous, current)| previous != current)
}
let Some(old_context_window) = previous_model_turn_context.model_context_window() else {
return Ok(());
};
let Some(new_context_window) = turn_context.model_context_window() else {
return Ok(());
};
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;
if should_run {
run_auto_compact(
sess,
step_context,
fallback_step_context,
client_session,
InitialContextInjection::DoNotInject,
CompactionReason::ModelDownshift,
CompactionPhase::PreTurn,
)
.await?;
}这条逻辑有一个容易忽略的条件:BodyAfterPrefix 分支在降档检查中只用新有效窗口做 hard cap 判断, 不会把旧窗口的 prefix baseline 直接当成新模型的 body budget。真正 compaction 如何替换历史属于后续文章。
10. 未知窗口
窗口信息为 None 时,model_context_window() 返回 None,full hard cap 不会触发;如果没有显式 model_auto_compact_token_limit,自动压缩水位也为 None。这不是“无限窗口”,而是核心没有足够元数据计算 限制。
源码位置:codex-rs/protocol/src/openai_models.rs :: resolved_context_window、auto_compact_token_limit。
模型切换测试使用两个真实 ModelInfo:大模型窗口 272000、小模型窗口 128000,默认有效百分比 95, 断言 TurnStarted 和 TokenCount 分别报告 258400 与 121600。它证明窗口值随 Turn 的模型切换更新, 不证明远端服务一定接受该有效值,也不证明未知窗口模型不会被服务端拒绝。
11. 窗口测试
以下命令分别覆盖窗口过滤器的边界;运行数分别为 2、1、2、6、1,共 12 项:
cargo test -p codex-protocol --lib resolved_context_window -- --nocapture
cargo test -p codex-models-manager --lib model_context_window_override_clamps_to_max_context_window -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib turn_context_fragments -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all body_after_prefix -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all model_switch_to_smaller_model_updates_token_context_window -- --nocapture模型元数据单元测试验证 resolved_context_window 优先 context_window,缺失时回退 max_context_window,并验证缺失窗口时显式压缩水位仍可保留。ModelsManager 测试输入 500000 的覆盖值和 400000 的最大值,断言最终窗口被 clamp 到 400000。
窗口百分比测试把模型原始窗口设置为 100、有效百分比设为 50,断言 extension 看到的 expected_model_context_window 是 50;另一个测试使用 200 和 25,验证 steady-state context update 仍 携带 50 的有效窗口。这证明百分比在 TurnContext 层生效,而不是配置解析时直接改写原始 model metadata。
auto_compact_body_after_prefix_ignores_starting_window_prefix 先让首个窗口建立大 prefix,再提交后续输入, 断言仅 prefix 超过 body budget 不会立即压缩;auto_compact_body_after_prefix_counts_growth_after_compaction 在压缩后继续增长并断言第二次达到 body budget 时再次压缩。auto_compact_body_after_prefix_still_caps_at_context_window 则把 body limit 配得高于 full window,证明硬顶仍优先触发。
模型切换测试 model_switch_to_smaller_model_updates_token_context_window 输入两个 catalog 模型并先后修改 thread settings,断言大窗口和小窗口的 TurnStarted、TokenCount 值不同。它证明消费者读取的是新 Turn 的 TurnContext,不证明切换一定触发 compaction;是否触发还取决于历史使用量、scope 和 comp_hash。
| 测试组 | 输入 | 核心断言 | 未覆盖边界 |
|---|---|---|---|
| 窗口解析 | 当前值与最大值同时存在,或只存在最大值 | 优先当前值;缺失时回退最大值并导出 90% 水位 | catalog 数据是否准确 |
| 配置覆盖 | 500000 覆盖值、400000 最大值 | 最终 context_window 为 400000 | provider 是否接受 400000 |
| 有效百分比 | 100×50%、200×25% | 初始与 steady-state extension 都读取 50 | 服务端 tokenizer 的真实余量 |
| BodyAfterPrefix | 大 prefix、压缩后增长、超过 full window | prefix 不重复计数,后续增长计数,硬顶仍生效 | 摘要内容是否保持语义 |
| 模型切换 | 272000 与 128000 窗口,百分比 95 | 事件分别报告 258400 与 121600 | 任意历史量下都触发压缩 |
12. 路径练习
遇到“上下文明明没满却压缩”或“换小模型后请求失败”时,按下面顺序定位:
- 查
ModelInfo的context_window、max_context_window、auto_compact_token_limit和百分比。 - 查
with_config_overrides是否 clamp 了配置值。 - 查
TurnContext::model_context_window()得到的有效硬上限。 - 查
AutoCompactTokenLimitScope和prefill_input_tokens,确认计算的是 Total 还是 BodyAfterPrefix。 - 查
ContextWindowTokenStatus的 scope limit、fallback buffer 和 full hard cap 哪一个先达到。 - 模型切换时继续查旧/新窗口、comp_hash 和
maybe_run_previous_model_inline_compact。
只读搜索可以直接从源码重建这条链:
rg -n "resolved_context_window|model_context_window|auto_compact_token_limit|context_window_token_status|maybe_run_previous_model_inline_compact" \
codex-rs/protocol/src codex-rs/models-manager/src codex-rs/core/src/session如果只看 context_window 字段,最容易漏掉 95% 有效窗口、90% 自动压缩水位、BodyAfterPrefix baseline 和 模型降档前的旧模型压缩。真正的限制是这些层在同一个 Turn 中组合后的结果。
