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

对外协议是投影

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

同步字幕

章节导航(点击跳转)

0:00开场 · 你看见的不是内核1:57
1:57四条规则 · 改名、换容器、丢掉、拆开2:43
4:41以调度为准,未知必须失败1:59
6:40回包不是开跑2:11
8:52审批是把人拉进环里1:05
9:57实验面靠一次握手1:51
11:49两个 SDK 不是同一条协议面1:26
13:15可带走的原则1:31
解读全文

ep31 · 对外协议是投影

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

一句话速览

IDE 看见的是 Thread / Turn / Item,不是内核 EventMsg。一次 turn/start 先回响应、再推事件流;审批是反向请求,不回包这一轮就停住。

核心判断:对外协议是一张投影表,不是内核枚举的 JSON 导出。投影层的存在让内部可以自由演进(继续扇出 deprecated 事件给 rollout),同时把对外合同冻结在稳定面上。代价是必须维护这张表,且漏改不会报错,只会让客户端悄悄丢功能。


能力地图

投影的四条规则

规则含义例子
改名snake_case 的 type → 资源路径 + camelCase 字段turn_started → turn/started,startedAt
换容器delta 与工具生命周期收进 ThreadItem塞进 item/started / item/completed
丢掉线上没有对应通知ExecCommandBegin、ViewImageToolCall、通配臂
拆开一条事件既发通知又发反向请求ItemStarted(DynamicToolCall) + item/tool/call

出处:codex-rs/app-server/src/bespoke_event_handling.rs L159–188、L880–918、L996–1036。

线上四种消息对象(不对称)

类型方向是否等回包稳定面内容
ClientRequest客户端 → 服务端等initialize、turn/start
ServerNotification服务端 → 客户端不等turn/started、item/started
ServerRequest服务端 → 客户端等(反向)item/commandExecution/requestApproval
ClientNotification客户端 → 服务端不等展开后只有 Initialized

出处:codex-rs/app-server-protocol/src/protocol/common.rs L1663–1670、L1954–1956;rpc.rs L1–11、L34–72。

三种入口的协议面对照

入口载体协议面
Python SDKstdio完整 v2,含审批反向请求
TUI(进程内)内存通道同一套 v2,只换 transport
TypeScript SDKexec --experimental-json更窄的点号事件面,8 个变体,无握手、无审批

思路一 · 对外协议是投影

问题

你在给编辑器写插件。调试器里能看到内核往外抛事件(turn_started、exec_command_begin,snake_case)。第一包数据过来却对不上:方法名是 turn/started,字段是 threadId、startedAt。命令开始时你等 exec_command_begin,来的却是 item/started 里塞着一个 type: "commandExecution" 的 item。

如果编辑器按 81 种 EventMsg 写 switch,每加一种内部事件都是一次客户端升级,连 deprecated 别名都会从仓内兼容问题变成对外合同。

机制

调度函数 apply_bespoke_event_handling 吃一条内核 Event,按上述四条规则收成对外消息。

提示1 · 以调度为准,别被函数名骗了

item_event_to_server_notification 只覆盖一对一、无状态的投影。函数名像总入口,调用点才知道它是助手。

ExecCommandBegin 在助手里还能变成 item/started,在调度里却走进 deprecated 空分支。现场命令卡片来自后面的 ItemStarted。以调度为准。

出处:app-server-protocol/src/protocol/event_mapping.rs L25–37;item_builders.rs L1–11。

提示2 · 未知行必须失败

匹配里不能留空默认。空默认等于通配臂,新事件能通过编译,IDE 的 stdout 上什么都没有。

把漏改从「静默故障」推到「编译期报错」,是这一层最值钱的一条纪律。出处:bespoke_event_handling.rs L1238–1245。


四条规则摆在一起能看出一件事:投影不是格式转换。改名只是换了写法;换容器改变了聚合粒度;丢掉减少了对面积;拆开增加了交互次数。后三条都在改变客户端看到的世界形状,而不是把同一个东西换个编码 —— 这就是为什么不把它叫做序列化层。

为什么要付投影层的维护成本

客户端往往由第三方编写(编辑器扩展、内部工具、别人家的脚本),无法统一升级。若内核每加一种事件就要求所有客户端跟着改,成本会随客户端数量线性放大。有了一层投影,内部可自由重构,只要对外路径不变,外部代码一行都不用动。

相应的代价是:投影必须被持续维护,且漏改不会报错,只会让客户端悄悄丢功能。这正是「未知行必须失败」这条规则存在的理由。

问题

同事把 turn/start 的响应当成一轮已经开始。响应立刻回来,里面是一份空 items 的 turn,模型还没开口。真正开跑是后面那条 turn/started 通知。出处:codex-rs/app-server/README.md L81。

若把审批做成普通 notification,客户端可以不理,turn 会停在等待上直到超时或中断。

机制

线上能解出来的对象只有四种:带 id 的 request、不带 id 的 notification、成功 response、错误 response。看着像 JSON-RPC,结构体里没有 jsonrpc 字段,常量 JSONRPC_VERSION 还在但线上不带这个键。出处:rpc.rs L1–11、L34–72。

提示3 · 写客户端前先问一句「要不要回包」

三件事落在三个不同时刻:

  • 请求要回执 —— 带 id,响应对得上 id;
  • 通知是广播 —— 不带 id,天生不可回;
  • 反向请求把人拉进环 —— 必须回包,否则这一轮停在等待上。

