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

流还在走,工具已经开工 · 中途插话:这句话进本轮、下一轮,还是被拒

时长 17:05音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 两个时序问题1:24
1:24一 · 先盖章,再开工1:53
3:18二 · 流内挂起,每个出口都要认领1:46
5:04三 · 执行可以并行,历史按发出顺序写1:34
6:39四 · 同一道题的另外两种答法1:48
8:28五 · 中途插话,立刻回判定1:59
10:28六 · 只有常规任务收插话1:49
12:17七 · 一面翻牌,决定能不能续写2:20
14:38收尾 · 六条可以带走的原则2:26
解读全文

ep13 · 流还在走,工具已经开工 · 中途插话:这句话进本轮、下一轮,还是被拒

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

本集为两节合辑,已做取舍。


一句话速览

模型还在打字,读文件的声音已经响了。采样循环的时序是:流内建 future,流后统一 drain,先 persist 再等结果。

Agent 正在改第三个文件,你补了一句「配置用 YAML」—— 回车之后这句话是开工、插进当前轮,还是当场被拒,Core 当场拍板,不等模型开口。

统一主线:两节都在回答同一类问题 —— 一句已经说出口的话,什么时候还能改。

节问题答案
流内开工模型已发出的工具调用为什么取消不掉只能收尾,不能当作没发生
中途插话用户补的这句话为什么有时收、有时拒由任务类型 + 投递相位当场判定

能力地图

第一节 · 采样循环时序

阶段动作出处
1SSE 帧解成通用事件(尚无业务含义)responses.rs L164
2kind = output_item.done → 产出 OutputItemDoneresponses.rs L352
3采样循环交给 handle_output_item_doneturn.rs L2384
4先把 function_call 写入历史和 rolloutstream_events_utils.rs L316
5再 pin 工具,推进有序队列stream_events_utils.rs L320
6流结束/断流/取消 → 都只是离开循环(函数未返回)turn.rs L2282
7drain 按插入顺序把结果写入历史turn.rs L2135
8然后才看取消令牌;Stream 可重试turn.rs L2760

三条出口的对照

出口路径重试历史里留下
正常结束Completed—请求 + 结果
断流Stream可重试已落盘请求 + drain 结果,重试读这份历史
EscTurnAborted不可重试请求保留 + 中止文案 + TurnAborted 标记

对照「等流结束才开工」:断流时请求还没写下,重试从空历史开始;Esc 时什么都没写下。

第二节 · 提交的三种模式与三种判定

调用方模式判定结果
StartOrSteer(默认)先试 steer;只有 NoActiveTurn 才开工
StartIfIdle仅空闲时开工
Steer只插话

回的是 Started / Steered / NotSubmitted,回完就结束 —— 不等 hook、不等落盘、不等采样。


思路一 · 请求先盖章,工具再开工

问题

两种极端记账方式各有坏处:

  • 等 Completed 再写入 → 提前关流会把已经完整的调用一起扔掉,重试让模型再发一遍同样的调用;
  • 取消时跳过写入 → 历史只剩半截请求,模型和界面都看见一个没闭合的调用。

机制

OutputItemDone 一到,采样循环先把这一条写入会话历史和 rollout,再把工具执行包成 future 挂到有序队列上。取消令牌用子令牌 —— 父令牌一亮,这个工具跟着停。

取消来得再快,这一条 function_call 已经进历史。最多再多写一条 aborted by user。

提示1 · 历史只能往上加,不能改写

类型别名上方的注释把合同写死:完成的模型输出要立刻记下来,后面 turn 被取消,历史和 rollout 也保持同步。

工具已经读了磁盘 —— 这条事实已经发生。取消树可以打断执行,打断不了已经写下的请求。先写请求、后写结果,transcript 始终闭合。

出处:stream_events_utils.rs L190–192、L316–327;AGENTS.md L91–100(Model visible context 第一条:No history rewrite)。


思路二 · 流内挂起,每个出口都 drain

为什么要在流内开工

模型常常先发出读文件,再继续写一段说明。等收工哨再开工 = 把读文件延迟和打字延迟串起来。流内开工能让两段时间重叠。

代价:取消和断流必须认领已经开工的 future。没有认领人 → 孤儿任务(工具还在跑,历史对不上)。

机制

收流循环无论正常 Completed、提前关流还是 or_cancel,都只是离开 loop,函数还没返回。随后固定调用 drain_in_flight,按插入顺序等到每条 future 给出结果,再写入历史。然后才检查取消令牌。

提示2 · 顺序很关键:先等齐,再判取消

如果先判取消,还在跑的任务就成了没人认领的孤儿。

为什么长期成立:中途开工就必须在每个出口等齐。所有权留在采样函数的局部变量里,没有另一条后台回收队列。成功、错误、取消共用这一段收尾。换语言也一样:离开异步循环之后先 allSettled,再决定重试还是中止。

出处:turn.rs L2282–2284、L2744–2762;error.rs L88–93、L364–390。


