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

按下取消之后,各层怎么收手

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

同步字幕

章节导航(点击跳转)

0:00开场 · 按下取消键之后,谁先停1:41
1:41一 · 工具失败不等于对话失败1:51
3:33二 · 三道门,默认停在最浅一层1:25
4:58三 · 两档枚举就是分诊合同2:11
7:10四 · 取消是一棵令牌树1:49
8:59五 · 一百毫秒,和不可逆的那一刀1:52
10:52六 · 中止只追加,不回滚2:09
13:01七 · 自己动手的最小版本1:37
14:39收尾 · 今天可以带走的五条1:12
解读全文

ep14 · 按下取消之后,各层怎么收手

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

本集解决什么工程问题

一个 agent 系统里,按下取消键之后到底发生了什么?这个问题平日没人关心,直到出现这三种事故之一:

  1. 工具跑失败了,整轮对话跟着死。 模型还没看见那两屏报错,会话就结束了——它连自己错在哪都不知道。
  2. 取消只停了采样,工具还在写磁盘。 用户以为停了,文件系统不同意。
  3. 历史被改写了。 取消发生在工具已经写完文件之后,你把成功回执改写成"被用户中止",下一轮模型以为写入没做成,又打了一遍补丁。

这一集把这三件事串成一条链:工具层的失败默认回灌给模型 → 用户取消沿令牌树从上往下传 → 收手顺序是先发信号、再给宽限、最后硬拆 → 历史只追加不回滚。


能力地图

能力判据对应源码位置
失败分诊拿到一个工具错误,能说出它停在哪一道门,以及模型接下来会看见什么core/src/tools/context.rs L344–L353
终止意图知道"想让对话停"必须显式写出哪一类错误tools/src/function_call_error.rs L1–L10
取消建模能画出任务、采样、工具三层的令牌派生关系,并说清父子取消的方向core/src/session/turn.rs L1399、stream_events_utils.rs L319–L324
收手时序能背出取消、等待、硬拆、写片段、刷盘、发事件这六步的顺序core/src/tasks/mod.rs L880–L962
历史治理能解释为什么已完成的工具回执不能被改写core/src/tools/parallel.rs L177–L206、context/turn_aborted.rs L1–L35

一 · 工具失败为什么不停对话

三道门

失败发生在工具回到对话的那道门上,一共三层门槛,由浅到深:

层触发条件回执形态后果
最浅进程已经跑过:退出码非零、命令超时、沙箱拒绝工具回执,success = true(意思是 handler 跑完了,回执可喂模型)命令成不成功写在正文里,模型自己读
中间调用没做成:参数坏了、进程没拉起、apply_patch 上下文对不上走 RespondToModel,success = false模型读到文案,自己改再试
最深payload 对不上、任务 join 失败写 Fatal,升成 CodexErr停 turn

默认停在最浅一层。 cargo test 退出码 1 连错误枚举的门槛都没迈过。

两处 403 不要并成一档

这是一个高频踩坑点:

  • 代理拦下请求,给命令进程回 HTTP 403 → 停在最浅一层,只是命令没拿到数据。
  • 模型 API 返回 403 → 引擎错误,整轮停。

同一个状态码,分属两层。错误处理里凡是"看起来一样其实分属两层"的信号,都要单独点名。

两档枚举就是合同


pub enum FunctionCallError {
    #[error("{0}")]
    RespondToModel(String),
    #[error("Fatal error: {0}")]
    Fatal(String),
}

只有两档,没有第三档警告或重试。默认把非 Fatal 折成 Ok——想停对话必须显式写出终止意图,沉默等于继续。类型系统在这里承担了合同的角色。

代价也要看清楚:任何一个底层 IO 错误如果一路冒到 turn 循环,整轮就会跟着死。所以正确写法是在 handler 里接住它。


二 · 取消是一棵令牌树

为什么不能一刀切

用户按 Esc,要停的不只是当前那次采样:

  • 采样可能还在读流;
  • 工具可能正在写文件——只杀采样,工具会继续改磁盘;
  • 后台 terminal 可能已经拉起来——一刀切会把长期任务误杀,而用户只想停当前这一轮。

