跳转至

第 4 章 上下文工程:从片段拼装到 AGENTS.md

上一章末尾,run_turn 的第一步是 clone_history().for_prompt(...)——从历史里"拼"出这一轮要发给模型的输入。这一章,我们就钻进这一步:Codex 到底把什么、按什么顺序、用什么纪律,拼进每一轮喂给模型的上下文。

这是 harness 里最稀缺、最该精打细算的资源。我们尽量少贴代码、多用图,把"上下文是怎么来的"这件事讲到你能自己画出来。

引子:把所有东西都塞给模型,它反而变笨了

先做个思想实验。你在写一个 coding agent,想让它"什么都知道",于是把能想到的全塞进提示词:完整的编码规范、整个项目的目录树、最近 50 条对话、十几个工具的详细说明、还有一大段"你必须非常小心、非常仔细、非常专业……"的叮嘱。

结果呢?它常常更差了:忘了你这一轮真正要它干的小事,或者揪着某条无关紧要的规范不放,又或者上下文还没轮到正题就已经被这些"背景"撑满了。

这不是错觉。Anthropic 直接提醒:即便上下文窗口越来越大,依然会有 context pollution 和 relevance 的问题;窗口不是垃圾场,装得下不等于该往里塞。OpenAI 那篇文章也反复强调同一点——上下文是稀缺资源;而且"当什么都重要时,就没有什么是重要的":叮嘱太多,等于没有叮嘱,模型会退化成"就近模式匹配",而不是按你的意图行事。若用一句更口语的话来翻译,就是:上下文窗口更像工作内存预算,而不是仓库。

所以上下文工程的第一性原理,一句话:

上下文是预算。每多塞一个 token,就挤掉一点模型干正事的空间。

上下文预算条

图 1:上下文窗口是固定预算,左边和中间塞得越满,真正这一轮的任务空间就越小。

明白了"省着花"是底线,我们再来看 Codex 是怎么把这笔预算花出去的。答案有点反直觉。

一、反直觉的事实:上下文是"拼"出来的,不是"写"出来的

大多数人脑子里的模型是这样的:有一段写死的大提示词(system prompt),每次把用户的话接在后面发给模型。

Codex 不是这么干的。在它这里,每一轮喂给模型的上下文,是临时"拼装"出来的——由许多块小小的、各管一摊的"上下文片段"(fragment)拼成。环境信息是一片、用户指令是一片、可用技能的名录是一片、权限说明是一片、人格设定是一片、各种提醒又各是一片……每一轮,Codex 根据当时的情况,挑出该上的那些片,拼成这一轮的上下文。

上下文是拼出来的

图 2:Codex 的上下文不是一段写死的巨型 prompt,而是按当前这一轮需要动态拼装。

为什么要这么麻烦?因为"拼装"换来了四样"写死一大段"给不了的东西,而这四样恰恰是省预算、保正确的关键:

  • 可组合:要加一种新背景信息,加一片就行,不用去动那段谁都不敢碰的大提示词。
  • 可条件:哪片该上、哪片不上,可以按情况判断——这一轮用不到的,根本不进上下文(省预算)。
  • 可识别:每片都带标签,Codex 事后能在历史里把自己注入的内容认出来,用于去重、过滤、清理。
  • 可测试:每片是独立的小单元,可以单独测它渲染出来对不对。

这就是"上下文是工程化的工件,而不是一根随手粘起来的字符串"的真正含义。下面我们把这块"积木"拆开看。

二、上下文的"原子":一个片段长什么样

Codex 把每一片上下文都抽象成同一个 trait(接口),源码里叫 ContextualUserFragment——它的本体定义在一个独立的小 crate context-fragments(crate 名 codex_context_fragments)里,由 core 再导出给各处用;环境信息、用户指令等各种具体片段,则各自在 core/src/context/impl 这个 trait。你不用记这些名字,只要记住:每一片都由三件事定义(它们正是这个 trait 要求实现的三个方法)。

直接看这个 trait 真实的样子(context-fragments/src/fragment.rs,省略了部分默认方法;不熟 Rust 看注释即可,记号对照见附录):

pub trait ContextualUserFragment {
    fn role(&self) -> &'static str;                      // 以谁的身份说话:"user" / "developer"
    fn markers(&self) -> (&'static str, &'static str);   // 开/闭标签 <environment_context> … </environment_context>
    fn body(&self) -> String;                            // 正文:真正那段文字

