Skip to content

远程Compact-V2尝试与降级

追踪 Remote Compact V2 如何复用 Responses 流、校验压缩输出、保留历史并在可恢复错误上重试或切换模型。

基于rust-v0.150.0
CodexRustContextCompact

远程Compact-V2尝试与降级 ​

本文面向已经读过 远程Compact请求协议 和 本地Compact执行与写回 的读者。前一篇解释 v1 compact endpoint 的请求协议;本文转向 ResponsesCompactionV2:它为什么复用普通 Responses 流、怎样把一次流消费成唯一的 compaction item、哪些历史会被保留,以及失败时重试和 fallback 的边界。

本文不讨论专用 compact model 的选择策略,也不把 provider 的摘要质量当作 Codex 已证明的事实。读完后,读者应能从一次 V2 请求的日志或抓包定位:失败发生在 stream 建立、流重试、输出形状校验、模型 fallback,还是 replacement history 安装之前。

1. V2边界 ​

V2 的实现入口位于 compact_remote_v2.rs,但真正的一次 attempt 在独立的 compact_remote_v2_attempt.rs 中完成。上层只在 attempt 成功后接收一个结构化结果;失败不会提前推进 window,也不会写入 live history。

图中的两条失败线不能混为一谈:流重试仍使用同一个模型和 ModelClientSession;模型 fallback 重新调用完整 attempt,换的是 StepContext 中的模型,且只有 fallback 成功才继续安装。

2. 输入构造 ​

V2 attempt 先克隆 history、重写超长 function-call output,再把普通 prompt history 转成模型输入。与 远程Compact请求协议 的 v1 请求不同,V2 会额外追加 ResponseItem::CompactionTrigger {},让服务端在 Responses 流语义中知道当前请求的目标。

源码位置:codex-rs/core/src/compact_remote_v2_attempt.rs :: run_remote_compact_v2_attempt

rust
let mut history = sess.clone_history().await;
let base_instructions = sess.get_base_instructions().await;
let (rewritten_outputs, estimated_deleted_tokens) =
    trim_function_call_history_to_fit_context_window(
        &mut history,
        turn_context.as_ref(),
        &base_instructions,
    );
let trace_input_history = compaction_trace
    .is_enabled()
    .then(|| history.raw_items().to_vec());
let (mut input, prompt_input_metadata): (Vec<_>, Vec<_>) = history
    .for_prompt_annotated(&turn_context.model_info.input_modalities)
    .into_iter()
    .map(|envelope| (envelope.item, envelope.metadata))
    .unzip();
let tool_router = &step_context.tool_router;
input.push(ResponseItem::CompactionTrigger {});
let prompt = Prompt {
    input,
    tools: tool_router.model_visible_specs(),
    parallel_tool_calls: true,
    base_instructions,
    output_schema: None,
    output_schema_strict: true,
};

CompactionTrigger 是请求输入的一部分,不是响应中的摘要。测试会检查 compact request body 含有 "type":"compaction_trigger",并且不含旧摘要的 encrypted_content;这保证一次新的 V2 attempt 不会把上一次结果当作输入。

3. 流式尝试 ​

V2 调用 ModelClientSession::stream,因此请求路径是普通 /v1/responses,而不是 远程Compact请求协议 的 /v1/responses/compact。流重试次数取 provider 配置和 V2 上限的较小值,当前上限为 2。

源码位置:codex-rs/core/src/compact_remote_v2.rs :: run_remote_compaction_request_v2

rust
let max_retries = turn_context
    .provider
    .info()
    .stream_max_retries()
    .min(MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES);