树形结构

任务启动时现造一张取消令牌;采样请求用 child_token();工具派发再 child 一次。

  • 父令牌取消 → 子令牌一起取消;
  • 子令牌自己取消 → 不影响父令牌。

取消只能从上往下传。一次工具超时不该把整轮带下去;用户按 Esc 是顶层意图,必须穿透到每个子节点。

配套工具 or_cancel 把两个 future 的赛跑结果写成固定形状:任意 future 配一张令牌,取消先到就返回 CancelErr,一律变成 TurnAborted,不保留第三种可能——又一次用类型收窄分支。

协议层把意图写成合同

协议入口 Op::Interrupt 的合同写死在源码注释里:中止当前任务,不杀后台 terminal。 用户按 Esc 到底杀什么,是协议层的一句明文,不是散落在实现里的判断。


三 · 收手顺序与不可逆的那一刀


Op::Interrupt 到达
  ↓
任务令牌 cancel()                 ← 只是信号,不是硬拆
  ↓
采样收手:or_cancel → TurnAborted
工具收手:子令牌取消,看 handler 走没走完
  ↓
等 100 毫秒                        ← 协作式取消的宽限期
  ↓
未收完 → task.handle.abort()       ← 不可逆,没有回切
  ↓
追加 turn_aborted 片段 → flush_rollout()
  ↓
最后才发 EventMsg::TurnAborted

为什么必须有硬拆这一刀:协作式取消的前提是所有人都配合,第三方工具、卡住的网络请求、死循环子进程不一定配合。系统必须保留兜底的暴力手段,否则取消只是一个建议。

为什么又要先给一百毫秒:暴力手段会丢状态——写了一半的文件、没刷出去的日志、正在回滚的事务,都会在硬拆那一刻停在未知状态。短窗口让大多数正常情况优雅收尾,只有真正卡死的才吃那一刀。

界面上你看到的中止提示,是这条链全部走完之后才出现的。


四 · 中止只追加,不回滚

工具 future 同时等两件事:派发结果和取消令牌。令牌先到,还要再看一眼 handler 是否已到终态:

  • 已走完 → 保留真实结果,不动它;
  • 没走完 → abort,再造一条 AbortedToolOutput,正文 aborted by user after Xs。

然后追加一段模型可见标记,包在 <turn_aborted> 里。文案很老实,承认两件事:unified exec 可能还在后台跑,被中止的工具可能已经执行了一部分。

关键在"追加"二字。 标记写进历史之后立刻 flush_rollout()——有的客户端收到 TurnAborted 会同步重读 rollout,标记必须先落盘再发事件。顺序是:先写片段 → 刷盘 → 最后发事件。

于是下一轮模型同时看见三样东西:已完成的输出 + 被中止工具的回执 + 中止标记。三样都在,它自己判断哪些改动已生效。

历史只追加、不改写。这是上下文治理的第一条(AGENTS.md L91–L100)。

课堂练习 · Esc 落在两种时刻,历史里各留下什么

同一轮对话,把 Esc 按下的时刻改一改,历史里的记录完全不同。这是检验你有没有真正理解"只追加"的最好练习。

时刻 A:工具已经跑完,成功回执还在飞

用户按下 Esc 时,apply_patch 已经写入文件,handler 已到终态,只是回执还在往回传。

项结果原因
成功回执保留handler 已走完,保留真实结果
aborted by user 回执不出现不改写已完成的结果
<turn_aborted> 片段追加中止标记一律追加
后台 terminal仍在跑协议明确不杀,要杀走另一条操作
已写入的文件不动取消不等于回滚

时刻 B:handler 还在跑

用户按下 Esc 时,工具正在执行,还没到终态。

项结果原因
成功回执不出现还没跑完
aborted by user after Xs 回执新造一条未到终态才 abort
<turn_aborted> 片段追加同上
硬拆100 毫秒后执行,不可逆兜底手段

