学 AI 产品 · 专业 AI 产品经理播客第 3 章 · T3 解剖 OpenAI Codex:把安全观写进类型系统 · EP 04
第 3 章 · EP 04

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

时长 14:41音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 你塞进上下文的那段话,事后还认得出来吗2:19
2:19注入先变成类型 · 接口收在哪一层1:59
4:18有标与无标 · 可逆性要在类型上写清楚1:58
6:16一个具体的坑 · 按协议常量写会认漏1:06
7:23类型挡得住什么,挡不住什么1:12
8:36组装按类型字段分拣 · 顺序从哪来2:25
11:02另一种答法 · 闸开在发请求那一刻1:49
12:52可带走的设计原则 · 六条1:48
解读全文

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

来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版

本集定位

本集属于技术侧(S2)模块 T3「解剖 OpenAI Codex:把安全观写进类型系统」,取材自 1 节课:

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

这节课回答的是一个几乎所有 Agent 项目都会遇到、但很少被正面处理的问题:

运行时往模型上下文里塞的那段说明文字,进历史之后,你还认得出来吗?

批准前缀、工作区、权限档案、被中断的 turn、当前 UTC 时间——全是告诉模型一件它自己看不到的事实。一段 format! 就能写出来。但这段文字一旦进入历史,压缩、恢复、分叉、UI 呈现、乃至「模型是否已经知道了」的判定,全部依赖同一个能力:从纯文本里把它认回来。

Codex 的答案是:先给每一种注入造一个类型。类型自己知道 role、知道头尾 marker、知道 body 怎么写。


一、能力地图

读完本集(听完音频),你应该能够:

能力具体表现
识别问题本质明白注入的难点不在「塞进去」,而在事后能否认回来
解释为什么是类型说清 trait 收在哪一层,以及为什么它不在主 crate 里
复述四问每个实现必须回答:user/developer、marker、body、是否单独成条
写出渲染规则头尾 marker 与 body 直接相接,中间无分隔符;空 marker 只输出 body
区分两套调用markers() 走实例(渲染),type_markers() 走类型(识别)
判断有标/无标知道哪些必须 begin+end,哪些可以主动放弃可逆性
复述匹配规则trim 前缀 → trim 后缀 → ASCII 大小写不敏感 → 两者皆中才算命中
避开经典陷阱知道 UserInstructions 的实际 marker 与协议常量不一致,按常量写会认漏
复述分拣依据role / marker 开头 / requires_separate_message() 三个字段
复述输出顺序合并 developer → 单独 developer → contextual user
说清机制边界类型负责可追溯性,不负责节制(40KB/轮 类型层不响)
对比另一种答法说得清 DSH 的运行时对账各自的优势、代价与失效条件

二、思路一 · 注入先变成类型

2.1 它解决什么问题

三个真实现象,指向同一个来源:

现象表面症状实际原因
批准前缀丢失用户批准了 npm *,下一轮模型还在问要不要跑 npm test批准记录进历史后无人认得出
压缩后失忆压缩结束,模型忘了工作区在哪、沙箱是只读还是可写环境上下文在压缩中未被识别保留
fork 规则打架旧 AGENTS.md 与新目录提示并存,规则互相冲突分叉把过期的环境块当成用户输入重新提交

共同来源:运行时往模型上下文里塞了一段文字。

2.2 思路是什么

Codex 把每一种注入收成一个实现 ContextualUserFragment 的类型:trait 不在 codex-core 里,而在独立 crate codex-context-fragments 中。


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}")
}

这条只有十几行的函数,就是类型把自己收成一段可注入文本的全部规则:

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

2.3 两套调用:渲染走实例,识别走类型

方法是否带 self用途调用场景
markers()带 self渲染手里有原对象
type_markers()不带 self识别手里只有历史文本

这个区分是整套设计的枢纽:压缩之后、恢复之后你手上没有原对象,只有纯文本——此时仍然需要能问一句「这段像不像某某 fragment」。

约束:dyn ContextualUserFragment 调不了 matches_text,反向识别必须点到具体类型。这不是缺陷,而是刻意约束——它逼你明确说出要认的是哪一种。

2.4 加一种注入的摩擦力就是治理

新增一种注入要:新建文件 → 实现 trait → 挂进 core/src/context/mod.rs。目录里 39 个模块。