    fn render(&self) -> String {                         // 默认实现:把标签套在正文外面
        let (start, end) = self.markers();
        let body = self.body();
        if start.is_empty() && end.is_empty() { return body; }   // 无标签就只返回正文
        format!("{start}{body}{end}")
    }
    fn matches_text(text: &str) -> bool { /* 靠首尾标签,认出"自己注入过的片" → 去重/替换 */ }
    fn into(self) -> ResponseItem { /* 渲染好,再包成一条对话消息注入进去 */ }
}

你看,三个必填方法(role/markers/body)定义"这一片是什么",剩下的 render/matches_text/into 是统一的默认行为:拼标签、认指纹、包成消息——所有片段共享这套,不用各写一遍。这正是"原子"的好处:新增一种片段,作者只需回答三个问题——以谁的身份说(role)、套什么标签(markers)、正文写什么(body),剩下的渲染、识别、注入全都白送。后面 §三 那三十来个片段,无一例外都是这么"填三个空"做出来的;而 §五 的去重、§五 的截断,能成立的前提也都在这套默认行为里——matches_text 让"认出旧的那一片"成为可能,render 让"套标签"成为统一动作。换句话说,这个 trait 不只是个接口,它是后面一切"省预算"花招的地基。

光看接口还是悬空。举最直观的一片——EnvironmentContext(讲"你在什么环境里"),它走完上面这套 render(),模型实际收到的就是这么一段带标签文本(取自 Codex 测试里的真实渲染,字段含义下一节细拆):

<environment_context>
  <cwd>/repo</cwd>
  <shell>bash</shell>
  <current_date>2026-02-26</current_date>
  <timezone>America/Los_Angeles</timezone>
</environment_context>

看——body() 写的那几行字段,被 markers() 那对 <environment_context>…</environment_context> 一裹,就成了模型读到的一片上下文。抽象的 trait,落地就是这么个具体东西。

这里最值得停一下的,正是这对标签(markers)。为什么每片都要套一对像 HTML 一样的标签?两个原因:

  1. 给模型看的分隔:让模型一眼分清"这段是环境信息、那段是用户指令",而不是糊成一团。
  2. 给 Codex 自己看的指纹:因为带着固定标签,Codex 事后能在长长的历史里把"哪些是我注入的环境上下文"准确认出来(源码里就是 matches_text 在干这事)。能认出来,才能去重(同样的环境别注入两遍)、能替换(环境变了就换掉旧的)、能在需要时清理掉。

一个片段的解剖

图 3:一个上下文片段由 rolemarkersbody 组成,既服务模型分隔,也服务系统自身识别。

有了这个统一的"原子",所有种类的上下文就都能用同一套机制拼装、注入、识别了。那 Codex 里到底有哪些"片"?

三、Codex 上下文里的"片"清单

打开 core/src/context/ 这个目录,你会看到三十来个文件,每个就是一种片段。把它们归归类,你会发现一件事:harness 的每一层,几乎都在这里有一片"代言人",负责把自己那点事说给模型听。

  • 机器事实(你在哪、什么环境)EnvironmentContext(当前目录、日期、时区、网络与文件系统策略、是否有子代理)、PermissionsInstructions(这一轮的审批/权限画像,第 9 章)。
  • 仓库与用户指令UserInstructions——也就是 AGENTS.md 的内容(它太重要、也太容易做坏,本章后半专门讲它)。
  • 可插拔能力的"名录"AvailableSkillsInstructionsAvailablePluginsInstructions——注意是名录,只放"有哪些技能/插件、各一句话",正文用时才加载(第 7 章的核心机制)。
  • 人格与协作模式PersonalitySpecInstructionsCollaborationModeInstructions(plan / execute / pair_programming,外加缺省的 default)。
  • 提醒与标记TurnAborted(还记得吗?第 3 章那个"上一轮被中断了"的换班标记,就是一片上下文)、GuardianFollowupReviewReminder(第 9 章)、ModelSwitchInstructions(你中途换了模型时提醒一句)、SubagentNotification(第 11 章),还有几片只在老版本/特定情况下才出现的兼容性警告。

