Skip to content

模型上下文体系总览

从 Session 收到响应项开始,追踪它们如何进入 live history、模型 prompt、客户端事件和 rollout,并解释归一化、快照、token 估算与失败边界。

基于rust-v0.150.0
CodexRustContext

模型上下文体系总览 ​

Codex 的“上下文”不是一个在请求前临时拼出来的字符串。一次响应项进入 Core 后,至少会经过四个相互关联但不等价的表示:内存中的 ContextManager 历史、送给模型的 prompt 输入、发送给客户端的 raw response item,以及写入 rollout 的持久化记录。

本文回答一个具体问题:一个 ResponseItem 从 Session 进入系统后,哪些地方会保存它、哪些地方会改写它、何时才会成为模型输入,失败或回滚时哪些表示会被清理?

本文默认读者已经读过 Core运行时架构总览、TurnContext字段 和 StepContext模型请求,了解 Rust 的 Arc、异步函数和枚举匹配。读完后,读者应能:

  • 从 Session::record_conversation_items 定位一条响应项的 durable history 边界;
  • 解释 clone_history().for_prompt(...) 为什么会产生与 raw history 不同的输入;
  • 区分 reference_context_item、world_state_baseline、token_info 和 history_version 的所有者与生效时机;
  • 用上下文归一化测试验证工具调用配对、媒体清理、写时复制和 turn ID 补齐。

1. 问题边界 ​

本文只讲 codex-rs/core 中上下文的编排边界:Session 如何记录响应项,ContextManager 如何保存和准备历史,以及模型请求和 rollout/事件消费者如何读取它。本文不展开以下专题:

不在本文展开应继续阅读
ResponseItem 每个变体的协议字段内部事件映射
TurnContext/StepContext 每个字段如何构造TurnContext字段、StepContext模型请求
自动/手动/远程 compact 的后端算法CompactTask完整流程
rollout 反向扫描和恢复Rollout重建与恢复
WorldState 各字段如何采集WorldState环境快照

本文的核心停止线是:不把模型 prompt、客户端事件和 rollout 当成同一个数组,也不把 token 估算当成 tokenizer 的精确结果。

2. 四种表示 ​

先建立四个容易混淆的对象。它们可能都包含同一条用户消息,但 owner、修改时机和消费者不同。

表示主要 owner何时形成允许的修改主要消费者
live historyContextManager.items: Arc<Vec<ResponseItem>>Session::record_conversation_items 持锁写入后记录时截断 output;rollback/compact 可整体替换下一次 Turn、compact、resume
prompt historyContextManager::for_prompt 返回的 Vec<ResponseItem>sampling 或 compact 请求前补齐缺失 output、删除孤儿 output、按 modality 清理媒体run_sampling_request、compact client
raw eventSession::send_raw_response_itemsdurable history 写入后主要保留原始响应项及 ID/turn IDApp Server、TUI、协议 reducer
rollout itempersist_rollout_response_itemsdurable history 边界之后由 rollout policy 决定是否写入及如何分段resume、重建和恢复

这四者不是简单的“复制四份”。prompt history 由一个共享快照派生并在消费前规范化;raw event 与 rollout 由 Session 分别投递。因此,某个 prompt-only synthetic output 不一定会出现在客户端事件或 rollout 中。

图中的 DUR 是一个顺序边界:先在内存状态中记录并准备持久化输入,再向 rollout 和客户端发出结果。它不意味着 rollout flush 已经完成;flush/关闭语义由调用方和 Thread store 继续负责。

3. ContextManager ​

ContextManager 是 Session 状态中的上下文 owner。下面的字段注释对应 context_manager/history.rs:

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

rust
#[derive(Debug, Clone, Default)]
pub(crate) struct ContextManager {
    // 最旧项在前。只读快照共享同一个 Vec,写入时才复制。
    items: Arc<Vec<ResponseItem>>,
    // compact、rollback 等重写 history 时递增。
    history_version: u64,
    token_info: Option<TokenUsageInfo>,
    // 下一次 regular turn 用于计算设置差异的基线。
    reference_context_item: Option<TurnContextItem>,
    // 最近一次写入 model-visible history 的 WorldState 快照。
    world_state_baseline: Option<WorldStateSnapshot>,
}
字段所有者记录内容生效时机重写/清理条件
itemsContextManager按时间顺序排列的 ResponseItem记录后立即可被 clonereplace、rollback、compact;Arc::make_mut 触发写时复制
history_versionContextManagerhistory 被整体改写的版本replace 后可观察只在 replace 中递增,普通 append 不递增
token_infoSession stateserver usage 与最近一轮 usageresponse usage 到达或恢复 history 时update_token_info、set_token_usage_full
reference_context_itemContextManager/updates配置、权限、模型等上下文差异基线下一 regular turn 构造上下文更新时rollback 删除混合 context bundle 时清空
world_state_baselineContextManager最近一次 model-visible WorldState snapshot下一 step 计算 full/patch 与 fragment diff 时history replace 或新的 snapshot

