Agent X-Ray
NotesSkillsAbout
Notes/源码拆解/Codex Harness/第6章

第6章:上下文工程 —— 指令到底从哪来

11 分钟 · 更新于 2026-09-01

第6章:上下文工程 —— 指令到底从哪来

一次模型请求里的每一个字符,都能追到它的来源。本章把 Codex 的上下文拆成三层——基础指令、上下文片段、世界状态——并给出一个别处很少见的做法:用 JSON merge patch 只告诉模型"变了什么"。


一、基础指令:三级优先级

会话建立时决定 base_instructions,代码里的注释直接列了优先级 [源码 core/src/session/mod.rs:650]:

rust
// Resolve base instructions for the session. Priority order:
// 1. config.base_instructions override
// 2. conversation history => session_meta.base_instructions
// 3. rendered instructions_template for current model
let base_instructions = config
    .base_instructions
    .clone()
    .or_else(|| conversation_history.get_base_instructions().map(|s| s.text))
    .unwrap_or_else(|| model_info.get_model_instructions(config.personality));
优先级来源用途
1用户配置覆盖完全自定义(codex review 就是靠这个换成复核提示词)
2会话历史里的记录恢复旧会话时,用它当初的指令,而不是今天的
3当前模型的指令模板默认路径

第 2 条是容易被忽略但很关键的一条。第 12 章会讲会话恢复——如果恢复一个三个月前的会话时套上今天的新提示词,模型看到的历史行为和当前指令就对不上了。Codex 的选择是让历史指令跟着历史走。

代码里还有一个 BaseInstructionsProvenance(来源标记),把"这份指令是从哪来的"也一起记下来。


二、系统提示词是模型元数据的一个字段

第 5 章已经揭示了这一点,这里展开它对上下文工程的含义。

2.1 渲染逻辑

rust
pub fn get_model_instructions(&self, personality: Option<Personality>) -> String {
    if let Some(model_messages) = &self.model_messages
        && let Some(template) = &model_messages.instructions_template
    {
        if model_messages.instructions_variables.is_none() {
            return template.clone();                    // 无变量 → 字面文本
        }
        let personality_message = model_messages
            .get_personality_message(personality)
            .unwrap_or_default();
        template.replace(PERSONALITY_PLACEHOLDER, personality_message.as_str())
    } else {
        warn!(model = %self.slug, "Model has no instruction template; returning empty instructions.");
        String::new()
    }
}

模板机制极简:一个占位符 {{ 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 被当作字面文本。当变量存在但不完整时,缺失的值渲染为空字符串。)

降级路径全部指向"少说点"而不是"报错"。 这是提示词渲染该有的失败模式。

2.2 人格

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 }} 位置什么都不放。人格是加法,不是三选一。

2.3 模型元数据里还挂着什么

model_messages 除了 instructions_template 还有 7 个字段(本机 bundled 目录里全为 null,说明这些是给服务端下发用的)[源码,实测]:

字段推测用途
approvals该模型专属的审批相关提示
collaboration_modes协作模式(计划模式等)提示
auto_review自动复核提示
multi_agent多 agent 模式提示
permissions权限说明
token_budgettoken 预算提示
guardian_v2Guardian 相关提示

可迁移的判断 ⑦ 让"这个模型该被怎么提示"和"这个模型有什么能力"住在同一份可远程刷新的元数据里。

传统做法是提示词放代码里(或一个 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 里。


三、上下文片段:44 个可注入单元

基础指令之外,还有大量"按情况注入"的内容。Codex 把它们统一成一个 trait。

3.1 ContextualUserFragment

[源码 context-fragments/src/fragment.rs:57]

rust
pub trait ContextualUserFragment {
    fn role(&self) -> &'static str;                       // user / developer
    fn content_kind(&self) -> ContentItemKind;            // "<feature>.<name>" 分类
    fn requires_separate_message(&self) -> bool { false } // 是否必须独立成一条消息
    fn markers(&self) -> (&'static str, &'static str);    // 起止标记
    fn body(&self) -> String;                             // 正文
    fn type_markers() -> (&'static str, &'static str) where Self: Sized;
    fn matches_text(text: &str) -> bool where Self: Sized; // 反向识别
    fn render(&self) -> String { … }                       // markers + body
}

关键在 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]。

3.2 44 个具体片段

core/src/context/ 下有 44 个片段实现 [源码,实测]。分组看:

片段
用户与项目user_instructions(AGENTS.md)、developer_instructionscontextual_user_message
环境environment_contextenvironments_instructionsworld_state/
权限与安全permissions_instructionsapproved_command_prefix_savednetwork_rule_savedguardian_policyguardian_approved_actionguardian_review_evidenceguardian_followup_review_reminderguardian_node_repl_policy
扩展plugin_instructionsavailable_plugins_instructionsrecommended_plugins_instructionsapps_instructions
多 agentmulti_agent_mode_instructionsmulti_agent_role_instructionsmulti_agent_usage_hintinter_agent_messageinter_agent_completion_messagesubagent_notification
压缩与预算compaction_summarytoken_budget_contextrollout_budget
提醒current_time_remindermodel_switch_instructionsturn_aborted
兼容警告legacy_apply_patch_exec_command_warninglegacy_model_mismatch_warninglegacy_unified_exec_process_limit_warning
媒体image_resize_noticeunsupported_media
实时语音realtime_start_instructionsrealtime_end_instructionsrealtime_delegation