这份清单本身就透露了一条设计原则:凡是"模型该知道、但权重里没有"的东西,都被做成了一片上下文。 你顺着五组看下去会发现,它们恰好对应了 agent 干活时会问的五类问题——"我在什么环境里?"(机器事实)、"这个项目有什么规矩?"(用户指令)、"我手上能调动哪些能力?"(能力名录)、"我该用什么风格、什么协作模式?"(人格与模式)、"刚才发生了什么我得留意的?"(提醒与标记)。每一类问题,都有一片上下文专职回答。这种"一类事实一片"的切法,不只是为了好看:它让每片可以被单独决定上不上(§五第一招)、单独比对要不要重发(§五第二招)、单独测它渲染对不对(§二说的可测试)。如果把这些事实全揉进一段大文本,上面这些"片级"的精打细算就全都无从谈起了。

片段目录墙

图 4:前面各章讲过的层,最终都会在上下文里派出自己的“代言片段”。

这张图值得记住:上下文,是 harness 各层"对模型说话"的总出口。 工具有哪些、权限是什么、技能有哪些、上一轮发生了什么——这些都不是模型天生知道的,全靠这一片片上下文,在每一轮被"讲"给它听。

解剖一片:EnvironmentContext 到底打包了什么

光看清单还是抽象。我们挑最有代表性的一片——EnvironmentContext——拆开看看它"代言"的到底是什么。打开 core/src/context/environment_context.rs,你会发现它本身是个 enum(None / Single / Multiple,对应"没有环境 / 单一工作环境 / 多个并行环境"),而每个环境里塞进去的,是这样一组机器事实

  • cwd——agent 此刻站在哪个目录里干活;
  • current_date——今天几号(模型权重里没有"今天",这得现讲);
  • shell——用的是哪个 shell;
  • networkNetworkContext)——网络此刻通不通;
  • filesystemFileSystemContext)——它又含两件事:workspace_roots(哪几个目录是工作区根)和 permission_profile(文件系统的权限画像)。

那个 permission_profile 尤其值得一提,它的取值是 Managed(Restricted{ 可写条目 } | Unrestricted) | Disabled | External——翻译成人话就是:"我被托管着、只能写这几个目录"、"我被托管着、文件系统随便写"、"文件系统功能被关了"、"由外部接管"。这正是第 9 章那套权限/审批机制,在上下文这一侧的"投影"。

把这些字段拼起来,就是 §二你已经见过的那段最简 <environment_context>cwd/shell/current_date/timezone)。这里再看一个"更满"的:这一轮若还划了网络白名单,就会多出一行 <network>,把"哪些域名放行、哪些拒绝"也明明白白写给模型(文件系统边界同理,会渲染成一行 <filesystem>):

  <network enabled="true"><allowed>api.example.com,*.openai.com</allowed><denied>blocked.example.com</denied></network>

看到这段,"上下文是拼出来的"就不再是一句口号了:所谓"机器事实"压根不抽象,它就是这么几行带标签的纯文本——<cwd> 告诉模型"你此刻站在 /repo"、<current_date> 补上权重里没有的"今天"、<network> 划出"你能联到哪儿"。第三节那一长串"片",每一片都会渲染成这样一小段带标签文本,再按下一节讲的顺序叠起来——叠出来的,就是模型这一轮收到的"整个上下文"。

为什么要把这一坨东西每轮讲给模型?因为模型不会天生知道它在哪儿、能干什么。它不知道"我现在在 /proj 这个目录"、不知道"我此刻是断网的、别指望 curl"、不知道"沙箱只放我写工作区那一个根、写别处会被拒"。这些全是运行时才确定的事实,权重里一个字都没有。EnvironmentContext 这一片,就是每一轮把这些"我是谁、我在哪、我能动什么"老老实实讲给模型听——讲清楚了,模型才不会去做注定失败的事(比如断网时还想联网装包、或往不可写的目录里写文件)。这就是"机器事实代言人"的全部职责:把 harness 此刻的真实处境,翻译成模型读得懂的一段话。它也顺带解释了 §五 第二招为什么要去重——环境这种东西,多数轮次根本没变,没必要一遍遍重讲。而它那个 enum 形态(None/Single/Multiple)还埋了个伏笔:之所以要支持"多个并行环境",是因为 Codex 允许同时在多个工作区里干活——这正是第 11 章"子代理"会用到的能力,到时候你会看到,每个子代理各有各的 cwd,靠的就是这里 Multiple 这一支把它们分别讲清楚。

到这里,我们看清了单片长什么样。下一步不是再加新片,而是看这些片在一轮请求里按什么顺序叠起来

四、一轮上下文长什么样:四层叠起来

