Skip to content

RolloutBudget与截断策略

解释共享 rollout 预算如何计量、提醒、耗尽,以及历史和工具输出在写回时如何独立截断。

基于rust-v0.150.0
CodexRustContextBudget

RolloutBudget与截断策略 ​

Codex 同时存在两类“控制上下文成本”的机制:RolloutBudget 控制一次 session tree 还能消耗多少加权 rollout units;截断策略控制过大的历史 item 或工具结果以什么形式进入下一次请求。前者是共享账本,达到上限 会让当前和后续 Turn 失败;后者是写入或序列化边界,通常只改变 payload,不会返还已经记入账本的使用量。

本文面向已经读过 TokenBudget计算与传播 和 ContextHistory读写 的读者。本文不覆盖 context window 的 90%/95% 水位,也不讨论 compact 摘要内容;重点是 rollout units 从哪里来、如何跨 agent 共享、何时提醒或 拒绝请求,以及历史和工具输出的截断边界。读完后,读者应能从一个 response.completed usage 追到预算账本, 再定位一段过长工具输出最终在哪一层被裁剪。

1. 两套边界 ​

先把几个近似名称分开:

机制状态所有者输入超限结果
RolloutBudgetAgentControl 共享的 Arc<RolloutBudget>provider units 或 input/output usageSessionBudgetExceeded
Context window当前 TurnContext / Sessionactive context tokenpre/mid-turn compact 或 rollover
History truncationContextHistory单个 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。

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

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

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

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

rust
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 site
  • codex-rs/core/src/rollout_budget.rs :: pending_reminder
rust
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。

rust
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_usage
  • codex-rs/protocol/src/error.rs :: SessionBudgetExceeded
rust
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。

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

rust
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_budgetlimit、三个阈值、两种权重解析出完整 RolloutBudgetConfig配置字段传播运行时 usage
load_config_rejects_enabled_rollout_budget_without_limitfeature 开启但无 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_retryunits=-1Fatal error,request 数为 1非法 units 拒绝与不重试网络重试策略
subagent_usage_draws_from_the_shared_budgetroot 10、child 30、root 10follow-up 看到剩余 50root/child 共享账本并发竞态的所有排列
exhausted_budget_fails_current_and_later_turnslimit 30,首 Turn 消耗 30当前和后续 Turn 都收到 SessionBudgetExceeded耗尽状态持久化手动恢复接口
compaction_budget_exhaustion_fails_without_retrycompact usage 达到 limitcompact 请求不 retrycompact 也计入账本远程服务其他错误
restates_the_current_remainder_after_compaction使用 20、compact 10新窗口再次携带剩余 70,且摘要在提醒前window 变化后的提醒重发摘要质量
record_items_truncates_function_call_output_content超 policy 的 function outputhistory 保存截断后的 outputhistory 写回截断provider 实际 token 化
exec_command_tool_output_formats_truncated_response带 omission metadata 的大输出response 含 warning、原始 token count 和截断文本工具消费者格式TUI 自己的展示

可执行测试命令:

bash
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_response

12. 定位路径 ​

看到“预算还没耗尽却报错”时,先查 response.completed 是否有非法 codex_rollout_budget_units,再查 AgentControl 是否把多个 thread 接到同一 Arc<RolloutBudget>。看到“模型收到的工具结果变短”时,沿 record_items → process_item → truncate_function_output_payload 检查 history 写回,再沿工具对象的 truncated_output 检查面向消费者的第二次截断。

源码搜索可以从这条链开始:

bash
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 截断”是两个独立边界。