举两个最小的例子:

当前时间提醒 [源码 core/src/context/current_time_reminder.rs:36]

rust
fn type_markers() -> (&'static str, &'static str) {
    ("<current_time_reminder>", "</current_time_reminder>")
}
fn body(&self) -> String {
    format!("It is {}.", self.formatted_time())
}

模型不知道现在几点。长会话里时间会走过去。所以每轮检查一次,必要时注入一句 It is …一句话的片段,也走完整的 trait。

AGENTS.md 指令 [源码 core/src/context/user_instructions.rs]

rust
fn content_kind(&self) -> ContentItemKind { ContentItemKind("agents_md.instructions".to_string()) }
fn role(&self) -> &'static str { "user" }
fn type_markers() -> (&'static str, &'static str) { ("# AGENTS.md instructions", "</INSTRUCTIONS>") }
fn body(&self) -> String {
    let directory = self.directory.as_ref().map(|d| format!(" for {d}")).unwrap_or_default();
    format!("{directory}\n\n<INSTRUCTIONS>\n{}\n", self.text)
}

注意 role"user" 而不是 "developer""system"——项目指令被当成用户说的话注入

可迁移的判断 ⑧ 每一段注入的上下文都带一对可识别的标记和一个稳定的分类 ID。

没有这个,你在做压缩、做恢复、做审计的时候就只能靠正则猜。有了这个,"把所有系统注入的内容剔掉,只留用户真话"是一个 O(n) 的确定性操作。


四、AGENTS.md:发现算法

这是 Codex 少数几个明确公开的约定(相比之下 Claude Code 用 CLAUDE.md,本仓两者都有)。

模块顶部的文档注释给了完整算法 [源码 core/src/agents_md.rs:1]:

  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.
  2. Collect every AGENTS.md found from the project root down to the current working directory (inclusive) and concatenate their contents in that order.
  3. We do not walk past the project root.

三条规则,翻译过来:

  1. 先向上找项目根(默认标记是 .git),找不到就只看当前目录
  2. 从项目根往下到 cwd,把沿途所有 AGENTS.md 按顺序拼接
  3. 不越过项目根——不会读到你 home 目录或者更上层去

相关常量 [源码 core/src/agents_md.rs:42-53]:

rust
pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md";
pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md";
const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
const MAX_CONCURRENT_ANCESTOR_PROBES: usize = 256;

三个细节:

  • AGENTS.override.md 优先于 AGENTS.md——本地覆盖机制,可以 gitignore 掉
  • 拼接时插分隔符 --- project-doc ---,模型能看出这是多个文件拼的
  • 祖先探测并发上限 256——目录很深时不会把文件描述符打爆

和本仓 CLAUDE.md 机制的对照 本仓的 CLAUDE.md 有一条专门的规则:Claude Code 原生只自动加载 cwd→root 这条链,不会向下走进子目录,所以用 PreToolUse 钩子补上。

Codex 的算法同样是"向上找根、再从根向下到 cwd",一样不会主动读子目录的 AGENTS.md。两个独立实现遇到了同一个边界——这说明它不是某家的疏漏,而是"自动加载"这个机制的固有形状:你只能沿着一条路径加载,不能把整棵树都塞进去。


五、World State:只告诉模型变了什么

这是本章最值得单独讲的机制。

5.1 问题

会话跑了 30 轮,中间发生了:cwd 变了、沙箱策略改了、模型切换了、AGENTS.md 被编辑了、装了个新插件、进入了计划模式。

这些状态怎么让模型知道?

  • 每轮全量重发 → 浪费大量 token,而且破坏 KV cache(前缀变了)
  • 只在变化时发 → 那得知道"和上次说的比,变了哪些"

Codex 选第二条,并且做得相当彻底。

5.2 16 个 section

core/src/context/world_state/ 下有 16 个 section 实现 [源码,实测]:

section追踪什么
agents_mdAGENTS.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实时会话状态

5.3 merge patch

每个 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",会造成语义冲突。

5.4 三级降级

渲染时有三条路 [源码 core/src/context/world_state/mod.rs:397-413]:

方法条件
全部当作新的渲染没有任何已知的先前状态(新会话)
对照精确的持久化快照渲染最理想:rollout 里存着上次的快照
回退到保留的模型历史没有精确快照时,从历史里找那个片段(靠 markers 识别)

第三条正是第三节 markers 机制的用处:即使快照丢了,也能从历史文本里认出"上次跟模型说过这段"

