来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
本集属于技术侧(S2)模块 T3「解剖 OpenAI Codex:把安全观写进类型系统」,取材自 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 的运行时对账各自的优势、代价与失效条件 |
三个真实现象,指向同一个来源:
| 现象 | 表面症状 | 实际原因 |
|---|---|---|
| 批准前缀丢失 | 用户批准了 npm *,下一轮模型还在问要不要跑 npm test | 批准记录进历史后无人认得出 |
| 压缩后失忆 | 压缩结束,模型忘了工作区在哪、沙箱是只读还是可写 | 环境上下文在压缩中未被识别保留 |
| fork 规则打架 | 旧 AGENTS.md 与新目录提示并存,规则互相冲突 | 分叉把过期的环境块当成用户输入重新提交 |
共同来源:运行时往模型上下文里塞了一段文字。
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}")
}
这条只有十几行的函数,就是类型把自己收成一段可注入文本的全部规则:
into() 把渲染结果收成一条 ResponseItem::Message,content 只有一个 InputText| 方法 | 是否带 self | 用途 | 调用场景 |
|---|---|---|---|
markers() | 带 self | 渲染 | 手里有原对象 |
type_markers() | 不带 self | 识别 | 手里只有历史文本 |
这个区分是整套设计的枢纽:压缩之后、恢复之后你手上没有原对象,只有纯文本——此时仍然需要能问一句「这段像不像某某 fragment」。
约束:dyn ContextualUserFragment调不了matches_text,反向识别必须点到具体类型。这不是缺陷,而是刻意约束——它逼你明确说出要认的是哪一种。
新增一种注入要:新建文件 → 实现 trait → 挂进 core/src/context/mod.rs。目录里 39 个模块。
这组摩擦力是有意为之的:
Box<dyn ContextualUserFragment>,业务侧失去了随手拼 XML/标签的调用点format! 出来的标签进不去组装函数,也进不了 matcher 列表startsWith为什么长期成立:渲染和识别共用一份定义。换个语言重写,最小形态仍是一份接口、一份注册表、同一对 marker。它不依赖任何具体序列化格式。
| 极端 | 后果 |
|---|---|
| 全部带标 | 一次性通知也会在压缩后被重认、再喂一遍(噪声) |
| 全部无标 | 压缩后的历史分不清用户原话与运行时说明;fork 会把过期的环境块当成用户输入重新提交 |
带 begin + end(需要事后认):
无标(type_markers() 返回两个空串,默认 matches_text 恒为 false):
1. trim 开头 → 比前缀
2. trim 结尾 → 比后缀
3. ASCII 大小写不敏感
4. 两个都中才算命中(只匹配开头不算)
5. 中间夹了什么,完全不管
要点:第 4 条「两者皆中才算命中」能挡掉一大批误判——很多人实现的 startsWith 版本在这里会出错。
developer 侧另有一张前缀表(event_mapping.rs),用来补一部分无标识别,但覆盖面比 user 侧 matcher 列表更窄。
这张表的存在本身就说明:无标不是免费的。失去可逆性之后,总有一天会发现有几处还是想认回来,于是被迫再补一张表——而补表覆盖的永远是过去已知的标签,覆盖不了将来。
表里至今留着 <token_budget> 旧标签,注释写明是为了认旧版本持久化下来的包装。这句话分量很重:
marker 一旦写进 rollout,就变成恢复合同的一部分。改标签等于改协议。
| 项目 | 实际值 |
|---|---|
UserInstructions.begin | Markdown 一级标题 # AGENTS.md instructions |
UserInstructions.end | </INSTRUCTIONS> |
| 协议常量(未使用) | <user_instructions> |
后果:如果有人按协议常量去写 matches_text,会认漏仓库里真实渲染出来的文本——不报错,只是静默地少做事。
这类 bug 隐蔽性极高:代码读起来完全正确,常量名字也对得上,只是与真实渲染产物对不上。而且改错常量时编译器不会响——两边都是合法字符串。
唯一的防线是:用真实渲染产物做断言,而不是从常量名反推匹配逻辑。把这个写成 CI 里的一条测试,成本极低。
再往前推一步:打开「批准前缀」这个开关,压缩之后默认 matches_text 认不回来(因为它无标)。developer 侧只能靠那张前缀表补这一刀。
| 类型层能否处理 | 说明 | |
|---|---|---|
没登记的注入(随手 format!) | ✅ 挡得住 | 组装函数只收已实现 trait 的类型 |
实现体里 format! 出的动态字符串 | ❌ 挡不住 | 类型只约束形状与标记,不约束 body 语义 |
| 登记了但每轮塞 40KB | ❌ 挡不住 | 类型只回答 role/marker/body,不回答大小 |
准确表述:类型负责可追溯性,不负责节制。 把两件事混在一起、期待一个类型全解决,是常见误判。
TokenBudgetContext 与权限说明挤在同一条 developer 消息里event_mapping.rs的注释自己承认:build_initial_context可能把 contextual fragment 和持久 developer 文本捆在一起。
第一次组装发生在 Session::build_initial_context_with_world_state,按三个字段分组:
role()markers() 的开头requires_separate_message()分成四组:可合并的 developer 段 / 必须单独成条的 developer 段 / user 段 / 置顶或置底的特殊段。
窗口身份特别处理:Feature::TokenBudget 打开且模型有 context window 时,它在 world_state 循环之前就被推进单独组。
最终输出顺序:
① 一条合并好的 developer 消息
② 一条条单独的 developer 消息
③ 一条 contextual user 消息
注意:分拣依据是类型字段,不是字符串内容。字符串里有没有某个词帮不上忙——那不是结构,只是巧合。
这套分拣带来一个直接好处:点到模型输入里的任意一段,都能回到某个具体的 fragment 类型。这条性质让上下文审计成为可能。
requires_separate_message():能不能和别人挤在一起选择单独成条的三类:TokenBudgetContext、ImageResizeNotice、ManagedDeveloperInstructions
| 说明 | |
|---|---|
| 代价 | 多占一条消息、多一次 role 切换 |
| 好处 | 压缩滤网可以按条处理;要丢弃某类内容时不用先拆字符串 |
这个取舍可以在自己的项目里照做。判断标准很简单:
这类内容会不会被单独处理、单独丢弃、单独展示?会,就单独成条;不会,就合并。
别为了省一条消息的开销把它们捆在一起——捆起来的代价在压缩和回滚时才付,而且远比一条消息贵。
| 维度 | 内容 |
|---|---|
| 原则 | 「模型可见即已记录」(写在仓库根 AGENTS.md 第 107 行) |
| 含义 | 抵达模型请求的一切必须能从会话日志重建;新增一项模型可见输入 → 新增一个会话事件 |
| 执行面 | invariant.ts,在 llm/stream 上挂监听 |
| 做法 | 从 session 日志 deriveMessages() 得期望值 → 与即将发出的 options.messages 做序列化比较 → 对不上就 fail |
| 优势 | 能抓住「组装之后又改了 messages」这类只有运行时才出现的漂移 |
| 失效条件 | 关掉 invariants 服务,这道闸就没了 |
| Codex | DSH | |
|---|---|---|
| 真源 | 封闭的类型集合 | 事件日志(messages 只是投影) |
| 是否需要 fragment trait | 需要 | 不需要 |
| 付钱的时点 | 每次加注入时(编译期摩擦) | 每次请求时(运行时断言开销) |
| 挡不住的东西 | 实现体内 format! 的动态字符串 | 关闭 invariants 后的一切 |
代价对比:
不必二选一,但要知道钱付在哪一次:
两边都为可追溯付钱,区别只在于钱付在哪一次。
新增或修改一种上下文注入时,逐项打勾:
ContextualUserFragment 的类型(不是临时 format!)requires_separate_message()render() 的输出已被真实样例断言覆盖(不是按常量名推出来的匹配)dyn 场景下是否调用了 matches_text(若是,需点到具体类型)core/src/context/mod.rs 的注册表本页严格遵守以下约束,引用时请保持同样克制:
openai/codex,commit 4f39251a01,核对日期 2026-08-22;代码块保留源码原文。build_initial_context_with_world_state,逻辑轨迹行号对应上述 commit。往模型上下文里塞的每一段,先收成一个类型,类型自己知道 role、marker 和 body。需要事后认的带 begin 和 end,一次性通知主动放弃可逆。组装按类型字段分拣,没登记的字符串进不了信封。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版