知道了"片"是什么、有哪些,现在把它们叠成一轮完整的输入。Codex 发给模型的一次请求(源码里叫 Prompt),其实只有两大部分:基础指令(base_instructions对话输入(input。展开来,是这样四层自上而下叠起来的:

┌──────────────────────────────────────────────┐
│  ① 基础指令 base_instructions                  │  ← 系统提示词:你是谁、怎么干活、工具怎么用
│     (来自 core/gpt-5.2-codex_prompt.md 等)    │     稳定、长、每轮基本不变
├──────────────────────────────────────────────┤
│  ② 上下文片段(第三节那些)                     │  ← 环境 / 用户指令(AGENTS.md) / 技能名录 /
│     environment / user_instructions / skills…  │     权限 / 人格 / 提醒…… 按需拼装
├──────────────────────────────────────────────┤
│  ③ 对话历史                                    │  ← 之前轮次的你来我往、工具调用与结果
│     (history.for_prompt 整理出来)             │
├──────────────────────────────────────────────┤
│  ④ 这一轮的新输入                               │  ← 你刚敲的那句话
└──────────────────────────────────────────────┘
        ↓  合起来就是一次 Prompt,发给模型

一次模型请求的结构(四层堆叠)

图 5:一次真正送进模型的 Prompt,由基础指令、上下文片段、历史和本轮输入四层叠加而成。

两个要点:

第一,①基础指令就是那段写死的系统提示词,存在 core/gpt-5.x_prompt.md 这类 Markdown 文件里。它定义了 agent 的角色、纪律、工具使用规范——是一份质量极高的 prompt engineering 范本,很值得你单独找出来通读(本书不整段贴,太长了)。这里要点出一个容易被忽略的设计取舍:为什么它和②上下文片段是两类东西、分开放?因为二者的"变化频率"截然不同。①几乎一字不变——它是这个 agent 的"宪法",每一轮都一样;而②是按当时情况临时拼的,每轮都可能不同。

把不变的单拎出来,最大的好处是可缓存:模型供应商对一段稳定不变的前缀,可以做 prompt caching。OpenAI 官方给出的口径是:对满足条件的前缀,请求可自动命中缓存,输入成本最多降到 1/10、延迟最多降 80%;Anthropic 对长前缀报出的延迟降幅最多达 85%,并明确写出缓存是严格按前缀顺序命中的(tools → system → messages)。这组信息真正想说明的不是"厂商帮你省钱了",而是你得配合它的缓存边界来组织上下文:越稳定的块越往前放,越高频变化的块越往后放,别把会频繁变化的日期、目录、临时提醒掺进本来可缓存的稳定前缀里。否则哪怕只改动一小块,前缀指纹也会变,缓存收益立刻消失。

第二,回扣第 3 章:run_turn 里那句 history.for_prompt(...),产出的就是上面的②+③+④——它把"该注入的片段、历史、新输入"整理成一串对话项;再配上①基础指令,就凑成一次完整的 Prompt所谓"拼上下文",拼的就是这四层。

这话不是比喻,源码里 Prompt 这个结构体就是这么长的(core/src/client_common.rs,只留两个主字段,看注释即可):

pub struct Prompt {
    pub input: Vec<ResponseItem>,            // ②③④:片段+历史+本轮输入,全是一条条 ResponseItem
    pub base_instructions: BaseInstructions, // ①:系统提示词(来自 *_prompt.md)
}

短短两行,把"四层"折成了"两大块":上面那个 input,是一个 ResponseItem线性列表——注意这里的设计取舍:②上下文片段、③历史、④本轮输入,最终都被拍平成同一种东西(一条条 ResponseItem),混在一个列表里。 这不是偷懒,恰恰是 §二那个"原子化"的回报:因为每片上下文都能 into 成一条 ResponseItem,它就能和真正的对话消息、工具调用结果平等地排进同一个序列,不需要为"片段"另开一条特殊通道。而 base_instructions(①)单拎出来,是因为它扮演的角色不同——它是整场对话的"宪法",稳定、不随轮次变,单独放也方便模型供应商对它做 prompt caching。

history.for_prompt(...) 产出的,正是这个 input 列表。第 3 章我们看它"从历史里拼出这一轮的输入",现在你能更精确地说出它干了什么:把该注入的片段渲染成 ResponseItem、接上整理过的历史、再缀上本轮新输入,组装成 input 这一串;Prompt 再给它配上 base_instructions 一次喂给模型的请求,本质上就这两块——而拼装的全部纪律,都花在了 input 这一串怎么填上。

五、省着花:条件注入、去重、截断

第一节说"上下文是预算"。现在我们有了"片"这个粒度,就能看清 Codex 是怎么在片这一级精打细算的——三招,招招都为省 token。这三招能存在,前提全在 §二那个"原子":正因为每片是独立、带标签、能被认出来的小单元,才谈得上"这片上不上"(条件)、"这片重不重复"(去重)、"这片多大"(截断)。换句话说,原子化是因,精打细算是果。

第一招:按需注入,用不到的根本不上。 不是每片每轮都注入。比如那些兼容性警告,只在你用了过时配置时才出现;ModelSwitchInstructions 只在你中途换了模型时才提醒一句;技能、插件只放"名录",正文等你真要用某个时再加载(第 7 章)。这一轮用不到的上下文,连进都不进。 这一招看着平平无奇,其实是三招里最省的——因为它省的不是"重复的 token"或"超长的 token",而是把一整片本来就不该出现的内容,从源头掐断。回想本章开头那个"垃圾场"反例:把什么都塞进去的人,缺的正是这一道"这轮用得到吗?"的闸门。Codex 的做法是,让每片都先回答这个问题,答"否"就根本不进 input 那个列表。

第二招:没变就别重发(去重)。 环境信息(当前目录、日期、沙箱策略)这种,如果跟上一轮一模一样,重复注入纯属浪费。EnvironmentContext 里有个很实在的细节——它能判断"两次环境是否除了 shell 之外相等",相等就不再重发。真实源码并不是只看 cwd,而是先比较环境列表本身是否只差 shell,再继续比较 current_datetimezonenetworkfilesystemsubagents 这些字段。核心代码是这样(core/src/context/environment_context.rs,只留主干):

pub(crate) fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
    self.environments.equals_except_shell(&other.environments)
        && self.current_date == other.current_date
        && self.timezone == other.timezone
        && self.network == other.network
        && self.filesystem == other.filesystem
        && self.subagents == other.subagents
}

注意它的判断标准是:忽略 shell 抖动,但不忽略真正会影响行动边界的环境变化。 也就是说,"换了个 shell"通常不值得重发整片环境上下文;但只要日期、时区、网络权限、文件系统权限、子代理信息变了,它就会把这视作环境变化。换句话说,Codex 在这里不是偷懒,而是在定义一套"什么才算环境真的变了"的等价标准:忽略无关噪声,保留会改变决策的事实。 这一招能成立,靠的还是 §二说的"标签即指纹":先得在历史里认出"上一片环境上下文是哪条",才谈得上拿它和这一轮的环境比一比、决定要不要换掉。认得出,才删得掉、换得掉。

第三招:单片也有上限(截断)。 哪怕是注入的外部上下文,也不能无限大。Codex 给这类"额外上下文"(AdditionalContext)的每个值定了 1000 token 的预算,超了就从中间截断。源码很短(context-fragments/src/additional_context.rs,看注释即可):

const MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS: usize = 1_000;     // 单片外部上下文上限
fn additional_context_body(key: &str, value: &str) -> String {
    // 超 1000 token → 掐掉中段、留头尾
    let value = truncate_middle_with_token_budget(value, MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS).0;
    format!("{key}>{value}</external_{key}")                  // 再套上 <external_…> 标签
}

为什么是从中间截、而不是简单粗暴地砍掉尾巴?因为一段内容里,开头通常交代"这是什么",结尾往往是结论或最新状态,这两头信息密度最高;真要省,中段最经得起省。这不只是工程直觉——斯坦福 Nelson Liu 等人那篇被反复引用的《Lost in the Middle》用实验坐实了同一件事:模型用长上下文时,对开头和结尾的信息记得最牢,夹在中间的最容易被"看漏"。"掐中段、留头尾"正是顺着模型这个脾气来的——把它本来就最不上心的那段省掉,代价最小。truncate_middle_with_token_budget 这个名字直白地写明了它的策略:保留头尾、掏空中间。最后那行 format! 也呼应了 §二——截好的正文,照样要套上 <external_…> 这对标签,好让它在历史里依旧带着"指纹",将来一样能被认出、去重、清理。

省预算三招

图 6:按需注入、片段去重、中间截断,是上下文层最直接的三种省预算手段。

这三招都是"片级"的省法,它们守的是同一条边界:别让任何一片,吃掉超过它该吃的预算。 第一招管"这片该不该出现",第二招管"这片是不是重复了",第三招管"这片是不是太胖了"——三道闸口卡的位置不同,但目标一致。还有一招"turn 级"的大杀器——当整段历史都太长、连三道片级闸口都拦不住时,就得把它整体压缩——那是第 10 章"长程作业"的主角,这里先按下不表。你可以这么记:片级三招是"日常节流",turn 级压缩是"超支后的总清算",两者一上一下,护的是同一个窗口。

一句话串起来:条件注入决定"上不上",去重决定"重不重复上",截断决定"单片能多大",压缩(第 10 章)决定"整体超了怎么办"。 四道闸,守的是同一笔预算。

六、最金贵的一片:AGENTS.md 与"地图而非手册"

在第三节那张"片段目录墙"上,有一片格外特殊——UserInstructions,也就是 AGENTS.md 的内容。它是你(人类)能往 agent 上下文里塞的、最直接也最重要的一片:项目的规矩、约定、踩坑记录,都靠它传给模型。也正因如此,它最容易被做坏。

失败模式:一个巨大的 AGENTS.md

很多团队的第一反应是:把所有规范、架构说明、历史决策、注意事项,一股脑写进一个越来越大的 AGENTS.md。OpenAI 试过,然后总结了这种做法"四宗罪":

  1. 挤占上下文——一份巨型说明书把任务、代码、相关文档挤出窗口,模型要么漏掉关键约束,要么盯着错的东西优化(正是本章开头那个"垃圾场")。
  2. 处处重点 = 没有重点——当所有内容都标成"重要",模型只好退化成"就近模式匹配",而不是按你的意图行事。
  3. 即刻腐烂——巨型手册很快变成一座"过期规则的坟场":模型分不清哪条还作数,人也懒得维护,它就成了一个"看着有用、实则误导"的累赘。
  4. 难以校验——一整块大文本没法做机械检查(覆盖率、新鲜度、归属、交叉链接),漂移不可避免。

解法:把 AGENTS.md 当"目录",把 docs/ 当"事实源"

OpenAI 的解法,一句话:别把 AGENTS.md 当百科全书,把它当目录(table of contents)。

具体做法是:根目录放一份很短(约 100 行)的 AGENTS.md,它本身不存放知识,只当"地图"——告诉 agent"详细的东西在哪儿找"。真正的知识库放进一个结构化的 docs/ 目录,作为"系统事实源(system of record)":架构文档、设计文档与核心信念、产品规格、执行计划(进行中 / 已完成 / 技术债)、外部库的参考说明等,各就各位、还互相交叉索引。

这样换来一个关键性质——渐进式披露(progressive disclosure):agent 一开始只拿到一个小而稳的入口(那 100 行地图),需要细节时再被"教"着去 docs/ 里翻,而不是一上来就被淹没。

而且这套知识库是机械维护的:专门的 linter 和 CI 任务检查它是否最新、是否交叉链接正确;在 OpenAI 那个内部 agent-first 仓库里,甚至还有一个"文档园丁(doc-gardening)" agent 定期扫描、发现过期文档就自动开修复 PR。地图不靠人力勤奋保鲜,靠工程保鲜。

千页手册 vs 地图 + docs

图 7:好的 AGENTS.md 更像地图和索引,而不是试图把全部知识直接塞进上下文。

活样本:读一眼 Codex 自己的 AGENTS.md

最好的老师就是 Codex 自己。它仓库根目录那份 AGENTS.md,正是一份"地图式"文档——它不堆知识,而是给规矩 + 给指针。随手摘几条它真实写着的:

  • 画红线:明确哪些东西绝对不要碰(比如和沙箱网络开关相关的那几个环境变量),并解释为什么(测试在沙箱里会用它提前退出)。
  • 架构纪律:那句我们第 2 章引过的"抵制往 codex-core 加代码",连同"加之前先想想能不能放别的 crate / 该不该新开一个 crate"。
  • 给指针而非全文:讲 TUI 样式时,它不把规范抄一遍,而是写"详见 codex-rs/tui/styles.md"——这正是"目录"的用法。
  • 可机械校验的约定:命名、format! 内联、match 穷尽、模块行数上限……这些都能被 lint 挡住(呼应第 2 章那几十条 deny 级 lint)。

你会发现,它读起来不像"说明书",像"老员工给新人留的一页 onboarding 便条 + 一摞'细节看这里'的便利贴"。

七、Codex 怎么找到并合并 AGENTS.md

AGENTS.md 还有一个容易被忽略、但很能体现"工程"的细节:它是分层的。

打开 core/src/agents_md.rs,它的查找逻辑是这样(机制本身就是一张图):

  1. 从你当前所在的目录出发,一路向上走,直到遇到"项目根"标记(默认是 .git)——这就划定了项目根。
  2. 然后从项目根往下到你当前目录,沿途遇到的每一个 AGENTS.md 都收集起来,按从根到叶的顺序拼接
  3. 不越过项目根

难得的是,agents_md.rs 把这套算法直接写进了文件头注释,三步一字不差:① 从 cwd 向上走,直到遇到 project_root_markers(默认就是 .git)的那一层,定为项目根;② 从根到 cwd,沿途每个 AGENTS.md 都收集起来,按从根到叶的顺序拼接;③ 不越过根。第一步"向上找根"的真实代码长这样(core/src/agents_md.rs,看注释即可):

for ancestor in dir.ancestors() {              // 从 cwd 一层层往上走
    for marker in &project_root_markers {      // 默认就一个标记:".git"
        if ancestor.join(marker).exists() {    // 这层有 .git → 它就是项目根
            project_root = Some(ancestor.clone());
            break;
        }
    }
    if project_root.is_some() { break; }        // 找到根就停,不再往上
}

(注意:真实源码里那句"是否存在"是一次异步的文件系统判断——fs.get_metadata(...)——这里为了讲清主干,写成了 .exists(),逻辑完全一致。)找到根之后,代码再从根往下收集每一份 AGENTS.md,用 parts.join("\n\n") 把它们按根→叶的顺序拼起来。

这意味着什么?仓库根目录的通用规矩,和你正在改的那个子目录的特定规矩,会同时生效、叠加在一起——根目录那份讲全局约定,子目录那份补"这个模块的特殊情况"。而"从根到叶"的拼接顺序也不是随意的:通用的在前打底,局部的在后追加、必要时收紧或覆写——离任务越近的规矩,排得越靠后、份量越重。再加上来自 ~/.codex 的全局指令、以及一个可选的 AGENTS.override.md(源码里那个常量 LOCAL_AGENTS_MD_FILENAME)本地覆盖文件,一起拼成最终那片 UserInstructions

这里有个容易被忽略、却很贴心的细节:合并时,每一份 AGENTS.md 都被单独打上"它来自哪个目录"的标签,而不是糊成一团。一片 UserInstructions 渲染出来是这样(注意它的 roleuser,开头那行就写明出处目录):

# AGENTS.md instructions for codex-rs/tui

<INSTRUCTIONS>
(codex-rs/tui 这一层那份 AGENTS.md 的正文……)
</INSTRUCTIONS>

于是"从根到叶"的那几份,在模型眼里就是一摞各自标着出处目录的指令块:它一眼能分清"这条是仓库根的通用约定、那条是 tui/ 这个模块的特殊要求",越靠下的越具体、份量越重。这又一次落回 §二那句话——连"合并多份指令"这种事,靠的也还是"给每片套上标签"这套统一动作。

这里还藏着一个"工程"上的体面:默认根标记是 .git,而它是可配置的project_root_markers)。换句话说,"项目根"不是写死的猜测,而是顺着你本来就有的版本控制边界来划——绝大多数仓库里,.git 在哪,"项目"就到哪为止,既符合直觉,又不需要你额外配置。而"不越过根"这一条同样关键:它保证 agent 不会糊里糊涂把根目录之外、和当前项目无关的某份 AGENTS.md 也卷进来——边界清楚,上下文才干净。