还有个 PreviousWorldStateSection 枚举明确区分了三种"未知程度" [源码 mod.rs:207]:

  • 没有持久化快照,历史里也没有匹配片段
  • 历史里有这个 section,但类型化快照不可用
  • 精确的持久化快照可用

把"我不知道"细分成三种,然后每种给一条不同的路。 这是可靠性工程的典型手法。

5.5 指纹

每个片段有一个 SHA-1 指纹 [源码 mod.rs:262]:

rust
let mut hasher = Sha1::new();
hasher.update(b"codex-world-state-fragment-v1\0");
hash_component(&mut hasher, fragment.role());
hash_component(&mut hasher, &fragment.render());

注意两个细节:带版本前缀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]):

text
┌─ instructions(Responses API 的顶层字段)
│   └─ base_instructions:模型元数据里的模板 + 人格替换(12–21 KB)
│
├─ input(消息数组)
│   ├─ 世界状态片段(只含本轮变化的 section)
│   ├─ AGENTS.md 指令(role=user,带 <INSTRUCTIONS> 标记)
│   ├─ 权限说明(按沙箱模式与审批策略选模板)
│   ├─ 技能与插件说明(按需)
│   ├─ 历史消息(含压缩摘要)
│   ├─ 当前时间提醒(必要时)
│   └─ 用户本轮输入
│
└─ tools(工具 schema,来自 StepContext.tool_router)

client.rs:890 那段决定了 base_instructions 怎么送 [源码]:

rust
if !prompt.base_instructions.text.is_empty() {
    …prompt.base_instructions.text.clone()…
}

注意 instructions 是 Responses API 的独立字段,不混在消息数组里。 这对 KV cache 友好——它是最稳定的前缀。


八、动手复核

bash
cd codex/codex-rs

# 1. 基础指令的三级优先级
sed -n '648,668p' core/src/session/mod.rs

# 2. 真正生效的系统提示词(取一个模型完整打印)
python3 - <<'PY'
import json
d = json.load(open('models-manager/models.json'))
m = [x for x in d['models'] if x['slug'] == 'gpt-5.2'][0]
print(m['model_messages']['instructions_template'])
PY

# 3. 三种人格
python3 - <<'PY'
import json
d = json.load(open('models-manager/models.json'))
m = [x for x in d['models'] if x['slug'] == 'gpt-5.5'][0]
for k, v in m['model_messages']['instructions_variables'].items():
    print(f'--- {k} ({len(v)} B) ---'); print(v[:400]); print()
PY

# 4. 44 个上下文片段
ls core/src/context/*.rs | wc -l
ls core/src/context/

# 5. 片段 trait 与标记机制
cat context-fragments/src/fragment.rs

# 6. AGENTS.md 发现算法
sed -n '1,55p' core/src/agents_md.rs

# 7. World State 的 merge patch
grep -n 'merge patch\|RFC 7386\|Sha1' core/src/context/world_state/mod.rs

# 8. 权限模板全文
for f in prompts/templates/permissions/*/*.md; do echo "=== $f ==="; cat "$f"; done

# 9. 那些没被引用的历史提示词文件(确认没有 include_str!)
grep -rn 'include_str!' core/src --include=*.rs | grep -i prompt

九、总结

  1. 基础指令三级优先级:用户覆盖 > 会话历史记录 > 当前模型模板。第 2 条保证恢复旧会话时用的是当初的指令
  2. 系统提示词是模型元数据的字段,随目录远程下发,{{ personality }} 一次字符串替换。仓库里那 6 个 *_prompt.md 是没人引用的化石
  3. 44 个上下文片段共用一个 trait,每个带一对标记和一个稳定分类 ID,用于事后反向识别"哪些是系统注入的"
  4. AGENTS.md 的算法是"向上找根、从根向下拼到 cwd、不越过根",支持 AGENTS.override.md 本地覆盖,和 Claude Code 遇到了同一个"不向下走"的边界
  5. World State 用 RFC 7386 merge patch 只发变化——16 个 section、三级降级、带版本前缀和长度前缀的 SHA-1 指纹。本教程认为这是最值得抄的上下文工程手法
  6. 安全策略有两份表达:给代码的枚举、给模型的模板,同源保证一致

下一章处理上下文的另一半问题:装不下的时候怎么办。Codex 做了两套压缩,本地一套,服务端一套。


  • 第5章-模型调用-只说一种协议的客户端
  • 第7章-上下文压缩-本地与远端两条路
  • 第11章-审批与策略-从静态规则到模型判官 —— 权限模板对应的策略实现
  • Pi 教程第 8 章
  • dsh 教程第 9 章 —— "多插件各贡献一段"的对照

本章目录
一、基础指令:三级优先级二、系统提示词是模型元数据的一个字段三、上下文片段:44 个可注入单元四、AGENTS.md:发现算法五、World State:只告诉模型变了什么六、权限说明:策略变成给模型看的话七、一次请求最终长什么样八、动手复核九、总结Related Documents
苏ICP备2025204887号-2