往模型上下文里塞东西,先给它造一个类型
批准前缀、工作区、AGENTS.md、被中断的 turn,全是告诉模型一件它自己看不到的事实。Codex 先给每一种注入造一个类型,类型自己知道 role、marker 和 body。
- 取 role,决定进 user 还是 developerfragment.rs L15
- 问要不要单独成条fragment.rs L18
- 实例要 marker,拿去 renderfragment.rs L22
- 空 marker 只输出 body,且永不匹配fragment.rs L41
- 窗口身份在循环前推进单独组session/mod.rs L3661
- 其余 fragment 按 role 和 marker 分拣session/mod.rs L3677
- user 侧按注册表 .any() 认回contextual_user_message.rs L18
- developer 侧按前缀表认回event_mapping.rs L40
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。注入的终点是协议对象。
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 行
渲染和识别共用一份定义。换个语言重写,最小形态仍是一份接口、一份注册表、同一对 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 和 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 消息里也收进类型。TokenBudgetContext、ImageResizeNotice、ManagedDeveloperInstructions 选择单独成条。单独成条的代价是多占一条消息、多一次 role 切换。好处是压缩滤网可以按条处理。
组装入口只收已登记类型。这是把没登记的注入挡在门外的通用形状。类型挡得住没登记,挡不住登记了但每轮塞 40KB。那一层是评审规则,下一章展开。
闸开在哪:DeepSeek Harness 选择发请求时对账
DSH 写在仓库根 AGENTS.md 第 107 行,中文原则收成「模型可见即已记录」。抵达模型请求的一切都必须能从会话日志重建,新增一项模型可见输入就需要新增一个会话事件。
执行面是 invariant.ts。它在 llm/stream 上挂监听,从 session 日志 deriveMessages() 得到期望值,再和即将发出的 options.messages 做 JSON.stringify 比较,对不上就 fail。闸开在崩溃点,能抓住组装之后又改了 messages 这类只有运行时才出现的漂移。关掉 invariants 服务,这道闸就没了。
真源不同:事件日志,还是封闭的类型集合
DSH 可以没有 fragment trait,因为它的真源是事件日志,messages 是投影。Codex 把能出现在上下文里的东西先收成封闭的类型集合,再用 marker 做事后识别。
代价也不同。Codex 的类型挡得住没实现 trait 就塞进 render_full,挡不住 impl 里面 format! 出来的动态字符串。matches_text 认的是文本形状。两边都为可追溯付钱,一个付在每次请求的断言,一个付在每次加注入的类型摩擦。
认漏的会是哪一段
UserInstructions 的 begin marker 是 # AGENTS.md instructions,协议常量却是 <user_instructions>。如果有人按协议常量去写 matches_text,会认漏现在仓库里真实渲染出来的哪一种文本?
再推一步:把上面演示里的批准前缀打开,压缩之后默认 matches_text 还能不能把它认回来。developer 侧要靠哪张表补这一刀,改错常量时编译器会不会响。