AGENTS.md 的发现与合并

图 8:Codex 会自底向上发现 AGENTS.md,再按根到叶的顺序叠加成最终用户指令。

看出来了吗?这本身就是渐进式披露的又一种形态:越靠近根,规则越通用、越稳定;越往下,越具体、越局部。它和我们第 7 章会讲的"技能分层作用域(User/Repo/System)"是同一个思路。

值得多想一层的是:这套"分层 + 合并"的机制,不需要你做任何配置就自动生效。你只要把通用约定写在仓库根那份 AGENTS.md、把模块特有的约定写在子目录那份里,Codex 在你切到哪个目录干活时,就会自动把"从根到你脚下"这一路的规矩都收齐、按层拼好。换句话说,目录结构本身就成了规则的作用域——你拿现成的项目布局,免费换来了一套"全局规矩自动打底、局部规矩按需追加"的分层指令系统。这正呼应本章反复在说的那件事:好的上下文工程,是把"该让模型知道什么、什么时候知道"这件事,做成一套机制,而不是靠人每轮手动塞。~/.codex 的全局指令和 AGENTS.override.md 的本地覆盖,则给这套机制留了两个逃生口:前者让你把跨项目的个人偏好放在一处,后者让你在不污染团队共享文件的前提下,临时盖掉某条规矩——既有默认的好用,又有覆盖的灵活。

