RolloutBudget与截断策略
Codex 同时存在两类“控制上下文成本”的机制:RolloutBudget 控制一次 session tree 还能消耗多少加权 rollout units;截断策略控制过大的历史 item 或工具结果以什么形式进入下一次请求。前者是共享账本,达到上限 会让当前和后续 Turn 失败;后者是写入或序列化边界,通常只改变 payload,不会返还已经记入账本的使用量。
本文面向已经读过 TokenBudget计算与传播 和 ContextHistory读写 的读者。本文不覆盖 context window 的 90%/95% 水位,也不讨论 compact 摘要内容;重点是 rollout units 从哪里来、如何跨 agent 共享、何时提醒或 拒绝请求,以及历史和工具输出的截断边界。读完后,读者应能从一个 response.completed usage 追到预算账本, 再定位一段过长工具输出最终在哪一层被裁剪。
1. 两套边界
先把几个近似名称分开:
| 机制 | 状态所有者 | 输入 | 超限结果 |
|---|---|---|---|
RolloutBudget | AgentControl 共享的 Arc<RolloutBudget> | provider units 或 input/output usage | SessionBudgetExceeded |
| Context window | 当前 TurnContext / Session | active context token | pre/mid-turn compact 或 rollover |
| History truncation | ContextHistory | 单个 ResponseItem 与 policy | 工具输出被截断后写入 history |
| Tool response truncation | 工具输出对象 | raw bytes、token/byte policy | 返回模型或 Code Mode 的文本缩略 |
截断发生在 payload 层,预算耗尽发生在 session 生命周期层。一个工具输出即使被截断,provider 已经报告的 usage 仍然会被 record_rollout_budget_usage 计入共享账本。
2. 配置入口
RolloutBudget 只有在 Feature::RolloutBudget 开启时才解析。与 TokenBudget计算与传播 的 token budget 不同,它不接受 模型 catalog 的默认阈值:配置必须提供正数 limit_tokens 和非空的提醒阈值列表;sampling/prefill 权重 缺省为 1.0。
源码位置:codex-rs/core/src/config/mod.rs :: resolve_rollout_budget_config。
fn resolve_rollout_budget_config(
config_toml: &ConfigToml,
features: &ManagedFeatures,
) -> std::io::Result<Option<RolloutBudgetConfig>> {
if !features.enabled(Feature::RolloutBudget) {
return Ok(None);
}
let Some(FeatureToml::Config(config)) = config_toml
.features
.as_ref()
.and_then(|features| features.rollout_budget.as_ref())
else {
return Err(missing_limit_error());
};
let Some(limit_tokens) = config.limit_tokens else {
return Err(missing_limit_error());
};
if limit_tokens <= 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"features.rollout_budget.limit_tokens must be positive",
));
}提醒阈值还必须满足 0 < threshold < limit_tokens。权重则允许为零,但必须是 finite 且非负;这允许测试 或实验只计算 sampling 或只计算 prefill,而不会接受 NaN、正负无穷或负权重。
源码位置:codex-rs/core/src/config/mod.rs :: RolloutBudgetConfig。
pub struct RolloutBudgetConfig {
pub limit_tokens: i64,
pub reminder_at_remaining_tokens: Vec<i64>,
pub sampling_token_weight: f64,
pub prefill_token_weight: f64,
}3. 共享所有权
预算不是每个 Session 各自创建的计数器。AgentControl::new 收到配置后初始化一个 Arc<RolloutBudget>; root thread 和 cloned sub-agent control handle 持有同一份 Arc,因此子 agent 的 usage 会消耗同一条账本。
源码位置:codex-rs/core/src/agent/control.rs :: AgentControl::new、rollout_budget。
pub(crate) fn new(
manager: Weak<ThreadManagerState>,
rollout_budget: Option<RolloutBudgetConfig>,
) -> Self {
let control = Self {
manager,
..Default::default()
};
if let Some(rollout_budget) = rollout_budget {
control.rollout_budget.configure(rollout_budget);
}
control
}
pub(crate) fn rollout_budget(&self) -> &RolloutBudget {
self.rollout_budget.as_ref()
}RolloutBudget 内部再用 OnceLock<Mutex<RolloutBudgetState>> 保护配置、累计 weighted usage 和每个 thread 的提醒投递状态。OnceLock 让账本只初始化一次,Mutex 保证 root 与 child 并发写入时累计操作不会丢失。
4. 加权计量
账本每次接收 TokenUsage 时先选择计量来源:如果服务端提供 codex_rollout_budget_units,优先使用它; 否则用 output token 和未缓存 input token 按配置权重计算。注意 fallback 不是“服务端 units 缺失时使用所有 input token”,而是调用 non_cached_input(),缓存 input 不计入本地 fallback 公式。
源码位置:codex-rs/core/src/rollout_budget.rs :: RolloutBudget::record_usage。
pub(crate) fn record_usage(&self, usage: &TokenUsage) -> CodexResult<bool> {
let Some(mut state) = self.lock() else {
return Ok(false);
};
let units = if let Some(units) = usage.codex_rollout_budget_units.as_ref() {
let units = units.as_f64().unwrap_or(f64::NAN);
if !units.is_finite() || units < 0.0 {
return Err(CodexErr::Fatal(
"response.completed usage.codex_rollout_budget_units must be finite and non-negative"
.to_string(),
));
}
units
} else {
usage.output_tokens.max(0) as f64 * state.config.sampling_token_weight
+ usage.non_cached_input() as f64 * state.config.prefill_token_weight
};
state.weighted_tokens_used += units;
Ok(state.weighted_tokens_used >= state.config.limit_tokens as f64)
}例如 limit 为 100、sampling weight 为 2、prefill weight 为 0.5 时,output=15、input=60、cached=40 的 fallback 消耗是 15×2 + (60-40)×0.5 = 40。如果同一 usage 带有 provider units=40.5,则记账 40.5, 不会再叠加本地公式。
5. 记账时机
usage 并不是请求发出时记账,而是在 Session::record_token_usage_info 收到完成 usage 后记账。这个顺序 很重要:token info 和 BodyAfterPrefix baseline 先更新,随后调用 record_rollout_budget_usage;如果账本返回 错误或已耗尽,错误向上返回。
源码位置:codex-rs/core/src/session/mod.rs :: record_token_usage_info。
if let Some(token_usage) = token_usage {
let token_info = {
let mut state = self.state.lock().await;
state.update_token_info_from_usage(token_usage, turn_context.model_context_window());
if matches!(
turn_context.config.model_auto_compact_token_limit_scope,
AutoCompactTokenLimitScope::BodyAfterPrefix
) {
state.ensure_auto_compact_window_server_prefill_from_usage(token_usage);
}
state.token_info()
};
let budget_result = self.record_rollout_budget_usage(token_usage);
// extensions observe token_info before budget_result is returned.
budget_result?;
}record_rollout_budget_usage 是 Session 到共享账本的薄入口:它不重新计算 token,也不决定重试,只把 SessionBudgetExceeded 交给当前调用链。压缩路径也通过同一入口记账,所以一次 compact 请求同样可能耗尽共享 预算。
6. 提醒状态
每个 Turn 的 sampling 前都会调用 maybe_record_reminder。它使用当前 context window id 查询剩余量和已经跨过 的阈值,再用 (thread_id, window_id, reminder_index) 去重。状态不是全局只提醒一次:同一个阈值在不同 thread 或新 context window 中可以重新出现。
相关源码:
codex-rs/core/src/session/turn.rs :: maybe_record_reminder call sitecodex-rs/core/src/rollout_budget.rs :: pending_reminder
pub(crate) fn pending_reminder(
&self,
thread_id: ThreadId,
window_id: &str,
) -> Option<RolloutBudgetReminder> {
let state = self.lock()?;
let remaining_tokens = (state.config.limit_tokens as f64 - state.weighted_tokens_used)
.max(0.0)
.floor() as i64;
let reminder_index = state
.config
.reminder_at_remaining_tokens
.iter()
.filter(|&&threshold| remaining_tokens <= threshold)
.count() as i64;
if state.deliveries.get(&thread_id).is_some_and(|delivery| {
delivery.window_id.as_str() == window_id && delivery.reminder_index >= reminder_index
}) {
return None;
}
Some(RolloutBudgetReminder {
remaining_tokens,
reminder_index,
})
}提醒只有在 history insertion 完成后才 mark delivered;如果取消发生在写入前,下一次 sampling 仍可以重试提醒。 这是“状态去重”与“写回成功”之间的顺序约束,不能把 pending_reminder 的返回当作已经投递。
源码位置:codex-rs/core/src/session/rollout_budget.rs :: maybe_record_reminder。
let Some(reminder) = budget.pending_reminder(sess.thread_id(), window_id) else {
return;
};
let response_item = ContextualUserFragment::into(crate::context::RolloutBudgetContext {
remaining_tokens: reminder.remaining_tokens,
});
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
budget.mark_reminder_delivered(sess.thread_id(), window_id, reminder);7. 耗尽路径
当 record_usage 累计值达到或超过 limit,当前 usage 仍然先被记入,然后返回 SessionBudgetExceeded。这不是 自动 compact,也不是 retry signal。上层把它转换成协议错误,因此后续 Turn 也会继续失败,因为账本保留了已 耗尽状态。
相关源码:
codex-rs/core/src/session/rollout_budget.rs :: Session::record_rollout_budget_usagecodex-rs/protocol/src/error.rs :: SessionBudgetExceeded
pub(crate) fn record_rollout_budget_usage(&self, usage: &TokenUsage) -> CodexResult<()> {
if self
.services
.agent_control
.rollout_budget()
.record_usage(usage)?
{
return Err(CodexErr::SessionBudgetExceeded);
}
Ok(())
}非法 provider units 是另一条失败路径:负数、NaN 或无穷值会返回 Fatal error,且测试明确断言不会 retry。不要 把“预算耗尽”和“usage 格式非法”归为同一个错误,它们的错误信息和恢复可能性不同。
8. Agent共享
子 agent 不拥有独立预算。root 产生 10 units、child 产生 30 units、root follow-up 再产生 10 units 时, 共享账本剩余 50;后续请求携带的 <rollout_budget> 片段由当前 thread 查询同一账本后生成。
源码位置:codex-rs/core/tests/suite/rollout_budget.rs :: subagent_usage_draws_from_the_shared_budget。
这也解释了为什么 thread 的 reminder delivery 状态仍按 ThreadId 分开:消耗是共享的,提醒可见性却必须让 每个 thread 都观察到自己跨过的阈值。共享的是 weighted_tokens_used,不是一份只发送给 root 的消息。
9. 历史截断
ContextHistory::record_items 在 item 写入 history 时接收 TruncationPolicy。它先过滤不属于 API message 的 item,再对每个保留 item 调 process_item;函数调用输出和 custom tool 输出会经过额外的 serialization budget, 普通 message、reasoning、call 和 compaction item 不在这个分支中改写。
源码位置:codex-rs/core/src/context_manager/history.rs :: record_items、process_item。
pub(crate) fn record_items<I>(&mut self, items: I, policy: TruncationPolicy)
where
I: IntoIterator,
I::Item: std::ops::Deref<Target = ResponseItem>,
{
for item in items {
let item_ref = item.deref();
if !is_api_message(item_ref) {
continue;
}
let processed = Self::process_item(item_ref, policy);
Arc::make_mut(&mut self.items).push(processed);
}
}
fn process_item(item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem {
let policy_with_serialization_budget = policy * 1.2;
match item {
ResponseItem::FunctionCallOutput {
id,
call_id,
output,
internal_chat_message_metadata_passthrough: metadata,
} => ResponseItem::FunctionCallOutput {
id: id.clone(),
call_id: call_id.clone(),
output: truncate_function_output_payload(output, policy_with_serialization_budget),
internal_chat_message_metadata_passthrough: metadata.clone(),
},
ResponseItem::CustomToolCallOutput {
id,
call_id,
name,
output,
internal_chat_message_metadata_passthrough: metadata,
} => ResponseItem::CustomToolCallOutput {
id: id.clone(),
call_id: call_id.clone(),
name: name.clone(),
output: truncate_function_output_payload(output, policy_with_serialization_budget),
internal_chat_message_metadata_passthrough: metadata.clone(),
},
_ => item.clone(),
}
}这里的 1.2 是序列化预算,不是给 rollout ledger 的 20% credit,也不是把模型窗口扩大 20%。截断后的 function/custom output 仍然是历史的一部分,后续请求会携带它的截断形态。
这张状态图表达的是 history 写回边界,不是 rollout budget 的状态机:Truncated 仍然会进入 History,而 Dropped 的 item 从未进入模型历史。
10. 工具输出
工具输出还有更靠近消费者的截断层。以 Code Mode 的 ExecCommandToolOutput 为例,model_output_max_tokens 先取调用方的 max tokens 与 truncation policy 的较小值;如果已有 omission metadata,结果会在必要时加上 原始 token count 和省略提示,再调用 truncate_text。
源码位置:codex-rs/core/src/tools/context.rs :: ExecCommandToolOutput::model_output_max_tokens、 truncated_output。
fn model_output_max_tokens(&self) -> usize {
resolve_max_tokens(self.max_output_tokens).min(self.truncation_policy.token_budget())
}
pub(crate) fn truncated_output(&self, max_tokens: usize) -> String {
let text = String::from_utf8_lossy(&self.raw_output).to_string();
let policy = TruncationPolicy::Tokens(max_tokens);
let Some(omitted_bytes) = self.output_omitted_bytes else {
return formatted_truncate_text(&text, policy);
};
let original_token_count = self
.original_token_count
.unwrap_or_else(|| approx_token_count(&text));
let truncated = truncate_text(&text, policy);
format!(
"Warning: truncated output (original token count: {original_token_count})\n{truncated}"
)
}这层处理的是返回给模型、Code Mode 或日志预览的字符串,不会改变 TokenUsage 中 provider 已报告的 usage。 因此调试时要同时查看:历史中保存的 FunctionCallOutput、工具对象生成的 response item,以及账本收到的 TokenUsage;三者可能来自不同阶段。
11. 预算测试
核心测试覆盖以下输入和断言:
| 测试 | 输入 | 关键断言 | 覆盖范围 | 未覆盖 |
|---|---|---|---|---|
load_config_resolves_rollout_budget | limit、三个阈值、两种权重 | 解析出完整 RolloutBudgetConfig | 配置字段传播 | 运行时 usage |
load_config_rejects_enabled_rollout_budget_without_limit | feature 开启但无 limit | 配置加载返回缺少 limit 错误 | 必填字段校验 | 其他非法组合 |
adds_weighted_initial_and_threshold_reminders | 权重 2/0.5;provider units 缺失或为 40.5 | 分别使用本地公式或 provider units,提醒剩余值正确 | 计量优先级与提醒 | provider units 的生产端正确性 |
invalid_provider_rollout_budget_units_fail_without_retry | units=-1 | Fatal error,request 数为 1 | 非法 units 拒绝与不重试 | 网络重试策略 |
subagent_usage_draws_from_the_shared_budget | root 10、child 30、root 10 | follow-up 看到剩余 50 | root/child 共享账本 | 并发竞态的所有排列 |
exhausted_budget_fails_current_and_later_turns | limit 30,首 Turn 消耗 30 | 当前和后续 Turn 都收到 SessionBudgetExceeded | 耗尽状态持久化 | 手动恢复接口 |
compaction_budget_exhaustion_fails_without_retry | compact usage 达到 limit | compact 请求不 retry | compact 也计入账本 | 远程服务其他错误 |
restates_the_current_remainder_after_compaction | 使用 20、compact 10 | 新窗口再次携带剩余 70,且摘要在提醒前 | window 变化后的提醒重发 | 摘要质量 |
record_items_truncates_function_call_output_content | 超 policy 的 function output | history 保存截断后的 output | history 写回截断 | provider 实际 token 化 |
exec_command_tool_output_formats_truncated_response | 带 omission metadata 的大输出 | response 含 warning、原始 token count 和截断文本 | 工具消费者格式 | TUI 自己的展示 |
可执行测试命令:
cargo test -p codex-core --lib load_config_resolves_rollout_budget
cargo test -p codex-core --lib load_config_rejects_enabled_rollout_budget_without_limit
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all adds_weighted_initial_and_threshold_reminders
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all invalid_provider_rollout_budget_units_fail_without_retry
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all subagent_usage_draws_from_the_shared_budget
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all exhausted_budget_fails_current_and_later_turns
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all compaction_budget_exhaustion_fails_without_retry -- --test-threads=1
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all restates_the_current_remainder_after_compaction
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib record_items_truncates_function_call_output_content
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib exec_command_tool_output_formats_truncated_response12. 定位路径
看到“预算还没耗尽却报错”时,先查 response.completed 是否有非法 codex_rollout_budget_units,再查 AgentControl 是否把多个 thread 接到同一 Arc<RolloutBudget>。看到“模型收到的工具结果变短”时,沿 record_items → process_item → truncate_function_output_payload 检查 history 写回,再沿工具对象的 truncated_output 检查面向消费者的第二次截断。
源码搜索可以从这条链开始:
rg -n "record_usage|record_rollout_budget_usage|pending_reminder|SessionBudgetExceeded|record_items|process_item|truncated_output" \
codex-rs/core/src codex-rs/core/tests/suite/rollout_budget.rs codex-rs/core/src/context_manager/history_tests.rs读者可以用 100 limit、sampling weight=2、prefill weight=0.5 的例子手算一次 usage,再把同一个 usage 放入 root/child 测试,确认共享账本只增加一次。最后把超长 function output 与超长 exec output 分别送入两条截断 路径,比较它们的 marker、warning 和 history 形态;这能验证“预算控制”和“payload 截断”是两个独立边界。
