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

SQ 进、EQ 出:同一件事两副面孔

时长 16:03音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场:一个 type 名引发的三种失败1:46
1:46先玩一遍:同一句话,进和出各长什么样1:30
3:16命令从提交队列进,只有 id 和 op2:06
5:23事件从事件队列出,必须能写成 JSON1:33
6:57线上名保住磁盘,代码名可以改1:31
8:29队列上为什么会有重复语义1:13
9:42未知 type 的三条路径,答案都写在代码里2:32
12:15横向对比:不认识的 type,两家怎么答1:49
14:04可带走的设计原则1:58
解读全文

codex19 · SQ 进、EQ 出:同一件事两副面孔

模块:T3 解剖 OpenAI Codex:把安全观写进类型系统
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页为《学 AI 产品,从入门到精通》配套解读,音频为二次演绎配音版。

一句话速览

命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。

本集主题:同一件事在"进"与"出"两侧各自长什么样,以及碰和不认识的 type 时该如何选择默认方向。

本集解决什么工程问题

一个真实的三次失败:

  1. 侧栏等 type === "turn_started",联调那天字段对得上、type 却是 task_started;改成新名后旧夹具里的旧名还能解出来。
  2. 自己加了一个事件,本地与内核一起编过了;隔壁旧版 MCP 客户端解不出来。
  3. 新版写下的 rollout 拿到旧版 resume,那一行被跳过,parse_errors 加一,会话能开但少了一段生命周期。

同一件事在三个地方三种失败方式 → 设计时没有把默认方向写下来。

模式声明

```
//! Defines the protocol for a Codex session between a client and an agent.
//! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate
//! between user and agent.
```
出处:codex-rs/protocol/src/protocol.rs 第 1–4 行 · 源码快照:openai/codex @ 4f39251a01 · 核对日期 2026-08-22

四行注释写在模块头,不是写在外部文档里。把说话方式写死在代码最前面,是本讲最先能拿走的一条。

能力地图

能力落点判断标准
区分命令通道与事件通道Submission / Event 两个类型能说清为何前者无 serde、后者必须有
掌握两条通道的容量策略SQ bounded 512 / EQ unbounded能解释为何命令可反压、事件不可
用同一根 id 对账sub_id → Event.id能追溯一条事件对应哪次提交
wire 名与代码名分离#[serde(rename)] + alias能说清改名正确顺序
处理未知 type 的三条边界编译期 / JSON / resume能复述三条路径各自的默认方向
落盘白名单rollout 写入判定能说明瞬时事件为何不写真源

主线一 · 命令和事件拆成两种语言

进的形状

  • Submission:关联用 id + 要执行的 Op(内核动词,当前 28 个),只派生 Debug,没有 serde。
  • 原因:命令里带 oneshot 回调、审批决定、realtime 音频帧——不可、也不该变成 JSON。整封 Submission 不做序列化是设计约束推导的结果。
  • 出处:codex-rs/protocol/src/protocol.rs 第 185–200 行

出的形状

  • Event:有 serde;id 对上当初那条提交,msg 是事件本体。
  • 出处:codex-rs/protocol/src/protocol.rs 第 1276–1283 行

两条通道的容量

通道容量策略
SQ(下行)bounded 512客户端连打 512 条未被 loop 收走 → 下一次 send 等待(反压)
EQ(上行)unbounded事件可堆积、占内存,不反压这一轮
  • 会话启动时同时建两条通道。出处:codex-rs/core/src/session/mod.rs 第 460–461、533–534 行
  • 为什么相反:命令是人发的,频率低,堵住让人等一下代价很小;事件是模型和工具喷出来的,堵住会把这一轮卡住。

事件链路八步

  1. 生成 UUID7 作为提交 id(session/mod.rs L918)
  2. 把 Op 包成 Submission(L817)
  3. 送进容量 512 的 SQ(L833)
  4. submission_loop 按变体分发(handlers.rs L526)
  5. send_event 用 sub_id 做 Event.id(session/mod.rs L1952)
  6. 需要时再发 legacy 副本(L1965)
  7. 按白名单决定是否写入 rollout(L2169)
  8. 送进 unbounded EQ(L2185)
