OpenAI Codex · 代码模式

往模型上下文里塞东西,先给它造一个类型

批准前缀、工作区、AGENTS.md、被中断的 turn,全是告诉模型一件它自己看不到的事实。Codex 先给每一种注入造一个类型,类型自己知道 role、marker 和 body。

课程目标读完能说清三件事。第一,往模型上下文里塞的每一段文字,为什么要先收成一个类型。第二,有标和无标差在哪,压缩和恢复靠什么认回。第三,一条发给模型的消息按什么字段分拣,顺序从哪来。
先玩一遍 · 开哪些开关,信封里就有哪些块
同一轮输入:五个 fragment 开关,看消息怎么被拼出来
打开
开关一变,左边信封立刻重排。点播放,看每一块怎么被问 role、问 marker、再分进三条消息。
发给模型的信封0 条消息 · 0
产地标签先问类型
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 取 role,决定进 user 还是 developerfragment.rs L15
  2. 问要不要单独成条fragment.rs L18
  3. 实例要 marker,拿去 renderfragment.rs L22
  4. 空 marker 只输出 body,且永不匹配fragment.rs L41
  5. 窗口身份在循环前推进单独组session/mod.rs L3661
  6. 其余 fragment 按 role 和 marker 分拣session/mod.rs L3677
  7. user 侧按注册表 .any() 认回contextual_user_message.rs L18
  8. developer 侧按前缀表认回event_mapping.rs L40
打开或关掉开关,看最终注入模型的内容由哪几块构成。点播放,看它们怎么被分拣。
构成从哪来可合并的 developer 段先收成一条消息,必须单独成条的各成一条,user 段再收成一条。顺序写在类型字段上,字符串里有没有某个词帮不上忙。
事后能不能认有标的靠首尾 marker 认回。无标的默认认不回,developer 侧用前缀表补了一部分。
为什么要先有类型组装入口收的是已实现 trait 的 fragment。随手 format 出来的标签进不去,matcher 列表也认不出没登记的标签。
教学示意:开关组合与正文措辞为课程化设定,分拣顺序对齐 build_initial_context_with_world_state。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 注入先变成类型
它解决什么问题

你给 agent 加过这类功能:用户刚批准了 npm *,下一轮模型还在问要不要跑 npm test。或者压缩刚结束,模型突然忘了工作区在哪、沙箱是只读还是可写。再或者 fork 一条会话,旧的 AGENTS.md 还在,新的目录提示叠上去,模型同时看见两套互相打架的规则。

这三件事的共同来源是同一类操作:运行时往模型上下文里塞了一段文字。批准前缀、工作区、权限档案、被中断的 turn、当前 UTC 时间,全是告诉模型一件它自己看不到的事实。一段 format! 就能写出来。

问题出在事后。这段文字进了历史之后,压缩要不要留它、会话恢复要不要认它、UI 要不要把它藏起来、world state 要不要拿它当模型已经知道了的证据,全都依赖一件事:你还能从纯文本里把它认回来。各处再写一遍 startsWith,过几周四处会漂。未知标签还会漏进用户消息解析。

思路是什么

Codex 把每一种注入收成一个实现 ContextualUserFragment 的类型。trait 不在 codex-core 里,而在独立 crate codex-context-fragments。每个实现必须同时回答:这段以 user 还是 developer 进 Responses API,头尾 marker 是什么,中间 body 怎么写,要不要单独成一条消息。

render() 把头尾 marker 和 body 直接首尾相接,中间不加分隔符。空白和换行算 body 的。空 marker 只输出 body。into() 把渲染结果收成一条 ResponseItem::Message,content 只有一个 InputText。注入的终点是协议对象。

codex-rs/context-fragments/src/fragment.rs第 38 至 46 行
    fn render(&self) -> String {
        let (start_marker, end_marker) = self.markers();
        let body = self.body();
        if start_marker.is_empty() && end_marker.is_empty() {
            return body;
        }

        format!("{start_marker}{body}{end_marker}")
    }