思路三 · 执行可以并行,历史按发出顺序写

三个工具可以同时跑,但谁先跑完不影响写入顺序 —— 历史仍按模型发出的顺序写结果。

提示3 · 并发是性能问题,顺序是正确性问题

维度属性
执行顺序可并行、可优化
写入顺序必须确定(按发出顺序)

按完成顺序写 → 同一段会话重放两次可能对不上,prompt cache 也会更脆。

有序队列把观测顺序和执行顺序拆开。挂起顺序就是日后 drain 的顺序。并发闸门是另一层的责任。

实际收益:重放时结果顺序确定,与机器快慢、网络抖动、磁盘负载无关;缓存前缀稳定。

出处:turn.rs L2130–2154、L2391–2397。


思路四 · 提交立刻回判定

三种日常结局都不好受

  1. 立刻打断 → 前两个文件的改动可能半成品留在磁盘上;
  2. 排到下一轮 → 只能看着它把剩下的文件按旧方向改完;
  3. 塞进上下文却不叫醒循环 → 你补的约束等于迟到。

机制

调用方选 StartOrSteer / StartIfIdle / Steer,不直接喊 start。Core 按现场忙闲和任务种类判定,立刻回 Started / Steered / NotSubmitted。

提示4 · 设置不能先改再判定

prepare 先预览线程设置,预览失败直接 InvalidRequest。真正写入发生在 apply_started 或 apply_steered。

被拒绝的输入连设置都不改。 插话成功后只落持久设置,当前轮的 TurnContext 不换。开工专用选项(如结构化输出 schema)只在 Started 上用。

为什么判定和入队要在同一把锁里

先看有没有活动轮 → 再看 kind → 再写入 pending,中间不能让另一次提交把轮次换掉。

拆成两次加锁,用户回车和子邮件同时到达时,可能出现「判定时还空闲、入队时已经有人占坑」的窗口。

出处:turn_input.rs L1–9、L58–80、L141–156、L195–249;protocol/src/turn_input.rs L127–136。


思路五 · 只有 Regular 收插话

机制

TaskKind 只有 Regular / Review / Compact 三个变体:

任务类型是否收插话
Regular收
Review拒(ActiveTurnNotSteerable)
Compact拒(ActiveTurnNotSteerable)

steer_input 拿着 active_turn 锁做完全部检查。没有活动轮、或有槽没有 task,都算 NoActiveTurn。StartOrSteer 也不会因此开工。

提示5 · 拒收是明确失败,不是悄悄换地方重试

调用方收到拒绝,这条输入不会被默默排进一条新对话。

悄悄重试比明确失败危险得多 —— 用户以为自己的话被听到了。

类型系统层面的收益

检查过关后,用户输入推进 pending_input,同时把信箱相位打回 CurrentTurn。这里的 match 是穷尽的 —— 新加一种任务类型,编译器会逼你回答它能不能插话。

方法论边界:类型系统能逼你回答「能不能」,但回答不了「该不该」。穷尽匹配是下限保障,不是正确性证明。

出处:turn_input.rs L507–519、L546–564;state/turn.rs L67–72。


思路六 · 一面翻牌决定能不能续写

问题

主 agent 已打出一段看起来像最终答案的话,子 agent 同时发回一条进度。并进去 → 用户已看见的答案被续写;一律等到下一轮 → 子 agent 结果可能要隔一次采样才进模型。

机制 · MailboxDeliveryPhase

存货位置
用户插话turn 内 pending_input
子 agent 的信会话级 mailbox_pending_mails

相位从 CurrentTurn 起步;用户已看见最终答案之后切到 NextTurn;用户再插一句、或模型又发出工具调用 → 相位重开。

例外:pending_input 里只要还有一条不是「排队不叫醒」的子邮件,就保持当前相位。

取信规则:

  • NextTurn → turn 内 pending 不拿,会话信箱更不掏;
  • CurrentTurn → 先拿 turn 内 pending,再掏会话信箱,拼在后面。
于是出现一个反直觉但合理的结果:最终答案落地后,子邮件可以躺在信箱里,循环却认为没有待处理输入,本轮收束。

什么算「用户可见的最终答案」

  • ✅ 助手正文;
  • ❌ phase 是 Commentary 的不算;
  • ❌ trim 之后为空的不算;
  • 未打标的助手消息按最终答案处理;未打标的提供方默认走更安全的那条(先把信箱关到下一轮)。

不进这套信箱的五张独立 oneshot 表:审批、权限、提问、elicitation、动态工具 —— 各等各的回执。

出处:state/turn.rs L37–56、L87–103;input_queue.rs L206–227、L284–336;stream_events_utils.rs L486–501。


横向对比 · 同一道题的另外两种答法

