远程Compact请求协议
本文承接 本地Compact执行与写回,研究当前实现中远程 compact 的公共协议层。这里的“协议”不只是 URL:它包括调用方选择的模型与能力、由普通 Responses 请求复用的 body 字段、认证和 session headers、历史 item 的编码,以及服务端返回后哪些 item 能进入下一次模型请求。
本文不展开 V2 attempt 的重试分类和模型 fallback;那些行为属于后续文章。读完后,读者应该可以沿着代码回答:
- 为什么 compact 请求使用
/v1/responses/compact,却仍然携带普通 Responses 的tools、reasoning和缓存键; - API key、ChatGPT 和 Agent Identity 三种认证为什么得到不同的 header;
- 服务端返回的
developer、tool call 和普通 user message 为什么不会全部原样写回; - 请求失败、空输入和取消分别在哪一层结束,哪些状态尚未被替换。
1. 能力边界
远程 compact 的入口已经由上层选择为 CompactionImplementation::ResponsesCompact。自动压缩和手动压缩共享 请求构造器,但触发元数据不同:自动路径带有窗口压力原因,手动路径使用用户请求原因。协议层只消费这些元数据, 不会自行决定是否应该压缩。
图中最重要的边界是 D 与 F:HTTP 成功只代表服务端返回了候选 transcript,不代表 live history 已经改变;只有 后续筛选、上下文注入和 replace_compacted_history 完成,结果才会被下一次 turn 消费。
2. 请求入口
远程 attempt 先克隆会话历史,再为上下文窗口重写尾部 function-call output。这个步骤发生在网络请求之前,目的是让 Compact 服务收到一个仍然可解释、但不会因为最后几个巨大 tool output 而超过窗口的输入。
源码位置:codex-rs/core/src/compact_remote_request.rs :: run_remote_compact_attempt
let turn_context = &step_context.turn;
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,
);
if rewritten_outputs > 0 {
info!(
turn_id = %turn_context.sub_id,
rewritten_outputs,
"rewrote history outputs before remote compaction"
);
}注意这里修改的是克隆出来的 history,不是 session 的 live history。即使网络调用随后失败,当前会话仍保留原始历史; estimated_deleted_tokens 只用于分析数据修正,不会提前提交新的 token 状态。
请求输入随后转换为 Prompt。这里的 input_modalities 决定历史如何编码,model_visible_specs() 决定服务端看到的 工具集合;因此 compact 并不是只发送一段摘要提示词。
源码位置:codex-rs/core/src/compact_remote_request.rs :: run_remote_compact_attempt
let trace_input_history = compaction_trace
.is_enabled()
.then(|| history.raw_items().to_vec());
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
let tool_router = &step_context.tool_router;
let prompt = Prompt {
input: prompt_input,
tools: tool_router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
base_instructions,
output_schema: None,
output_schema_strict: true,
};output_schema 明确为空,因为 compact 的结果是新的 ResponseItem 列表,而不是普通 turn 的结构化最终答案。 但 tools 和 parallel_tool_calls 仍然保留,是为了让 provider 使用与当前模型请求一致的上下文能力。
3. 请求载荷
ModelClient::compact_conversation_history 先调用已有的 build_responses_request,再从普通请求中解构出 compact endpoint 支持的字段。这种实现把模型选择、指令渲染、推理设置和缓存键的来源集中到一条路径,降低了普通 Responses 与 compact 请求发生字段漂移的风险。
源码位置:codex-rs/core/src/client.rs :: ModelClient::compact_conversation_history
let request = self.build_responses_request(
&client_setup.api_provider,
prompt,
model_info,
settings.effort,
settings.summary,
settings.service_tier,
responses_metadata,
)?;
let ResponsesApiRequest {
model,
instructions,
mut input,
tools,
parallel_tool_calls,
reasoning,
service_tier,
prompt_cache_key,
text,
..
} = request;
self.prepare_response_items_for_request(&mut input);
let payload = ApiCompactionInput {
model: &model,
input: &input,
instructions: &instructions,
tools,
parallel_tool_calls,
reasoning,
service_tier: service_tier.as_deref(),
prompt_cache_key: prompt_cache_key.as_deref(),
text,
};可以把 body 分成三组来阅读:
| 组 | 字段 | 消费者 |
|---|---|---|
| 历史 | input、instructions | Compact 服务的 transcript 归纳器 |
| 能力 | tools、parallel_tool_calls、text | provider 的请求解释器 |
| 控制 | model、reasoning、service_tier、prompt_cache_key | 路由、推理和缓存层 |
prepare_response_items_for_request 在包装 payload 前运行,说明历史的 wire 编码是请求协议的一部分,而不是服务端 收到后才补救的内部细节。
空输入是一个明确的短路边界:
源码位置:codex-rs/core/src/client.rs :: ModelClient::compact_conversation_history
if prompt.input.is_empty() {
return Ok(Vec::new());
}此时不会创建 transport、不会发 HTTP 请求,也不会得到 replacement history。上层若继续安装结果,安装的只能是空列表; 因此调用方必须保证空 compact 不会被误判为一次成功摘要。
4. 认证边界
compact 请求的 endpoint 由 provider transport 拼接为 /v1/responses/compact。认证本身由 ApiCompactClient 使用 当前 api_auth 完成,Codex 额外添加安装、session、thread 和兼容性 headers。
下面的 headers 由同一个方法加入,因此调试抓包时应把它们视作一个协议集合,而不是普通 Responses 请求的可选装饰。
源码位置:codex-rs/core/src/client.rs :: ModelClient::compact_conversation_history
let mut extra_headers = ApiHeaderMap::new();
if let Ok(header_value) = HeaderValue::from_str(&responses_metadata.installation_id) {
extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value);
}
extra_headers.extend(build_responses_headers(
self.state.beta_features_header.as_deref(),
turn_state.as_ref(),
));
add_originator_header(&mut extra_headers, self.state.originator.as_str());
extra_headers.extend(self.build_responses_compatibility_headers(responses_metadata));
extra_headers.extend(build_session_headers(
Some(responses_metadata.session_id.to_string()),
Some(responses_metadata.thread_id.to_string()),
));Agent Identity 是认证差异中最容易漏读的一项。测试没有断言“所有请求都使用 Bearer”,而是专门断言任务范围的 AgentAssertion,并同时校验 account id;这说明认证 scheme 与 compact endpoint 无关,取决于当前 session 的 CodexAuth。
源码位置:codex-rs/core/tests/suite/compact_remote.rs :: remote_compact_uses_agent_identity_assertion
assert_eq!(compact_request.path(), "/v1/responses/compact");
assert!(
compact_request
.header("authorization")
.is_some_and(|value| value.starts_with("AgentAssertion ")),
"compact request should use task-scoped AgentAssertion auth"
);
assert_eq!(
compact_request.header("chatgpt-account-id").as_deref(),
Some("account-compact")
);5. 服务差异
CompactConversationRequestSettings 只把 service_tier 在非 API key 认证下传入。这个判断发生在远程 attempt,而 不是 provider 内部,因此请求构造阶段已经知道认证模式。
源码位置:codex-rs/core/src/compact_remote_request.rs :: run_remote_compact_attempt
CompactConversationRequestSettings {
effort: turn_context.reasoning_effort.clone(),
summary: turn_context.reasoning_summary,
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
None
} else {
turn_context.config.service_tier.clone()
},
}测试用同一个 parity helper 构造五轮不同输入,再比较普通 /responses 与 compact request。API key 的断言是 service_tier == None,ChatGPT auth 的断言则是 Some("priority");两者都要求复用 prompt_cache_key,并且 都不把 responses-only 字段带进 compact body。
源码位置:codex-rs/core/tests/suite/compact_remote.rs :: assert_remote_manual_compact_request_parity
assert_remote_manual_compact_request_parity(
CodexAuth::from_api_key("dummy"),
Some(ServiceTier::Fast),
None,
"remote_manual_compact_api_auth_prompt_cache_key_request_diff",
"After five varied API-key-auth turns, remote manual compaction omits service_tier, reuses prompt_cache_key, and still omits responses-only fields.",
)
.await?;Bedrock 测试进一步说明“provider 能发 compact 请求”与“启用 V2”是两个维度:Amazon Bedrock 仍使用 v1 endpoint, 请求 model 使用 Bedrock provider model,响应中的 type: compaction item 会进入后续 Responses 请求。
6. 历史协议
服务端返回的响应项在进入 Session 前会被包装为 ResponseItemEnvelope。Codex 不会把列表原样交给 Session。process_compacted_history 先按 history_item_groups 还原关联项,再调用 should_keep_compacted_history_item 做协议到内部语义的筛选。
源码位置:codex-rs/core/src/compact_remote.rs :: process_compacted_history
// Remote output is filtered before replacement-history installation.
let (initial_context, world_state_baseline) =
build_compaction_initial_context(sess, initial_context_injection).await;
let compacted_history = history_item_groups(compacted_history)
.filter(|group| should_keep_compacted_history_item(&group.source))
.flat_map(HistoryItemGroup::into_items)
.collect();
(
insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context),
world_state_baseline,
)筛选规则不是按 role 做简单白名单。真实 user message 需要经过 parse_turn_item,hook prompt 也会保留; developer 消息和普通工具调用则丢弃。这样做的原因是远程输出可能包含过时的指令包装,不能让它覆盖当前 session 重新构造的初始上下文。
源码位置:codex-rs/core/src/compact_remote.rs :: should_keep_compacted_history_item
pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { role, .. } if role == "developer" => false,
ResponseItem::Message { role, .. } if role == "user" => {
matches!(
crate::event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_))
)
}
ResponseItem::Message { role, .. } if role == "assistant" => true,
ResponseItem::AgentMessage { .. } => true,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger { .. } => false,
_ => false,
}
}图中“保留”仍然不是 live history:注入策略决定初始 context 放在最后一个真实 user 或 summary 之前还是留给下一次 普通 turn。DoNotInject 用于手动和 pre-turn;mid-turn 才需要立即注入,以便 continuation 仍能看到当前 turn 的 系统上下文。
远程任务还会为 ContextCompactionItem 建立一个 trace compaction ID,把 lifecycle event、endpoint attempt 和安装 checkpoint 关联起来。fallback attempt 复用该 compaction ID,但使用 fallback TurnContext 的模型与 provider;只有成功的 attempt 才进入窗口推进和 history 安装。
源码位置:codex-rs/core/src/compact_remote.rs :: run_remote_compact_task_inner_impl
let compaction_id = context_compaction_item.id.clone();
let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context(
turn_context.sub_id.as_str(),
compaction_id.as_str(),
turn_context.model_info.slug.as_str(),
turn_context.provider.info().name.as_str(),
);7. 超时取消
Compact endpoint 是 unary 调用,没有 SSE 增量事件,所以 timeout 不能沿用一次流式响应的等待语义。客户端把 provider 的 stream_idle_timeout 乘以 4,作为一次 compact 请求的 idle timeout。
源码位置:codex-rs/core/src/client.rs :: ModelClient::compact_conversation_history
let compact_request_timeout = client_setup
.api_provider
.stream_idle_timeout
.saturating_mul(COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER);
let client = ApiCompactClient::new(
transport,
client_setup.api_provider,
client_setup.api_auth,
)
.with_telemetry(Some(request_telemetry));
let result = client
.compact_input(
&payload,
extra_headers,
compact_request_timeout,
turn_state.as_deref(),
)
.await
.map_err(|error| self.state.provider.map_api_error(error));失败时 result 只返回错误,run_remote_compact_attempt 也只返回 Err;窗口推进和 history 替换都在上层成功分支 之后。因此 HTTP 错误、认证错误、超时或取消不会把半成品写进 live history。V2 的 fallback 和失败分类不在本文展开, 但这个返回边界是后续降级逻辑能够安全重试的前提。
这张图只说明客户端的写回边界,不表示 provider 一定会在取消后立即停止服务器端计算。
8. 写回边界
远程响应变成可见历史的最后一段代码如下。先推进窗口,再过滤和注入,最后安装、重算 token、发送完成事件。
源码位置:codex-rs/core/src/compact_remote.rs :: run_remote_compact_task_inner_impl
let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await;
let (new_history, world_state_baseline) =
process_compacted_history(sess.as_ref(), new_history, &initial_context_injection).await;
sess.replace_compacted_history(
new_history,
reference_context_item,
world_state_baseline,
CompactedHistoryMetadata {
message: String::new(),
window_number: new_window_number,
window_ids: new_window_ids,
},
)
.await;
sess.recompute_token_usage(compaction_turn_context).await;这段顺序给出了一个可执行的调试断点表:网络成功但没有 replace_compacted_history,问题在响应处理;history 已替换 但 token 未更新,问题在提交后的重算;下一次请求仍带旧 item,则应检查恢复或 request history 视图,而不是重新猜测 Compact API 的摘要内容。
这张图把三个层次分开:Prompt 是 Codex 内部输入,ResponsesApiRequest 是公共请求构造结果, ApiCompactionInput 才是 compact endpoint 的 body;返回值重新回到 ResponseItem,之后还要经过历史筛选。
9. 协议测试
测试的输入、断言和覆盖边界必须分开阅读:
| 测试 | 输入 | 断言 | 未覆盖范围 |
|---|---|---|---|
remote_compact_replaces_history_for_followups | 一次正常 turn 后手动 compact | endpoint、headers、request kind、字段复用、follow-up 只带 replacement item | provider 摘要质量 |
remote_manual_compact_api_auth_omits_service_tier_and_reuses_prompt_cache_key | API key、配置 fast tier、多轮混合输入 | 不发送 tier,复用 cache key | ChatGPT/Agent Identity 认证 |
remote_manual_compact_chatgpt_auth_reuses_service_tier_and_prompt_cache_key | ChatGPT auth、fast tier | 发送 priority,复用 cache key | API key 的错误响应 |
remote_compact_uses_agent_identity_assertion | 任务范围 Agent Identity | AgentAssertion scheme 与 account id | assertion 过期后的刷新 |
amazon_bedrock_uses_remote_compaction_endpoint | Bedrock provider、V2 开启 | 仍使用 v1 endpoint,compaction item 进入后续请求 | 非 Bedrock provider 的路由 |
snapshot_request_shape_remote_mid_turn_continuation_compaction | mid-turn continuation | 压缩后上下文注入与请求形状 | 网络中断后的恢复 |
这些测试能证明协议边界和安装前后的请求形状,不能证明服务端如何生成摘要,也不能替代取消、网络断开和 provider 限流的端到端测试。
10. 源码练习
- 在
compact_conversation_history的prepare_response_items_for_request前后打印input,比较内部ResponseItem与 wire item;解释哪些字段是编码归一化而不是历史筛选。 - 给
should_keep_compacted_history_item增加一个user角色但无法解析为TurnItem的 fixture,验证它不会 进入 replacement history,并说明这如何防止旧 developer 包装泄漏。 - 将 API key parity 测试中的
expected_service_tier临时改为Some("priority"),观察断言失败位置;再反向修改run_remote_compact_attempt,确认测试能捕获认证策略回归。 - 在 HTTP 返回错误时记录
Session::current_window_id和history_version,证明错误路径不会触发写回;不要只检查 UI 的 error event。
完成这些练习后,读者应能从一个 compact 抓包反查到 Prompt、ResponsesApiRequest、ApiCompactionInput 和 process_compacted_history,并判断问题发生在请求构造、provider 传输还是 replacement history 安装阶段。
11. 可执行验证
在本版本源码对应的 codex-rs workspace 中运行协议测试。若测试因环境凭据或显式 skip 被跳过,应把它说明为 “未执行”,不能把 0 tests 当作通过。
cd codex-rs
RUST_MIN_STACK=16777216 cargo test -p codex-core --test suite compact_remote_replaces_history_for_followups -- --exact --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --test suite remote_compact_uses_agent_identity_assertion -- --exact --nocapture第一条命令验证 endpoint、公共字段复用、headers 和 follow-up replacement history;第二条验证任务范围认证方案。 它们不能证明摘要内容质量、provider 限流行为或取消后的服务器端资源回收。