这里有一个关键区分:items 是历史本体,而 reference_context_item 和 world_state_baseline 是生成下一次上下文更新所需的辅助基线;它们不是可以直接发送给模型的消息。

4. 写入边界 ​

4.1 Session分发准备 ​

Session 不直接把模型返回的 slice 塞进 ContextManager。prepare_conversation_items_for_history 先复制输入,处理媒体,并为缺失的 turn/item ID 补值:

源码位置:codex-rs/core/src/session/mod.rs :: prepare_conversation_items_for_history

rust
pub(crate) fn prepare_conversation_items_for_history<'a>(
    &self,
    turn_context: &TurnContext,
    items: &'a [ResponseItem],
) -> (Cow<'a, [ResponseItem]>, Vec<ImagePreparationMetadata>) {
    let mut items = items.to_vec();
    let image_preparations = prepare_image_response_items(&mut items, image_mode);
    prepare_audio_response_items(&mut items);

    for item in &mut items {
        item.set_turn_id_if_missing(&turn_context.sub_id);
    }

    let items = Cow::Owned(items);
    (Self::assign_missing_response_item_ids(items), image_preparations)
}

这一步的副作用有三个:

  1. durable history 和 raw event 可以用同一组稳定的 turn/item ID 关联一次 Turn;
  2. 图像准备失败或 resize notice 在进入 history 前就已经决定;
  3. 原始调用方的 slice 不被原地修改,因为 Session 先取得 owned Vec。

4.2 分发顺序 ​

源码位置:codex-rs/core/src/session/mod.rs :: record_conversation_items

rust
pub(crate) async fn record_conversation_items(
    &self,
    turn_context: &TurnContext,
    items: &[ResponseItem],
) {
    let (items, image_preparations) =
        self.prepare_conversation_items_for_history(turn_context, items);
    let items = items.as_ref();
    {
        let mut state = self.state.lock().await;
        state.current_time_reminder.note_recorded_items(items);
        state.record_items(
            items.iter(),
            turn_context.model_info.truncation_policy.into(),
        );
    }
    self.persist_rollout_response_items(items).await;
    self.send_raw_response_items(turn_context, items).await;
}

因此,ContextManager::record_items 是 history 的写入点,但它不是唯一消费者。rollout 和 raw event 使用同一批已准备 items;之后的 prompt 仍会对 history snapshot 做一次单独的规范化。

4.3 记录过滤截断 ​

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

rust
pub(crate) fn record_items<I>(&mut self, items: I, policy: TruncationPolicy)
where
    I: IntoIterator,
    I::Item: 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 会丢弃 role == "system" 的 Message、CompactionTrigger 和 Other;函数调用、工具输出、reasoning、ContextCompaction 等仍属于 API history。对 FunctionCallOutput 和 CustomToolCallOutput,process_item 会按 TruncationPolicy * 1.2 截断 payload,避免过大的工具结果直接挤占后续请求。

5. 快照与 prompt ​

5.1 Arc共享边界 ​

clone_history 返回 ContextManager 的快照。快照复制 Arc,但后续 record_items、replace 或归一化需要修改时会通过 Arc::make_mut 获得独立 Vec:

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

rust
pub(crate) fn for_prompt(mut self, input_modalities: &[InputModality]) -> Vec<ResponseItem> {
    self.normalize_history(input_modalities);
    Arc::unwrap_or_clone(self.items)
}

pub(crate) fn raw_items(&self) -> &[ResponseItem] {
    &self.items
}

这使得模型请求可以消费一个即将被改写的 prompt 副本,而不会把 synthetic output、媒体替换或孤儿删除反向写回 live history。

5.2 Prompt归一化 ​

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

rust
fn normalize_history(&mut self, input_modalities: &[InputModality]) {
    let items = Arc::make_mut(&mut self.items);
    normalize::ensure_call_outputs_present(items);
    normalize::remove_orphan_outputs(items);
    normalize::strip_images_when_unsupported(input_modalities, items);
    normalize::strip_audio_when_unsupported(input_modalities, items);
}
顺序动作输入异常prompt 结果是否回写 live history
1ensure_call_outputs_presentcall 没有 output在 call 后插入 aborted synthetic output否
2remove_orphan_outputsoutput 找不到 call删除孤儿,debug 构建下可触发 panic 边界否
3strip_images_when_unsupported模型不支持 image替换或清空媒体内容否
4strip_audio_when_unsupported模型不支持 audio替换或清空音频内容否

工具调用配对不是展示层格式化,而是模型协议约束。缺失 output 时插入的位置紧跟 call;synthetic ID 由 call item ID 通过固定 UUID namespace 派生,重复准备同一 snapshot 时保持稳定,有利于 prompt cache。

