远程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
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
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
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
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
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
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
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
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_followups | V2 开启、包含图片、工具项和 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_compaction | assistant output 后出现唯一 compaction | 允许额外 output,但必须恰好一个 compaction | 输出校验 | 多个 compaction 的 provider 行为 |
remote_compact_v2_reuses_compaction_trigger_for_followups | 手动 compact 后 follow-up | compaction metadata 不泄漏到普通 follow-up,replacement item 被消费 | 写回后的请求边界 | resume/fork |
测试证明的是客户端协议和状态边界,不证明 provider 如何生成加密摘要、不证明 fallback 一定提升质量,也不覆盖取消后的服务器资源回收。
12. 源码练习
- 在
collect_compaction_output中构造“没有response.completed”和“两个 compaction item”的 stream fixture,分别确认错误类型和错误消息。 - 将
RETAINED_MESSAGE_TOKEN_BUDGET改成一个很小的值,观察最新 message 如何被截断、旧 message 如何被丢弃,并检查 attached notice 是否仍保持相邻关系。 - 在
remote_compact_v2_retries_failures_with_stream_retry_budget中把stream_max_retries改成 0,确认失败输出不会写入 follow-up;不要只检查测试返回值,还要检查 request body。 - 给
should_retry_with_current_model增加TurnAborted的临时分支并运行取消测试,说明为什么取消不应进入 model fallback。
13. 可执行验证
在本版本源码对应的 codex-rs workspace 中,可以运行 V2 相关过滤器。当前仓库将 suite 测试汇总到统一 all target;若过滤结果显示 0 tests,表示测试入口或 feature 没有加载该 suite,不能计为通过。
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 影响,命令输出必须同时记录实际运行数量。
