两个问题,一个仓库。
问题一:落点。 一个三十多万行、一百三十五个工作区成员的大仓库,你要加一个小功能,这个文件应该落在哪个 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 既放错了位置,又把状态塞进了不该塞的层。
仓库的 AGENTS.md 用加粗英文写着 resist adding code to codex-core。它旁边是三问:
配套的是评审纪律:评审遇到往 core 堆功能的 PR,被要求主动挡回去。
第 2 问里那句"允许重构"很容易被忽略,但它恰恰是这条禁令能执行下去的前提。很多团队不敢新建包,不是不知道该拆,而是怕背上重构的债。仓库在这里明确告诉你:重构是被许可的代价。
禁令听着像洁癖,直到你把数字摊开:
.rs 行数:core 33 万行,tui 27 万,app-server 14.8 万;≥ 1 万行的 24 个,< 2 千行的 77 个——典型的幂律分布。codex-app-server-client,再由 client 拉 core。所以往 core 加一行,25 个直接下游重编,tui 仍被间接带上。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 也红。也就是说,机器层面兜底的是构建一致性,不是架构整洁度。
everything is a plugin,运行时只收服务、类型化事件和可逆副作用,没有需要打补丁的特权内核;新包落进现有分组时根 package.json 都不用改,glob 会发现它。Cargo.toml 标明是自动生成的,人只改各 crate 自己的清单;members 按同一口径数 79 个;分层写在根 README——pager 是 TUI,shell 是运行时,tools/workspace 是领域能力,common/build 是叶子。Codex 用编译期 crate 换掉了插件式装卸器,代价是新能力必须改 members 数组、写 BUILD.bazel、重新编译,能下手的位置只剩评审和 CI。前者赢在灵活,后者赢在类型安全和编译期可见的依赖图。
| 层 | 源码 | 它问的问题 | 它能做什么,不能做什么 |
|---|---|---|---|
任务壳 RegularTask::run | tasks/regular.rs L76–L90 | 这一趟任务还活着吗 | 能再开一轮 run_turn;不能重发 TurnStarted 之外的语义 |
轮次 run_turn | session/turn.rs L423、L500–L525 | 这一轮还要不要再采样 | 能续采样、接插话、跑 stop hook;不能重发 TurnStarted |
采样 sampling | stream_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。
两处问的是两个时刻的两个问题:
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 才轮次层把控制权交回任务壳。
while (await this.turn()),turn() 内部再套 while (true),每圈先 preStep 再 step;第三层不是第三条 while 而是一对队列(next-turn / next-step),调用方入队时选 followup、steer 还是 inject。因此能从 session 事件重放两条队列。query.ts,可变状态放进一个 state 对象,循环体顶部解构,continue 处写回整袋;needsFollowUp 只由助手消息里的 tool_use 块点亮。改一处 continue,要同时核对 stopHookActive、turnCount 和 transition。差别不在层数,在切分维度。按生命周期切,你能把流重试、取消、end_turn 各自关在采样层;按语义切,你能从会话事件重放两条队列;状态袋的代价是改一处就要核对好几个字段。
把下面这份清单直接贴进你的 PR 模板。前三条管落点,后三条管循环。
落点侧
循环侧
这一集的结论有明确的适用边界,超出边界就会出现误导。
约束一:禁令挡的是默认动作,不是体积增长。 禁令写进文件的日期是 2026-03-26,那时 core 已是最大 crate;立完之后 workspace 成员从 75 个长到 135 个,core 的生产代码却还在涨。别指望靠一条文本给核心模块减肥。
约束二:这些数字是一次快照。 行数、成员数、下游依赖数都来自特定 commit(教学示意对应 openai/codex 仓库 commit 4f39251a01),用于展示依赖边蔓延的形状,不是可供引用的长期指标。
约束三:目录名不等于包名。 目录叫 core,crate 名叫 codex-core,use 的时候写成 codex_core。三处不一致在跨 crate 引用时会直接报错。
约束四:架构约束无法自动化。 架构约束需要理由,构建约束没有第二种正确答案。别试图给架构整洁度装一个 CI 红灯——仓库里检索那句禁令只命中一处,就是证据。
约束五:三问是语言无关的,落点不是。 换个语言重写,最小形态仍是这三问;但具体该落在哪个包,取决于那门语言的编译单元边界。
拿一张纸,画出中心和叶子。把你的新类型放进去,问一句:它被复用的时候,需要把多少东西一起拖走?需要拖走沙箱和守卫的类型,位置就放错了。判据只有一条——复用成本。
不需要任何工具支持,一条检查项就够。明文的价值不在强制执行,而在于把老员工的默契变成新人第一天就能读到的文本,让评审有权挡回去。
三个问题三个层次:这一条流结束了吗,这一轮还要再采吗,这一趟任务还活着吗。如果答案全在同一个 while 里,那多半是状态袋,改一处 continue 要同时核对好几个字段。
前者能自动化,后者只能靠评审。把力气花在前者上,把理由写在后者上。指望给架构整洁度装红灯,装不上。
新概念先找现有的非 core crate,否则新建 crate 并重构;core 是最后一档,评审对进核心的 PR 必须问为什么拆不出去。叶子类型待在叶子 crate,编译图才不会从两边一起胀。三层循环各自的终止条件写成三个函数,插话只进 pending,stop hook 只进轮次循环,任务壳只在轮次返回后再看队列——不要收成一个 while 加三个布尔。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页文字稿与音频为同源二次演绎,音频由文本合成,措辞以本页为准。