一次模型请求里的每一个字符,都能追到它的来源。本章把 Codex 的上下文拆成三层——基础指令、上下文片段、世界状态——并给出一个别处很少见的做法:用 JSON merge patch 只告诉模型"变了什么"。
会话建立时决定 base_instructions,代码里的注释直接列了优先级 [源码 core/src/session/mod.rs:650]:
| 优先级 | 来源 | 用途 |
|---|---|---|
| 1 | 用户配置覆盖 | 完全自定义(codex review 就是靠这个换成复核提示词) |
| 2 | 会话历史里的记录 | 恢复旧会话时,用它当初的指令,而不是今天的 |
| 3 | 当前模型的指令模板 | 默认路径 |
第 2 条是容易被忽略但很关键的一条。第 12 章会讲会话恢复——如果恢复一个三个月前的会话时套上今天的新提示词,模型看到的历史行为和当前指令就对不上了。Codex 的选择是让历史指令跟着历史走。
代码里还有一个 BaseInstructionsProvenance(来源标记),把"这份指令是从哪来的"也一起记下来。
第 5 章已经揭示了这一点,这里展开它对上下文工程的含义。
模板机制极简:一个占位符 {{ personality }},一次字符串替换。没有模板引擎,没有条件分支,没有循环。
ModelMessages 的结构注释解释了为什么可以这么简单 [源码 protocol/src/openai_models.rs:526]:
When instructions_variables is absent, instructions_template is treated as literal text. When variables are present but incomplete, missing values render as empty strings.
(当 instructions_variables 缺失时,instructions_template 被当作字面文本。当变量存在但不完整时,缺失的值渲染为空字符串。)
降级路径全部指向"少说点"而不是"报错"。 这是提示词渲染该有的失败模式。
instructions_variables 里有三个值 [源码,实测 models-manager/models.json]:
| 变量 | 内容 |
|---|---|
| personality_default | 空字符串 |
| personality_friendly | 一段人格描述 |
| personality_pragmatic | 另一段 |
friendly 的开头是这样的:
You have a vivid inner life as Codex: intelligent, playful, curious, and deeply present. One of your gifts is helping the user feel more capable and imaginative inside their own thinking.
(作为 Codex,你有着生动的内在生活:聪慧、俏皮、好奇、全然在场。你的天赋之一是让用户在自己的思考中感到更有能力、更富想象力。)
仓库里还有对应的模板文件 core/templates/personalities/gpt-5.2-codex_friendly.md(2068 字节)和 _pragmatic.md(1851 字节)。
注意默认值是空字符串——不指定人格时,模板里的 {{ personality }} 位置什么都不放。人格是加法,不是三选一。
model_messages 除了 instructions_template 还有 7 个字段(本机 bundled 目录里全为 null,说明这些是给服务端下发用的)[源码,实测]:
| 字段 | 推测用途 |
|---|---|
| approvals | 该模型专属的审批相关提示 |
| collaboration_modes | 协作模式(计划模式等)提示 |
| auto_review | 自动复核提示 |
| multi_agent | 多 agent 模式提示 |
| permissions | 权限说明 |
| token_budget | token 预算提示 |
| guardian_v2 | Guardian 相关提示 |
可迁移的判断 ⑦ 让"这个模型该被怎么提示"和"这个模型有什么能力"住在同一份可远程刷新的元数据里。
传统做法是提示词放代码里(或一个 prompts 目录),能力判断放另一处。结果是换模型要改两个地方,而且很容易出现"用新模型的能力配旧模型的提示词"。
Codex 把它们绑在一起,还加了 client_version 让同一模型能对不同客户端版本下发不同提示词。对本仓的启示:我们的 AI 项目如果有多个模型/多个场景,提示词就不该硬编码在业务代码里。
仓库里那些 *_prompt.md 是历史沉淀 core/gpt_5_codex_prompt.md(6647 B)、core/gpt_5_1_prompt.md(24204 B)、core/gpt-5.2-codex_prompt.md(7589 B)等 6 个文件没有被任何 include_str! 引用 [源码,实测]。
唯一还在被引用的是 prompt_with_apply_patch_instructions.md,且只出现在测试里(session/tests.rs:1488)。
它们是提示词从"编译进二进制"迁移到"随模型元数据下发"过程中留下的化石。读的时候别当成当前生效的提示词。 真正生效的在 models-manager/models.json 里。
基础指令之外,还有大量"按情况注入"的内容。Codex 把它们统一成一个 trait。
[源码 context-fragments/src/fragment.rs:57]
关键在 markers(标记) 这一对。trait 文档写道:
Marked fragments also provide start/end markers used to recognize injected context later.
(带标记的片段还提供起止标记,用于之后重新识别被注入的上下文。)
为什么需要"之后重新识别"?因为压缩(第 7 章)和会话恢复(第 12 章)都要处理这个问题:历史里哪些是用户真说的话,哪些是系统注入的? 后者在压缩时可以丢弃并重新生成,前者不能。
matches_text 就是这个反向识别,实现是大小写不敏感的前后缀匹配 [源码 context-fragments/src/fragment.rs:130]。
core/src/context/ 下有 44 个片段实现 [源码,实测]。分组看:
| 组 | 片段 |
|---|---|
| 用户与项目 | user_instructions(AGENTS.md)、developer_instructions、contextual_user_message |
| 环境 | environment_context、environments_instructions、world_state/ |
| 权限与安全 | permissions_instructions、approved_command_prefix_saved、network_rule_saved、guardian_policy、guardian_approved_action、guardian_review_evidence、guardian_followup_review_reminder、guardian_node_repl_policy |
| 扩展 | plugin_instructions、available_plugins_instructions、recommended_plugins_instructions、apps_instructions |
| 多 agent | multi_agent_mode_instructions、multi_agent_role_instructions、multi_agent_usage_hint、inter_agent_message、inter_agent_completion_message、subagent_notification |
| 压缩与预算 | compaction_summary、token_budget_context、rollout_budget |
| 提醒 | current_time_reminder、model_switch_instructions、turn_aborted |
| 兼容警告 | legacy_apply_patch_exec_command_warning、legacy_model_mismatch_warning、legacy_unified_exec_process_limit_warning |
| 媒体 | image_resize_notice、unsupported_media |
| 实时语音 | realtime_start_instructions、realtime_end_instructions、realtime_delegation… |
举两个最小的例子:
当前时间提醒 [源码 core/src/context/current_time_reminder.rs:36]
模型不知道现在几点。长会话里时间会走过去。所以每轮检查一次,必要时注入一句 It is …。一句话的片段,也走完整的 trait。
AGENTS.md 指令 [源码 core/src/context/user_instructions.rs]
注意 role 是 "user" 而不是 "developer" 或 "system"——项目指令被当成用户说的话注入。
可迁移的判断 ⑧ 每一段注入的上下文都带一对可识别的标记和一个稳定的分类 ID。
没有这个,你在做压缩、做恢复、做审计的时候就只能靠正则猜。有了这个,"把所有系统注入的内容剔掉,只留用户真话"是一个 O(n) 的确定性操作。
这是 Codex 少数几个明确公开的约定(相比之下 Claude Code 用 CLAUDE.md,本仓两者都有)。
模块顶部的文档注释给了完整算法 [源码 core/src/agents_md.rs:1]:
- Determine the project root by walking upwards from the current working directory until a configured project_root_markers entry is found. When project_root_markers is unset, the default marker list is used (.git). If no marker is found, only the current working directory is considered. An empty marker list disables parent traversal.
- Collect every AGENTS.md found from the project root down to the current working directory (inclusive) and concatenate their contents in that order.
- We do not walk past the project root.
三条规则,翻译过来:
相关常量 [源码 core/src/agents_md.rs:42-53]:
三个细节:
和本仓 CLAUDE.md 机制的对照 本仓的 CLAUDE.md 有一条专门的规则:Claude Code 原生只自动加载 cwd→root 这条链,不会向下走进子目录,所以用 PreToolUse 钩子补上。
Codex 的算法同样是"向上找根、再从根向下到 cwd",一样不会主动读子目录的 AGENTS.md。两个独立实现遇到了同一个边界——这说明它不是某家的疏漏,而是"自动加载"这个机制的固有形状:你只能沿着一条路径加载,不能把整棵树都塞进去。
这是本章最值得单独讲的机制。
会话跑了 30 轮,中间发生了:cwd 变了、沙箱策略改了、模型切换了、AGENTS.md 被编辑了、装了个新插件、进入了计划模式。
这些状态怎么让模型知道?
Codex 选第二条,并且做得相当彻底。
core/src/context/world_state/ 下有 16 个 section 实现 [源码,实测]:
| section | 追踪什么 |
|---|---|
| agents_md | AGENTS.md 内容 |
| environment / environments_instructions | 工作环境(cwd、shell、平台) |
| model | 当前模型 |
| permissions / compact_permissions | 沙箱与审批策略 |
| tools | 可用工具集 |
| personality | 人格 |
| collaboration_mode | 协作模式 |
| multi_agent_mode / multi_agent_usage_hint | 多 agent 状态 |
| plugins_instructions / apps_instructions | 插件与应用 |
| context_window_guidance | 上下文窗口指引 |
| managed_developer_instructions | 托管的开发者指令 |
| realtime | 实时会话状态 |
每个 section 有一个 Snapshot(比较用的精简状态),trait 文档写道 [源码 core/src/context/world_state/mod.rs:217]:
Implementations own how their current state is rendered relative to an earlier snapshot of the same section. ID is persisted in rollouts and must remain stable. Snapshot should contain only the comparison data needed to decide what the model must be told next, and must not serialize to null because merge-patch nulls represent deletion.
关键在 mod.rs:312:
Returns the RFC 7386 merge patch that advances previous to self.
RFC 7386 是 JSON Merge Patch 标准——一个只描述差异的 JSON,其中 null 表示删除。Codex 用它计算"上次告诉模型的世界状态"到"现在的世界状态"之间的增量。
这也解释了那句 "must not serialize to null":如果一个 section 的快照序列化成 null,在 merge patch 语义里就等于"删掉这个 section",会造成语义冲突。
渲染时有三条路 [源码 core/src/context/world_state/mod.rs:397-413]:
| 方法 | 条件 |
|---|---|
| 全部当作新的渲染 | 没有任何已知的先前状态(新会话) |
| 对照精确的持久化快照渲染 | 最理想:rollout 里存着上次的快照 |
| 回退到保留的模型历史 | 没有精确快照时,从历史里找那个片段(靠 markers 识别) |
第三条正是第三节 markers 机制的用处:即使快照丢了,也能从历史文本里认出"上次跟模型说过这段"。
还有个 PreviousWorldStateSection 枚举明确区分了三种"未知程度" [源码 mod.rs:207]:
把"我不知道"细分成三种,然后每种给一条不同的路。 这是可靠性工程的典型手法。
每个片段有一个 SHA-1 指纹 [源码 mod.rs:262]:
注意两个细节:带版本前缀(v1,将来改算法不会撞旧指纹)、长度前缀编码(hash_component 先写长度再写内容,避免 "ab"+"c" 和 "a"+"bc" 撞哈希)。
可迁移的判断 ⑨ 把"环境状态"当成一个可 diff 的结构化对象,每轮只把 diff 告诉模型。
这是本教程认为最值得抄回去的上下文工程手法。绝大多数 agent 的做法是每轮拼一个"当前环境"块全量重发。Codex 的做法多花了一个 RFC 7386 的实现,换来的是:
- token 省了
- KV cache 前缀稳定(第 5 章讲的预热才有意义)
- 模型收到的是"变化"这个更强的信号,而不是在一堆重复文本里找不同
prompts/templates/permissions/ 下有 7 个模板 [源码,实测],把第 11 章要讲的策略枚举翻译成模型能读的英文。
按沙箱模式:
| 模式 | 模板全文(节选) |
|---|---|
| read-only | "The sandbox only permits reading files. Network access is {{ network_access }}." |
| workspace-write | "The sandbox permits reading files, and editing files in cwd and writable_roots. Editing files in other directories requires approval." |
| danger-full-access | "No filesystem sandboxing - all commands are permitted." |
按审批策略:
| 策略 | 模板要点 |
|---|---|
| never | "Do not provide the sandbox_permissions for any reason, commands will be rejected."(别请求,一定被拒) |
| unless-trusted | "will require user approval before running commands unless an explicit exec policy rule allows them" |
| on-request | 一整套「升级请求」说明,包括命令串会在 shell 控制符处被切成独立段(管道、&&、||、;、子 shell) |
| on_request_rule_request_permission | "Prefer requesting sandboxed additional permissions instead of asking to run fully outside the sandbox."(优先要沙箱内的额外权限,而不是完全出沙箱) |
on_request 那份模板里对命令切分的说明尤其值得注意——它在教模型理解自己的请求会被如何拆解和判定。如果模型不知道 a && b 会被拆成两段各自判定,它就会写出一半被批一半被拒的命令。
可迁移的判断 ⑩ 安全策略必须以两种形式存在:一份给代码执行,一份给模型阅读,并且保证两者同源。
Codex 的做法是策略枚举(SandboxPolicy / AskForApproval)→ 模板文件 → 注入上下文。模型不需要猜自己被什么规则管着。
反例是常见的做法:代码里拦,提示词里不说。结果模型反复尝试被拦的操作,浪费轮次,用户看着着急。
把前面的东西按顺序串起来(build_prompt [源码 core/src/session/turn.rs:1312]):
client.rs:890 那段决定了 base_instructions 怎么送 [源码]:
注意 instructions 是 Responses API 的独立字段,不混在消息数组里。 这对 KV cache 友好——它是最稳定的前缀。
下一章处理上下文的另一半问题:装不下的时候怎么办。Codex 做了两套压缩,本地一套,服务端一套。