AI Agent 的停止按钮为什么不好做:取消信号、工具进程、会话历史与执行状态
用户点击 AI Agent 的“停止”按钮后,界面不再输出文字,任务就真的停了吗?
如果 Agent 只是生成回答,问题相对简单。可一旦它开始执行命令、修改文件、调用远程工具,停止就涉及另一件事:已经启动的工作由谁负责收尾,下一轮对话又该怎样理解这些工作的结果。
本文介绍取消机制的基本原理,并给出一套工程设计建议。涉及 API 的具体规则会标明来源;状态名称、界面文案和测试清单属于设计示例,不是某个框架的统一标准。
1. 先分清用户究竟想停什么
设计停止按钮之前,建议把下面几种意图区分开:
| 用户操作 | 系统应该做什么 |
|---|---|
| 停止展示输出 | 界面停止更新,但不能据此宣称后台任务已停止 |
| 取消本轮任务 | 停止调度新工作,处理已经启动的工作,并保存执行状态 |
| 拒绝一次操作 | 不执行尚未获批的工具调用 |
| 补充要求 | 在约定的安全边界注入新消息,调整后续方向 |
| 撤销已完成操作 | 执行另外一项回滚或补偿操作,需要单独判断是否可行 |
这些是不同的产品行为,不宜全部用一个“停止”状态表示。特别是取消与撤销:停止接下来的执行,不应该让用户误以为已经写入的文件、已经发送的消息也被恢复了。
可以用一个假设场景理解:Agent 正在修改代码,前两个文件已经保存,第三个文件尚未处理。用户取消任务后,合理的结果是停止后续工作,记录哪些文件已改、哪些未改;如果要恢复前两个文件,还需要有备份、版本记录或明确的撤销步骤。
2. 取消信号是通知,不是强制终止
取消通常采用协作式机制。上层发出信号,下层收到后停止等待或工作,再释放资源。
JavaScript 的 AbortController / AbortSignal、Go 的 context.Context 都是这类机制的例子。Go 官方文档明确说明,CancelFunc 不会等待工作停止。因此,“调用了 cancel”与“所有工作已经退出”是两个不同的时刻。[1][2]
在 Agent 系统里,建议让一轮任务拥有自己的取消作用域,把信号传给模型请求、工具执行和重试等待。不要只在聊天页面保存一个全局布尔值,然后期待所有后台操作自动停下。
用 Go 的思路理解:调用链一路接收 ctx,并不意味着执行中的函数已经支持取消。如果下层从来不检查 ctx,也不把它传给可取消的 I/O,信号就没有真正影响工作。
建议重点检查这些位置:
- 发起下一次模型请求前。
- 接收流式输出的过程中。
- 将工具调用放入执行队列前。
- 工具真正启动前。
- 重试的退避等待过程中。
- 准备执行有副作用的步骤前。
其中“工具启动前再检查一次”很容易漏掉。排队时任务还正常,轮到执行时用户可能已经取消。
但检查也不是万能的。检查通过与外部操作开始之间仍可能发生取消;越接近有副作用的步骤,越要配合执行记录与结果核实,而不是只依赖一个判断。
Claude Code 与 Codex 如何暴露取消能力
Claude Code 的交互文档明确说明:按 Esc 可以中断本轮响应或工具调用,已经完成的工作会保留;如果已有消息排队,后续仍可能发送这些消息。在授权提示里按 Esc 则是拒绝该操作。同一个按键在不同场景中承担不同语义,不能把中断等同于撤销或清空队列。[11]
程序接入时,Claude Agent SDK 提供了明确的取消接口。官方发布的 TypeScript SDK 0.3.185 类型定义包含 Options.abortController,用于取消 query 并清理资源;流式输入/输出模式下的 Query.interrupt() 则是中断当前执行、交还控制权的控制请求。[12] Agent SDK 官方概述说明,它提供了驱动 Claude Code 的工具、Agent Loop 和上下文管理能力,因此这些接口比普通模型客户端的断开连接更贴近 Agent 执行生命周期。[13]
两者不应被当成完全相同的操作:需要结束 query 时使用对应的取消能力,需要在持续会话中中断当前执行时使用受支持的控制接口,再按具体版本处理后续输入。
Codex 的公开 Rust 源码则能看到取消如何进入工具层:tools/router.rs 的分发函数接收 CancellationToken,并把它放入 ToolInvocation 后交给工具注册表执行。[14] 这直接支持了“取消信号应沿调用链向下传递”的实现思路,但不代表每个外部工具都一定响应取消。
本文 Codex 源码引用固定到 2026 年 10 月 7 日的提交 ed59a6c1cdf5e6fc96351fd46dfc1ef8a16db385,便于对照具体代码,不把变化中的 main 分支当成永久不变的行为规范。
3. 停止调度和清理已有工作,要分开做
推荐把取消处理分成两个阶段。
第一阶段关上入口:本轮任务不再派发新的模型请求和工具调用,不再继续普通业务重试。排队但没开始的工作标记为未执行。
第二阶段收尾:通知正在运行的工具退出,等待有界的清理时间,保存已知输出,核实执行状态。
下面是建议的流程,不是某个 SDK 的固定实现:
flowchart TD
A[收到取消请求] --> B[禁止本轮新增工作]
B --> C[向运行中的工作传递取消]
C --> D[限时等待与必要的强制终止]
D --> E[记录结果和未确认事项]
E --> F[补齐会话并结束本轮]
清理本身也需要时间预算。否则用户取消任务后,系统可能无限等待一个永远不退出的工具,停止按钮依然不好用。
建议为清理使用独立、短时限的执行作用域,而不是拿已经取消的业务作用域完成所有落盘和核实。它只允许保存结果、释放资源和核实状态,不能趁机继续原来的业务任务。这是基于取消传播特性的工程安排。[1]
Codex 把“请求中断”与“回合结束”分开表示
Codex App Server 的官方文档提供了 turn/interrupt:客户端用 threadId 和 turnId 指定要中断的回合,成功应答为 {},回合最终以 interrupted 状态结束;回合生命周期通过 turn/completed 通知呈现。[15]
从这个接口设计可以得出一个实用的接入建议:收到中断请求的成功应答后,继续处理结束通知和已有工具记录,不要马上销毁所有会话状态。中断针对某个回合,也不必通过杀掉整个 App Server 来完成。
需要注意,回合的 interrupted 是执行生命周期状态,不是“所有业务修改均已撤销”的声明。远端副作用仍应按工具的实际结果核实。
Claude Agent SDK 的更新记录也体现了队列清理需要单独处理:0.3.219 为 interrupt 控制请求增加了可选的 cancel_queued,需要相应能力支持,用于同时取消排队及待派发消息。[16] 因而实现停止按钮时,必须明确是只停当前回合,还是连尚未应用的新要求一起取消,不能默认两者总是同时发生。
4. 杀掉 shell,不代表命令的所有工作都结束了
命令工具经常经过 shell 启动其他程序。例如执行一个构建命令,实际运行的可能是 shell、包管理器、Node 进程以及多个工作进程。
Node.js 官方文档明确提醒:终止父进程不一定会终止子进程。成功发送 kill 信号,也不能直接当作进程已经退出。[3]
Linux/macOS 上,一个常见做法是为工具建立独立进程组,再对这个组执行终止。通常先请求优雅退出,等待一段时间;仍未结束时,再强制终止。Agent 自己不能混在准备终止的进程组里。
进程组也有边界。Node 的 detached 选项在非 Windows 平台上可以让子进程成为新会话和新进程组的首进程。也就是说,进程树的后代与当前进程组的成员并不是同一个集合,不能把“杀进程组”写成“保证杀掉所有后代”。[3]
Windows 上应采用适合该平台的管理机制,例如 Job Object。它可以把进程作为一个整体管理和终止,但仍要考虑进程加入方式、breakaway 配置和嵌套作业等边界。[4]
工程上建议抽象出“工具运行单元”:统一管理启动、取消、等待、输出回收与残留核查,内部再按操作系统实现。不要让每个工具各写一段随意的 kill 逻辑。
Codex 源码中的进程组清理
Codex 的 utils/pty/src/process_group.rs 将这部分做成了公共辅助模块。Unix 路径中,set_process_group 用于建立独立进程组;terminate_process_group 向指定组发送 SIGTERM,kill_process_group 使用 SIGKILL;kill_process_group_by_pid 则先查询 PGID,再面向整个组发信号。[17]
模块还包含平台差异处理,例如 macOS 在组信号被拒绝时尝试对组内成员发送信号。这些实现说明,“按运行单元管理相关进程”确实是实际 Agent 工程中的工作,而不只是抽象建议。
不过,这些函数采用 best-effort 语义,该文件中的非 Unix 实现有空操作分支。不能仅凭这份源码宣称 Codex 在所有平台、所有执行路径上都必定采用同一套退出顺序,更不能把发送信号当成所有后代已经退出。本文建议的“限时等待、必要时升级终止、确认结果”仍需由执行器结合实际路径完成。
5. 进程退出了,输出管道也可能还没结束
进程管理还有一个不太显眼的坑:某个后代继承了 stdout / stderr 的管道。即使直接启动的进程已经退出,管道读取仍可能等不到 EOF。
Go 的 os/exec 文档就说明了这类等待问题。WaitDelay 可以限制取消后的退出等待,以及进程退出后 I/O 管道仍未关闭的等待;默认值为零时,不会施加这个限制。[5]
所以工具执行器应分别管理:进程是否退出、输出是否读完、文件描述符是否释放。不要把“stdout 还没读完”直接当成“程序还在执行”,也不要把“主进程已经退出”当成“一切已经清理完”。
这里还要区分进程退出码与业务结果。命令被终止只能说明执行结束了,不能说明它没有修改任何文件。
6. 远程工具的取消,是另一层问题
本地 Agent 停止等待远程响应,不能单凭这一点断言远端工作已经停止。远端需要接收取消,并将它继续传到自己的请求、进程或后台任务。
以 MCP 为例,2025-06-18 版规范定义了 notifications/cancelled:用请求 ID 指明希望取消哪次调用。规范也允许接收方在请求已经完成、无法取消等情况下忽略通知,并要求双方处理取消与完成之间的竞态。[6]
MCP Go SDK 文档同样区分了“通知已发送”和“服务器已经观察到通知”,前者不保证后者。[7]
这意味着,远程工具执行器最好能提供任务 ID、状态查询和取消能力。如果接口只会返回“连接断开”,Agent 就应保留结果未知的可能,而不是自动补成“未执行”。
MCP 的取消规则与传输行为存在版本差异,接入时应核对协商使用的协议版本。不要把某一版规范的细节当成所有 MCP 服务的共同实现。
7. 会话历史要补齐,但不能编造结果
工具调用已经进入历史后,突然中止执行可能留下一个没有结果的调用。后续模型请求是否接受这种历史,取决于具体 API。
以 Claude 的客户端工具为例,tool_use 与 tool_result 通过 ID 对应;官方要求工具结果紧接对应的工具调用消息,不能在中间插入其他消息。并行调用多个工具时,也要分别匹配结果。[8]
但补齐历史不等于统一写一句“用户拒绝了操作”。建议按真实状态描述:
| 已知情况 | 建议记录 |
|---|---|
| 用户拒绝授权 | 未获批准,工具没有启动 |
| 排队期间取消 | 未执行,不应声称运行失败 |
| 启动后被终止 | 运行中取消,记录已知输出与终止情况 |
| 工具已经完成 | 保存真实完成结果,即使本轮随后取消 |
| 无法确认远端结果 | 结果未知,继续前先核实 |
这些文案应映射成目标 API 接受的工具结果格式。对于平台托管、由服务端执行的工具,也不能擅自伪造客户端结果;Claude 文档明确区分了客户端与服务端工具的处理方式。[8]
还有一种情况:流式响应只到了一半,工具参数尚未形成有效调用。建议将它保留在调试记录中,而不是为了“补齐历史”强行解析并执行。一个完整的执行记录和一份可继续发送给模型的历史,可以是两个不同的数据视图。
Codex 会修补缺失的工具输出,但这不是业务结果核实
Codex 的 context_manager/normalize.rs 有一个明确的修补函数:ensure_call_outputs_present。在本文引用的提交中,它检查调用与输出的对应关系,为缺少结果的 FunctionCall、CustomToolCall 等类型构造内容为 aborted 的输出,并插入到对应调用之后。[18]
这是“工具结果不能无故缺失”的直接源码例子。不过,这属于准备模型上下文时的兜底处理;该文件的注释也说明,合成输出可能只用于提示词归一化而不被持久化。不能把它描述成“每次取消都已完整保存真实执行结果”。
aborted 能补上协议结构,却不能说明文件到底改了几行、邮件是否已经提交。实际工具记录仍应保存已知输出、结果未知的原因和恢复步骤。
Claude 这边,Messages API 的调用与结果规则见本节前面的官方说明;Agent SDK 0.3.216 的更新记录还增加了 tool_result_meta,让接入方能够区分拒绝、中断、取消等情况,而不必只匹配结果文本。[8][16] 这支持了“不要把所有非成功结果统一写成用户拒绝”的状态设计,但不是对 Claude Code 全部内部补齐路径的源码证明。
8. 任务取消了,某个工具仍然可以是成功的
建议把任务状态与工具状态分开保存。
假设本轮依次执行三个步骤:读取项目、写入配置、部署服务。用户在部署之前取消,整轮任务可以记为取消,但读取和写入已经完成,不应被一起改成取消。
设计上可以让任务拥有 running、cancelling、cancelled 等生命周期;工具则单独记录未启动、执行中、成功、失败、运行中取消或结果未知。名称可以调整,信息不能丢。
对于有副作用的工具,还建议另外保存“副作用是否已核实”。例如,进程已终止,但配置文件是否写完仍待确认。单一 status 字段往往不足以表达这种状态。
取消与成功同时到达时,应按可核实的执行事实保存结果。即使某个传输协议要求客户端忽略晚到的响应,也不应因此推断业务副作用没有发生。[6]
9. 取消不是回滚,重试也不是天然安全
考虑一个假设的发送邮件工具:请求到达服务器,邮件已经提交,但客户端没收到响应就取消了。此时直接重试,存在重复发送的风险。
建议恢复顺序是:先用操作 ID 或业务记录查询状态,再决定是否重试。支持幂等的接口可以降低重复执行风险。
Stripe 的官方文档提供了一个具体例子:请求携带幂等键,同一个键的后续请求可以返回此前保存的结果,避免重复创建或更新。但键有保存期限,参数也需要一致,不能把它理解成永久有效的通用去重保证。[9]
对 Agent 工具而言,建议把“同一次业务操作”对应的幂等键保存下来。恢复任务时如果每次重新生成一个键,服务端就可能将它们当成新的操作。
文件修改则需要自己的保护措施。可选做法包括执行前记录版本、保留差异、在隔离工作区修改、执行后核查。回滚前还要确认文件没有被其他人继续改动,避免恢复旧内容时覆盖新的工作。
这是恢复与补偿的设计,不是取消机制自动提供的能力。
Claude Code 的 rewind 也有明确边界
Claude Code 将中断与回退区分为不同操作。交互文档说明,输入框为空时双按 Esc 可以打开 rewind 菜单,恢复或整理先前的代码与会话状态;这与单次 Esc 的中断行为不同。[11]
官方 checkpointing 文档同时写明:通过 Bash 命令修改的文件不在这套检查点追踪范围内,不能靠 rewind 恢复;检查点追踪的是 Claude 文件编辑工具直接做出的修改。[19]
这给“取消不等于回滚”提供了具体产品例子:连专门的回退功能都有范围限制,普通停止按钮更不应该承诺任意操作都能恢复。调用外部服务产生的影响,则需要服务自身提供撤销、查询或补偿能力。
10. Steering 用来改方向,不用来替代取消
用户说“后面再加一节说明”,通常是在补充要求;用户说“不要发送了”,则需要停止相关操作。产品上应明确区分。
一种应用侧实现是把新消息放入队列,在工具结果归集后、下一次调用模型前注入。这种边界容易管理,但不是所有系统都必须等整批工具完成。
OpenAI 的 Mid-turn steering 文档区分了消息被接受、排队与实际应用;同时说明 Steering 不会改写已经输出的内容、撤销早先动作,或取消已经启动的工具。[10]
因此建议界面展示“补充要求已排队”和“已用于后续执行”两个状态,不要收到消息就立刻显示“要求已生效”。如果用户要求影响尚未执行的删除、发送、部署,应先阻止这些操作继续启动,再决定如何调整计划。
Codex 明确区分 Queue、Steer 与 Interrupt
OpenAI 的 Codex 官方使用文章区分了 Queue 与 Steer:Queue 等当前响应完成后,把新输入作为下一轮发送;Steer 向进行中的工作注入指导。[20] 它们也不应与 Interrupt 混用。
Codex App Server 则把这种区分做成了接口:turn/steer 向活跃回合追加用户输入,不启动新的回合;expectedTurnId 必须匹配当前回合,没有活跃回合时请求会失败。要请求取消,则使用前面介绍的 turn/interrupt。[15]
这说明 Steering 不只是聊天输入框里“再发一条消息”。系统要知道消息属于哪个回合、是否被接受,以及应该作为新任务排队还是影响当前工作。
Claude Agent SDK 的 Streaming Input 文档也描述了长生命周期会话、排队消息、中断与跨轮上下文保留等能力。[21] 但两家的具体处理时机并不因此完全一致,尤其不能将 Codex 的 turn/steer 接口、OpenAI Responses API 的 Mid-turn steering,以及 Claude 的输入队列写成同一套协议。
11. 一套可落地的取消设计
综合以上边界,可以把实现要求整理成以下清单。这部分是工程建议:
- 每轮任务有独立 ID 与取消作用域,避免一个会话的停止影响另一个会话。
- 发出取消后,先禁止新增工作,再收尾正在运行的工作。
- 队列、模型请求、重试等待和工具执行都支持取消。
- 取消可以重复调用,清理与结果写入需要避免重复执行。
- 本地命令由统一运行单元管理,按平台处理进程组或 Job Object。
- 清理有时限,并记录哪些资源未能确认释放。
- 工具启动前留下执行记录,结束后保存实际结果;记录写入失败不能继续对外宣称状态已保存。
- 会话适配层按 API 规则补齐结果,不把取消、拒绝和失败混成一个状态。
- 结果未知的写操作先核实,再恢复;支持幂等的工具复用原操作键。
- Steering 使用单独的消息队列和生效状态,不复用取消按钮的语义。
界面也应反映真实进度。比如“正在停止”“本地进程已退出”“远端执行状态待确认”,比统一弹出“已取消”更准确。尚未核实的工作可以继续保存在恢复记录中,不必为了结束本轮等待而伪造一个确定结果。
一个停止按钮是否可靠,最终要看取消之后还剩下什么:有没有残留工作,有没有未确认的修改,会话能否继续,以及下一次恢复会不会重复执行。
参考资料
[1] Go:context 包
[2] AbortController 与 AbortSignal
[5] Go:os/exec 包
[6] MCP 2025-06-18:Cancellation
[9] Stripe:Idempotent requests
Claude Code、Claude Agent SDK 与 Codex 的直接材料
以下材料分别对应正文中的产品行为、SDK 接口和具体源码。文档与源码用于说明已有实现,不代表所有版本与第三方工具都具备同样保证。
[11] Claude Code:Interactive mode,Esc 中断、授权拒绝与 rewind
[12] Anthropic 官方发布包:Claude Agent SDK 0.3.185 类型定义,Options.abortController 与 Query.interrupt
[13] Claude Agent SDK:Overview,与 Claude Code 的运行能力关系
[14] OpenAI Codex 源码:tools/router.rs,CancellationToken 传递
[15] OpenAI:Codex App Server,turn/interrupt、turn/steer 与回合生命周期
[16] Anthropic:Claude Agent SDK TypeScript 更新记录,0.3.219 的 cancel_queued 与 0.3.216 的 tool_result_meta
[17] OpenAI Codex 源码:process_group.rs,进程组信号与平台差异
[18] OpenAI Codex 源码:context_manager/normalize.rs,缺失工具结果修补
[19] Claude Code:Checkpointing,回退能力与 Bash 修改限制
[20] OpenAI:Mastering remote engineering work from your phone,Queue 与 Steer 的区别
[21] Claude Agent SDK:Streaming Input,持续会话、消息队列和中断
原文发布于 SunAI 论坛。
术语与实现细节补充见 原帖评论。
最后更新于 2026-10-09
评论 0