let mut retries = 0;
loop {
    let result = match client_session
        .stream(
            prompt,
            &turn_context.model_info,
            &turn_context.session_telemetry,
            turn_context.reasoning_effort.clone(),
            turn_context.reasoning_summary,
            turn_context.config.service_tier.clone(),
            responses_metadata,
            &InferenceTraceContext::disabled(),
        )
        .await
    {
        Ok(stream) => collect_compaction_output(stream).await,
        Err(err) => Err(err),
    };

    match result {
        Ok(compaction_output) => return Ok(compaction_output),
        Err(err) if !err.is_retryable() => return Err(err),
        Err(err) => {
            handle_retryable_response_stream_error(
                &mut retries,
                max_retries,
                err,
                client_session,
                sess,
                turn_context,
                ResponsesStreamRequest::RemoteCompactionV2,
            )
            .await?;
        }
    }
}

这里有两个独立条件:err.is_retryable() 决定错误能否进入重试处理;handle_retryable_response_stream_error 再根据计数和 provider 策略决定是否还有下一次。超过上限后返回原错误,后续才可能进入模型 fallback。

图中重试仍属于同一次 V2 attempt:它不会生成新的 ContextCompactionItem,也不会写 trace checkpoint。只有最终成功返回,attempt 才把输出交给上层。

4. 输出校验 ​

collect_compaction_output 不接受“有一个看起来像摘要的 item”这种模糊成功。它必须看到 response.completed,并且整个流中恰好有一个 ResponseItem::Compaction。

源码位置:codex-rs/core/src/compact_remote_v2.rs :: collect_compaction_output

rust
if !saw_completed {
    return Err(CodexErr::Stream(
        "remote compaction v2 stream closed before response.completed".to_string(),
    ));
}

if compaction_count != 1 {
    return Err(CodexErr::Fatal(format!(
        "remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items"
    )));
}

Ok(RemoteCompactionV2Output {
    compaction_output,
    response_id,
    token_usage: completed_token_usage,
})

额外的 assistant output 不会自动成为摘要,也不会被安装;只要 compaction 数量不是 1,整个结果失败。测试 remote_compact_v2_accepts_additional_output_items_before_compaction 证明了一个特殊例外:流可以先出现其他 output item,但最终必须有唯一的 compaction item。

5. 尝试结果 ​

V2 attempt 返回的不只是摘要。RemoteCompactV2Attempt 同时携带原始 prompt 输入、compaction output、可选 token usage、trace 输入历史,以及 standalone 场景下保持存活的 client session。

源码位置:codex-rs/core/src/compact_remote_v2_attempt.rs :: RemoteCompactV2Attempt

rust
pub(super) struct RemoteCompactV2Attempt {
    pub(super) trace_input_history: Option<Vec<ResponseItem>>,
    pub(super) prompt_input: Vec<ResponseItem>,
    pub(super) prompt_input_metadata: Vec<Option<CodexHarnessMetadata>>,
    pub(super) compaction_output: ResponseItem,
    pub(super) token_usage: Option<TokenUsage>,
    /// Keeps a session created for standalone compaction alive through lifecycle completion.
    pub(super) owned_client_session: Option<ModelClientSession>,
}

prompt_input_metadata 与去掉 CompactionTrigger 后的 prompt_input 一一对应,使重建后的 ResponseItemEnvelope 能保留 client-authored、turn lineage 等 harness 标注。owned_client_session 的生命周期 也很容易被忽略:手动 standalone compact 没有外层 turn session 时,attempt 自己创建并持有它,直到生命周期完成。

6. 保留预算 ​

V2 的 replacement history 不是简单的“筛选后追加摘要”。build_v2_compacted_history 先筛选带 metadata 的原始消息,再按 RETAINED_MESSAGE_TOKEN_BUDGET = 64_000 从后往前保留,最后追加新的 compaction item。开启 retain_client_developer_messages 时,带 client_authored 标注的 developer message 也可保留。

源码位置:codex-rs/core/src/compact_remote_v2.rs :: build_v2_compacted_history

rust
let retained = v2_history_item_groups(prompt_input)
    .filter(|group| is_retained_for_remote_compaction_v2(group.source))
    .filter(|group| {
        should_keep_compacted_history_item(&group.source.item)
            || (retain_client_developer_messages
                && is_client_authored_developer_message(&group.source))
    })
    .flat_map(HistoryItemGroup::into_items)
    .cloned()
    .collect::<Vec<_>>();
