Skip to content

ContextWindow与模型限制

追踪模型窗口从远端元数据、配置覆盖、有效百分比到压缩水位和模型切换的完整决策链。

基于rust-v0.150.0
CodexRustContextToken

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_windowcatalog 提供的当前值与覆盖上限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 窗口字段。

rust
#[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。

rust
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。

rust
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。

rust
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
1000007000070000
10000012000090000
未知7000070000
未知无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。

rust
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 :: AutoCompactTokenLimitScope
  • codex-rs/core/src/session/context_window.rs :: context_window_token_status
rust
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。

rust
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

rust
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。

rust
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。

rust
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。

rust
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 项:

bash
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 为 400000provider 是否接受 400000
有效百分比100×50%、200×25%初始与 steady-state extension 都读取 50服务端 tokenizer 的真实余量
BodyAfterPrefix大 prefix、压缩后增长、超过 full windowprefix 不重复计数,后续增长计数,硬顶仍生效摘要内容是否保持语义
模型切换272000 与 128000 窗口,百分比 95事件分别报告 258400 与 121600任意历史量下都触发压缩

12. 路径练习 ​

遇到“上下文明明没满却压缩”或“换小模型后请求失败”时,按下面顺序定位:

  1. 查 ModelInfo 的 context_window、max_context_window、auto_compact_token_limit 和百分比。
  2. 查 with_config_overrides 是否 clamp 了配置值。
  3. 查 TurnContext::model_context_window() 得到的有效硬上限。
  4. 查 AutoCompactTokenLimitScope 和 prefill_input_tokens,确认计算的是 Total 还是 BodyAfterPrefix。
  5. 查 ContextWindowTokenStatus 的 scope limit、fallback buffer 和 full hard cap 哪一个先达到。
  6. 模型切换时继续查旧/新窗口、comp_hash 和 maybe_run_previous_model_inline_compact。

只读搜索可以直接从源码重建这条链:

bash
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 中组合后的结果。