2741 字
约 9 分钟
1
按下取消之后,各层怎么收手
无标签

OpenAI Codex · 取消与错误 按下取消之后,各层怎么收手 工具失败回给模型,用户按 Esc 才停 turn。取消令牌从任务传到采样再传到工具。已完成的结果留在历史里,100 毫秒之后的硬拆不可逆。 一句话速览 工具失败回给模型,用户按 Esc 才停 turn。取消令牌从任务传到采样再传到工具。已完成的结果留在历史里,100 毫秒之后的硬拆不可逆。

课程目标 读完能说清三件事。工具失败为什么继续对话。用户按 Esc 之后,任务、采样、工具按什么顺序收手。哪一层的收手一旦落下就回不去。

先玩一遍 · 按下 Esc,看谁先停

同一轮对话,用户中途按 Esc

播放 单步 重置

按 Esc 时

工具还在跑 工具已经跑完

拨到另一档,看历史里成功回执会不会被覆盖,以及哪一步标了不可逆。

收手顺序 等待开始

1 协议入口 Op::Interrupt 到达 2 任务令牌 cancellation_token.cancel 3 采样收手 or_cancel 变成 TurnAborted 4 工具收手 子令牌取消,看 handler 走没走完 5 硬拆 100 毫秒后 task.handle.abort 不可逆 6 落盘与事件 先写片段,再发 TurnAborted

历史与副作用 0 条

逻辑轨迹 · 动画每一步对应源码里的哪一段

协议入口是 Interrupt,不杀后台 terminal protocol.rs L546 任务令牌先 cancel,这是信号还不是硬拆 tasks/mod.rs L887 采样用子令牌盯着流,or_cancel 变成 TurnAborted turn.rs L2273 工具派发再 child 一次,父令牌取消则一起取消 stream_events_utils.rs L319 handler 已走完就保留真实结果,没走完才 abort parallel.rs L182 等 100 毫秒,没收完就 task.handle.abort tasks/mod.rs L913 追加 turn_aborted 片段,立刻 flush_rollout tasks/mod.rs L927 最后才发 EventMsg::TurnAborted tasks/mod.rs L955

点播放,看 Esc 之后各层按什么顺序收手,哪一层不可逆。

收手顺序 令牌先发信号,采样和工具各自收尾,最后才硬拆、写片段、发事件。界面上的中止,是这条链走完之后才出现的。 不可逆的那一层 100 毫秒之后的 task.handle.abort 没有回切。已经完成的工具回执也不会被改写成 aborted。后台 terminal 继续跑,要杀它走另一条操作。 拨到另一档 工具已经跑完时,历史里留下成功回执加中止标记。工具还在跑时,留下 aborted by user 加中止标记。两种都是追加,都不是回滚。

教学示意:步骤时序按源码调用链编排,耗时被拉成可点的单步。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。

思路一 · 工具失败回给模型,引擎失败才停 turn

它解决什么问题 你让 Codex 改一个测试文件。模型先跑 cargo test ,编译器吐了两屏 rustc 报错,退出码是 1。下一秒对话停了,界面弹出 turn aborted 。很多人第一次写 agent 会这么干:工具返回 Err ,整轮跟着死。模型还没看见 stderr,会话已经结束。

思路是什么 分诊发生在工具回到对话的那道门上,一共三层门槛。 最浅一层:进程已经跑过。退出码非零、命令超时、沙箱拒绝,都收成工具回执。 success 写成 true ,意思是 handler 跑完了,回执可以喂给模型。命令成不成功写在正文里。 出处: codex-rs/core/src/tools/context.rs 第 344 至 353 行 中间一层:调用没做成。参数坏了、进程没拉起来、 apply_patch 上下文对不上,走 RespondToModel 。回执 success 才是 false 。模型读到文案,自己改再试。 最深一层:payload 对不上、任务 join 失败,才写 Fatal ,升成 CodexErr ,停 turn。 工具层自己的枚举只有两档。 RespondToModel 把字符串喂回模型。 Fatal 才升成引擎错误。默认把非 Fatal 折成 Ok 。想停对话,得写出 Fatal 或 CodexErr 。

codex-rs/tools/src/function_call_error.rs 第 1 至 10 行

use thiserror::Error;

/// Error returned while executing a model-visible tool invocation.
#[derive(Debug, Error, PartialEq)]
pub enum FunctionCallError {
 #[error("{0}")]
 RespondToModel(String),
 #[error("Fatal error: {0}")]
 Fatal(String),
}

源码快照说明:依据本地仓库 openai/codex ,核对文件 codex-rs/tools/src/function_call_error.rs ,commit 4f39251a01 ,核对日期 2026-08-22。这段枚举本身就是分诊合同:两档,没有第三档警告或重试。 cargo test 退出码 1 停在最浅一层,连错误枚举的门槛都没迈过。沙箱拒绝同样走 Ok 。代理拦请求时给命令进程回 HTTP 403,也停在最浅一层。模型 API 的 403 才是引擎错误。两处 403 不要并成一档。 出处: codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs 第 384 至 411 行; codex-rs/network-proxy/src/responses.rs 第 76 至 83 行

最浅 · 进程已经跑过

cargo test 退出码 1

工具回执 success true 命令成败写在正文里

对话继续 中间 · 调用没做成

patch 上下文对不上

RespondToModel 回执 success false

对话继续 最深 · 引擎事故

payload 对不上 / Esc

Fatal 或 TurnAborted 升成 CodexErr

停 turn

教学化结构图:失败从工具回到对话,先过三道门。默认停在最浅一层。

为什么长期成立 handler 里一个 IO 错误如果直接冒到 turn 循环,整轮对话跟着死。默认回灌,想停必须显式写出。这条线换语言重写也用得上。自己做内部 agent,先抄这一档:工具失败回灌,引擎失败停循环。