Claude Code · 默认等流结束,另有一扇流内闸门

  • 默认:流式循环只收集 tool_use,for await 结束后才 runTools。流断了只需丢掉已收集的 block,省掉每个出口都 drain 的局部所有权。代价:工具延迟与打字延迟串行。
  • streamingToolExecution 打开 → 行为靠近 Codex(流内 addTool、立刻开工)。失败回退要 discard 已开工的工具,避免旧 id 漏进重试。
  • Codex 没有对等的 discard —— 因为它选择先 persist,重试读历史。

出处:query.ts L551–568、L1380–1382。

DSH · 三段瀑布加单调 Guard

pre / guard / around / post 回答「谁能拒绝」,以及拒绝之后结果还在不在。Guard 只有拒绝理由或弃权,没有「放行」这个选项。它的 drained 是单次 execute 内部的收尾 —— SSE 还在飞的时候,这套瀑布还没开始。

两边词面相近,出口不同:一边护权限单调,一边护流式 transcript 闭合。把 Guard 搬进 Codex,挡不住断流丢 transcript;把 persist-then-drain 搬进 DSH,也回答不了插件能不能把拒绝改成放行。
做架构借鉴时,先问清楚对方那套机制保护的到底是什么,别看着名字像就搬。

DSH · 两个参数两条轨道(插话侧)

DSH 对外三个别名(followup / steer / inject)底层共用 send;目标队列和唤不唤醒是两个正交参数。Codex 没有这三个公开函数,StartOrSteer 把空闲开工和忙时插话焊在一次判定里。相位这面翻牌 DSH 可以没有(因为它把等整轮和等下一站写成两条列表);代价是答案已上屏后 next-step 非空仍会续命当前 Turn。

Claude Code · 一条队列,用优先级补时机

用户输入、任务通知、孤儿权限走同一条 commandQueue。优先级 now > next > later,同级 FIFO。用户命令默认 next,任务通知默认 later。消费发生在当前流结束之后,没有步级插话 —— 你补的那句 YAML 约束要等当前生成器收尾才进模型。


审查清单

  • 完成的模型输出是否立刻写入历史(先 persist 再 pin)?
  • 取消令牌是否用子令牌,父令牌一亮工具跟着停?
  • 系统里有没有「No history rewrite」的硬规矩?
  • 每个出口(正常/断流/取消)是否都调用了 drain?
  • 收尾顺序是否为「先等齐,再判取消」?
  • 历史写入是否按发出顺序而非完成顺序?
  • 提交是否立刻回判定(不等 hook / 落盘 / 采样)?
  • 设置是否先预览后写入,被拒输入不改设置?
  • 判定与入队是否在同一把锁里完成?
  • 任务类型的 match 是否穷尽(新类型会逼你回答能否插话)?
  • 是否有「一面翻牌」防止续写已上屏的答案?

排查路径

  1. 取消后历史里仍有请求 → 这是设计内行为(persist 先于 pin),不是 bug。
  2. 工具在取消后仍在跑 → 查是否有出口漏掉 drain(孤儿任务)。
  3. 重放结果对不上 / 缓存不命中 → 查写入顺序是否用了完成顺序。
  4. 补的约束没生效 → 查提交回了什么判定(Started / Steered / NotSubmitted)。
  5. 子 agent 结果迟迟不进模型 → 查投递相位是否已切到 NextTurn(答案已上屏)。

课堂练习

  1. 在 handle_output_item_done 的工具分支里,把 record_completed_response_item 和 Box::pin(handle_tool_call) 对调。取消发生在 pin 之前、persist 之前。下一轮采样和会话恢复会看见什么?(落到「历史只能增量追加」与 drain_in_flight 的写入时机上)
  2. 最终答案落地之后,先入队一封 trigger_turn: false 的子邮件,再喂一条 FunctionCall。推演 get_pending_input 该返回什么。(对照:正文落盘时相位切到 NextTurn,这封信看不见;工具项到达后相位重开,下一圈才能掏出并进同轮的下一次请求)

约束说明

  1. 取材约束:本集全部内容取自小山学堂《学 AI 产品,从入门到精通》对应课节,未跨集取材,未虚构源码行号或产品行为。
  2. 取舍说明:两节合计 7939 字,口播稿按 4600–5300 字容量取舍,保留最能带走的原理与踩坑点,未为凑字数硬扩。
  3. 题库隔离:题库页(quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。
  4. 源码时效:文中行号对应 openai/codex 仓库 commit 4f39251a01;横向对比部分核对时间为 2026-08-22。
  5. 解读边界:本文为二次演绎的解读稿,用于配合音频理解;具体行为以实际运行版本为准。

Takeaway

OutputItemDone 一到就先写入再开工。流的每个出口先 drain,结果按发出顺序写。取消打断执行,已经写下的 transcript 留在原处。

提交立刻回判定 —— 回的是收下还是拒绝,采样还没开始。只有 Regular 收插话,审查和压缩当场拒。答案上屏后默认不续写,用户再插或工具再调,才把门打开。

统一提醒:已经发生的事只能收尾,不能当作没发生。 设计循环时,先想清楚哪些东西一旦写下就改不了。

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