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

新功能先找落脚的 crate,core 是最后一档 等 2 节

时长 15:53音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 三十三万行的仓库,新功能往哪放1:46
1:46一 · 最省事的那个动作,最贵1:39
3:25二 · 把默认落点写成明文1:47
5:12三 · 编译图上的那几个数字1:40
6:53四 · 叶子类型待在叶子包1:37
8:30五 · 没有红灯的禁令靠什么活着1:54
10:25六 · 一轮对话,内部转三层1:47
12:13七 · 待处理队列为什么要问两次2:15
14:29收尾 · 今天可以带走的五条1:23
解读全文

ep12 · 新功能先找落脚的 crate,core 是最后一档 等 2 节

  • 模块:T3 解剖 OpenAI Codex:把安全观写进类型系统
  • 集页:https://xueai-podcast.pages.dev/t/codex01/
  • 来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
  • 说明:本页为音频的文字稿与延展解读,音频为二次演绎配音版,内容以源课程素材为准。

本集解决什么工程问题

两个问题,一个仓库。

问题一:落点。 一个三十多万行、一百三十五个工作区成员的大仓库,你要加一个小功能,这个文件应该落在哪个 crate?几乎所有团队的默认答案都是错的——往核心里加,因为最省事。这个省事会在半年后变成复利式的高利贷:往 codex-core 加一行,二十五个直接下游要重新编译,界面层还会经客户端被间接带上;更贵的是,一个只想复用上下文片段类型的人,被迫把沙箱、远程工具协议、安全守卫一起拖走。

问题二:层次。 界面上看到的一轮对话,内部叠了几层循环?谁有资格决定继续?如果插话、工具续跑、停止钩子挤在同一个 while 里抢出口,谁先检查、谁能打断谁就变成口头约定。少一层,就少一个干净插口。

两件事的共同点是一句话:结构决定成本。 你把代码放在哪,决定以后谁为它付账;你把循环切成几层,决定插话有没有干净的插口。


能力地图

读完这一集,你应该能独立回答下面五类问题。每一条都对应一个可验证的动作,不是"我好像懂了"。

能力判据对应源码位置
落点判断拿到一个新功能,能按三问推出它该落在哪个 crate,并说出被挡住的那条依赖边AGENTS.md L76–L83
依赖方向能画出"箭头只允许从中心指向叶子"的图,并解释叶子回头依赖中心的后果codex-rs/core/Cargo.toml L26–L42
体量控制知道文件目标五百行、超八百开新模块、非机械改动一次不超八百这三把尺子AGENTS.md L49–L61、L125–L131
循环分层能说清任务壳、轮次、采样三层各自的终止条件,以及控制权此刻在哪一层tasks/regular.rs L76–L90、session/turn.rs L423–L525
插话归属能推演一句中途插入的话由哪一层取走,以及它落在哪个 turn_id 里session/turn_input.rs L207、L242

一个提醒:这张地图是按"你能做什么"来切的,不是按课程小节切的。第一节主要覆盖前三项,第二节覆盖后两项,但落点判断和循环分层在真实评审里经常同时出现——一个 PR 既放错了位置,又把状态塞进了不该塞的层。


第一节 · 新功能先找落脚的 crate,core 是最后一档

禁令的原文形态

仓库的 AGENTS.md 用加粗英文写着 resist adding code to codex-core。它旁边是三问:

  1. 现有的非 core crate 能不能住下这个新概念?能住就住,不要新建。
  2. 住不下,该不该新建一个 workspace crate?该建就建,并且允许为此重构旧代码。
  3. 非要进 core,你得说清为什么拆不出去。这是最后一档,不是默认档。

配套的是评审纪律:评审遇到往 core 堆功能的 PR,被要求主动挡回去。

第 2 问里那句"允许重构"很容易被忽略,但它恰恰是这条禁令能执行下去的前提。很多团队不敢新建包,不是不知道该拆,而是怕背上重构的债。仓库在这里明确告诉你:重构是被许可的代价。

编译图上的数字

禁令听着像洁癖,直到你把数字摊开:

  • 按 .rs 行数:core 33 万行,tui 27 万,app-server 14.8 万;≥ 1 万行的 24 个,< 2 千行的 77 个——典型的幂律分布。
  • core 去掉测试后剩约 10.3 万行,86 个 mod,25 个 workspace 成员直接依赖它。
  • tui 的依赖清单里没有 core 这一行,它依赖 codex-app-server-client,再由 client 拉 core。所以往 core 加一行,25 个直接下游重编,tui 仍被间接带上。

叶子类型待在叶子 crate

