Skip to content

ContextHistory读写

从 ContextManager 的真实实现出发,分析内存历史如何过滤并截断输入、共享只读快照、生成模型请求、按回合回滚,以及用版本号保护增量消费者。

基于rust-v0.150.0
CodexRustContext

ContextHistory读写 ​

Codex 中至少有三种容易被叫作“历史”的对象:供模型继续推理的内存 history、用于后续会话重建的 rollout,以及保存输入框记录的 message-history JSONL。本文只研究第一种,也就是 SessionState.history: ContextManager。

问题不是“怎样把一个 Vec 追加几项”,而是:一次追加怎样变成多个并发消费者可安全读取的快照,模型请求为何不能直接使用原始容器,回滚和压缩又怎样让旧的增量游标失效?

读者需要熟悉 Rust 的 Arc、Clone、slice 和所有权转移。可以先读 模型上下文体系总览 区分 history、prompt 与 rollout,再读 ContextItem角色语义 理解 ResponseItem 和回合边界。工具对归一化的全部算法留到下一篇展开;本文只说明它为什么发生在读取投影而不是普通追加阶段。

1. 容器边界 ​

1.1 Session所有权 ​

源码位置:codex-rs/core/src/state/session.rs :: SessionState

rust
/// Persistent, session-scoped state previously stored directly on `Session`.
pub(crate) struct SessionState {
    pub(crate) session_configuration: SessionConfiguration,
    pub(crate) history: ContextManager,
    pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
    pub(crate) server_reasoning_included: bool,
    pub(crate) mcp_dependency_prompted: HashSet<String>,
    pub(crate) additional_context: AdditionalContextStore,
    previous_turn_settings: Option<PreviousTurnSettings>,
    auto_compact_window: AutoCompactWindow,
    pub(crate) startup_prewarm: Option<SessionStartupPrewarmHandle>,
    pub(crate) current_time_reminder: CurrentTimeReminderState,
    pub(crate) active_connector_selection: HashSet<String>,
    pub(crate) pending_session_start_sources: VecDeque<SessionStartSource>,
    granted_permissions_by_environment_id: HashMap<String, AdditionalPermissionProfile>,
    next_turn_is_first: bool,
}

ContextManager 的 owner 是受 Session 状态锁保护的 SessionState。调用方通常不长期持有这把锁,而是调用 clone_history() 得到快照,再在锁外构造 prompt、Guardian transcript、compact 请求或诊断数据。这样,模型请求准备不必把 Session 的其他状态冻结在一次可能较长的遍历中。

这里的 ContextManager 不是 durable store:进程恢复依赖 rollout 重建后再调用 replace 或 record_items。它也不是 UI history;UI 可以消费事件或 thread projection,而模型 history 允许存在 reasoning、工具调用和上下文片段。

1.2 五类状态 ​

源码位置:codex-rs/core/src/context_manager/history.rs :: ContextManager

rust
/// Transcript of thread history
#[derive(Debug, Clone, Default)]
pub(crate) struct ContextManager {
    /// The oldest items are at the beginning of the vector. Snapshots share the vector until a
    /// caller needs to mutate it, avoiding deep copies for read-only history consumers.
    items: Arc<Vec<ResponseItem>>,
    /// Bumped whenever history is rewritten, such as compaction or rollback.
    history_version: u64,
    token_info: Option<TokenUsageInfo>,
    reference_context_item: Option<TurnContextItem>,
    /// World state most recently appended to model-visible history.
    world_state_baseline: Option<WorldStateSnapshot>,
}
字段所表达的状态普通追加整体替换
items从旧到新的模型历史尾部追加换成新 Arc<Vec<_>>
history_versionhistory lineage 是否被重写不变饱和加一
token_info最近服务端 usage 与累计值独立更新replace 不清除
reference_context_item下一轮设置 diff 的基线独立更新由上层决定,rollback 可能清除
world_state_baseline最近注入的 world-state 快照独立更新清除

history_version 不是“当前 item 数量”,也不是每次 mutation 都递增的 revision。它只标记会破坏旧索引含义的重写。普通追加以后,旧 transcript 仍是新 transcript 的前缀,因此增量消费者还可以用 entry count 继续读取;压缩、恢复替换或回滚后,相同的索引可能指向完全不同的 item,必须改变 lineage。

2. 追加路径 ​

2.1 入口过滤 ​