思路二 · 取消做成一棵令牌树

它解决什么问题 用户按 Esc,要停的不只是当前那次采样。采样可能还在读流,工具可能正在写文件,后台 terminal 可能已经拉起来了。只杀采样,工具会继续改磁盘。一刀切杀掉所有进程,unified exec 的后台任务也会被误杀。

思路是什么 任务启动时现造一张取消令牌。采样请求用 child_token() ,工具派发再 child 一次。父令牌一取消,子令牌一起取消。子令牌自己取消,不影响父令牌。这就是取消树。 出处: codex-rs/core/src/session/turn.rs 第 1399 行; codex-rs/core/src/stream_events_utils.rs 第 319 至 324 行 or_cancel 把谁先到写成固定形状。任意 future 配一张令牌。取消先到,返回 CancelErr ,一律变成 TurnAborted 。 出处: codex-rs/async-utils/src/lib.rs 第 4 至 31 行; codex-rs/protocol/src/error.rs 第 269 至 273 行 用户按 Esc,协议入口是 Op::Interrupt 。合同写死:中止当前任务,不杀后台 terminal。 handle_task_abort 先 cancel() ,再等 100 毫秒。超时就 task.handle.abort() 。令牌先发信号,任务有机会自己收尾。收不完,再硬拆。硬拆这一步不可逆。 出处: codex-rs/protocol/src/protocol.rs 第 546 至 548 行; codex-rs/core/src/tasks/mod.rs 第 66 行、第 880 至 913 行

用户 Esc Op::Interrupt

任务令牌 cancel 信号,还可收尾

采样 or_cancel child token

工具子令牌 再 child 一次

handle.abort 100ms 后,不可逆

turn_aborted 先落盘再发事件

事件 后台 terminal 不在这棵树上。Interrupt 的合同就是不杀它。

教学化结构图:一份用户意图往下传,硬拆是最后一刀,也是不可逆的那一刀。

令牌先发信号,硬拆才不可逆。

为什么长期成立 一份用户意图,多层各自收尾。合作式取消加硬期限,是并发系统的通用形状。Python 里用 asyncio.Event 就能做最小版。不必抄 37 个变体的 CodexErr 。

思路三 · 中止只追加,不回滚

它解决什么问题 取消发生在工具已经改了文件之后,历史里怎么记。如果把已经完成的输出改写成 aborted by user ,模型下一轮会以为写入没做成,可能再打一遍补丁。

思路是什么 工具 future 同时等派发结果和令牌。令牌先到,还要看 handler 是否已经走到终态。已经走完,就保留真实结果。没走完,才 abort,再造一条 AbortedToolOutput ,正文是 aborted by user after Xs 。 出处: codex-rs/core/src/tools/parallel.rs 第 177 至 206 行、第 243 至 260 行 然后追加一段模型可见标记,包在 <turn_aborted> 里。文案承认两件事:unified exec 可能还在后台跑,被中止的工具可能已经执行了一部分。标记写进历史之后立刻 flush_rollout() 。有的客户端收到 TurnAborted 会同步重读 rollout,标记必须先落盘。 出处: codex-rs/core/src/context/turn_aborted.rs 第 1 至 35 行; codex-rs/core/src/tasks/mod.rs 第 920 至 962 行

为什么长期成立 历史只追加、不改写。中止只往后面加片段,已落盘的工具输出不动。下一轮模型能同时看见完成的输出、被中止工具的回执、以及中止标记。上下文治理的第一条就是这个形状。 出处: AGENTS.md 第 91 至 100 行

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

DSH:throw 折成 isError,循环 throw 才停 turn DSH 每轮新建一个 AbortController 。工具 body 的 throw 不会穿过循环。执行器把它收成 isError: true 回执,轮次继续。bash 的注释写成产品合同:Non-zero exits are reported, not errored。循环自己的失败才停 turn。用户取消写成 turn/end aborted 。 DSH 省掉两档枚举,因为执行器 catch 已经分了模型能看见和引擎必须终止。代价是约定:漏过 dispatchToolBody 的 throw 仍会变成 turn/end error 。Codex 用类型把这条路收窄。 两侧均已核对源码 · 2026-08-22 · DSH agent.ts / tools/index.ts / tool-bash/render.ts

Grok:整份 ToolError 回模型,引擎停靠 SamplingError Grok 的 ToolError 模块头把 detail 写成必须回给模型的说明。 Cancelled 、 Timeout 、 Execution 都是同一种回灌。工具层没有 Fatal 档。会话层把执行失败收成 tool_result ,turn 继续。 引擎停靠另一套类型。 SamplingError 不可重试才停采样。Actor 上的 CancellationToken 触发是关机,单次工具取消走 CancelRegistry 。令牌不承担 Codex 那种工具分诊。 两侧均已核对源码 · 2026-08-22 · xai-tool-runtime / xai-grok-sampling-types / xai-grok-shell

课堂练习

01

Esc 落在两种时刻,历史里各留下什么 apply_patch 已经写入文件,成功回执还在飞。这时用户按 Esc。下一轮模型会在历史里看见什么:成功回执、 aborted by user 、 <turn_aborted> 片段,这三样各会不会出现?后台 terminal 还在不在? 再把时刻改成 handler 还在跑,重答一遍。哪一步是不可逆的,为什么已经落盘的文件不会跟着中止事件一起消失。

Takeaway: 工具失败回给模型,默认停在最浅一层。用户按 Esc,令牌从上往下传,已完成的结果留下,100 毫秒之后硬拆不可逆。中止只追加片段,不回滚历史。

按下取消之后,各层怎么收手
http://www.clxhxhhr.top/posts/4048/
作者
clxstart
发布于
2026-09-25
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。