本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T3 解剖 OpenAI Codex:把安全观写进类型系统
来源:xueai.miyang.cn(小山学堂 · 洛小山)
本集为两节合辑,已做取舍。
模型还在打字,读文件的声音已经响了。采样循环的时序是:流内建 future,流后统一 drain,先 persist 再等结果。
Agent 正在改第三个文件,你补了一句「配置用 YAML」—— 回车之后这句话是开工、插进当前轮,还是当场被拒,Core 当场拍板,不等模型开口。
统一主线:两节都在回答同一类问题 —— 一句已经说出口的话,什么时候还能改。
| 节 | 问题 | 答案 |
|---|---|---|
| 流内开工 | 模型已发出的工具调用为什么取消不掉 | 只能收尾,不能当作没发生 |
| 中途插话 | 用户补的这句话为什么有时收、有时拒 | 由任务类型 + 投递相位当场判定 |
| 阶段 | 动作 | 出处 |
|---|---|---|
| 1 | SSE 帧解成通用事件(尚无业务含义) | responses.rs L164 |
| 2 | kind = output_item.done → 产出 OutputItemDone | responses.rs L352 |
| 3 | 采样循环交给 handle_output_item_done | turn.rs L2384 |
| 4 | 先把 function_call 写入历史和 rollout | stream_events_utils.rs L316 |
| 5 | 再 pin 工具,推进有序队列 | stream_events_utils.rs L320 |
| 6 | 流结束/断流/取消 → 都只是离开循环(函数未返回) | turn.rs L2282 |
| 7 | drain 按插入顺序把结果写入历史 | turn.rs L2135 |
| 8 | 然后才看取消令牌;Stream 可重试 | turn.rs L2760 |
| 出口 | 路径 | 重试 | 历史里留下 |
|---|---|---|---|
| 正常结束 | Completed | — | 请求 + 结果 |
| 断流 | Stream | 可重试 | 已落盘请求 + drain 结果,重试读这份历史 |
| Esc | TurnAborted | 不可重试 | 请求保留 + 中止文案 + TurnAborted 标记 |
对照「等流结束才开工」:断流时请求还没写下,重试从空历史开始;Esc 时什么都没写下。
| 调用方模式 | 判定结果 |
|---|---|
StartOrSteer(默认) | 先试 steer;只有 NoActiveTurn 才开工 |
StartIfIdle | 仅空闲时开工 |
Steer | 只插话 |
回的是 Started / Steered / NotSubmitted,回完就结束 —— 不等 hook、不等落盘、不等采样。
两种极端记账方式各有坏处:
OutputItemDone 一到,采样循环先把这一条写入会话历史和 rollout,再把工具执行包成 future 挂到有序队列上。取消令牌用子令牌 —— 父令牌一亮,这个工具跟着停。
取消来得再快,这一条 function_call 已经进历史。最多再多写一条 aborted by user。
类型别名上方的注释把合同写死:完成的模型输出要立刻记下来,后面 turn 被取消,历史和 rollout 也保持同步。
工具已经读了磁盘 —— 这条事实已经发生。取消树可以打断执行,打断不了已经写下的请求。先写请求、后写结果,transcript 始终闭合。
出处:stream_events_utils.rs L190–192、L316–327;AGENTS.md L91–100(Model visible context 第一条:No history rewrite)。
模型常常先发出读文件,再继续写一段说明。等收工哨再开工 = 把读文件延迟和打字延迟串起来。流内开工能让两段时间重叠。
代价:取消和断流必须认领已经开工的 future。没有认领人 → 孤儿任务(工具还在跑,历史对不上)。
收流循环无论正常 Completed、提前关流还是 or_cancel,都只是离开 loop,函数还没返回。随后固定调用 drain_in_flight,按插入顺序等到每条 future 给出结果,再写入历史。然后才检查取消令牌。
如果先判取消,还在跑的任务就成了没人认领的孤儿。
为什么长期成立:中途开工就必须在每个出口等齐。所有权留在采样函数的局部变量里,没有另一条后台回收队列。成功、错误、取消共用这一段收尾。换语言也一样:离开异步循环之后先 allSettled,再决定重试还是中止。
出处:turn.rs L2282–2284、L2744–2762;error.rs L88–93、L364–390。
三个工具可以同时跑,但谁先跑完不影响写入顺序 —— 历史仍按模型发出的顺序写结果。
| 维度 | 属性 |
|---|---|
| 执行顺序 | 可并行、可优化 |
| 写入顺序 | 必须确定(按发出顺序) |
按完成顺序写 → 同一段会话重放两次可能对不上,prompt cache 也会更脆。
有序队列把观测顺序和执行顺序拆开。挂起顺序就是日后 drain 的顺序。并发闸门是另一层的责任。
实际收益:重放时结果顺序确定,与机器快慢、网络抖动、磁盘负载无关;缓存前缀稳定。
出处:turn.rs L2130–2154、L2391–2397。
调用方选 StartOrSteer / StartIfIdle / Steer,不直接喊 start。Core 按现场忙闲和任务种类判定,立刻回 Started / Steered / NotSubmitted。
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。
TaskKind 只有 Regular / Review / Compact 三个变体:
| 任务类型 | 是否收插话 |
|---|---|
| Regular | 收 |
| Review | 拒(ActiveTurnNotSteerable) |
| Compact | 拒(ActiveTurnNotSteerable) |
steer_input 拿着 active_turn 锁做完全部检查。没有活动轮、或有槽没有 task,都算 NoActiveTurn。StartOrSteer 也不会因此开工。
调用方收到拒绝,这条输入不会被默默排进一条新对话。
悄悄重试比明确失败危险得多 —— 用户以为自己的话被听到了。
检查过关后,用户输入推进 pending_input,同时把信箱相位打回 CurrentTurn。这里的 match 是穷尽的 —— 新加一种任务类型,编译器会逼你回答它能不能插话。
方法论边界:类型系统能逼你回答「能不能」,但回答不了「该不该」。穷尽匹配是下限保障,不是正确性证明。
出处:turn_input.rs L507–519、L546–564;state/turn.rs L67–72。
主 agent 已打出一段看起来像最终答案的话,子 agent 同时发回一条进度。并进去 → 用户已看见的答案被续写;一律等到下一轮 → 子 agent 结果可能要隔一次采样才进模型。
| 存货 | 位置 |
|---|---|
| 用户插话 | turn 内 pending_input |
| 子 agent 的信 | 会话级 mailbox_pending_mails |
相位从 CurrentTurn 起步;用户已看见最终答案之后切到 NextTurn;用户再插一句、或模型又发出工具调用 → 相位重开。
例外:pending_input 里只要还有一条不是「排队不叫醒」的子邮件,就保持当前相位。
取信规则:
NextTurn → turn 内 pending 不拿,会话信箱更不掏;CurrentTurn → 先拿 turn 内 pending,再掏会话信箱,拼在后面。于是出现一个反直觉但合理的结果:最终答案落地后,子邮件可以躺在信箱里,循环却认为没有待处理输入,本轮收束。
Commentary 的不算;不进这套信箱的五张独立 oneshot 表:审批、权限、提问、elicitation、动态工具 —— 各等各的回执。
出处:state/turn.rs L37–56、L87–103;input_queue.rs L206–227、L284–336;stream_events_utils.rs L486–501。
tool_use,for await 结束后才 runTools。流断了只需丢掉已收集的 block,省掉每个出口都 drain 的局部所有权。代价:工具延迟与打字延迟串行。streamingToolExecution 打开 → 行为靠近 Codex(流内 addTool、立刻开工)。失败回退要 discard 已开工的工具,避免旧 id 漏进重试。出处:query.ts L551–568、L1380–1382。
pre / guard / around / post 回答「谁能拒绝」,以及拒绝之后结果还在不在。Guard 只有拒绝理由或弃权,没有「放行」这个选项。它的 drained 是单次 execute 内部的收尾 —— SSE 还在飞的时候,这套瀑布还没开始。
两边词面相近,出口不同:一边护权限单调,一边护流式 transcript 闭合。把 Guard 搬进 Codex,挡不住断流丢 transcript;把 persist-then-drain 搬进 DSH,也回答不了插件能不能把拒绝改成放行。
做架构借鉴时,先问清楚对方那套机制保护的到底是什么,别看着名字像就搬。
DSH 对外三个别名(followup / steer / inject)底层共用 send;目标队列和唤不唤醒是两个正交参数。Codex 没有这三个公开函数,StartOrSteer 把空闲开工和忙时插话焊在一次判定里。相位这面翻牌 DSH 可以没有(因为它把等整轮和等下一站写成两条列表);代价是答案已上屏后 next-step 非空仍会续命当前 Turn。
用户输入、任务通知、孤儿权限走同一条 commandQueue。优先级 now > next > later,同级 FIFO。用户命令默认 next,任务通知默认 later。消费发生在当前流结束之后,没有步级插话 —— 你补的那句 YAML 约束要等当前生成器收尾才进模型。
drain?drain(孤儿任务)。handle_output_item_done 的工具分支里,把 record_completed_response_item 和 Box::pin(handle_tool_call) 对调。取消发生在 pin 之前、persist 之前。下一轮采样和会话恢复会看见什么?(落到「历史只能增量追加」与 drain_in_flight 的写入时机上)trigger_turn: false 的子邮件,再喂一条 FunctionCall。推演 get_pending_input 该返回什么。(对照:正文落盘时相位切到 NextTurn,这封信看不见;工具项到达后相位重开,下一圈才能掏出并进同轮的下一次请求)quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。OutputItemDone 一到就先写入再开工。流的每个出口先 drain,结果按发出顺序写。取消打断执行,已经写下的 transcript 留在原处。
提交立刻回判定 —— 回的是收下还是拒绝,采样还没开始。只有 Regular 收插话,审查和压缩当场拒。答案上屏后默认不续写,用户再插或工具再调,才把门打开。
统一提醒:已经发生的事只能收尾,不能当作没发生。 设计循环时,先想清楚哪些东西一旦写下就改不了。
*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*