源码位置:codex-rs/core/src/context_manager/history.rs :: record_items

rust
/// `items` is ordered from oldest to newest.
pub(crate) fn record_items<I>(&mut self, items: I, policy: TruncationPolicy)
where
    I: IntoIterator,
    I::Item: std::ops::Deref<Target = ResponseItem>,
{
    for item in items {
        let item_ref = item.deref();
        if !is_api_message(item_ref) {
            continue;
        }

        let processed = Self::process_item(item_ref, policy);
        Arc::make_mut(&mut self.items).push(processed);
    }
}

追加包含三个有顺序的动作:先用 is_api_message 决定是否进入模型历史,再用 process_item 对可无限增长的工具输出执行预算截断,最后才通过 Arc::make_mut 写入。顺序很重要:被过滤的控制项不会触发无意义的 vector 写时复制;实际存储的是处理后的 owned item,而不是调用方对象的引用。

过滤条件也不是“只保存 user 和 assistant”。

源码位置:codex-rs/core/src/context_manager/history.rs :: is_api_message

rust
fn is_api_message(message: &ResponseItem) -> bool {
    match message {
        ResponseItem::Message { role, .. } => role.as_str() != "system",
        ResponseItem::AdditionalTools { .. }
        | ResponseItem::AgentMessage { .. }
        | ResponseItem::FunctionCallOutput { .. }
        | ResponseItem::FunctionCall { .. }
        | ResponseItem::ToolSearchCall { .. }
        | ResponseItem::ToolSearchOutput { .. }
        | ResponseItem::CustomToolCall { .. }
        | ResponseItem::CustomToolCallOutput { .. }
        | ResponseItem::LocalShellCall { .. }
        | ResponseItem::Reasoning { .. }
        | ResponseItem::WebSearchCall { .. }
        | ResponseItem::ImageGenerationCall { .. }
        | ResponseItem::Compaction { .. }
        | ResponseItem::ContextCompaction { .. } => true,
        ResponseItem::CompactionTrigger { .. } => false,
        ResponseItem::Other => false,
    }
}

system message、CompactionTrigger 和 Other 不进入容器;developer/user/assistant message、工具对、reasoning 和压缩结果都可以进入。CompactionTrigger 只在构造特定远程压缩请求时临时追加到 prompt,不应污染 Session 的长期 history。

2.2 输出截断 ​

源码位置:codex-rs/core/src/context_manager/history.rs :: process_item