八、第一性原理:"对 agent 可见的,才存在"

把这一章再往上拔一层,你会摸到贯穿全书的那条第一性原理。

OpenAI 那篇文章有一句很扎心的话,大意是:"Codex 看不见的东西,就等于不存在。" 那些躺在 Google Docs 里、飘在 Slack 聊天里、装在某个同事脑子里的知识——对 agent 而言,统统不存在。它就像一个三个月后才入职的新人:只能读到仓库里写下来的东西。

所以上下文工程(乃至整个 harness 工程)最深的一招,其实是:

把人类隐性的环境、纪律和判断,编码成 agent 在上下文里能读到、还能被机械校验的工件。

那条"团队在 Slack 里吵了三天才达成一致的架构约定",如果不落进 docs/ 或 lint 规则,对 agent 就是不可见的——下一个 agent 该踩的坑还会再踩一遍。前面讲的 AGENTS.md(地图)、docs/(事实源)、deny 级 lint(把品味焊死成规则)、甚至第 3 章那条"被中断"标记,本质上都是同一件事:让原本"不可见"的东西,变得对 agent 可见。

agent 的视野气泡

图 9:对 agent 来说,仓库内可见的信息才真正存在;仓库外知识不先落地,就无法被稳定利用。

这条原理,后面每一章都会以不同形式回响:工具要让模型"看见"能力(第 5 章),技能要让模型"看见"名录(第 7 章),可观测性要让模型"看见"运行时(第 13 章)。记住它,你就抓住了 harness 工程的魂。