5.3 模型请求调用 ​

Turn::run_sampling_request 在记录本 step 的 WorldState 变化后,才构造模型输入:

源码位置:codex-rs/core/src/session/turn.rs :: run_turn

rust
let sampling_request_input: Vec<ResponseItem> = async {
    sess.clone_history()
        .await
        .for_prompt(&turn_context.model_info.input_modalities)
}
.instrument(trace_span!("run_turn.prepare_sampling_request_input"))
.await;

run_sampling_request(
    Arc::clone(&sess),
    Arc::clone(&step_context),
    /* ... */
    sampling_request_input,
    cancellation_token.child_token(),
)
.await

这解释了“记录后立即可见”和“发送给模型”之间的时间差:输入先进入 live history;下一次 sampling 才基于当时的 modality、WorldState 和历史 snapshot 生成 prompt。

6. 上下文片段 ​

6.1 User角色语义 ​

Codex 会把环境、权限、AGENTS、时间提醒、插件和子 agent 消息包装成 role: "user" 的上下文片段。ContextualUserFragment 负责定义片段的 role、marker、body 和到 ResponseItem 的转换;因此仅检查 role 不能判断一条消息是否是用户原文。

这些片段的来源和消费者不同:WorldState 负责根据 snapshot 生成 diff,Session 把 diff 记录为 history item;事件映射会进一步决定哪些 item 形成可见 TurnItem。所以“上下文已进入模型”不能推出“用户界面显示了同样内容”。

6.2 WorldState基线 ​

ContextManager::update_world_state 同时返回模型可见 fragment 和 rollout 用的 WorldStateItem:

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

rust
pub(crate) fn update_world_state(
    &mut self,
    world_state: &WorldState,
) -> (Vec<Box<dyn ContextualUserFragment>>, Option<WorldStateItem>) {
    let snapshot = world_state.snapshot();
    let fragments = world_state.render_history_diff(
        self.world_state_baseline.as_ref(),
        &self.items,
    );
    let rollout_item = self.world_state_baseline.as_ref().map_or_else(
        || Some(WorldStateItem::full(snapshot.clone().into_value())),
        |previous| snapshot.merge_patch_from(previous).map(WorldStateItem::patch),
    );
    self.world_state_baseline = Some(snapshot);
    (fragments, rollout_item)
}

第一次 snapshot 生成 full item;后续 snapshot 只生成 patch。若 history 被 replace 或 rollback,world_state_baseline 被清空,下一次必须重新建立基线,不能把旧 patch 应用到已经替换的历史。

7. Token 账本 ​

ContextManager 的 token 逻辑有两个层次:server usage 是模型响应带回的事实,history estimate 是本地为了判断窗口而做的粗略估算。

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

rust
pub(crate) fn estimate_token_count_with_base_instructions(
    &self,
    base_instructions: &BaseInstructions,
) -> Option<i64> {
    let base_tokens = i64::try_from(
        approx_token_count(&base_instructions.text),
    ).unwrap_or(i64::MAX);
    let items_tokens = self.items.iter()
        .map(estimate_item_token_count)
        .fold(0i64, i64::saturating_add);
    Some(base_tokens.saturating_add(items_tokens))
}

estimate_item_token_count 先估算 model-visible bytes,再使用 truncation helper 的近似换算;图片、音频和 encrypted reasoning 有独立调整。它不是 tokenizer 结果,也不能单独证明服务端会接受请求。

get_total_token_usage 则以最近一次 server last_token_usage.total_tokens 为基线,再补上最后一个模型生成项之后新增的本地 items;当 server 没有计入历史 reasoning 时,才额外估算旧 reasoning。两种数值不可混用:前者用于 telemetry/预算累计,后者用于当前 history 的容量决策。

8. 回滚、替换与失败 ​

8.1 Rollback语义 ​

drop_last_n_user_turns 会先找到 instruction-turn boundary,再向前删除紧邻的 contextual developer/user updates。如果删除的是混合的初始 context bundle,还必须把 reference_context_item 清空,使下一 regular turn 进行完整 reinjection,而不是基于已不存在的旧基线计算 diff。

操作itemshistory_versionworld_state_baselinereference_context_item
append追加 processed item不变不变不变
replace/compact换成新 Vec+1清空由调用方决定
rollback 普通边界截断至 user boundary+1清空通常保留
rollback 混合 context bundle截断并移除上下文更新+1清空清空,触发全量注入

8.2 Compact快照 ​

本地 compact 和远程 compact 都会对 history snapshot 调用 for_prompt,然后把规范化后的输入交给 compact 请求。compact 完成后由 Session 的 replacement history 路径整体替换 live history;这正是本文与 CompactTask完整流程 的边界:本文解释“compact 读哪种上下文”,后者解释“摘要如何生成并写回”。