let mut retained =
    truncate_retained_messages(retained, RETAINED_MESSAGE_TOKEN_BUDGET, image_budget);
let retained_image_count = retained
    .iter()
    .map(retained_input_image_count)
    .sum::<usize>();
retained.push(compaction_output);
(retained, retained_image_count)

第一层 is_retained_for_remote_compaction_v2 保留 user、developer、system message,以及未超过 10,000 token 且不是最终回答的 AgentMessage。第二层复用 远程Compact请求协议 的语义筛选,丢弃不可解析的 wrapper 和工具项。两个过滤器的顺序很重要:先决定类型范围,再执行统一语义规则。

7. 截断规则 ​

保留预算从最新 history group 向前消费。完整 group 能放下就整体保留;放不下时只对 message 文本做 token 截断,notice 会作为附属 item 一起计算。图片和音频不消耗这里的文本 token 预算,但图片数量会单独计入 analytics。

源码位置:codex-rs/core/src/compact_remote_v2.rs :: truncate_retained_messages_for_remote_compaction

rust
for group in history_item_groups(items)
    .collect::<Vec<_>>()
    .into_iter()
    .rev()
{
    if remaining == 0 {
        continue;
    }
    let notice_tokens = group
        .attached_notice
        .as_ref()
        .map_or(0, |notice| message_text_token_count(notice).max(1));
    let token_count = message_text_token_count(&group.source)
        .max(1)
        .saturating_add(notice_tokens);
    if token_count <= remaining {
        if let Some(notice) = group.attached_notice {
            truncated_reversed.push(notice);
        }
        truncated_reversed.push(group.source);
        remaining = remaining.saturating_sub(token_count);
    } else if remaining > notice_tokens
        && let Some(truncated_item) = truncate_message_text_to_token_budget(
            group.source,
            remaining - notice_tokens,
        )
    {
        if let Some(notice) = group.attached_notice {
            truncated_reversed.push(notice);
        }
        truncated_reversed.push(truncated_item);
        remaining = 0;
    }
}

因此 V2 的保留预算不是“保留最后 N 条消息”,而是“从最新 group 开始按估算 token 预算保留”。旧消息可能完全消失,最新消息也可能被文本截断;最终 compaction item 始终追加到尾部。

8. 模型降级 ​

流重试耗尽后,run_remote_compact_task_inner_impl 才检查是否有 fallback StepContext。没有 fallback,立即返回原错误;有 fallback 但错误不属于模型相关类别,也立即返回。只有 should_retry_with_current_model 返回 true 才会以 fallback model 重跑完整 V2 attempt。

源码位置:codex-rs/core/src/compact_remote.rs :: run_remote_compact_task_inner_impl

rust
let attempt = run_remote_compact_v2_attempt(
    sess,
    step_context,
    client_session.as_deref_mut(),
    &compaction_trace,
    compaction_metadata,
    analytics_details,
)
.await;
let (attempt, compaction_turn_context) = match attempt {
    Ok(attempt) => (attempt, turn_context),
    Err(error) => {
        let Some(fallback_step_context) = fallback_step_context else {
            return Err(error);
        };
        if !should_retry_with_current_model(&error) {
            return Err(error);
        }
        let fallback_result = run_remote_compact_v2_attempt(
            sess,
            fallback_step_context,
            client_session,
            &fallback_compaction_trace,
            compaction_metadata,
            analytics_details,
        )
        .await;
        record_model_fallback(/* telemetry arguments */);
        match fallback_result {
            Ok(attempt) => (attempt, fallback_turn_context),
            Err(_) => return Err(error),
        }
    }
};

可触发 fallback 的错误包括 invalid request、unexpected status、context window exceeded、usage limit、server overloaded、internal server error 和 retry limit。取消错误不在这个集合中,因此取消不会被伪装成模型 fallback。

9. 遥测提交 ​