rust
fn process_item(item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem {
    let policy_with_serialization_budget = policy * 1.2;
    match item {
        ResponseItem::FunctionCallOutput {
            id,
            call_id,
            output,
            internal_chat_message_metadata_passthrough: metadata,
        } => ResponseItem::FunctionCallOutput {
            id: id.clone(),
            call_id: call_id.clone(),
            output: truncate_function_output_payload(
                output,
                policy_with_serialization_budget,
            ),
            internal_chat_message_metadata_passthrough: metadata.clone(),
        },
        ResponseItem::CustomToolCallOutput {
            id,
            call_id,
            name,
            output,
            internal_chat_message_metadata_passthrough: metadata,
        } => ResponseItem::CustomToolCallOutput {
            id: id.clone(),
            call_id: call_id.clone(),
            name: name.clone(),
            output: truncate_function_output_payload(
                output,
                policy_with_serialization_budget,
            ),
            internal_chat_message_metadata_passthrough: metadata.clone(),
        },
        _ => item.clone(),
    }
}

当前入库截断只重建 FunctionCallOutput 和 CustomToolCallOutput,并保留 ID、call ID、name、success 与内部 metadata。policy * 1.2 为 JSON 序列化开销预留空间;它不等于模型精确 tokenizer 预算。其他 item 在这一层完整 clone,图片、音频和不完整工具对要到 prompt 投影时再处理。

3. 快照成本 ​

3.1 写时复制 ​

#[derive(Clone)] 对 items: Arc<Vec<_>> 只增加强引用计数,不会立即 clone 整个 Vec<ResponseItem>。因此 SessionState::clone_history() 适合把一致的历史视图交给多个只读消费者。

源码位置:codex-rs/core/src/state/session.rs :: clone_history

rust
pub(crate) fn clone_history(&self) -> ContextManager {
    self.history.clone()
}

一旦任一快照调用 record_items、remove_first_item 或 prompt normalization,Arc::make_mut 会检查是否独占:强引用计数为一时原地修改,否则 clone 底层 vector 后再修改。成本由“是否仍有共享者”决定,而不是由方法名是否叫 clone 决定。

操作是否消费快照独占时仍共享时
raw_items()否借用 slice借用同一 slice
clone()否Arc 计数增加Arc 计数增加
into_raw_items()是unwrap 原 vectorclone vector
for_prompt()是原地归一化后 unwrap先 copy-on-write,再返回 vector
record_items()否原地 pushclone vector 后 push
replace()否新建 Arc新建 Arc,不复制旧 vector

这个设计优化的是“快照经常只读”的工作负载。如果多个消费者都要修改各自的完整历史,每个消费者仍可能承担一次深拷贝;Arc 没有消除这项成本,只把它推迟到真正需要 mutation 的路径。

3.2 三种遍历 ​

源码位置:codex-rs/core/src/context_manager/history.rs :: raw_items、into_raw_items、for_prompt

rust
/// Returns the history prepared for sending to the model.
pub(crate) fn for_prompt(mut self, input_modalities: &[InputModality]) -> Vec<ResponseItem> {
    self.normalize_history(input_modalities);
    Arc::unwrap_or_clone(self.items)
}

/// Returns raw items in the history.
pub(crate) fn raw_items(&self) -> &[ResponseItem] {
    &self.items
}

/// Returns raw items in the history and consumes the snapshot.
pub(crate) fn into_raw_items(self) -> Vec<ResponseItem> {
    Arc::unwrap_or_clone(self.items)
}

raw_items() 用于 shutdown turn 计数、Guardian transcript、Realtime 上下文和 trace 等需要观察真实容器的消费者。into_raw_items() 用于恢复结果等已经拥有快照、需要取得 owned vector 的路径。for_prompt() 则是模型边界:它会补齐缺失工具输出、删除孤儿输出,并按模型能力剥离不支持的图片或音频。

因此,raw_items().to_vec() 与 clone().for_prompt(...) 不是等价写法。前者保留原始不完整形状,适合 trace;后者生成满足模型输入约束的投影。把 trace 改成只记录 for_prompt 结果,会掩盖原容器中曾经存在的残缺工具对。

4. 头部删除 ​

4.1 配对不变量 ​

本地 compact 遇到 ContextWindowExceeded 时,会从最旧端逐步删除 item,以保留最近内容和 prefix cache。删除不能只执行 Vec::remove(0):如果最旧项是 call 或 output,它的配对项也必须一起删除,否则下一次 prompt normalization 会看到人为制造的孤儿。

源码位置:codex-rs/core/src/context_manager/history.rs :: remove_first_item

rust
pub(crate) fn remove_first_item(&mut self) {
    if !self.items.is_empty() {
        // Items are ordered from oldest → newest, so index 0 is the first entry recorded.
        let items = Arc::make_mut(&mut self.items);
        let removed = items.remove(0);
        // Keep call/output invariants intact without running a full normalization pass.
        normalize::remove_corresponding_for(items, &removed);
        self.world_state_baseline = None;
    }
}

Vec::remove(0) 要移动后续元素,因此一次删除是线性成本;寻找并删除远处 counterpart 还会增加遍历和移动。这个方法服务于“窗口超限后的降级重试”,不是高频队列操作。若将来该路径成为性能热点,不能简单改成 VecDeque 就结束:代码还依赖 slice、位置索引、范围复制以及 normalization 对连续 vector 的操作,需要一起评估。

4.2 Compact消费者 ​

源码位置:codex-rs/core/src/compact.rs :: run_compact_task_inner_impl

rust
let mut history = sess.clone_history().await;
history.record_items(
    &[initial_input_for_turn.into()],
    turn_context.model_info.truncation_policy.into(),
);

loop {
    let turn_input = history
        .clone()
        .for_prompt(&turn_context.model_info.input_modalities);
    let turn_input_len = turn_input.len();
    let prompt = Prompt {
        input: turn_input,
        base_instructions: sess.get_base_instructions().await,
        ..Default::default()
    };

    // ... drain model response ...

    if matches!(error.details(), CodexErrorDetails::ContextWindowExceeded) {
        if turn_input_len > 1 {
            history.remove_first_item();
            retries = 0;
            continue;
        }
        // 单项仍超限时结束,而不是删除到空历史后无限重试。
    }
}

注意这里有两层快照:compact task 的 history 可以反复裁剪,而每次请求前再 clone 一份供 for_prompt 消费。一次 normalization 不会把 synthetic output 或媒体剥离结果写回下一次重试所使用的原始 compact history;下一轮仍从裁剪后的 raw snapshot 重新投影。

5. 回合回滚 ​

5.1 切点计算 ​

按回合删除不能从尾部寻找字符串 role == "user"。上下文 user fragment 不是用户回合,而结构化 AgentMessage 和 assistant inter-agent instruction 可以作为 instruction turn。边界判定由 is_user_turn_boundary 统一提供。

源码位置:codex-rs/core/src/context_manager/history.rs :: drop_last_n_user_turns

rust
pub(crate) fn drop_last_n_user_turns(&mut self, num_turns: u32) {
    if num_turns == 0 {
        return;
    }

    let snapshot = self.items.clone();
    let user_positions = user_message_positions(&snapshot);
    let Some(&first_instruction_turn_idx) = user_positions.first() else {
        self.replace(Arc::unwrap_or_clone(snapshot));
        return;
    };

    let n_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
    let mut cut_idx = if n_from_end >= user_positions.len() {
        first_instruction_turn_idx
    } else {
        user_positions[user_positions.len() - n_from_end]
    };

    cut_idx = self.trim_pre_turn_context_updates(
        &snapshot,
        first_instruction_turn_idx,
        cut_idx,
    );

    self.replace(snapshot[..cut_idx].to_vec());
}

算法先收集全部 instruction-turn 位置,再决定切片起点。请求删除的回合数超过现有回合数时,切到第一个真实回合之前,因此 session prefix 得以保留。usize::try_from(...).unwrap_or(usize::MAX) 和饱和语义确保极端 u32 输入不会产生下标溢出。

5.2 前置上下文 ​

真实用户输入之前可能紧邻 environment、permissions、plugins、collaboration mode 等 contextual update。如果只从 user item 开始截断,这些 update 会错误地留给更早的回合。trim_pre_turn_context_updates 因而从初始 cut 向前走,连续删除 contextual developer/user item,但绝不越过第一个真实回合边界。

源码位置:codex-rs/core/src/context_manager/history.rs :: trim_pre_turn_context_updates

rust
while cut_idx > first_instruction_turn_idx {
    match &snapshot[cut_idx - 1] {
        ResponseItem::Message { role, content, .. }
            if role == "developer" && is_contextual_dev_message_content(content) =>
        {
            if has_non_contextual_dev_message_content(content) {
                self.reference_context_item = None;
            }
            cut_idx -= 1;
        }
        ResponseItem::Message { role, content, .. }
            if role == "user" && is_contextual_user_message_content(content) =>
        {
            cut_idx -= 1;
        }
        _ => break,
    }
}

混合 developer bundle 同时包含可回滚 contextual fragment 和持久文本时,删除 bundle 后无法从 steady-state diff 还原先前完整基线。实现会把 reference_context_item 清空,让下一轮执行完整 reinjection;保留旧基线虽然看似省 token,却可能漏掉已经随 bundle 一同删除的持久设置。

6. 版本语义 ​

6.1 Replace重写 ​

源码位置:codex-rs/core/src/context_manager/history.rs :: replace

rust
pub(crate) fn replace(&mut self, items: Vec<ResponseItem>) {
    self.items = Arc::new(items);
    self.history_version = self.history_version.saturating_add(1);
    self.world_state_baseline = None;
}

replace 被压缩写回、rollout reconstruction、显式 history replacement 和 drop_last_n_user_turns 使用。它总会改变 history_version,包括“没有真实用户回合时回滚最终替换为同内容”这种保守路径。消费者因此不需要比较所有 item 来猜测索引是否仍然可靠。

record_items 没有提高 version 是有意设计。Guardian 保存 (parent_history_version, transcript_entry_count):版本相同且旧 count 不超过新 count 时,说明只发生了可安全增量读取的尾部扩展;版本不同则退回完整 transcript。

源码位置:codex-rs/core/src/guardian/prompt.rs :: GuardianTranscriptCursor

rust
/// Points to the end of the transcript that the guardian has already reviewed.
/// The saved count is only reusable when `parent_history_version` still matches.
#[derive(Clone, Copy, Debug)]
pub(crate) struct GuardianTranscriptCursor {
    pub(crate) parent_history_version: u64,
    pub(crate) transcript_entry_count: usize,
}

let prompt_shape = match mode {
    GuardianPromptMode::Full => GuardianPromptShape::Full,
    GuardianPromptMode::Delta { cursor } => {
        if cursor.parent_history_version == transcript_cursor.parent_history_version
            && cursor.transcript_entry_count <= transcript_cursor.transcript_entry_count
        {
            GuardianPromptShape::Delta {
                already_seen_entry_count: cursor.transcript_entry_count,
            }
        } else {
            GuardianPromptShape::Full
        }
    }
};
History变化versionentry countGuardian结果
尾部追加不变增加或不变可以发送 delta
prompt normalization只发生在消费快照只影响该快照Session cursor 不变
compaction replace增加可能减少发送 full
rollback增加通常减少发送 full
rollout恢复 replacement增加任意变化发送 full

这套协议把“内容谱系”和“当前长度”分开。只比较长度无法识别“压缩后长度恰好相同”,只比较版本又无法定位同一谱系中新追加的起点,两者必须组合。

7. 验证矩阵 ​

下面这些测试分别对应本文讨论的历史过滤、快照共享、截断、配对删除和回滚边界:

bash
just test -p codex-core filters_non_api_messages
just test -p codex-core cloned_history_shares_items_until_mutated
just test -p codex-core record_items_truncates_function_call_output_content
just test -p codex-core remove_first_item_removes_matching_output_for_function_call
just test -p codex-core drop_last_n_user_turns_preserves_prefix
just test -p codex-core drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn
just test -p codex-core drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles
测试构造的关键输入核心断言不能外推
filters_non_api_messagessystem、reasoning、Other、user、assistant只保留允许的 API items,顺序不变所有未来新增变体的策略
cloned_history_shares_items_until_mutated一个大 assistant item 后 clonemutation 前指针相同,追加后分离allocator 的具体耗时
record_items_truncates_function_call_output_content超长 function output 与 metadata内容有截断标记,metadata 保留tokenizer 精确 token 数
remove_first_item_removes_matching_output_for_function_call相邻 call/output删除 call 后 output 也消失所有 normalization 分支
drop_last_n_user_turns_preserves_prefixprefix + 两个 user turns删除一个或超量回合仍保留 prefixrollout event 的持久化顺序
drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn回合前多类 context updateupdate 与目标回合一起删除下一轮所有 fragment 的最终文本
drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundlescontextual + persistent developer bundlehistory 截断且 diff baseline 清空完整 reinjection 的 token 成本

还有一个容易误读的边界:部分 normalization 测试只在非 debug 构建启用,因为 debug 构建会对不完整工具对执行断言。本文的七项结果没有证明 release-only synthetic output 的所有分支;下一篇分析归一化算法时,需要分别验证 debug invariant 和 release 修复行为。

8. 源码导航 ​

如果要验证一次具体 history 异常,可以按现象选择入口,而不是从整个 Core 搜索“history”:

现象第一入口继续追踪
某 item 没进入模型历史record_items、is_api_message上游 SessionState::record_items 调用方
工具输出入库后变短process_itemtruncate_function_output_payload 与 policy 来源
clone 后修改影响原历史ContextManager.itemsArc::make_mut 和目标测试
raw history 与请求 input 不同for_promptnormalize.rs 和 input modalities
compact 重试丢掉两项remove_first_itemremove_corresponding_for
rollback 残留环境或插件上下文drop_last_n_user_turnstrim_pre_turn_context_updates 与 fragment 判定
Guardian 突然发送 full transcripthistory_versionGuardianTranscriptCursor

可以用下面的只读搜索建立本地调用图:

bash
rg -n "record_items\(|clone_history\(|for_prompt\(" codex-rs/core/src
rg -n "remove_first_item|drop_last_n_user_turns|replace\(" codex-rs/core/src
rg -n "history_version|GuardianTranscriptCursor" codex-rs/core/src

读完本文后,面对一份 ContextManager 快照,应当能判断:它是否与 Session 共享底层 vector、一次 mutation 何时触发深拷贝、当前看到的是 raw history 还是模型投影、删除为什么可能连带影响工具配对或上下文基线,以及旧的增量游标何时必须失效。下一篇将进入 normalize.rs,逐个验证 call/output 配对、孤儿清理和媒体剥离的输入输出不变量。