源码快照说明:依据本地仓库 openai/codex,核对文件 codex-rs/context-fragments/src/fragment.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,这段就是类型把自己收成一段可注入文本的全部规则。

markers()self,渲染走实例。type_markers() 不带 self,识别走类型。手里只有历史文本、没有原对象时,仍然能问这段像不像某某 fragment。dyn ContextualUserFragment 调不了 matches_text,反向识别必须点到具体类型。

加一种注入要新建文件、实现 trait、挂进 core/src/context/mod.rs。目录里 39 个模块。这个摩擦力本身就是治理:临时 format! 走不通,因为组装函数收的是 Box<dyn ContextualUserFragment>出处:codex-rs/core/src/context/mod.rs 第 3 至 41 行;codex-rs/core/src/session/mod.rs 第 3677 至 3707 行

运行时事实 工作区、指令、中断 fragment 类型 role · markers · body render 首尾相接 Message 事后只剩一段 InputText 用 type_markers 问具体类型,matches_text 看首尾,没有实例也能认回
教学化结构图:渲染走实例,识别走类型,两套调用读同一对 marker。
为什么长期成立

渲染和识别共用一份定义。换个语言重写,最小形态仍是一份接口、一份注册表、同一对 marker。组装函数的参数写成 fragment 类型,业务侧就失去了随手拼 XML 的调用点。识别函数只从注册表做匹配,不许再写一套 text.startsWith

思路二 · 有标和无标要写清楚
它解决什么问题

所有注入都带标,一次性通知也会在压缩后被重认、再喂一遍。所有注入都不带标,压缩后的历史里分不清用户原话和运行时塞进去的说明。fork 会把过期的 <environment_context> 当成用户输入重新提交。

思路是什么

窗口身份、环境、中断、图片缩放、管理侧开发者指令,事后要认、要 diff、要在压缩后决定是否重注,所以带 begin 和 end。一次性通知,比如批准前缀、网络规则入册、剩余 token 一句提醒,type_markers() 返回两个空串。默认 matches_text 恒为 false。

匹配规则只看整段文本的首尾。先 trim 开头比前缀,再 trim 结尾比后缀,ASCII 大小写不敏感,两个都中才算命中。中间夹什么不管。无标 fragment 主动放弃可逆性。出处:codex-rs/context-fragments/src/fragment.rs 第 89 至 103 行

developer 侧另有一张前缀表,在 event_mapping.rs,用来补一部分无标识别。覆盖面比 user 侧 matcher 列表窄。前缀表里至今留着 <token_budget> 旧标签,注释写明是为了认旧版本持久化下来的包装。marker 一旦写进 rollout,就变成恢复合同的一部分。改标签等于改协议。

UserInstructions 有一处容易看走眼。它的 begin marker 是 Markdown 标题 # AGENTS.md instructions,end marker 是 </INSTRUCTIONS>。协议里另有 <user_instructions> 那对标签,这个 struct 没用。按协议常量去写 matches_text,会认漏仓库里真实渲染出来的文本。出处:codex-rs/core/src/context/user_instructions.rs 第 18 至 19 行;codex-rs/protocol/src/protocol.rs 第 112 至 113 行

有标 · 事后要认 环境 / 窗口 / 中断 begin + body + end matches_text 能认回 无标 · 只当时说一声 批准前缀 / 网络规则 只输出 body 默认识别放弃 developer 靠前缀表补
教学化对照:需要压缩后重认的做成协议,一次性通知主动放弃可逆。
为什么长期成立

需要事后认的做成协议,一次性的主动放弃,并且在类型上写清楚。压缩后要重认的强制 begin 和 end,一次性通知可以无标,但要在注释里写放弃可逆。

思路三 · 组装按类型字段分拣
它解决什么问题