core 自己已经依赖 61 个 codex-* crate,包括拆出去的 context-fragments 和 features。这个方向说明一件事:core 是可以当调用方的。

context-fragments 的包清单几乎没有业务依赖,只碰 protocol 和一段字符串工具,对外 re-export 两个片段类型和一个 trait。它小到可以被任何一层复用:core 依赖它,它不依赖 core。git 分支名这种片段就该走这条路——类型落在 fragments,core 当调用方。模型供应商适配也已经抽到 codex-model-provider。

只有必须摸到 session 内部状态的东西,才走到最后一档,而且评审仍要问一句"为什么拆不出去"。

这条禁令没有红灯

必须说清一个让人不舒服的事实:这条禁令没有 lint,没有 CI job。在整个仓库里检索那句英文,只命中 AGENTS.md 这一处。它挡得住习惯,挡不住有理由的例外,也挡不住有人根本没读。

真正有红灯的是旁边那几条:依赖清单改了但没刷 Bazel lock,CI 红;include_str! 没在 BUILD.bazel 里登记,Bazel 也红。也就是说,机器层面兜底的是构建一致性,不是架构整洁度。

横向对比

  • DSH:根 AGENTS 写的是 everything is a plugin,运行时只收服务、类型化事件和可逆副作用,没有需要打补丁的特权内核;新包落进现有分组时根 package.json 都不用改,glob 会发现它。
  • Grok Build:根 Cargo.toml 标明是自动生成的,人只改各 crate 自己的清单;members 按同一口径数 79 个;分层写在根 README——pager 是 TUI,shell 是运行时,tools/workspace 是领域能力,common/build 是叶子。

Codex 用编译期 crate 换掉了插件式装卸器,代价是新能力必须改 members 数组、写 BUILD.bazel、重新编译,能下手的位置只剩评审和 CI。前者赢在灵活,后者赢在类型安全和编译期可见的依赖图。


第二节 · 三层 Turn Loop:谁有资格决定继续

三层各自的终止条件

层源码它问的问题它能做什么,不能做什么
任务壳 RegularTask::runtasks/regular.rs L76–L90这一趟任务还活着吗能再开一轮 run_turn;不能重发 TurnStarted 之外的语义
轮次 run_turnsession/turn.rs L423、L500–L525这一轮还要不要再采样能续采样、接插话、跑 stop hook;不能重发 TurnStarted
采样 samplingstream_events_utils.rs L326这一条流结束了吗能重试、能取消;不能收整轮

对外入口 start_or_steer_turn 自己不看会话空不空闲,返回值只表示 Core 接没接住这条输入,不等 hooks,也不等采样。空闲就 spawn RegularTask,忙碌就 Steered 写入 pending。三层循环从任务壳才开始转。

任务壳发一次 TurnStarted,然后只要队列里还有待处理输入,就再调一次 run_turn。第二次进去时 next_input 为空,新消息从 input_queue 取,turn_id 钉死,界面不会再闪一次"新的一轮开始了"——这个细节决定回放日志里是一个桶还是两个桶。

轮次层把采样回来的两件事合成一个布尔:model_needs_follow_up || has_pending_input。为真就自己 continue,为假才跑 stop hook。hook 带 prompt 就拦住收工,这一层自己再转,此时任务壳和采样层都还没退。

采样层自己还有两圈:外圈处理可重试错误,内圈消费一条 SSE 流。工具调用不等 Completed——OutputItemDone 当时就挂上 future;流收到 Completed,先 drain_in_flight,再把结果交回 run_turn。

pending 为什么要问两次

两处问的是两个时刻的两个问题:

  • 轮次层在采样刚刚结束时问:这一轮还要不要再采一次?为真就把插话和工具结果留在同一个 turn_id 里。
  • 任务壳在 run_turn 已经 break 之后问:这一趟还要不要再进一次 run_turn?处理的是界面已可收工、队列里又来了必须处理的输入。

去掉任何一问,价值立刻显形:

  • 去掉轮次那一问:模型写出最终答案后 run_turn 会去跑 stop hook 并 break,中途那句"测试用 pytest"只能等任务壳再进一次 run_turn。功能能补上,但 stop hook 会在插话进模型之前先跑一轮——顺序错了。
  • 去掉任务壳那一问:run_turn 因 should_stop 返回后任务直接结束,后到的用户消息要么消失,要么等会话变空闲,由 maybe_start_turn_for_pending_work 换一个 turn_id 新开任务,TurnStarted 再闪一次,回放里变成两个桶。

