本地Compact提示词构造
本地 compact 的职责不是“把历史直接拼给模型再让模型总结”。它先选择一个提示词资产,把它包装成一次合成的 用户输入;请求完成后,再从 compact turn 的输出中取出最后一条 assistant message,给它加上固定摘要前缀, 并按 20,000 个近似 token 的预算重建替换历史。这个过程同时决定了哪些真实用户消息还能保留、哪些消息会被识别为 旧摘要,以及 mid-turn 时初始上下文应该插在哪里。
本文面向已经读过 AutoCompact触发算法 的读者。AutoCompact触发算法 解决“何时 触发以及选择哪种实现”;本文只解决本地实现收到触发后,如何构造 compact 请求和替换历史,不展开远程 compact 协议,也不把最终 history 写入 rollout 的持久化细节当成本篇主线。读完后,读者应能从 compact.rs 追到模型 请求的 input,解释摘要为何总在最后,以及在请求过大、取消或自定义 prompt 时应该观察哪些源码和测试。
1. 入口边界
本地路径有两个入口:自动 compact 由 run_inline_auto_compact_task 进入,手动 /compact 由 run_compact_task 进入。二者都汇入 run_compact_task_inner,但注入策略和遥测元数据不同。自动路径只把 config.compact_prompt 或默认模板变成合成输入;手动路径的 input 来自任务层,任务层在本地 provider 分支中 使用同样的配置选择逻辑。
源码位置:codex-rs/core/src/compact.rs :: run_inline_auto_compact_task, run_compact_task
pub(crate) async fn run_inline_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
initial_context_injection: InitialContextInjection,
reason: CompactionReason,
phase: CompactionPhase,
) -> CodexResult<()> {
let prompt = turn_context
.config
.compact_prompt
.as_deref()
.unwrap_or(SUMMARIZATION_PROMPT)
.to_string();
let input = vec![UserInput::Text {
text: prompt,
text_elements: Vec::new(),
}];
run_compact_task_inner(
sess,
turn_context,
input,
initial_context_injection,
CompactionTrigger::Auto,
reason,
phase,
)
.await?;
Ok(())
}text_elements 为空不是遗漏。这个输入不是编辑器提交的用户文本,没有需要保留的 UI 范围;它的唯一用途是把 compact 指令作为一个普通 UserInput::Text 送进同一条请求管线。真正的历史仍由 Session 持有,入口函数不 复制或自行筛选历史。
图中要区分两种“输入”:提示词是本次 compact turn 新增的合成输入,历史则在汇合点由 Session 克隆。因而 “自定义 prompt”只替换合成消息,不会直接替换历史,也不会改变摘要前缀。
2. 模板资产
默认提示词不在 core 中以长字符串维护。core 重新导出 codex_prompts 的两个常量;prompts crate 再用 include_str! 在编译期嵌入模板文件。这样运行时只看到 &'static str,修改模板必须重新构建,且 summary_prefix.md 与 prompt.md 是两个不同职责的资产。
源码位置:codex-rs/core/src/compact.rs :: SUMMARIZATION_PROMPT, SUMMARY_PREFIX
pub use codex_prompts::SUMMARIZATION_PROMPT;
pub use codex_prompts::SUMMARY_PREFIX;
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;源码位置:codex-rs/prompts/src/compact.rs :: 模板常量
pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md");
pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md");prompt.md 要求模型生成交接摘要,内容包括当前进展、关键决策、约束、待办和关键引用;它描述的是“怎样写 摘要”。summary_prefix.md 是摘要消息的稳定开头,描述的是“怎样识别摘要”。这两个概念不能互换:前者进入 compact 请求,后者在请求完成后参与历史重建和摘要过滤。
配置解析还会丢弃空白 prompt;非空配置优先于默认值,实验性 prompt 文件作为更晚的回退来源。这个规则位于 配置模块,不应误读为 compact.rs 在每次请求时读取文件。
源码位置:codex-rs/core/src/config/mod.rs :: compact_prompt 归一化
let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let file_compact_prompt = Self::try_read_non_empty_file(
fs,
experimental_compact_prompt_path,
"experimental compact prompt file",
)
.await?;
let compact_prompt = compact_prompt.or(file_compact_prompt);3. 请求形状
run_compact_task_inner_impl 先发出 ContextCompactionItem 的开始事件,再把合成输入记录到 history 的克隆中。 随后每次循环都从这份 history 调用 for_prompt,并把结果与 base instructions 组成 Prompt。因此模型收到的 不是“只有 prompt 的请求”,而是“当前历史 + 合成 prompt + base instructions”。
源码位置:codex-rs/core/src/compact.rs :: run_compact_task_inner_impl
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
sess.emit_turn_item_started(&turn_context, &compaction_item)
.await;
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
let mut history = sess.clone_history().await;
history.record_items(
&[initial_input_for_turn.into()],
turn_context.model_info.truncation_policy.into(),
);
let turn_input = history
.clone()
.for_prompt(&turn_context.model_info.input_modalities);
let prompt = Prompt {
input: turn_input,
base_instructions: sess.get_base_instructions().await,
..Default::default()
};这也解释了 pre-turn 的一个边界:触发 compact 的新用户输入尚未进入这份 history 克隆,所以它不会自动成为 摘要模型的输入;mid-turn 则已经把当前 turn 的工具和模型产物记录进 history,compact 请求会看到这些内容。
模型流中的 OutputItemDone 会被记录回 session,Completed 才结束本次请求并更新 token usage。这个时序很重要: 摘要结果不是从 HTTP body 某个独立字段读取,而是作为本次 compact turn 的 assistant message 进入 history。
4. 历史筛选
请求成功后,代码从最新 history 快照中取得最后一条 assistant message,组成摘要文本;再遍历同一快照收集用户 消息。parse_turn_item 负责把协议 item 映射为 TurnItem,因此 assistant、tool、other item 不会被误当成 用户原文。
源码位置:codex-rs/core/src/compact.rs :: collect_user_messages, is_summary_message
let history_snapshot = sess.clone_history().await;
let history_items = history_snapshot.raw_items();
let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default();
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
let user_messages = collect_user_messages(history_items);summary_suffix 与 user_messages 来自同一份完成后快照,但筛选规则不同:前者只取本次 turn 最后的 assistant 文本,后者只保留被解析为真实用户消息的 envelope,同时保留 harness metadata,供 replacement history 继续 携带持久化标注。下面的函数实现了第二条规则。
源码位置:codex-rs/core/src/compact.rs :: collect_user_messages, is_summary_message
pub(crate) fn collect_annotated_user_messages(
items: &[ResponseItemEnvelope],
) -> Vec<CompactedUserMessage> {
items
.iter()
.filter_map(|envelope| match crate::event_mapping::parse_turn_item(&envelope.item) {
Some(TurnItem::UserMessage(user)) => {
if is_summary_message(&user.message()) {
None
} else {
Some(CompactedUserMessage {
message: user.message(),
internal_chat_message_metadata_passthrough: match &envelope.item {
ResponseItem::Message {
internal_chat_message_metadata_passthrough,
..
} => internal_chat_message_metadata_passthrough.clone(),
_ => None,
},
harness_metadata: envelope.metadata.clone(),
})
}
}
_ => None,
})
.collect()
}
pub(crate) fn is_summary_message(message: &str) -> bool {
message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str())
}识别条件是前缀加换行,而不是“消息里包含 summary”或 role 等宽松猜测。这样重复 compact 时,旧摘要会被排除, 不会作为新的真实用户消息再次占用预算。用户消息的 passthrough metadata 会被复制到重建项,摘要消息则不继承 这份用户 metadata。
5. 用户预算
build_compacted_history 的输入是已经筛选过的用户消息,不是完整 history。它从新到旧倒序选择,优先保留最近 消息;完整消息未超出剩余预算就整体保留,第一条超预算的消息按剩余 token 截断,然后停止继续向更旧方向选择。 最后把选中的消息恢复为旧到新的顺序,并追加摘要。
源码位置:codex-rs/core/src/compact.rs :: build_compacted_history_with_limit
let mut selected_messages: Vec<CompactedUserMessage> = Vec::new();
if max_tokens > 0 {
let mut remaining = max_tokens;
for message in user_messages.iter().rev() {
if remaining == 0 {
break;
}
let tokens = approx_token_count(&message.message);
if tokens <= remaining {
selected_messages.push(message.clone());
remaining = remaining.saturating_sub(tokens);
} else {
let truncated =
truncate_text(&message.message, TruncationPolicy::Tokens(remaining));
selected_messages.push(CompactedUserMessage {
message: truncated,
internal_chat_message_metadata_passthrough: message
.internal_chat_message_metadata_passthrough
.clone(),
});
break;
}
}
selected_messages.reverse();
}倒序扫描只负责决定保留哪些消息;第二段代码负责把选择结果变成协议 item,并无条件把摘要放到末尾。
源码位置:codex-rs/core/src/compact.rs :: build_compacted_history_with_limit
for message in &selected_messages {
history.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: message.message.clone(),
}],
phase: None,
internal_chat_message_metadata_passthrough: message
.internal_chat_message_metadata_passthrough
.clone(),
});
}
let summary_text = if summary_text.is_empty() {
"(no summary available)".to_string()
} else {
summary_text.to_string()
};
history.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: summary_text }],
phase: None,
internal_chat_message_metadata_passthrough: None,
});这里的 approx_token_count 是筛选预算的估算器,不是 provider 最终 tokenizer 的精确账单。20,000 是“保留的 用户消息”上限;摘要本身不从这段预算中扣除。若摘要为空,代码仍追加一个 user message,文本为 (no summary available),所以替换历史不会因为模型没有返回 assistant 文本而没有末尾标记。
6. 上下文插入
初始上下文是否重新插入由 InitialContextInjection 决定。DoNotInject 返回空 items 和 None baseline, 适用于 pre-turn/manual:压缩后清掉 reference context,下一次普通 turn 再完整注入。mid-turn 使用 BeforeLastUserMessage,先用同一个 WorldState 构造 items 和 baseline,再把 items 插到最后一个真实用户或 agent message 之前。
源码位置:codex-rs/core/src/compact.rs :: build_compaction_initial_context
pub(crate) async fn build_compaction_initial_context(
sess: &Session,
initial_context_injection: &InitialContextInjection,
) -> (Vec<ResponseItem>, Option<Arc<WorldState>>) {
match initial_context_injection {
InitialContextInjection::BeforeLastUserMessage {
world_state,
step_context,
} => {
let items = sess
.build_initial_context_with_world_state(
step_context.turn.as_ref(),
world_state.as_ref(),
)
.await;
(items, Some(Arc::clone(world_state)))
}
InitialContextInjection::DoNotInject => (Vec::new(), None),
}
}插入函数按四级优先级寻找位置:最后真实 user/agent、摘要、最后一个 compaction item、末尾追加。目标始终是让 summary 或 compaction item 保持最后;这不是简单的 push(initial_context)。
源码位置:codex-rs/core/src/compact.rs :: insert_initial_context_before_last_real_user_or_summary
let insertion_index = last_real_user_index
.or(last_user_or_summary_index)
.or(last_compaction_index);
if let Some(insertion_index) = insertion_index {
compacted_history.splice(insertion_index..insertion_index, initial_context);
} else {
compacted_history.extend(initial_context);
}7. 失败路径
compact 请求使用同一个 ModelClientSession 重试,以保留 turn 级 routing 和 websocket 增量状态。失败分类决定 是否重试:取消和中断立即返回;session 预算超限直接记录并发错误事件;context window 超限则从 history 开头 移除一个 item 后重试,以保留较新的消息;其他错误按 provider 的 stream_max_retries 和 backoff 重试。
注意,context 超限的 trim 只作用于 compact 请求使用的本地 history 克隆,不等于已经成功替换了 session history。 只有模型请求成功、摘要和新 history 构造完成后,后续代码才会调用 replace_compacted_history;失败不会伪造 一个“已压缩”的检查点。
8. 提示词测试
单元测试直接验证算法边界。build_token_limited_compacted_history_truncates_overlong_user_messages 用 16 个 token 的小预算输入超长消息,断言结果包含截断标记且不再包含完整原文,同时断言 summary 仍是最后一项。 build_compacted_history_preserves_user_message_passthrough_metadata 证明真实用户消息的 metadata 会复制到重建 项,而摘要项没有该 metadata。
源码位置:codex-rs/core/src/compact_tests.rs :: 预算与 metadata 测试
let history = super::build_compacted_history_with_limit(
Vec::new(),
std::slice::from_ref(&user_message),
"SUMMARY",
max_tokens,
);
assert_eq!(history.len(), 2);
assert!(truncated_text.contains("tokens truncated"));
assert!(!truncated_text.contains(&big));
assert_eq!(summary_text, "SUMMARY");请求级测试验证代码确实把 prompt 放进 provider 请求,而不是只在内存中选择了字符串。 manual_compact_uses_custom_prompt 设置 config.compact_prompt,检查 request input 含自定义文本且不含默认 模板;snapshot_request_shape_mid_turn_continuation_compaction 则在工具调用后触发 mid-turn compact,断言 compact 请求包含工具输出和 SUMMARIZATION_PROMPT,并用 snapshot 保存 compact 后的历史布局。
源码位置:codex-rs/core/tests/suite/compact.rs :: manual_compact_uses_custom_prompt, snapshot_request_shape_mid_turn_continuation_compaction
另一个边界测试 snapshot_request_shape_pre_turn_compaction_context_window_exceeded 将 provider 返回 context_length_exceeded,并断言 compact 请求不含即将进入的新用户消息,最终收到 context window 错误。它证明的是 请求形状和错误传播,不证明摘要内容质量、provider tokenizer 与 approx_token_count 完全一致,或远程 compact 的 服务端替换语义。
9. 源码练习
可以用下面的搜索路线复现本文主线:先打开 run_inline_auto_compact_task,跟进 run_compact_task_inner_impl 的 history.record_items 和 drain_to_completed;请求成功后继续到 collect_user_messages、build_compacted_history_with_limit,最后检查 insert_initial_context_before_last_real_user_or_summary。阅读时应能回答三个问题:
- 把
compact_prompt改成空白字符串时,为什么仍会回到默认模板? - 连续两次 compact 时,旧 summary 为什么不会进入用户消息预算?
- pre-turn 请求失败时,为什么新用户消息既不在 compact request,也不在一个已经成功写回的摘要 history 中?
若需要观察真实请求,运行 manual_compact_uses_custom_prompt 并查看其 request input;若要观察截断边界,运行 build_token_limited_compacted_history_truncates_overlong_user_messages。这两条路径分别覆盖“资产进入请求”和 “结果重建历史”,也是修改本地 compact 代码前最小的回归基线。
RUST_MIN_STACK=16777216 cargo test -p codex-core \
compact::tests::build_token_limited_compacted_history_truncates_overlong_user_messages
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all \
compact::manual_compact_uses_custom_prompt