TokenBudget计算与传播
Codex 的 TokenBudget 不是一个“把上下文窗口减去已用 token”的孤立函数,而是一个受 feature gate 控制的 上下文协议。配置解析出提醒阈值、提示模板、guidance 和 fallback 参数;模型 catalog 可以在没有显式配置时 提供整套默认值;每个 TurnContext 再次应用这些默认值;窗口状态把 scope 水位与 hard cap 合并成 base_window_tokens_remaining;最后这个剩余值被提醒、get_context_remaining 和自动压缩共同消费。
本文面向已经读过 ContextWindow与模型限制 的读者。本文只追踪 TokenBudget 配置和剩余量的传播,不展开 rollout budget 的共享单位,也不展开摘要内容如何生成。读完后应能 回答三个问题:模型默认值何时覆盖配置、同一个剩余值为何能触发不同消费者、以及 token budget feature 关闭或 窗口未知时哪些路径会自然失效。
1. 先分三种预算
源码中至少有三种容易混淆的预算:
| 名称 | 所属 | 计量对象 | 主要作用 |
|---|---|---|---|
| TokenBudget | features.token_budget | 当前 context window 剩余 token | 提醒、查询、自动 rollover |
| RolloutBudget | features.rollout_budget | provider 报告的 rollout units | 根 thread 与 sub-agent 共享限额 |
| Tool output budget | tool_output_token_limit | 单个工具输出 | 写入历史前截断工具结果 |
TokenBudget 的剩余量来自 ContextWindow与模型限制 的窗口状态,而不是 provider 报告的 rollout units。后两者即使数值相同, 也不能互相替代。
2. 配置解析
配置解析只有在 Feature::TokenBudget 开启时才返回 Some(TokenBudgetConfig)。没有开启 feature,即使 TOML 中写了 token budget 字段,核心也不会把它传播到 Turn。
源码位置:codex-rs/core/src/config/mod.rs :: resolve_token_budget_config。
fn resolve_token_budget_config(
config_toml: &ConfigToml,
features: &ManagedFeatures,
) -> std::io::Result<Option<TokenBudgetConfig>> {
if !features.enabled(Feature::TokenBudget) {
return Ok(None);
}
let token_budget_config = token_budget_toml_config(config_toml.features.as_ref());
let mode = token_budget_config
.and_then(|config| config.mode)
.unwrap_or_default();
let reminder_threshold_tokens =
token_budget_config.and_then(|config| config.reminder_threshold_tokens);
let reminder_message_template = token_budget_config
.and_then(|config| config.reminder_message_template.clone())
.unwrap_or_else(|| DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string());
let guidance_message = token_budget_config
.and_then(|config| config.guidance_message.clone())
.filter(|message| !message.trim().is_empty());
let auto_compact_fallback_prompt = token_budget_config
.and_then(|config| config.auto_compact_fallback_prompt.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let auto_compact_fallback_buffer_tokens =
token_budget_config.and_then(|config| config.auto_compact_fallback_buffer_tokens);
let use_history_notes_extension = token_budget_config
.and_then(|config| config.use_history_notes_extension)
.unwrap_or(false);
let token_budget = TokenBudgetConfig {
mode,
reminder_threshold_tokens,
reminder_message_template,
guidance_message,
auto_compact_fallback_prompt,
auto_compact_fallback_buffer_tokens,
use_history_notes_extension,
};
token_budget.validate()?;
Ok(Some(token_budget))
}这里有三个重要边界:默认提醒模板只在本地没有模板时补入;空白 guidance 和 fallback prompt 会被转成 None;fallback prompt 一旦存在,就必须同时提供正数 buffer。校验失败会让配置加载失败,而不是把非法值 悄悄带入窗口计算。
源码位置:codex-rs/core/src/config/mod.rs :: TokenBudgetConfig::validate、fallback_buffer_tokens。
pub(crate) fn fallback_buffer_tokens(&self) -> i64 {
if self.auto_compact_fallback_prompt.is_some() {
self.auto_compact_fallback_buffer_tokens.unwrap_or(0)
} else {
0
}
}因此,单独配置一个 buffer 并不会改变 ContextWindowTokenStatus;只有 fallback prompt 也存在时,buffer 才会进入自动压缩水位。
3. 默认值来源
模型默认值放在 ModelInfo::model_messages.token_budget,不是直接放在 context_window 字段旁边。它包含 提醒阈值、提醒模板、guidance、fallback prompt 和 fallback buffer 五个字段。
源码位置:codex-rs/protocol/src/openai_models.rs :: ModelMessages、ModelTokenBudgetConfig。
pub struct ModelMessages {
pub instructions_template: Option<String>,
pub instructions_variables: Option<ModelInstructionsVariables>,
pub approvals: Option<ApprovalMessages>,
pub collaboration_modes: Option<CollaborationModeMessages>,
pub auto_review: Option<AutoReviewMessages>,
pub permissions: Option<PermissionMessages>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_budget: Option<ModelTokenBudgetConfig>,
}
pub struct ModelTokenBudgetConfig {
pub reminder_threshold_tokens: i64,
pub reminder_message_template: String,
pub guidance_message: String,
pub auto_compact_fallback_prompt: String,
pub auto_compact_fallback_buffer_tokens: i64,
}模型默认值不是无条件覆盖。apply_model_defaults 只有在 feature 已开启且配置没有显式 token budget 设置时 才会运行;显式设置可以只覆盖一部分字段,但“存在显式设置”会阻止整套模型默认值注入。
源码位置:codex-rs/core/src/session/token_budget.rs :: has_explicit_settings、apply_model_defaults。
pub(super) fn apply_model_defaults(config: &mut Config, model_info: &ModelInfo) {
if !config.features.enabled(Feature::TokenBudget) || has_explicit_settings(config) {
return;
}
let Some(model_defaults) = model_info
.model_messages
.as_ref()
.and_then(|messages| messages.token_budget.as_ref())
else {
return;
};
let token_budget = TokenBudgetConfig {
mode: config
.token_budget
.as_ref()
.map(|token_budget| token_budget.mode)
.unwrap_or_default(),
reminder_threshold_tokens: Some(model_defaults.reminder_threshold_tokens),
reminder_message_template: model_defaults.reminder_message_template.clone(),
guidance_message: Some(model_defaults.guidance_message.clone()),
auto_compact_fallback_prompt: Some(model_defaults.auto_compact_fallback_prompt.clone()),
auto_compact_fallback_buffer_tokens: Some(
model_defaults.auto_compact_fallback_buffer_tokens,
),
};
if let Err(error) = token_budget.validate() {
tracing::warn!(
model = %model_info.slug,
%error,
"ignoring invalid model-owned token-budget defaults"
);
return;
}
config.token_budget = Some(token_budget);
}模型默认值验证失败时只记录 warning 并放弃默认值,不会把无效模型 catalog 变成有效本地配置。这个行为和 本地 TOML 校验失败不同:本地配置在解析阶段返回错误,模型默认值在 Turn 构造阶段被忽略。
4. Turn级应用
Session 创建和每个 Turn 创建都可能接触模型信息。真正把模型默认值放入当前 Turn 的位置是 TurnContext::make_turn_context,因此文章不能只看 Session 初始化。
源码位置:codex-rs/core/src/session/turn_context.rs :: make_turn_context。
let mut per_turn_config = per_turn_config;
super::token_budget::apply_model_defaults(&mut per_turn_config, &model_info);
per_turn_config.service_tier = get_service_tier(
per_turn_config.service_tier,
per_turn_config.features.enabled(Feature::FastMode),
&model_info,
);
let per_turn_config = Arc::new(per_turn_config);这意味着模型切换后的新 Turn 可以获得新模型提供的 token budget 默认值;但如果用户已经显式配置过 token budget,模型切换不会用 catalog 默认值覆盖用户选择。Config 是 Turn 的快照,不能据此推断后续 Turn 必然 使用同一模型默认值。
Session 在需要保存“由模型解析出的配置”时也会调用相同函数:
源码位置:codex-rs/core/src/session/mod.rs :: session configuration lock 分支。
if config.config_lock_export_dir.is_some()
&& config.config_lock_save_fields_resolved_from_model_catalog
{
self::token_budget::apply_model_defaults(Arc::make_mut(&mut config), &model_info);
}这里的调用目的是导出解析后的配置锁,不是另起一套预算计算。预算真正被消费时,仍以当前 TurnContext.config 为准。
5. 窗口剩余
ContextWindow与模型限制 已经说明 context_window_token_status 如何得到 hard cap 和 scope limit。本篇只抓住它对 TokenBudget 的交付接口:base_window_tokens_remaining 是两个剩余量的较小值。
源码位置:codex-rs/core/src/session/context_window.rs :: context_window_token_status。
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));注意 base_window_tokens_remaining 使用未加 fallback buffer 的基础剩余量,而 token_limit_reached 使用加过 buffer 的自动压缩水位。这样 reminder 仍然报告“距离基础窗口还剩多少”,fallback 只影响是否允许继续采样 或是否触发 rollover,不会让模型看到一个被 buffer 人为放大的剩余值。
6. 提醒写回
maybe_record 是剩余量进入历史的第一个消费者。它只在 feature 开启、窗口剩余已知且配置存在时工作;达到 阈值后通过 Session 状态的 claim 位保证同一个 context window 只写入一次提醒。
相关源码:
codex-rs/core/src/session/token_budget.rs :: maybe_recordcodex-rs/core/src/state/auto_compact_window.rs :: claim_token_budget_reminder
if config
.reminder_threshold_tokens
.is_some_and(|threshold| base_window_tokens_remaining <= threshold)
{
let reminder_due = {
let mut state = sess.state.lock().await;
state.claim_token_budget_reminder()
};
if reminder_due {
let response_item =
ContextualUserFragment::into(crate::context::TokenBudgetReminder::new(
&config.reminder_message_template,
base_window_tokens_remaining,
));
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
}
}TokenBudgetReminder 是无 marker 的 developer fragment,模板中的 {n_remaining} 在构造时替换。它会进入 历史,因此下一次模型请求能看到提醒;它不是事件层的 toast,也不是只给 TUI 使用的诊断字符串。
这里的 claim 发生在 record_conversation_items 之前,和 rollout 预算“写入后再确认交付”的顺序不同。 如果任务恰好在 claim 后、历史写入完成前被取消,当前窗口不会自动重新 claim;只有窗口推进或其他恢复逻辑 重置窗口状态后,提醒才会再次获得交付资格。
7. 查询工具
当 TokenBudget feature 开启时,工具注册表同时暴露 get_context_remaining 和 new_context_window。前者重新计算当前 Turn 的窗口状态,并把同一个 base_window_tokens_remaining 包装成 模型可见的 function output;Code Mode 则额外得到结构化的 tokens_left。
相关源码:
codex-rs/core/src/tools/spec_plan.rs :: TokenBudget tool registrationcodex-rs/core/src/tools/handlers/get_context_remaining.rs :: GetContextRemainingHandler
if features.enabled(Feature::TokenBudget) {
registry.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly);
registry.add(GetContextRemainingHandler);
}注册只决定模型能否看到工具;执行时不会读取注册阶段缓存的 token 数,而是重新查询当前 Session 和 Turn。
源码位置:codex-rs/core/src/tools/handlers/get_context_remaining.rs :: GetContextRemainingHandler::handle。
let token_status = crate::session::context_window::context_window_token_status(
invocation.session.as_ref(),
invocation.turn.as_ref(),
)
.await;
Ok(boxed_tool_output(GetContextRemainingOutput::new(
token_status.base_window_tokens_remaining,
)))窗口未知时工具仍可以注册,但返回的是“unknown tokens left”,而不是伪造一个无限大数字。这个分支保留了 未知 metadata 的事实边界。
源码位置:codex-rs/core/src/context/token_budget_context.rs :: TokenBudgetRemainingContext。
fn body(&self) -> String {
match self.tokens_left {
Some(tokens_left) => {
format!("You have {tokens_left} tokens left in this context window.")
}
None => "You have unknown tokens left in this context window.".to_string(),
}
}8. Fallback压缩
在采样后,run_turn 先重新计算窗口状态,再把 base_window_tokens_remaining 交给 maybe_record。只有不需要 rollover、没有达到 token limit 且基础剩余量恰好为 0 时,fallback prompt 才会写入历史;否则自动压缩路径优先。
源码位置:codex-rs/core/src/session/turn.rs :: post-sampling token decision。
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;fallback prompt 自己不是压缩动作。它只是模型可见的 developer fragment;真正建立新 context window 的动作由 run_auto_compact 进入 compact_token_budget::run_inline_auto_compact_task,再调用 Session::start_new_context_window。
源码位置:codex-rs/core/src/compact_token_budget.rs :: run_inline_auto_compact_task、run_compact_task_inner。
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
sess.emit_turn_item_started(turn_context, &compaction_item).await;
sess.start_new_context_window(step_context, world_state).await;
sess.emit_turn_item_completed(turn_context, compaction_item).await;9. 身份与窗口
TokenBudget 的 full-context developer fragment 还会携带窗口身份:thread id 或 agent name、first window id、 current window id 和 previous window id。TokenBudgetMode 只改变第一行身份,不改变剩余量计算。
源码位置:codex-rs/core/src/context/token_budget_context.rs :: TokenBudgetContext::body。
let identity = match self.mode {
TokenBudgetMode::Thread => format!("Thread id: {}", self.thread_id),
TokenBudgetMode::Name => format!("Agent name: {}", self.agent_path),
};
let mut lines = vec![
identity,
format!("First context window id: {first_window_id}"),
format!("Current context window id: {window_id}"),
];
if let Some(previous_window_id) = self.previous_window_id {
lines.push(format!("Previous context window id: {previous_window_id}"));
}Session 在构造初始上下文时才写入这段 full-context metadata;steady-state diff 不重复发射它。窗口前进时, AutoCompactWindow::advance 会清除 reminder 和 fallback 的 claim 位,所以新窗口可以再次提醒一次。
10. 预算测试
以下测试围绕配置、传播和消费者执行;它们不证明 provider 的 tokenizer 与本地估算完全一致。
| 测试 | 输入 | 断言 | 覆盖范围 | 未覆盖 |
|---|---|---|---|---|
load_config_resolves_token_budget_config | feature 开启、mode=name、阈值与 fallback 字段 | TOML 解析成 TokenBudgetConfig | 配置字段和默认模板 | 模型 catalog 默认值 |
load_config_rejects_non_positive_token_budget_reminder_threshold | 阈值为 0 或负数 | 配置加载返回指定错误 | 本地输入校验 | 运行时动态修改 |
token_budget_uses_model_message_defaults | 只显式配置 mode=name,模型提供整套默认值 | 身份使用 agent name,模型 guidance 进入请求 | 模型默认值注入 | provider 是否遵循 guidance |
token_budget_explicit_default_template_overrides_model_defaults | 显式写入本地默认模板 | 不继承模型 guidance | 显式设置优先级 | 任意配置层合并顺序 |
token_budget_defaults_follow_the_active_model | 从 gpt-5.2 切换到 gpt-5.4 | 旧历史保留,新模型 guidance 只新增一次 | Turn 级默认值刷新 | compaction 后的模型切换 |
token_budget_ignores_invalid_model_message_defaults | 0 阈值、负 buffer、超长文本 | 无效模型默认值被忽略 | catalog 校验失败路径 | warning 的外部采集 |
token_budget_reminder_uses_body_after_prefix_window | 窗口 10000、body limit 1000、两次 usage | prefix 不触发提醒,后续剩余 400 时写入一次提醒 | BodyAfterPrefix 到 reminder | 摘要后的语义保留 |
get_context_remaining_returns_token_budget_remaining_fragment | 窗口 10000、累计 token 2500 | function output 为 6500 tokens left | 工具查询与历史回灌 | 外部模型如何使用输出 |
get_context_remaining_returns_unknown_when_threshold_is_unbounded | 无可解析窗口 | 输出 unknown,而非伪造数值 | 未知窗口边界 | provider 实际服务端限制 |
token_budget_context_uses_new_window_after_compaction | 调用 new context window 工具后继续 follow-up | first/current/previous window id 正确变化 | 手动 reset 后的身份传播 | 自动压缩的所有 provider 路径 |
精确执行命令应在 codex-rs workspace 中运行:
cargo test -p codex-core --lib load_config_resolves_token_budget_config
cargo test -p codex-core --lib load_config_rejects_non_positive_token_budget_reminder_threshold
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_uses_model_message_defaults
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_explicit_default_template_overrides_model_defaults
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_model_defaults_survive_config_lock_replay
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_defaults_follow_the_active_model
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_ignores_invalid_model_message_defaults
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_guidance_follows_context_window
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_reminder_uses_body_after_prefix_window
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all get_context_remaining_returns_token_budget_remaining_fragment
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all get_context_remaining_returns_unknown_when_threshold_is_unbounded
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_context_uses_new_window_after_compaction
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all token_budget_auto_compact_fallback_uses_buffer_until_new_context11. 路径练习
调试“模型没有看到预算提醒”“查询工具返回 unknown”或“fallback 没有触发”时,按这条顺序核对:
Feature::TokenBudget是否开启。resolve_token_budget_config是否生成了配置,输入是否通过校验。- 当前 Turn 是否因为显式设置跳过了模型默认值。
TurnContext::model_context_window()是否为Some。ContextWindowTokenStatus的 scope limit、hard cap 和 fallback buffer 哪一个先达到。maybe_record是否因 reminder claim、allow_auto_compact_fallback或token_limit_reached被短路。- 如果是工具调用,检查工具是否在
spec_plan中注册,以及 output 是否进入下一次 history。
可以从真实源码重建整条链:
rg -n "resolve_token_budget_config|apply_model_defaults|context_window_token_status|maybe_record|get_context_remaining|run_inline_auto_compact_task" \
codex-rs/core/src codex-rs/protocol/src codex-rs/features/src最容易误判的地方是把模型默认值当作配置覆盖、把 fallback buffer 当作模型剩余量,或者把 get_context_remaining 的结果当作 provider 的精确 token 计数。源码只保证本文所列传播和边界,不替模型服务端 承诺未被测试证明的行为。