TurnInput 的路由结果走 oneshot,不走 Event Queue。EventMsg 描述这一轮发生了什么;oneshot 只回答"这条提交有没有被接住"。出处:codex-rs/core/src/session/handlers.rs 第 515–526 行

主线二 · wire 名保住磁盘,代码名可以改

  • Rust 变体已改名 TurnStarted;serde 写出 task_started,读入时同时认 turn_started;Display 与指标走 turn_started。
  • 出处:codex-rs/protocol/src/protocol.rs 第 1337–1340 行
  • 改名正确顺序:先改代码标识符并同时用 rename 把旧字符串钉死在序列化层,再谈迁移完成。顺序反过来(先改字符串)→ 已落盘旧文件再也读不回来。

队列上为什么会有重复语义

  • item 生命周期会再喷一份旧名字的副本:新前端看 ItemStarted,旧前端看 ExecCommandBegin 或 AgentMessage → 队列上出现重复语义条目。
  • 出处:codex-rs/core/src/session/mod.rs 第 1965–1973 行;codex-rs/protocol/src/legacy_events.rs 第 65–69 行
  • 这是迁移动线,给尚未迁到 TurnItem 的消费者留的;迁完后副本才会退场。

两条提醒:

  1. 队列上出现重复语义不一定是 bug,可能是有意的兼容期——动手删之前先确认。
  2. 兼容副本必须带退场计划(写在白名单里不落盘,或在文档中标注退役时间与阈值)。否则多收的那条会让指标与日志翻倍。

主线三 · 未知 type 的默认方向要先写下来

EventMsg 有 81 个变体,没有 #[serde(other)],也没标 non_exhaustive。三条路径的答案全部写在代码里:

路径行为出处
同进程、同版本穷尽 match 编译不过;旧客户端不会与这份新内核链在一起—
跨版本 JSONMCP 把整个 Event 序列化成 codex/event;旧词表解未知 type → serde 失败,失败发生在客户端mcp-server/src/outgoing_message.rs 第 108–133 行
resume 旧文件坏行 parse_errors += 1 后 continue;未知 type 不会让会话打不开,只少一行,函数仍返回已解出的 itemsrollout/src/recorder.rs 第 1009–1071 行

梯度规律:越靠近开发者态度越强硬(编译期编不过),越靠近用户数据态度越宽容(resume 跳行),中间的网络边界最无奈(内核已发出,只能让对方失败)。梯度对应三件事的成本——开发者改代码最便宜,用户数据丢了就没了,网络对面控制不了。

Op 正好反过来:标了 non_exhaustive,submission_loop 末尾 _ => false,未知命令被丢掉、loop 不崩。

出处:codex-rs/core/src/session/handlers.rs 第 684 行
为什么相反:事件是对外词表,漏一个变体要在编译期被看见;命令面向内部扩展,丢掉比崩掉更安全。

横向对比 · 不认识的 type 怎么办

方案定位默认方向代价 / 收益
DSH事件日志是唯一真源信封缺 ignorable?: true 时,读取器碰未知 type 必须拒绝重建旧 harness 打不开新日志;换来"能打开就完整"。过分拒绝比静默恢复被掏空的会话更安全
Grok事件是通知流Unknown 带 #[serde(other)],解不出就收成 Unknown,消费者必须静默忽略;原始类型名不保留只有 6 个变体;通知丢了会话还能靠别的状态活
Codex落盘文件既要当真源,又要保证老文件能开resume 跳行(比 Grok 更接近"打开",比 DSH 更接近"尽量打开")取中间位置
  • DSH 出处:packages/core/session/src/types.ts 第 404–422 行 · 核对 2026-08-22
  • Grok 出处:crates/common/xai-tool-protocol/src/session_event.rs 第 11–65 行 · 核对 2026-08-22
追到底都是定位问题:你先回答这份日志是什么,默认值自然就出来了。