各处随手 push 字符串,顺序靠约定,单独成条靠注释。TokenBudgetContext 会和权限说明挤在同一条 developer 消息里。压缩滤网没法按条处理。混装之后回滚也拆不开。event_mapping.rs 的注释承认:build_initial_context 可能把 contextual fragment 和持久 developer 文本捆在一起。出处:codex-rs/core/src/event_mapping.rs 第 69 至 71 行

思路是什么

第一次组装发生在 Session::build_initial_context_with_world_state。它把 fragment 按 role()markers() 的开头、requires_separate_message() 分进几组:可合并的 developer 段、必须单独成条的 developer 段、user 段,以及要置顶或置底的特殊段。

窗口身份这一条有点特别。Feature::TokenBudget 打开且模型有 context window 时,它在 world_state 循环之前就被推进单独组。输出顺序是:先一条合并好的 developer 消息,再一条条单独的 developer 消息,再一条 contextual user 消息。分拣依据是类型字段。出处:codex-rs/core/src/session/mod.rs 第 3630 至 3728 行

requires_separate_message() 把能不能和别人挤在同一条 developer 消息里也收进类型。TokenBudgetContextImageResizeNoticeManagedDeveloperInstructions 选择单独成条。单独成条的代价是多占一条消息、多一次 role 切换。好处是压缩滤网可以按条处理。

已打开的类型 批准前缀 窗口身份 环境信息 AGENTS.md 中断通知 分拣 role() markers().0 requires_separate 看字段,不扫正文 developer · 可合并成一条 developer · 各成一条 user · 收成一条 contextual
教学化分拣图:输出顺序是合并 developer、单独 developer、contextual user。
点进模型输入的任意一段,都能回到某个 fragment 类型。
为什么长期成立

组装入口只收已登记类型。这是把没登记的注入挡在门外的通用形状。类型挡得住没登记,挡不住登记了但每轮塞 40KB。那一层是评审规则,下一章展开。

横向对比 · 同一道题的另一种答法

闸开在哪:DeepSeek Harness 选择发请求时对账

DSH 写在仓库根 AGENTS.md 第 107 行,中文原则收成「模型可见即已记录」。抵达模型请求的一切都必须能从会话日志重建,新增一项模型可见输入就需要新增一个会话事件。

执行面是 invariant.ts。它在 llm/stream 上挂监听,从 session 日志 deriveMessages() 得到期望值,再和即将发出的 options.messagesJSON.stringify 比较,对不上就 fail。闸开在崩溃点,能抓住组装之后又改了 messages 这类只有运行时才出现的漂移。关掉 invariants 服务,这道闸就没了。

两侧均已核对源码 · 2026-08-22 · DSH · Model-visible ⟺ logged

真源不同:事件日志,还是封闭的类型集合

DSH 可以没有 fragment trait,因为它的真源是事件日志,messages 是投影。Codex 把能出现在上下文里的东西先收成封闭的类型集合,再用 marker 做事后识别。

代价也不同。Codex 的类型挡得住没实现 trait 就塞进 render_full,挡不住 impl 里面 format! 出来的动态字符串。matches_text 认的是文本形状。两边都为可追溯付钱,一个付在每次请求的断言,一个付在每次加注入的类型摩擦。

两侧均已核对源码 · 2026-08-22
课堂练习
01

认漏的会是哪一段

UserInstructions 的 begin marker 是 # AGENTS.md instructions,协议常量却是 <user_instructions>。如果有人按协议常量去写 matches_text,会认漏现在仓库里真实渲染出来的哪一种文本?

再推一步:把上面演示里的批准前缀打开,压缩之后默认 matches_text 还能不能把它认回来。developer 侧要靠哪张表补这一刀,改错常量时编译器会不会响。

Takeaway:往模型上下文里塞的每一段,先收成一个类型,类型自己知道 role、marker 和 body。需要事后认的带 begin 和 end,一次性通知主动放弃可逆。组装按类型字段分拣,没登记的字符串进不了信封。