8.3 失败形态 ​

失败点影响的表示代码选择
input item 不是 API messagelive historyrecord_items 跳过,raw event 仍可能由 Session 发送
工具 output 缺失prompt snapshot插入 aborted synthetic output,不回写 history
孤儿 outputprompt snapshot删除;debug 断言可暴露上游协议错误
模型不支持 image/audioprompt snapshot替换媒体内容,live history 保留原信息
response item 缺 turn IDdurable history/raw eventSession 在持久化边界补齐当前 TurnContext.sub_id
rollback/compact 替换 historylive history 与 world baseline版本递增,清理基线,后续重新建立上下文
rollout flush 失败rolloutSession/Thread store 继续按其 best-effort 或重试策略处理,不能声称 durable 已完成

恢复不是把旧进程冻结后再解冻。record_initial_history 先调用 rollout reconstruction,得到 ResponseItemEnvelope 历史、reference context 和 WorldState baseline,再写入新的 SessionState;旧的 Tokio task、CancellationToken、MCP worker 和 provider client 不属于持久化上下文,因此不会随 rollout 复活。

源码位置:codex-rs/core/src/session/mod.rs :: record_initial_history

rust
let reconstructed = self
    .reconstruct_history_from_rollout(turn_context.as_ref(), &rollout_items)
    .await;
state.replace_annotated_history(
    reconstructed.history,
    reconstructed.reference_context_item,
);
if let Some(world_state) = reconstructed.world_state_baseline {
    state.history.set_world_state_baseline(world_state);
}

这一步解释了为什么本文的四种表示不能合并成一个“上下文数组”:恢复时只安装可持久化投影,运行时依赖 仍由新 Session 的装配路径重新建立。

9. 读源码验证 ​

9.1 推荐定位顺序 ​

bash
rg -n "record_conversation_items|prepare_conversation_items_for_history" \
  codex-rs/core/src/session/mod.rs
rg -n "struct ContextManager|record_items|for_prompt|normalize_history|update_world_state" \
  codex-rs/core/src/context_manager
rg -n "ensure_call_outputs_present|remove_orphan_outputs|strip_images|strip_audio" \
  codex-rs/core/src/context_manager/normalize.rs
rg -n "clone_history\(\).*for_prompt|run_sampling_request" \
  codex-rs/core/src/session/turn.rs

9.2 归一化测试 ​

下面的测试分别覆盖工具调用配对、媒体清理、写时复制和 turn ID 补齐:

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 for_prompt_strips_media_when_model_does_not_support_it
just test -p codex-core normalize_adds_missing_output_for_function_call
just test -p codex-core normalize_removes_orphan_function_call_output
just test -p codex-core record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id
测试输入/设置关键断言覆盖范围未覆盖
filters_non_api_messages记录 system、CompactionTrigger、Other 与 API itemsraw history 不保存非 API itemrecord_items 的过滤条件Session raw event 是否仍发送该输入
cloned_history_shares_items_until_mutatedclone 同一 history,再修改 cloneclone 先共享 Vec,修改后不影响原 historyArc 写时复制语义并发线程的全局调度公平性
for_prompt_strips_media_when_model_does_not_support_itimage/audio 输入 modality 不支持prompt 中媒体被清理或替换modality filter 只作用于 prompt snapshot服务端 tokenizer 和真实模型质量
normalize_adds_missing_output_for_function_call只有 function call,没有 outputcall 后插入 aborted synthetic output配对 invariant 与插入位置真实 provider 是否发送残缺 stream
normalize_removes_orphan_function_call_outputoutput 没有对应 calldebug 构建触发边界并移除孤儿孤儿清理规则所有工具变体的 provider 错误分类
record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id一项缺 ID、一项已有 ID缺失 ID 使用当前 Turn,已有 ID 不被覆盖durable history 边界的 ID 归属rollout flush 已经落盘的时刻

这些测试共同证明的是“表示之间的转换契约”,不是完整 Turn 成功。模型请求失败、compact 重试和 rollout 恢复必须分别回到 Turn主循环与退出条件、CompactTask完整流程、Rollout重建与恢复 的测试集合。

10. 验证练习 ​

读者可以不运行模型,通过源码回答下面的问题:

  1. 为什么 for_prompt 可以插入 synthetic output,却不把它写入 raw event?
  2. 如果 rollback 删除了混合的初始 context developer bundle,为什么必须清空 reference_context_item?
  3. world_state_baseline 和 token_info 都是“历史相关状态”,它们分别在什么时机更新?
  4. 为什么 record_conversation_items 中的 turn ID 补齐发生在 record_items 之前,而不是事件 reducer 中?

能回答这四题,说明已经区分了 durable history、prompt snapshot、context baseline 和客户端投影;下一步可阅读 ContextItem角色语义 和 ContextHistory读写。