代理停止按钮如何真正终止后台任务
文章将取消操作定义为跨层状态转换,区分浏览器断流、服务端接收取消、工作器响应和模型供应商取消等不同事实。重点是阻止后续任务启动、记录最终状态,并明确仍在执行的操作。
AI 深度解读
用户点了 Stop,聊天窗口停止出字,后台 Agent 却可能还在生成、点击页面,甚至准备发送邮件。原文讨论的就是这个落差:怎样让一次取消请求穿过浏览器、模型、工具和任务队列,最终得到可信的执行结果。
作者的结论是:取消应该是持久化的状态变化,不能只靠一个布尔值或断开的连接。这篇没有性能对照或实测数据,但给出了一套具体的工程约束。对正在做 AI 对话、工具调用和后台任务的前端、全栈开发者,它最有用的地方是把「停止显示」和「停止执行」之间的账理清了。
Stop 按钮应该承诺到哪一步
一次取消至少涉及几件不同的事:用户提出停止、系统收到请求、后台开始收尾、任务确认终止,以及外部操作是否已经发生。把这些事实塞进一个 cancelled 字段,界面和后台很容易各说各话。
作者建议使用这样的主路径:
running → cancel_requested → cancelling → cancelled
此外还要有 completed、failed 和 needs_reconciliation 等终态。最后一个可以理解为「结果待核实」:外部写入已经发出,却还不知道有没有成功,此时不能直接宣布取消完成。
产品应当先约定:有权限的用户可以取消任务;执行方观察到持久化的取消请求后,不再启动新的模型轮次、工具调用或排队步骤;正在运行的工作接收尽力而为的取消信号,并有确认期限。只有编排器记录了最终决定,API 才能返回 cancelled。
这里有个边界:请求已经落库,与所有执行方都已观察到它,并非同一时刻。传播期间仍可能有工作在途,需要靠后续确认处理。
停止按钮的可信度,取决于后台能否解释最后发生了什么。
取消也不能触发静默重试。用户已经改变意图,重新运行应该创建新的 attempt,或者经过明确的恢复操作。
为什么需要数据库记录,不能只传 AbortSignal
AbortController 适合控制当前进程里的请求,但进程重启、队列转交、多个 worker 并行时,它无法独自承担最终依据。作者建议把权威状态放进数据库或工作流引擎。
下面这段类型定义,把取消原因、时间和版本一起存下来:
type RunStatus =
| "queued" | "running" | "cancel_requested" | "cancelling"
| "cancelled" | "completed" | "failed" | "needs_reconciliation";
type AgentRun = {
id: string;
tenantId: string;
status: RunStatus;
cancelRequestedAt?: string;
cancelReason?: "user" | "deadline" | "policy" | "operator";
activeStepId?: string;
version: number;
terminalAt?: string;
};
要留意 version:作者希望借它实现比较并更新(compare-and-set),防止迟到的 worker 把 cancel_requested 覆盖成 running。不过,字段本身不会提供并发保护,写入时必须真正检查版本。
取消入口是幂等的 POST /runs/:id/cancel:检查权限,记录请求,返回当前状态,再通知 worker。重复点击、重连和网络重试,应当指向同一次取消过程。
作者使用事务性 outbox:在同一个事务里更新任务并写入 run.cancel_requested 事件,之后再投递通知。它解决的是「通知发出但记录没存下」或「记录存下但进程来不及通知就崩了」的问题。原文认为不必所有技术栈都使用 outbox,但必须先保存决定,再通知执行方。
浏览器端则先结束本地流,再请求服务端取消:
const controller = new AbortController();
stopButton.onclick = async () => {
controller.abort("user_pressed_stop"); // 立即结束本地流
await fetch(`/api/runs/${runId}/cancel`, { method: "POST" });
showStatus("Stopping…");
};
关键是最后一行只显示 Stopping…,不能显示 Stopped;终态需要等服务端事件确认。这个简化示例也没有处理取消接口失败,实际接入还需要补上请求失败后的状态查询和提示。
页面断线不能自动等同于取消意图。移动网络切换、标签页休眠,都可能造成连接中断。SSE 重连应该从单调递增的事件序号继续回放,而不是重新启动任务。
模型、并行工具和队列,要分别处理什么
编排器应在每个步骤边界读取持久化状态;长模型流还要传递取消信号,关闭网络请求,或使用供应商提供的取消 API。但关闭 HTTP 连接只能证明客户端停止等待,不能证明上游推理已经停止,也不能证明没有计费。需要核实时,应保存 provider request ID,再确认最终状态。
超时也要沿调用链传递。作者举的例子是:检索已经花了 50 秒,后续模型调用不该因为重新计时,又获得完整的 60 秒。应设置绝对 deadline,每层只拿剩余时间。
并行任务尤其容易漏取消。搜索、内部查询、写入工具同时启动时,每个子任务都要收到信号,但处理策略不同:
- 只读工具通常可以取消或放弃等待。
- 尚未越过提交点的写入,可以尝试取消。
- 已经发出的写入,需要回执或目标系统的权威查询。
- 清理任务可能需要一个短暂的受保护窗口,用来记录最终状态。
Python 的 asyncio 是协作式取消,在 await 位置抛出 CancelledError。作者建议通过 try/finally 清理,或捕获后记录中断,再继续抛出;不能吞掉异常后继续执行 Agent 工作。
队列同样需要检查。worker 应先获取租约,在每次对外可见的动作前重新确认状态,退出时释放租约。消息已经进队列,不代表仍有执行权限。Temporal 可以把取消建模成显式工作流命令;使用 Postgres 和 worker,也需要实现相应语义。
已经发出去的写入,为什么不能直接算取消
假设 Agent 已经发出创建发票的请求,还没保存返回值,用户就点了停止。此时发票可能已经存在,只是本地不知道。
直接标记 cancelled,容易让后续重试再创建一张;直接认定成功,同样没有证据。作者建议进入 needs_reconciliation,通过稳定的 operation key 查询目标系统:存在就展示已创建,需要撤销时再走独立且获得授权的补偿操作;确认不存在,才能把步骤记为取消。
取消负责阻止未来工作,回滚负责补偿已经发生的工作。
因此,写入工具仍需要幂等键和操作账本。网络超时也不能当作写入失败的证据。
验证时,要测的是竞态窗口。作者列出至少八类场景:排队时取消、生成 token 时取消、模型输出后工具派发前取消、两个并行工具运行时取消、写入派发后取消、进程崩溃时取消、重复取消,以及取消后的恢复尝试。
可以在模型调用前、工具派发前、派发后但回执存储前、worker 持有租约时设置确定性的测试钩子,并保存 trace,在运行时变化后回放。监控则关注请求到终态的耗时、取消后仍启动的工作、孤儿工具调用、对账比例,以及部分输出被误当最终答案的比例。
我的判断
我认同作者把取消放进任务生命周期。只要 Agent 能调用外部工具,前端流停止就不足以代表工作结束。相比只使用 AbortController,持久化记录能让重启后的 worker 和重新打开页面的用户看到同一份事实。
不过,完整方案有适用前提。只有单次文本生成、没有后台队列和外部写入的功能,可以从较小的实现开始;引入事务性 outbox、租约和操作账本,会增加实现与运维成本。原文没有提供不同规模团队的成本或可靠性对照,这些选择仍属于工程经验,不能据此认定 Temporal 一定优于自建 worker。
原文的取消接口示例还有一处需要补齐:它只排除了 completed、failed、cancelled,重复请求仍可能更新取消时间、增加版本并再次插入事件,甚至把 cancelling 改回 cancel_requested;needs_reconciliation 也没有被保护。因此,「接口必须幂等」是正确要求,但示例还没有完整满足它。
此外,动作前检查状态与真正派发之间仍存在竞态窗口。原文提出了检查点,却没有给出彻底关闭这个窗口的协议。落地时必须结合目标系统的幂等能力与回执机制,明确哪些结果可以确认,哪些仍要对账。
能拿来做什么
- 做流式聊天时,把「本地停止出字」和「服务端取消任务」拆成两个动作;界面显示停止中,收到终态后再确认,并把截断文本标记为中断内容,避免自动送入下一轮 Agent。
- 给 Node 编排服务增加持久化 run 状态、取消原因和绝对 deadline,在每次模型调用及工具派发前检查状态,并向下传递剩余时间。
- 给发送邮件、创建记录等写入工具配置稳定的幂等键和操作账本;取消撞上在途写入时先核实结果,避免自动重发。
- 为取消流程补上派发前、派发后回执落库前和进程崩溃后的测试,同时检查重复取消是否产生重复清理,以及恢复是否创建有来源记录的新 attempt。