V2 在 attempt 成功后把 server token usage 写入 analytics_details,发送 RawResponseCompleted,再进行 replacement history 处理。模型 fallback 无论成功还是失败都会调用 record_model_fallback,通过 reason、implementation 和 outcome 计数。

源码位置:codex-rs/core/src/compact_remote_v2.rs :: run_remote_compact_task_inner_impl

rust
if let Some(token_usage) = token_usage {
    sess.record_rollout_budget_usage(&token_usage)?;
    analytics_details.active_context_tokens_before = Some(token_usage.input_tokens);
    analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens);
    analytics_details.cached_input_tokens = Some(token_usage.cached_input_tokens);
    analytics_details.cache_write_input_tokens = Some(token_usage.cache_write_input_tokens);
}
let (compacted_history, retained_images) =
    build_v2_compacted_history(&prompt_input, compaction_output);
analytics_details.retained_image_count = Some(retained_images);

这意味着 token usage 已经可以被记录,但 history 仍可能在后续 process_compacted_history 或 replace_compacted_history 失败前尚未安装。调试时不要用“看到 usage event”证明压缩已提交。

10. 写回边界 ​

V2 成功后的写回顺序与 本地Compact执行与写回 共用:推进 window、处理历史、记录 trace checkpoint、替换 live history、重算 token、发送 completed item。失败 attempt 的 compaction_output 不会进入这条路径。

图中的 X 是 attempt 失败的语义边界;V2 不会把失败输出、部分流或旧 compaction item 混入新 replacement history。fallback 成功后重新从 O 开始,使用的是 fallback attempt 的输出。

11. 降级测试 ​

测试输入断言覆盖范围未覆盖边界
remote_compact_v2_reuses_compaction_trigger_for_followupsV2 开启、包含图片、工具项和 agent message/v1/responses、trigger、metadata、保留图片和 agent message、后续只保留 compaction 结果请求形状与保留规则摘要质量
remote_compact_v2_retries_failures_with_stream_retry_budget首次 500、随后流失败、第三次成功失败 attempt 被丢弃,按 stream retry budget 重试,follow-up 只含最终摘要流重试和结果隔离真实网络断连
remote_compact_v2_accepts_additional_output_items_before_compactionassistant output 后出现唯一 compaction允许额外 output,但必须恰好一个 compaction输出校验多个 compaction 的 provider 行为
remote_compact_v2_reuses_compaction_trigger_for_followups手动 compact 后 follow-upcompaction metadata 不泄漏到普通 follow-up,replacement item 被消费写回后的请求边界resume/fork

测试证明的是客户端协议和状态边界,不证明 provider 如何生成加密摘要、不证明 fallback 一定提升质量,也不覆盖取消后的服务器资源回收。

12. 源码练习 ​

  1. 在 collect_compaction_output 中构造“没有 response.completed”和“两个 compaction item”的 stream fixture,分别确认错误类型和错误消息。
  2. 将 RETAINED_MESSAGE_TOKEN_BUDGET 改成一个很小的值,观察最新 message 如何被截断、旧 message 如何被丢弃,并检查 attached notice 是否仍保持相邻关系。
  3. 在 remote_compact_v2_retries_failures_with_stream_retry_budget 中把 stream_max_retries 改成 0,确认失败输出不会写入 follow-up;不要只检查测试返回值,还要检查 request body。
  4. 给 should_retry_with_current_model 增加 TurnAborted 的临时分支并运行取消测试,说明为什么取消不应进入 model fallback。

13. 可执行验证 ​

在本版本源码对应的 codex-rs workspace 中,可以运行 V2 相关过滤器。当前仓库将 suite 测试汇总到统一 all target;若过滤结果显示 0 tests,表示测试入口或 feature 没有加载该 suite,不能计为通过。

bash
cd codex-rs
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all remote_compact_v2_retries_failures_with_stream_retry_budget -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib compact_remote_v2::tests -- --nocapture

第一条命令验证请求级重试;第二条验证保留预算、过滤和截断等本地单元规则。网络测试受显式 skip、凭据和测试 harness 影响,命令输出必须同时记录实际运行数量。