stop hook 的语义也靠层次才清楚:返回 block 且带 prompt,控制权留在轮次层,采样层早已返回,任务壳还在等这次 run_turn。block 是轮次层内部续跑,stop 才轮次层把控制权交回任务壳。

同类切法对比

  • DSH:按语义切成 Turn、Step、Inbox。外层 while (await this.turn()),turn() 内部再套 while (true),每圈先 preStep 再 step;第三层不是第三条 while 而是一对队列(next-turn / next-step),调用方入队时选 followup、steer 还是 inject。因此能从 session 事件重放两条队列。
  • Claude Code:单层 while 加状态袋。主循环在 query.ts,可变状态放进一个 state 对象,循环体顶部解构,continue 处写回整袋;needsFollowUp 只由助手消息里的 tool_use 块点亮。改一处 continue,要同时核对 stopHookActive、turnCount 和 transition。

差别不在层数,在切分维度。按生命周期切,你能把流重试、取消、end_turn 各自关在采样层;按语义切,你能从会话事件重放两条队列;状态袋的代价是改一处就要核对好几个字段。


审查清单

把下面这份清单直接贴进你的 PR 模板。前三条管落点,后三条管循环。

落点侧

  1. 这个新概念,现有的非核心包能不能住下?说得出具体包名,还是只能说"好像没有"?
  2. 如果新建包,需要重构哪些旧代码?这笔账有没有写进 PR 描述?
  3. 如果进核心,"为什么拆不出去"这句话,你能写给一个不了解上下文的评审看吗?
  4. 新增类型的依赖清单里,有没有出现反向边——叶子依赖了中心?

循环侧

  1. 新增的"继续"条件挂在哪个布尔上?它属于"这一条流结束了吗""这一轮还要再采吗""这一趟还活着吗"中的哪一个?
  2. 插话进来的时候,它会落在哪个 turn_id 里?回放日志里是一个桶还是两个桶?
  3. 停止钩子返回阻断时,控制权留在哪一层?这一层退出之后,谁接手?

约束说明

这一集的结论有明确的适用边界,超出边界就会出现误导。

约束一:禁令挡的是默认动作,不是体积增长。 禁令写进文件的日期是 2026-03-26,那时 core 已是最大 crate;立完之后 workspace 成员从 75 个长到 135 个,core 的生产代码却还在涨。别指望靠一条文本给核心模块减肥。

约束二:这些数字是一次快照。 行数、成员数、下游依赖数都来自特定 commit(教学示意对应 openai/codex 仓库 commit 4f39251a01),用于展示依赖边蔓延的形状,不是可供引用的长期指标。

约束三:目录名不等于包名。 目录叫 core,crate 名叫 codex-core,use 的时候写成 codex_core。三处不一致在跨 crate 引用时会直接报错。

约束四:架构约束无法自动化。 架构约束需要理由,构建约束没有第二种正确答案。别试图给架构整洁度装一个 CI 红灯——仓库里检索那句禁令只命中一处,就是证据。

约束五:三问是语言无关的,落点不是。 换个语言重写,最小形态仍是这三问;但具体该落在哪个包,取决于那门语言的编译单元边界。


实践提示

提示一 · 给新功能挑落点时,先画依赖边再动手

拿一张纸,画出中心和叶子。把你的新类型放进去,问一句:它被复用的时候,需要把多少东西一起拖走?需要拖走沙箱和守卫的类型,位置就放错了。判据只有一条——复用成本。

提示二 · 把落点三问写进 PR 模板

不需要任何工具支持,一条检查项就够。明文的价值不在强制执行,而在于把老员工的默契变成新人第一天就能读到的文本,让评审有权挡回去。

提示三 · 验收任何一段循环,先问"谁有资格决定继续"

三个问题三个层次:这一条流结束了吗,这一轮还要再采吗,这一趟任务还活着吗。如果答案全在同一个 while 里,那多半是状态袋,改一处 continue 要同时核对好几个字段。

提示四 · 区分构建约束和架构约束

前者能自动化,后者只能靠评审。把力气花在前者上,把理由写在后者上。指望给架构整洁度装红灯,装不上。


一句话 Takeaway

新概念先找现有的非 core crate,否则新建 crate 并重构;core 是最后一档,评审对进核心的 PR 必须问为什么拆不出去。叶子类型待在叶子 crate,编译图才不会从两边一起胀。三层循环各自的终止条件写成三个函数,插话只进 pending,stop hook 只进轮次循环,任务壳只在轮次返回后再看队列——不要收成一个 while 加三个布尔。


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

本页文字稿与音频为同源二次演绎,音频由文本合成,措辞以本页为准。