两种时刻的答案有个共同点:三种记录都是追加,没有一种是回滚。 下一轮模型同时看见已完成的输出、被中止工具的回执和中止标记,由它自己判断哪些改动已生效——这正是"历史是账本"的含义。


横向对比

项目工具层失败谁停循环取消建模
Codex三道门 + 两档枚举,默认回灌显式写出 Fatal / CodexErr任务→采样→工具的令牌树 + 100ms 宽限 + 硬拆
DSH执行器 catch 异常 → isError: true,轮次继续循环自己的失败;漏过 dispatchToolBody 的 throw 仍会变成 turn/end error每轮新建 AbortController;用户取消写成 turn/end aborted。bash 注释即产品合同:*Non-zero exits are reported, not errored*
Grok整份 ToolError 回模型,取消/超时/执行是同一种回灌,工具层没有 Fatal 档引擎靠另一套类型:SamplingError 不可重试才停采样CancellationToken 触发是关机;单次工具取消走 CancelRegistry,令牌不承担工具分诊

三条路的结论一致:工具层的失败,默认回给模型。 差别在用什么机制保证这句话——Codex 用类型,DSH 用执行器 catch,Grok 用模块约定。


审查清单

失败分诊

  1. 命令退出码非零时,你的系统是回灌还是中断?回执正文里有没有保留原始 stderr?
  2. 有没有出现过"看起来一样其实分属两层"的错误码?两处 403 是不是被并成一档了?
  3. 想让对话停下时,调用方是显式写出终止意图,还是靠异常冒泡?

取消建模

  1. 取消令牌是树形还是扁平?子级取消会不会误伤父级?
  2. "用户按 Esc 到底杀什么"这句话,写在协议文档里还是散落在实现里?
  3. 有没有不可逆的那一刀?它是不是最后一步而不是第一步?

历史治理

  1. 取消发生后,已完成的工具回执有没有被改写?
  2. 中止标记是先落盘还是先发事件?客户端同步重读时会不会读到缺标记的旧版本?

约束说明

约束一:这些行号是一次快照。 全部对应 openai/codex 仓库 commit 4f39251a01,核对日期 2026-08-22。用于理解调用链形状,不是可引用的长期指标。

约束二:一百毫秒是工程取值,不是物理常数。 它的意义是"短到用户无感、长到足以让正常路径收尾",换场景需要重新标定。

约束三:不可逆只针对任务句柄。 已经落盘的文件、已经发出的外部副作用,硬拆带不回来。取消不等于回滚,这是设计前提,不是缺陷。

约束四:后台 terminal 的生命周期独立于 turn。 协议明确不杀它,要杀走另一条操作(如 /kill)。别把"取消当前轮"实现成"清空所有子进程"。

约束五:三道门的分诊是针对命令类工具的。 纯函数型工具、检索型工具的失败语义不同,套用前先确认它有没有"进程已跑过"这一层。


实践提示

提示一 · 先定默认档,再写错误分支

在动手之前先回答一个问题:工具失败时,系统的默认反应是回灌还是中断?选回灌。把终止做成需要显式声明的例外。这个默认值决定了系统在面对意外时的性格。

提示二 · 用最小手段先做一版取消树

不需要抄那一大套错误类型。一个事件对象加一层派生关系就够:父取消带子,子取消不带父。先把方向性做对,比把变体补全重要。

提示三 · 把"用户按 Esc 杀什么"写进协议

一句话的明文,胜过散落各处的判断。协议层的价值就在于让这种意图可被引用、可被争论。

提示四 · 收手顺序写成注释贴在处理函数上方

发信号 → 给宽限 → 硬拆 → 写片段 → 刷盘 → 发事件。六步顺序错了,历史就会自相矛盾。写在代码里,评审时一眼能查。


一句话 Takeaway

工具失败回给模型,默认停在最浅一层;想停对话必须显式写出终止意图。用户按 Esc,取消令牌从任务传到采样再传到工具,先发信号、给一百毫秒宽限、最后才硬拆,硬拆不可逆。中止只往历史后面追加片段,已完成的结果一个字都不改——历史是账本,只能往后记,不能往回改。


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

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