漏画审批按钮的后果比看起来严重:用户以为模型在思考,实际是卡在等授权。id 对得上时,过载还能把 request 失败回给调用方,避免审批悬挂。


思路三 · 实验面一次握手,进程内也不另造合同

机制

  • 实验方法有 57 个方法级标记,不靠第二端口,靠 initialize 时一个布尔 experimentalApi,缺省 false。
  • 再次 initialize 收到 Already initialized。
  • 没开开关就打 server/diagnostics,错误码 -32600,固定文案 server/diagnostics requires experimentalApi capability。
  • Python SDK 把这个默认改成 True —— 官方脚本已经站在实验合同上。

出处:app-server/src/message_processor.rs L891–895;sdk/python/src/openai_codex/client.py L209。

提示4 · 进程内是 transport-local,不是 protocol-free

TUI 不直连 core。内嵌只换载体:socket / stdio 换成内存通道,MessageProcessor 还在。请求仍是 ClientRequest,响应仍走同一套 envelope。出处:app-server/src/in_process.rs L1–24。

为什么长期成立:远程和本机的差别应落在网络,不落在语义。用 capability 切实验面比文档里写一句「实验」更硬 —— schema 生成出两份,默认那份不含实验字段。


两个官方 SDK 不是同一条协议面

TypeScript SDK 拼的是 exec --experimental-json,事件 type 是点号,字段 snake_case,完整枚举只有 8 个变体。没有 initialize,没有审批 request。出处:sdk/typescript/src/exec.ts L89–90;codex-rs/exec/src/exec_events.rs L8–37。

提示5 · 选 SDK 按协议面,不按语言偏好

同一轮对话里模型要跑一条需要提问的命令:Python 客户端可以弹窗并回包;TypeScript 的 Thread.run() 做不到 —— 它压根不在有反向请求的环里。

  • 需要审批交互 / 完整事件流 → 走 Python 那条;
  • 只想跑一次拿结果 → TypeScript 那条更轻。

能力差在协议面,不差在语言。 把这条当语言差异排查会浪费大量时间。

横向对比

  • DSH:五个入口共用同一棵插件树,headless 入口把自己写成 composition base,跨进程时从 TypeScript 类型图生成 stub。内核类型就是协议类型 —— 改一个事件字段五张脸一起变,收益是永不分裂,代价是内部无法标 deprecated 继续扇出。
  • Claude Code:还原源码里只有入口判断(CLAUDE_CODE_ENTRYPOINT === 'claude-vscode'),没有对位的对外 IDE 协议 crate。第三方 IDE 靠 MCP 和进程入口嵌进来,没有带 schema 的双向 RPC 可对。
  • Codex:付了投影层的维护成本,换来 VS Code 扩展、Python SDK 和本机 TUI 共用同一份 v2。

审查清单

客户端接入前逐条过:

  • 是按对外投影写分支,还是直接按内核 EventMsg 写 switch?
  • 匹配分支有没有留空默认?(留了 = 新事件静默丢失)
  • 读投影逻辑时,有没有误把助手函数 item_event_to_server_notification 当总入口?
  • 是否把 turn/start 的响应当成「已开跑」?(应等 turn/started)
  • 服务端反向请求(如审批)是否都回了包?
  • 需要实验方法时,initialize 有没有传实验能力布尔?
  • 进程内嵌场景是否复用了同一套 MessageProcessor(只换 transport)?
  • 选 SDK 时是否比过协议面(有无审批反向请求、事件变体数量)?
  • 界面卡住时,是否先排查「有反向请求没人回」?

排查路径

  1. 看不到事件 → 先看该内部事件在投影里是不是被「丢掉」了(如 ExecCommandBegin)。
  2. 卡片不更新 → 看是不是该走「换容器」,被塞进了 ThreadItem 而你还在等裸事件。
  3. 界面卡住不动 → 优先查有没有 ServerRequest 未回包(审批悬挂)。
  4. 方法不存在 / 错误码 -32600 → 检查 initialize 是否开了实验能力。
  5. 两种客户端行为不一致 → 先比协议面,再比语言。

约束说明

  1. 取材约束:本集全部内容取自小山学堂《学 AI 产品,从入门到精通》对应课节,未跨集取材,未虚构源码行号、方法名或错误码。
  2. 题库隔离:题库页(quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。
  3. 源码时效:文中行号对应 openai/codex 仓库 commit 4f39251a01;横向对比部分核对时间为 2026-08-22。
  4. 解读边界:本文为二次演绎的解读稿,用于配合音频理解;具体行为以实际运行版本为准。
  5. 术语处理:为便于朗读,口播稿中方法名的斜杠与点号读作停顿,数字一律用中文。

课堂练习

  1. turn/start 的响应已经回来,items 是空的。编辑器现在该转圈,还是该等 turn/started?
  2. 如果内核新加一个 EventMsg 变体、投影没跟上,stdout 上会出现什么?
  3. 进阶:同一轮对话里模型要跑一条需要提问的命令,Python 客户端可以弹窗并回包,TypeScript 的 Thread.run() 为什么做不到?

Takeaway

对外协议是一张投影表,不是内核枚举的 JSON 导出。请求立刻回,事件随后到,审批是反向请求。两个官方 SDK 走的不是同一条协议面。


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