这组摩擦力是有意为之的:

  • 组装函数收的是 Box<dyn ContextualUserFragment>,业务侧失去了随手拼 XML/标签的调用点
  • 临时 format! 出来的标签进不去组装函数,也进不了 matcher 列表
  • 识别函数只从注册表做匹配,不许再写第二套 startsWith
为什么长期成立:渲染和识别共用一份定义。换个语言重写,最小形态仍是一份接口、一份注册表、同一对 marker。它不依赖任何具体序列化格式。

三、思路二 · 有标和无标要写清楚

3.1 两种极端都不行

极端后果
全部带标一次性通知也会在压缩后被重认、再喂一遍(噪声)
全部无标压缩后的历史分不清用户原话与运行时说明;fork 会把过期的环境块当成用户输入重新提交

3.2 结论:可逆性要在类型上写清楚

带 begin + end(需要事后认):

  • 窗口身份
  • 环境
  • 中断
  • 图片缩放
  • 管理侧开发者指令

无标(type_markers() 返回两个空串,默认 matches_text 恒为 false):

  • 批准前缀
  • 网络规则入册
  • 剩余 token 的一句提醒

3.3 匹配规则(只看首尾)


1. trim 开头 → 比前缀
2. trim 结尾 → 比后缀
3. ASCII 大小写不敏感
4. 两个都中才算命中(只匹配开头不算)
5. 中间夹了什么,完全不管
要点:第 4 条「两者皆中才算命中」能挡掉一大批误判——很多人实现的 startsWith 版本在这里会出错。

提示一 · 无标不等于没有代价

developer 侧另有一张前缀表(event_mapping.rs),用来补一部分无标识别,但覆盖面比 user 侧 matcher 列表更窄。

这张表的存在本身就说明:无标不是免费的。失去可逆性之后,总有一天会发现有几处还是想认回来,于是被迫再补一张表——而补表覆盖的永远是过去已知的标签,覆盖不了将来。

表里至今留着 <token_budget> 旧标签,注释写明是为了认旧版本持久化下来的包装。这句话分量很重:

marker 一旦写进 rollout,就变成恢复合同的一部分。改标签等于改协议。

3.4 一个具体的坑:按协议常量写会认漏

项目实际值
UserInstructions.beginMarkdown 一级标题 # AGENTS.md instructions
UserInstructions.end</INSTRUCTIONS>
协议常量(未使用)<user_instructions>

后果:如果有人按协议常量去写 matches_text,会认漏仓库里真实渲染出来的文本——不报错,只是静默地少做事。

提示二 · 拿真实输出验,不要拿常量名推

这类 bug 隐蔽性极高:代码读起来完全正确,常量名字也对得上,只是与真实渲染产物对不上。而且改错常量时编译器不会响——两边都是合法字符串。

唯一的防线是:用真实渲染产物做断言,而不是从常量名反推匹配逻辑。把这个写成 CI 里的一条测试,成本极低。

再往前推一步:打开「批准前缀」这个开关,压缩之后默认 matches_text 认不回来(因为它无标)。developer 侧只能靠那张前缀表补这一刀。


四、机制边界 · 类型挡得住什么,挡不住什么

类型层能否处理说明
没登记的注入(随手 format!)✅ 挡得住组装函数只收已实现 trait 的类型
实现体里 format! 出的动态字符串❌ 挡不住类型只约束形状与标记,不约束 body 语义
登记了但每轮塞 40KB❌ 挡不住类型只回答 role/marker/body,不回答大小
准确表述:类型负责可追溯性,不负责节制。 把两件事混在一起、期待一个类型全解决,是常见误判。

五、思路三 · 组装按类型字段分拣

5.1 不这么做的后果

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

5.2 分拣依据:三个类型字段

第一次组装发生在 Session::build_initial_context_with_world_state,按三个字段分组:

  1. role()
  2. markers() 的开头
  3. requires_separate_message()

分成四组:可合并的 developer 段 / 必须单独成条的 developer 段 / user 段 / 置顶或置底的特殊段。

窗口身份特别处理:Feature::TokenBudget 打开且模型有 context window 时,它在 world_state 循环之前就被推进单独组。

最终输出顺序:


