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

图 3:一个上下文片段由 role、markers 和 body 组成,既服务模型分隔,也服务系统自身识别。
有了这个统一的"原子",所有种类的上下文就都能用同一套机制拼装、注入、识别了。那 Codex 里到底有哪些"片"?
三、Codex 上下文里的"片"清单¶
打开 core/src/context/ 这个目录,你会看到三十来个文件,每个就是一种片段。把它们归归类,你会发现一件事:harness 的每一层,几乎都在这里有一片"代言人",负责把自己那点事说给模型听。
- 机器事实(你在哪、什么环境):
EnvironmentContext(当前目录、日期、时区、网络与文件系统策略、是否有子代理)、PermissionsInstructions(这一轮的审批/权限画像,第 9 章)。 - 仓库与用户指令:
UserInstructions——也就是AGENTS.md的内容(它太重要、也太容易做坏,本章后半专门讲它)。 - 可插拔能力的"名录":
AvailableSkillsInstructions、AvailablePluginsInstructions——注意是名录,只放"有哪些技能/插件、各一句话",正文用时才加载(第 7 章的核心机制)。 - 人格与协作模式:
PersonalitySpecInstructions、CollaborationModeInstructions(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;network(NetworkContext)——网络此刻通不通;filesystem(FileSystemContext)——它又含两件事: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_date、timezone、network、filesystem、subagents 这些字段。核心代码是这样(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 试过,然后总结了这种做法"四宗罪":
- 挤占上下文——一份巨型说明书把任务、代码、相关文档挤出窗口,模型要么漏掉关键约束,要么盯着错的东西优化(正是本章开头那个"垃圾场")。
- 处处重点 = 没有重点——当所有内容都标成"重要",模型只好退化成"就近模式匹配",而不是按你的意图行事。
- 即刻腐烂——巨型手册很快变成一座"过期规则的坟场":模型分不清哪条还作数,人也懒得维护,它就成了一个"看着有用、实则误导"的累赘。
- 难以校验——一整块大文本没法做机械检查(覆盖率、新鲜度、归属、交叉链接),漂移不可避免。
解法:把 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。地图不靠人力勤奋保鲜,靠工程保鲜。

图 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,它的查找逻辑是这样(机制本身就是一张图):
- 从你当前所在的目录出发,一路向上走,直到遇到"项目根"标记(默认是
.git)——这就划定了项目根。 - 然后从项目根往下到你当前目录,沿途遇到的每一个
AGENTS.md都收集起来,按从根到叶的顺序拼接。 - 不越过项目根。
难得的是,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 渲染出来是这样(注意它的 role 是 user,开头那行就写明出处目录):
# AGENTS.md instructions for codex-rs/tui
<INSTRUCTIONS>
(codex-rs/tui 这一层那份 AGENTS.md 的正文……)
</INSTRUCTIONS>
于是"从根到叶"的那几份,在模型眼里就是一摞各自标着出处目录的指令块:它一眼能分清"这条是仓库根的通用约定、那条是 tui/ 这个模块的特殊要求",越靠下的越具体、份量越重。这又一次落回 §二那句话——连"合并多份指令"这种事,靠的也还是"给每片套上标签"这套统一动作。
这里还藏着一个"工程"上的体面:默认根标记是 .git,而它是可配置的(project_root_markers)。换句话说,"项目根"不是写死的猜测,而是顺着你本来就有的版本控制边界来划——绝大多数仓库里,.git 在哪,"项目"就到哪为止,既符合直觉,又不需要你额外配置。而"不越过根"这一条同样关键:它保证 agent 不会糊里糊涂把根目录之外、和当前项目无关的某份 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 可见。

图 9:对 agent 来说,仓库内可见的信息才真正存在;仓库外知识不先落地,就无法被稳定利用。
这条原理,后面每一章都会以不同形式回响:工具要让模型"看见"能力(第 5 章),技能要让模型"看见"名录(第 7 章),可观测性要让模型"看见"运行时(第 13 章)。记住它,你就抓住了 harness 工程的魂。
本章小结¶
- 上下文是预算,不是垃圾场。 塞太多 ≠ 知道更多;"什么都重要"等于"什么都不重要"。
- 上下文是"拼"出来的。 Codex 把每一轮上下文,由一片片实现了
ContextualUserFragmenttrait(提供role() / markers() / body()的接口,本体在context-fragmentscrate,各片段在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.rs—ContextualUserFragmenttrait(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.rs—Prompt = base_instructions + inputcore/gpt-5.2-codex_prompt.md— 基础系统提示词
方法论
- Anthropic《Effective context engineering for AI agents》 — 大窗口仍会有
context pollution和relevance问题 - OpenAI《Harness engineering》 — “地图而非千页手册”、以及“对 agent 可见的,才存在”
- OpenAI Developers《AGENTS.md》 — 面向 Codex 的
AGENTS.md写法与分层约定 - Liu et al.《Lost in the Middle: How Language Models Use Long Contexts》 — 模型对长上下文中段信息最不敏感("中间截断"的研究依据)
- OpenAI《Prompt Caching in the API》 — 稳定前缀可自动命中缓存、输入成本↓最多 90%、延迟↓最多 80%
- Anthropic《Prompt caching》 — 缓存按
tools → system → messages前缀顺序命中、长前缀延迟↓最多 85%
注:
run_turn实际拼装时还涉及历史规整、缓存键、多环境等细节;本章为讲清"四层 + 片段"主干做了简化,函数名/常量(如 1000-token 预算)以你 clone 到的版本为准。