当我们与一个大语言模型进行多轮对话时,每一次交互都在不断积累信息——用户的指令、模型的回复、工具调用的结果、系统提示的变更……这些信息构成了模型理解当前任务的"上下文"。然而,模型的上下文窗口是有限的。当对话不断拉长,历史信息不断膨胀,一个核心问题就浮现出来:如何在有限的窗口内,让模型始终看到最关键的信息,而不丢失对任务的理解?
这个问题就是"上下文工程"—它不是简单的裁剪或丢弃,而是一套精密的管理体系,决定了什么信息需要保留、什么时候需要压缩、压缩后如何让模型无缝衔接。OpenAI 的Codex(codex-rust)作为当前最先进的 AI 编程助手之一,其上下文工程化设计堪称精妙。本文将带我们深入 codex 的源码,理解它如何系统性地解决这个核心难题。
Codex 的核心思路,是信息的性质差异化管理,它并不是把所有内容一视同仁地塞进去,而是让重要的信息始终可见、不变的信息不要重复、膨胀的信息及时压缩瘦身。
上下文信息的分类:不场景不同信息
Codex 需要在有限的窗口内同时承载五种信息,它们的性质完全不同:
(1)策略信息——"你必须这样做"
比如:"只允许写入 /tmp/project 目录"、"所有网络请求必须经过审批"、"你现在是 autonomous模式,不需要每步都问用户"。这些是系统级的强制指令,模型必须遵守,不能违背。
特点:指令性强、不可违背、一个 session 内几乎不变。
(2)能力信息——"你可以做什么"
比如:"你有一个 imagegen Skill 可以生成图片"、"你有 web_search 工具可以搜索网页"、"你可以通过 MCP 调用 GitHubAPI"。这些是告诉模型有哪些工具和能力可用,模型可以选择调用或不调用。Codex 对能力信息做了渐进式披露——Skill 的名字和简短描述始终在上下文中(占 ~2% 空间),但完整的 Skill 使用手册只有用户显式提及(Codex Skill工程设计器原理)时才加载,脚本和参考资料更是执行时才读取。
特点:描述性数量可能很多
(3)环境信息——"你现在在哪里"
比如:"当前工作目录是 /tmp/project"、"Shell 是 bash"、"文件系统只允许读写项目目录"、"今天是 2024-06-15 UTC+8"。这些是运行时的状态描述,模型需要知道但不必"遵守"。
特点:描述性、可能随时间变化(用户切换了目录、日期推进了)、占空间不大但不可或缺。
(4)意图信息——"用户要我做什么"
比如:"帮我重构这个函数"、"用 $imagegen 生成一张 Logo 图片"、"检查 CI 是否通过"。这是真实的用户输入,模型需要理解并响应——这是整个上下文中最重要的内容。
特点:每次不同、是模型工作的核心目标、绝对不能被压缩丢弃。
(5)执行信息——"之前做了什么、结果是什么"
比如:模型回答"我建议把函数拆成三个模块"、工具调用"执行了 npm run lint"、工具输出"lint 发现 42 个 warning"、推理过程"让我分析一下这个文件的结构..."这是模型产出和工具执行的过程与结果。一个 lint 输出可能几千字,一次文件搜索返回几千字,十轮下来执行信息就可能占掉 80% 的窗口空间。
特点:过程性、占用空间最大、增长最快、压缩时最应该优先瘦身。
五种信息性质不同,但如果分别用不同的格式存储、不同的方式注入、不同的逻辑压缩,整个系统会变得极度复杂,Codex 设计了统一载体设计ResponseItem。
上下文信息的组织:从碎片到秩序
想象一个场景:你在和一位同事协作完成一个项目,桌上堆满了各种文档——邮件、笔记、代码片段、会议纪要。如果没有一个得力的档案管理员,这些信息很快就会变成一团混乱。在 codex 中,ContextManager 就是这个档案管理员。
ContextManager是 codex 上下文管理的核心结构,它承载了整个对话的完整历史记录:
// codex-rs/core/src/context_manager/history.rs pub(crate) struct ContextManager { items: Vec<ResponseItem>, // 按时间顺序(最旧→最新)存储所有对话消息(基于ResponseItem的统一消息格式),未来通过record_items() 时逐条处理后追加 history_version: u64, // 版本号,在发生压缩或者回滚时,递增记录 token_info: Option<TokenUsageInfo>, // token计数,优先使用服务端真实数据,本地估计仅用于"服务端数据到达前"的过渡期和"历史变更后"的即时校验。 reference_context_item: Option<TurnContextItem>, // Diff 更新的基线快照 }🟥items 对话历史
是对话历史的有序队列,最老的条目在队首、最新的在队尾。但它并非只存"用户说了什么、模型回了什么"——它承载的是整个对话过程中出现的所有结构化事件:用户消息、模型推理、工具调用、工具输出、压缩摘要等,全部以ResponseItem枚举的形式统一表达。
🟥history_version 版本号
是一个单调递增的版本号,每当历史被"重写"(比如压缩替换、回滚裁剪)就自增。它的存在不是为了并发控制,而是为了让下游的缓存、diff增量更新追踪等机制能够感知"历史已经发生了结构性变更,之前的快照不再有效"。这是一个轻量但关键的变更信号。
🟥token_info token 用量
承载的是服务端返回的真实 token 用量信息(而非本地估算),包含 total_token_usage、last_token_usage 和 model_context_window。Codex 的 token预算判断之所以能做到较为精准,正是因为它优先使用服务端报告的真实用量,只在服务端数据尚未返回时才用本地估算补位。
🟥reference_context_item 配置快照
是整个上下文管理中最精妙的设计之一。它保存的是上一轮对话结束时的"配置快照"—包含当时的模型、权限、协作模式、环境上下文等。它的核心作用是支撑"增量 diff注入":
如果当前轮的配置与上一轮相同,就不需要重新注入完整的系统指令和环境信息,只需发送变更的部分;
如果它被置为None(比如压缩后、回滚后),则意味着"基线已丢失,必须重新全量注入"。这个字段是 Codex 在长对话中节省 token 的关键杠杆。
🟥 ResponseItem 通用消息格式
ResponseItem 是 OpenAI Responses API 的通用消息格式,每条消息有一个 role 字段标记语义层级,后续无论什么消息类型,都通过record_items() 时逐条处理后追加,类似于Langchain中的Message append设计:
模型看到的只是一个有序的消息列表,没有"特殊字段"——策略信息、能力信息、环境信息、用户意图、执行结果全部混在一起,靠 role字段区分"这是规则"还是"这是背景"还是"这是请求"还是"这是回答"。
这个设计带来了一个重要的工程收益:所有注入逻辑统一(都是往列表里追加一条消息),所有归一化逻辑统一(都是对列表做一次格式修整),所有压缩逻辑统一(都是对列表做一次整体替换)。 不需要为不同类型的信息维护不同的容器和不同的管理逻辑。
但这也带来了一个挑战,如何让模型正确区分五种信息的优先级?
如果所有内容都是同样的格式,模型可能把一条过时的 developer指令当作当前规则,也可能把用户的核心意图当作背景信息忽略掉。
Codex 的解决方案是语义分层——通过 role字段和注入顺序共同表达优先级,这正是后面"上下文注入"步骤要解决的核心问题。
🟥 上下文信息的三层分类
Codex 不是简单地把所有信息堆在一起发给模型。它将上下文信息分成了三个明确的"插槽"(Slot),对应不同的角色和注入方式:
Developer Slot(开发者层):系统级指令,以 role="developer"消息注入。包括权限策略、开发者指令(AGENTS.md)、协作模式说明、人格配置等。这是"幕后指挥官",告诉模型应该如何行为。
Contextual User Slot(上下文用户层):面向用户的上下文信息,以 role="user"消息注入。包括用户自定义指令、环境信息(工作目录、Shell类型、时区)等。这是"前台信息台",给模型提供当前工作环境的概况。
Separate Developer Slot(隔离开发者层):独立的开发者消息,比如 guardian 安全策略。这是"安全守卫",确保关键的安全约束不会被其他信息干扰。
🟥 上下文的配置基线:TurnContextItem
TurnContextItem 是 reference_context_item 的类型,它序列化保存了某个时间点的完整配置状态:模型信息、权限配置、协作模式、环境上下文、实时模式开关等。当一个新的 turn 开始时,Codex 都会把当前配置与 reference_context_item中保存的上一轮配置逐字段比对,例如:
模型是否换了?→ 发送 ModelSwitchInstructions
权限策略是否变了?→ 发送 PermissionsInstructions diff
协作模式是否变了?→ 发送 CollaborationModeInstructions diff
环境信息是否变了?→ 发送 EnvironmentContext diff
实时模式是否开关?→ 发送 RealtimeStart/EndInstructions
personality 是否变了?→ 发送 PersonalitySpecInstructions diff
所有有变更的字段合并为一个 developer 角色的消息注入历史,没有变更的字段完全不发送。这意味着,在绝大多数 turn 中,系统指令(策略信息、环境信息)的 token开销几乎为零,只有第一次 turn 或压缩后的第一次 turn 需要全量注入。这是一个典型的"基线 diff"策略,用版本化的快照作为参照点,只传输 delta。
上下文处理的时机与原理:run_turn 的生命周期
理解了上下文"装了什么"之后,下一个关键问题是"什么时候处理"。Codex 的上下文处理不是随机的,而是在对话轮次(turn)的不同阶段精确执行的。让我们追踪 run_turn函数的完整执行流程,看清楚每个阶段做了什么。
(1)采样前压缩检查——防患于未然(Pre-Turn 压缩)
这是整个管线的第一道关卡,在模型开始推理之前就检查上下文是否已经"超载"。它的逻辑非常清晰:
async fn run_pre_sampling_compact(...) -> CodexResult<()> { // 先检查是否切换了更小的模型,需要适配 maybe_run_previous_model_inline_compact(...).await?; // 再检查token是否已经超出限制 let token_status = auto_compact_token_status(...).await; if token_status.token_limit_reached { // 立即触发压缩,使用DoNotInject策略 run_auto_compact(..., InitialContextInjection::DoNotInject, CompactionReason::ContextLimit, CompactionPhase::PreTurn).await?; } Ok(()) }这里有两个重要检查模式:
模型降级检查
由于不同的模型上下文窗口是不一样的,假设上一轮用的是 200K 上下文窗口的模型,这一轮切换到了 128K 的模型,那么之前的历史可能已经超出了新模型的窗口。这种情况下,codex会先用旧模型的配置做一次压缩,确保历史能适配新模型的窗口。
Token限制检查
如果当前上下文的token数已经达到了配置的压缩阈值,就在模型推理之前先做压缩。注意这里用的是 InitialContextInjection::DoNotInject策略——意味着压缩后会清空基准快照,下一轮会重新注入完整初始上下文。
打个比方,这就像出发前检查油箱——如果油量不够到达目的地,先去加油站加油,而不是等开到半路油箱报警了再处理。
(2)上下文更新与基准设定—Diff增量注入的艺术
这个阶段是 codex 上下文工程中最精妙的环节之一。
这是什么意思?举个例子:
首次对话:模型什么都不知道,需要注入完整的环境信息(策略信息+环境信息)—工作目录、Shell类型、权限策略、协作模式、可用技能等。这是一大块信息。
第二轮对话:工作目录没变、权限没变、Shell没变……几乎所有配置都和上一轮一样。这时候如果再注入一大块完全相同的信息,就浪费了大量token。所以 codex只注入"变化的差异"—比如如果用户切换了协作模式,就只注入一条"协作模式已从suggest切换到auto"的更新消息。
这个设计通过TurnContextItem基准快照实现:
pub struct TurnContextItem { turn_id: Option<String>, cwd: PathBuf, // 工作目录 timezone: String, // 时区 approval_policy: ..., // 审批策略 sandbox_policy: ..., // 沙箱策略 permission_profile: ..., // 权限配置 model: String, // 模型名称 personality: ..., // 人格配置 collaboration_mode: ..., // 协作模式 realtime_active: bool, // 实时模式状态 // ... }每轮对话开始时,codex 将当前配置序列化为 TurnContextItem,然后与上一轮的基准做逐字段比较。codex会针对每个维度(环境、权限、协作模式、实时模式、人格)分别检查是否有变化,只在有变化时才生成对应的更新消息。
这种增量注入策略带来的token节省是显著的。假设初始上下文有 2000 tokens,10轮对话后如果每轮都完整注入,就要消耗 20000tokens;而增量注入可能只需要几轮各几十 tokens 的更新消息。
reference_context_item 的作用:增量diff的"锚点",如果为NONE则全量注入,否则增量注入
通常在上下文压缩后,就会清理reference_context_item,下一轮就会重新注入上下文(系统信息),这是因为
压缩后历史已经被替换为 [保留的用户消息 +摘要],原来的初始上下文(开发者指令、权限说明、环境信息等)都被丢弃了。模型此时看不到任何系统配置信息。如果保留旧的 reference_context_item作为基准,下一轮做diff时会认为"这些配置没变所以不需要注入"——但问题是历史里已经没有这些信息了!模型看不到,diff也不注入,结果就是模型丢失了所有系统约束。
所以必须清空基准,强制下一轮全量注入,重新把完整的系统指令塞进历史。
(3)采样循环——核心推理与动态压缩(Mid-Turn 压缩)
Pre-Turn 压缩是指,在turn开始初期做压缩检查时的压缩过程,此时LLM还没调用,在起跑线上检查和压缩。
Mid-Turn 压缩是指:LLM已经调用了至少一次,在两次LLM调用的间隙里检查和压缩,即ReACT中的压缩。
这个实现的核心在于它是在任务执行过程中,来包保证上下文不超。
关键点在于Mid-Turn 压缩。这是在模型推理过程中发现上下文爆满时的紧急处理:
if token_limit_reached && needs_follow_up { run_auto_compact(..., InitialContextInjection::BeforeLastUserMessage, CompactionReason::ContextLimit, CompactionPhase::MidTurn).await?; can_drain_pending_input = !model_needs_follow_up; continue; }压缩算法:从冗长到精炼的三条路径
Codex 提供了三种不同的压缩路径实现:Local Inline、Remote V1、Remote V2,主要是对不同场景和模型能力的适配:
Local Inline:模型自身生成摘要。适用于不支持远程压缩API的模型。
Remote V1:调用服务端的 /responses/compact API。利用服务端更强大的压缩能力。
Remote V2:使用 Responses API 的 CompactionTrigger 标记项,让服务端在推理过程中直接产出压缩结果。是最新的、最集成的方案。
其实逻辑很简单:
if provider.supports_remote_compaction() { if features.enabled(Feature::RemoteCompactionV2) { → 使用 Remote V2 } else { → 使用 Remote V1 } } else { → 使用 Local Inline }Local Inline 压缩(摘要 + 近期保留策略)
这是最基础但也最直观的压缩方式。核心思路是:用一段特殊的提示词让模型回顾整个对话历史,生成一份"交接摘要",然后用这份摘要替换冗长的原始历史。如果清楚常规的压缩策略的,可以看Langchain的记忆策略。
需要注意的是,摘要生成后,codex 不是简单地用摘要替换所有历史,而是执行一个精密的"筛选+拼接"算法:
这个算法的精髓在于从最新到最旧的贪心选择策略:
从最新的用户消息开始向前遍历,优先保留最新的信息
在 20,000 token 的预算内,尽可能多地保留完整的用户消息
预算耗尽时,对当前消息做截断(保留开头部分)
最后附上压缩摘要
上下文保障机制:确保历史的完整性
在把历史发给模型之前,codex 会执行三项规范化检查,确保历史结构完整,codex做了3个内容的检查,来确保上下文信息的完整性:
(1)每个工具调用必须有对应的输出
想象一下:模型发出了一条shell命令,但执行结果还没返回,历史里就只有"调用"没有"结果"。如果直接把这样的历史发给模型,模型会困惑——"我调用了命令但看不到结果
?"。规范化会为缺失的输出插入一条合成的 "aborted" 结果。
// 为缺失输出的FunctionCall插入合成结果 ResponseItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), }(2)每个输出必须有对应的调用
这是反向检查:如果历史里有工具输出但没有对应的调用(可能是压缩时只保留了输出但丢弃了调用),这些"孤儿输出"会被移除。没有调用的输出对模型来说是毫无意义的噪音。
(3)不支持图片的模型不看到图片
如果当前模型不支持图片输入,历史中的所有图片内容会被替换为文本说明 "image content omitted because you do not support imageinput"。这避免了模型收到无法处理的信息。
写在最后
Codex 的上下文工程不是单一的压缩算法,而是从数据结构统一化、到七阶段处理管线、到增量diff注入、到三层压缩路径、到规范化三道锁的完整系统工程——核心目标是在
有限窗口内让模型始终看到最关键的信息,压缩不是丢弃而是重构,增量不是省略而是精准,规范化不是多余而是兜底。