① 一条合并好的 developer 消息
② 一条条单独的 developer 消息
③ 一条 contextual user 消息
注意:分拣依据是类型字段,不是字符串内容。字符串里有没有某个词帮不上忙——那不是结构,只是巧合。

这套分拣带来一个直接好处:点到模型输入里的任意一段,都能回到某个具体的 fragment 类型。这条性质让上下文审计成为可能。

5.3 requires_separate_message():能不能和别人挤在一起

选择单独成条的三类:TokenBudgetContext、ImageResizeNotice、ManagedDeveloperInstructions

说明
代价多占一条消息、多一次 role 切换
好处压缩滤网可以按条处理;要丢弃某类内容时不用先拆字符串

提示三 · 判断标准只有一条

这个取舍可以在自己的项目里照做。判断标准很简单:

这类内容会不会被单独处理、单独丢弃、单独展示?会,就单独成条;不会,就合并。

别为了省一条消息的开销把它们捆在一起——捆起来的代价在压缩和回滚时才付,而且远比一条消息贵。


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

6.1 DSH:闸开在发请求那一刻

维度内容
原则「模型可见即已记录」(写在仓库根 AGENTS.md 第 107 行)
含义抵达模型请求的一切必须能从会话日志重建;新增一项模型可见输入 → 新增一个会话事件
执行面invariant.ts,在 llm/stream 上挂监听
做法从 session 日志 deriveMessages() 得期望值 → 与即将发出的 options.messages 做序列化比较 → 对不上就 fail
优势能抓住「组装之后又改了 messages」这类只有运行时才出现的漂移
失效条件关掉 invariants 服务,这道闸就没了

6.2 真源不同,代价也不同

CodexDSH
真源封闭的类型集合事件日志(messages 只是投影)
是否需要 fragment trait需要不需要
付钱的时点每次加注入时(编译期摩擦)每次请求时(运行时断言开销)
挡不住的东西实现体内 format! 的动态字符串关闭 invariants 后的一切

代价对比:

  • Codex 的类型挡得住「没实现 trait 就塞进渲染」,挡不住实现体里格式化出来的动态字符串(因为匹配认的是文本形状);但它在编译期就付完了成本,运行时额外开销很小。
  • DSH 的优势在于能抓住组装之后的漂移,代价是一道可被关闭的运行时保障。

提示四 · 怎么选

不必二选一,但要知道钱付在哪一次:

  • 上下文来源高度动态、一天新增好几种 → 每次加一个类型的摩擦会变成负担,运行时对账更合适
  • 上下文来源相对封闭、一年加不了几十种,但每条都要能被压缩/恢复/UI 单独处理 → 先收成类型更划算

两边都为可追溯付钱,区别只在于钱付在哪一次。


七、审查清单

新增或修改一种上下文注入时,逐项打勾:

  • 已有一个实现 ContextualUserFragment 的类型(不是临时 format!)
  • 四个问题都已回答:user/developer、marker、body、requires_separate_message()
  • 需要事后认的已提供 begin + end;一次性通知已明确注释「主动放弃可逆」
  • render() 的输出已被真实样例断言覆盖(不是按常量名推出来的匹配)
  • dyn 场景下是否调用了 matches_text(若是,需点到具体类型)
  • 已挂进 core/src/context/mod.rs 的注册表
  • 识别路径只走注册表,没有第二套文本前缀判断
  • 若选择合并,已确认它不会被单独丢弃/单独展示
  • body 的大小已由评审规则约束(类型层不会响)

八、约束说明

本页严格遵守以下约束,引用时请保持同样克制:

  1. 取材范围:仅使用本集素材,不跨集取材,不引入外部案例或未在源码中出现的数据。
  2. 源码快照:依据本地仓库 openai/codex,commit 4f39251a01,核对日期 2026-08-22;代码块保留源码原文。
  3. 教学化内容已标注:开关组合、参演演示与部分正文措辞为课程化设定;分拣顺序对齐 build_initial_context_with_world_state,逻辑轨迹行号对应上述 commit。
  4. 横向对比的两侧均已核对源码(2026-08-22)。
  5. 不作推断的范围:本页不推断模型表现、指标收益或生态规模;机制的存在不等于效果的保证。
  6. 未尽事项:Type 层无法约束「登记了但每轮塞 40KB」——那一层是评审规则,本集仅指出边界,不展开方案。

九、一句话 Takeaway

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

来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版