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
/// 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
/// 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_version | history 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
/// `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
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
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
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 原 vector | clone vector |
for_prompt() | 是 | 原地归一化后 unwrap | 先 copy-on-write,再返回 vector |
record_items() | 否 | 原地 push | clone 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
/// 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
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
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
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
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
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
/// 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变化 | version | entry count | Guardian结果 |
|---|---|---|---|
| 尾部追加 | 不变 | 增加或不变 | 可以发送 delta |
| prompt normalization | 只发生在消费快照 | 只影响该快照 | Session cursor 不变 |
| compaction replace | 增加 | 可能减少 | 发送 full |
| rollback | 增加 | 通常减少 | 发送 full |
| rollout恢复 replacement | 增加 | 任意变化 | 发送 full |
这套协议把“内容谱系”和“当前长度”分开。只比较长度无法识别“压缩后长度恰好相同”,只比较版本又无法定位同一谱系中新追加的起点,两者必须组合。
7. 验证矩阵
下面这些测试分别对应本文讨论的历史过滤、快照共享、截断、配对删除和回滚边界:
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_messages | system、reasoning、Other、user、assistant | 只保留允许的 API items,顺序不变 | 所有未来新增变体的策略 |
cloned_history_shares_items_until_mutated | 一个大 assistant item 后 clone | mutation 前指针相同,追加后分离 | 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_prefix | prefix + 两个 user turns | 删除一个或超量回合仍保留 prefix | rollout event 的持久化顺序 |
drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn | 回合前多类 context update | update 与目标回合一起删除 | 下一轮所有 fragment 的最终文本 |
drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles | contextual + persistent developer bundle | history 截断且 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_item | truncate_function_output_payload 与 policy 来源 |
| clone 后修改影响原历史 | ContextManager.items | Arc::make_mut 和目标测试 |
| raw history 与请求 input 不同 | for_prompt | normalize.rs 和 input modalities |
| compact 重试丢掉两项 | remove_first_item | remove_corresponding_for |
| rollback 残留环境或插件上下文 | drop_last_n_user_turns | trim_pre_turn_context_updates 与 fragment 判定 |
| Guardian 突然发送 full transcript | history_version | GuardianTranscriptCursor |
可以用下面的只读搜索建立本地调用图:
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 配对、孤儿清理和媒体剥离的输入输出不变量。