约束说明

  1. 命令与事件必须分成两条通道,且容量策略相反:命令有界可反压,事件无界不反压。
  2. Submission 不做序列化。命令带 oneshot 回调 / 审批决定 / 实时音频帧,不可也无须过网。
  3. oneshot 与 EventMsg 语义分离:前者回答"有没有被接住",后者描述"这一轮发生了什么"。混在一起会让幂等与重试变痛苦。
  4. 改标识符时用 rename + alias 保住已落盘字符串;顺序是先改代码、后谈迁移。
  5. 指标用哪套字符串要单独测,不要假设与 serde 一致。
  6. 瞬时事件进不进真源文件必须是白名单说了算,否则 JSONL 按 token 涨。
  7. 未知 type 的默认方向必须提前写下来,三条边界各一个落点,且写在信封/文档上。
  8. 对外词表与内部扩展的默认方向相反:对外漏变体要编译期可见(不标 non_exhaustive);内部命令兜底丢弃更安全(标 non_exhaustive)。

审查清单

  • 模块头是否写明双通道模式?还是只存在口头约定?
  • Submission 是否确实没有 serde?有无后来者偷偷加上?
  • SQ 是否 bounded、EQ 是否 unbounded?两者的反压语义是否符合"命令低频、事件高频"?
  • Event.id 是否复用 sub_id?能否凭 id 追溯某次提交的全部事件?
  • 哪些事件写 rollout?白名单是否显式列出而非默认全写?
  • 新增 / 重命名事件变体时,是否用 rename 保住了磁盘上的旧字符串?
  • 现有兼容副本是否记录了退场时间与阈值?
  • 事件词表是否有 serde(other)?没有时,三条边界的行为是否都写进文档?
  • Op 是否标了 non_exhaustive 并有 _ => false 兜底?
  • 指标的 type 字符串是否与 wire 做过一致性测试?
  • resume 时坏行是否只加 parse_errors 并 continue,而不是整体失败?

提示一 · 把模式声明写在模块头

不要只在设计文档里描述通信模型。把"用哪两条队列、谁有界谁无界、谁需要序列化"写成模块头注释(四行即可),让每个打开文件的人第一眼看到。

提示二 · 容量策略写成非对称,并在注释里写明理由

有界 512 与无界不是随手填的数字。在注释里写明"命令低频可反压 / 事件高频不可反压",后来的人才不会为了对称去给上行也加个上限。

提示三 · 改名分两步,先动代码后动字符串

新建 / 重命名事件重体时,第一步改 Rust 标识符并加 #[serde(rename = "<旧 wire 名>")] 与 alias;第二步等所有消费者就绪,再讨论是否退役旧字符串。绝不能先改 wire 名。

提示四 · 给每条事件标注"是否写真源"

在事件定义旁或一张白名单表里标注每条事件是否落盘。判据是"恢复会话时是否需要它"。命令输出、审批提示、流式增量这类瞬时事件不写真源,否则 JSONL 会按 token 涨。

提示五 · 未知 type 三条边界各写一条测试

用三行 JSON(task_started / turn_started / future_event)做夹具,分别断言:前两行在 Codex 里是同一个变体;第三行让 MCP 原样解失败;resume 时第三行计入 parse_errors 且会话照常打开。这三行夹具能防止有人在迁移中悄悄改掉默认方向。

自测卡

  1. 准备三行 JSON,type 分别是 task_started、turn_started、future_event。推演 MCP 原样解、Codex resume、DSH、Grok 各自怎样。哪一行会让 MCP 失败?哪一行会让 DSH 拒绝整份日志?哪两行在 Codex 里其实是同一个变体?
  2. 进阶:若把 TurnStarted 的 serde 改成只保留 rename = "turn_started",旧 rollout 会在哪一条边界上断?
  3. 你自己负责的事件流:日志是真源还是通知流?据此应该选拒绝、跳行,还是收成 Unknown?

要点回顾(Takeaway)

命令通道和事件通道分开,命令可以带回调,事件必须能写成 JSON。wire 名和代码名分开写,改标识符时用 rename 保住磁盘。未知 type 先选一条默认方向:拒绝、跳行,或收成 Unknown。

来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)

本页正文为二次演绎配音版的配套解读,与音频逐段对应。