本章小结

  • 上下文是预算,不是垃圾场。 塞太多 ≠ 知道更多;"什么都重要"等于"什么都不重要"。
  • 上下文是"拼"出来的。 Codex 把每一轮上下文,由一片片实现了 ContextualUserFragment trait(提供 role() / markers() / body() 的接口,本体在 context-fragments crate,各片段在 core/src/context/ 里实现)的小单元临时拼装而成——可组合、可条件、可识别、可测试。标签既给模型分隔,也给 Codex 自己留指纹。
  • 一次请求 = 四层叠加:① 基础指令(系统提示词,稳定、可缓存)+ ② 上下文片段 + ③ 对话历史 + ④ 本轮输入。第 3 章那句 history.for_prompt(...) 拼的就是 ②③④。
  • 片级省预算三招:按需注入、没变不重发(靠"指纹"识别)、单片中间截断(≤1000 token);turn 级整体压缩留给第 10 章。
  • AGENTS.md 要当"地图",不当"百科全书":根目录一份约 100 行的目录 + 结构化的 docs/ 事实源 + 渐进式披露 + 机械维护(doc-gardening)。Codex 从 cwd 向上找到 .git 根、再把根→叶的多份 AGENTS.md 分层拼接。
  • 第一性原理:对 agent 可见的,才存在。 harness 工程的魂,是把人类隐性知识编码成 agent 读得到、且可机械校验的工件。

参考来源

解剖标本(codex-rs 源码)

  • context-fragments/src/fragment.rsContextualUserFragment trait(role/markers/body
  • core/src/context/mod.rs — 片段总清单与再导出
  • core/src/context/environment_context.rs — 环境片段、equals_except_shell 去重
  • context-fragments/src/additional_context.rs — 额外上下文、1000-token 中段截断(MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS
  • core/src/context/user_instructions.rs — 用户指令片段
  • core/src/client_common.rsPrompt = base_instructions + input
  • core/gpt-5.2-codex_prompt.md — 基础系统提示词

方法论

注:run_turn 实际拼装时还涉及历史规整、缓存键、多环境等细节;本章为讲清"四层 + 片段"主干做了简化,函数名/常量(如 1000-token 预算)以你 clone 到的版本为准。

留言