一个 agent 系统里,按下取消键之后到底发生了什么?这个问题平日没人关心,直到出现这三种事故之一:
这一集把这三件事串成一条链:工具层的失败默认回灌给模型 → 用户取消沿令牌树从上往下传 → 收手顺序是先发信号、再给宽限、最后硬拆 → 历史只追加不回滚。
| 能力 | 判据 | 对应源码位置 |
|---|---|---|
| 失败分诊 | 拿到一个工具错误,能说出它停在哪一道门,以及模型接下来会看见什么 | 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 连错误枚举的门槛都没迈过。
这是一个高频踩坑点:
同一个状态码,分属两层。错误处理里凡是"看起来一样其实分属两层"的信号,都要单独点名。
pub enum FunctionCallError {
#[error("{0}")]
RespondToModel(String),
#[error("Fatal error: {0}")]
Fatal(String),
}
只有两档,没有第三档警告或重试。默认把非 Fatal 折成 Ok——想停对话必须显式写出终止意图,沉默等于继续。类型系统在这里承担了合同的角色。
代价也要看清楚:任何一个底层 IO 错误如果一路冒到 turn 循环,整轮就会跟着死。所以正确写法是在 handler 里接住它。
用户按 Esc,要停的不只是当前那次采样:
任务启动时现造一张取消令牌;采样请求用 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 是否已到终态:
AbortedToolOutput,正文 aborted by user after Xs。然后追加一段模型可见标记,包在 <turn_aborted> 里。文案很老实,承认两件事:unified exec 可能还在后台跑,被中止的工具可能已经执行了一部分。
关键在"追加"二字。 标记写进历史之后立刻 flush_rollout()——有的客户端收到 TurnAborted 会同步重读 rollout,标记必须先落盘再发事件。顺序是:先写片段 → 刷盘 → 最后发事件。
于是下一轮模型同时看见三样东西:已完成的输出 + 被中止工具的回执 + 中止标记。三样都在,它自己判断哪些改动已生效。
历史只追加、不改写。这是上下文治理的第一条(AGENTS.md L91–L100)。
同一轮对话,把 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 用模块约定。
失败分诊
取消建模
历史治理
约束一:这些行号是一次快照。 全部对应 openai/codex 仓库 commit 4f39251a01,核对日期 2026-08-22。用于理解调用链形状,不是可引用的长期指标。
约束二:一百毫秒是工程取值,不是物理常数。 它的意义是"短到用户无感、长到足以让正常路径收尾",换场景需要重新标定。
约束三:不可逆只针对任务句柄。 已经落盘的文件、已经发出的外部副作用,硬拆带不回来。取消不等于回滚,这是设计前提,不是缺陷。
约束四:后台 terminal 的生命周期独立于 turn。 协议明确不杀它,要杀走另一条操作(如 /kill)。别把"取消当前轮"实现成"清空所有子进程"。
约束五:三道门的分诊是针对命令类工具的。 纯函数型工具、检索型工具的失败语义不同,套用前先确认它有没有"进程已跑过"这一层。
在动手之前先回答一个问题:工具失败时,系统的默认反应是回灌还是中断?选回灌。把终止做成需要显式声明的例外。这个默认值决定了系统在面对意外时的性格。
不需要抄那一大套错误类型。一个事件对象加一层派生关系就够:父取消带子,子取消不带父。先把方向性做对,比把变体补全重要。
一句话的明文,胜过散落各处的判断。协议层的价值就在于让这种意图可被引用、可被争论。
发信号 → 给宽限 → 硬拆 → 写片段 → 刷盘 → 发事件。六步顺序错了,历史就会自相矛盾。写在代码里,评审时一眼能查。
工具失败回给模型,默认停在最浅一层;想停对话必须显式写出终止意图。用户按 Esc,取消令牌从任务传到采样再传到工具,先发信号、给一百毫秒宽限、最后才硬拆,硬拆不可逆。中止只往历史后面追加片段,已完成的结果一个字都不改——历史是账本,只能往后记,不能往回改。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页文字稿与音频为同源二次演绎,音频由文本合成,措辞以本页为准。