Skip to content

本地Compact提示词构造

追踪本地 compact 的提示词资产、合成请求、摘要识别、用户消息预算和初始上下文插入边界。

基于rust-v0.150.0
CodexRustContextCompact

本地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

rust
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

rust
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 :: 模板常量

rust
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 归一化

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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 测试

rust
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。阅读时应能回答三个问题:

  1. 把 compact_prompt 改成空白字符串时,为什么仍会回到默认模板?
  2. 连续两次 compact 时,旧 summary 为什么不会进入用户消息预算?
  3. pre-turn 请求失败时,为什么新用户消息既不在 compact request,也不在一个已经成功写回的摘要 history 中?

若需要观察真实请求,运行 manual_compact_uses_custom_prompt 并查看其 request input;若要观察截断边界,运行 build_token_limited_compacted_history_truncates_overlong_user_messages。这两条路径分别覆盖“资产进入请求”和 “结果重建历史”,也是修改本地 compact 代码前最小的回归基线。

bash
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