模型上下文体系总览
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 history | ContextManager.items: Arc<Vec<ResponseItem>> | Session::record_conversation_items 持锁写入后 | 记录时截断 output;rollback/compact 可整体替换 | 下一次 Turn、compact、resume |
| prompt history | ContextManager::for_prompt 返回的 Vec<ResponseItem> | sampling 或 compact 请求前 | 补齐缺失 output、删除孤儿 output、按 modality 清理媒体 | run_sampling_request、compact client |
| raw event | Session::send_raw_response_items | durable history 写入后 | 主要保留原始响应项及 ID/turn ID | App Server、TUI、协议 reducer |
| rollout item | persist_rollout_response_items | durable 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
#[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>,
}| 字段 | 所有者 | 记录内容 | 生效时机 | 重写/清理条件 |
|---|---|---|---|---|
items | ContextManager | 按时间顺序排列的 ResponseItem | 记录后立即可被 clone | replace、rollback、compact;Arc::make_mut 触发写时复制 |
history_version | ContextManager | history 被整体改写的版本 | replace 后可观察 | 只在 replace 中递增,普通 append 不递增 |
token_info | Session state | server usage 与最近一轮 usage | response usage 到达或恢复 history 时 | update_token_info、set_token_usage_full |
reference_context_item | ContextManager/updates | 配置、权限、模型等上下文差异基线 | 下一 regular turn 构造上下文更新时 | rollback 删除混合 context bundle 时清空 |
world_state_baseline | ContextManager | 最近一次 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
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)
}这一步的副作用有三个:
- durable history 和 raw event 可以用同一组稳定的 turn/item ID 关联一次 Turn;
- 图像准备失败或 resize notice 在进入 history 前就已经决定;
- 原始调用方的 slice 不被原地修改,因为 Session 先取得 owned
Vec。
4.2 分发顺序
源码位置:codex-rs/core/src/session/mod.rs :: record_conversation_items
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
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
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
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 |
|---|---|---|---|---|
| 1 | ensure_call_outputs_present | call 没有 output | 在 call 后插入 aborted synthetic output | 否 |
| 2 | remove_orphan_outputs | output 找不到 call | 删除孤儿,debug 构建下可触发 panic 边界 | 否 |
| 3 | strip_images_when_unsupported | 模型不支持 image | 替换或清空媒体内容 | 否 |
| 4 | strip_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
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
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
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。
| 操作 | items | history_version | world_state_baseline | reference_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 message | live history | record_items 跳过,raw event 仍可能由 Session 发送 |
| 工具 output 缺失 | prompt snapshot | 插入 aborted synthetic output,不回写 history |
| 孤儿 output | prompt snapshot | 删除;debug 断言可暴露上游协议错误 |
| 模型不支持 image/audio | prompt snapshot | 替换媒体内容,live history 保留原信息 |
| response item 缺 turn ID | durable history/raw event | Session 在持久化边界补齐当前 TurnContext.sub_id |
| rollback/compact 替换 history | live history 与 world baseline | 版本递增,清理基线,后续重新建立上下文 |
| rollout flush 失败 | rollout | Session/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
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 推荐定位顺序
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.rs9.2 归一化测试
下面的测试分别覆盖工具调用配对、媒体清理、写时复制和 turn ID 补齐:
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 items | raw history 不保存非 API item | record_items 的过滤条件 | Session raw event 是否仍发送该输入 |
cloned_history_shares_items_until_mutated | clone 同一 history,再修改 clone | clone 先共享 Vec,修改后不影响原 history | Arc 写时复制语义 | 并发线程的全局调度公平性 |
for_prompt_strips_media_when_model_does_not_support_it | image/audio 输入 modality 不支持 | prompt 中媒体被清理或替换 | modality filter 只作用于 prompt snapshot | 服务端 tokenizer 和真实模型质量 |
normalize_adds_missing_output_for_function_call | 只有 function call,没有 output | call 后插入 aborted synthetic output | 配对 invariant 与插入位置 | 真实 provider 是否发送残缺 stream |
normalize_removes_orphan_function_call_output | output 没有对应 call | debug 构建触发边界并移除孤儿 | 孤儿清理规则 | 所有工具变体的 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. 验证练习
读者可以不运行模型,通过源码回答下面的问题:
- 为什么
for_prompt可以插入 synthetic output,却不把它写入 raw event? - 如果 rollback 删除了混合的初始 context developer bundle,为什么必须清空
reference_context_item? world_state_baseline和token_info都是“历史相关状态”,它们分别在什么时机更新?- 为什么
record_conversation_items中的 turn ID 补齐发生在record_items之前,而不是事件 reducer 中?
能回答这四题,说明已经区分了 durable history、prompt snapshot、context baseline 和客户端投影;下一步可阅读 ContextItem角色语义 和 ContextHistory读写。
