大家好,我是吴师兄。
来个经典问题:给 Agent 的取消按钮配一句提示,你会写“已取消”,还是“正在停止”?
先别急着选!!!
你在客服后台点错了工单,Agent 已经开始查物流,这时按下取消,接口很快返回成功,页面也把进度条收了起来,可那次物流查询还在外面等结果。
最别扭的是后半段:查询回来,Agent 接着生成回复,又把一份答案写进数据库。刚才明明显示“已取消”,一刷新却成了“处理完成”。这个按钮到底取消了什么?
我用一个固定返回值的物流函数跑了这条路径。让工具先停在等待点,再从另一个连接修改任务状态,随后放开工具。只改状态的版本最后留下了成功结果;加上取消标记和执行端检查后,物流查询仍返回了一次,后面的回复生成没有再发生。
这次要停的是后面的工作,已经发出去的调用得单独交代。
面试里那句“支持取消”,追到这里才有具体内容。接口收到请求以后,谁来停,停在哪儿,什么时候能向页面确认,这几个动作得在代码里接起来。
| AGENT FIELD NOTE 01 | 01 / 05 |
状态改完了,执行到哪儿了?
用一个简化的客服任务说明。输入是工单里的订单号,目标是查到物流后生成回复建议。后台用一条 Run 记录这次处理,编号叫 run_id;接口负责接收操作,Worker 负责执行。两边通过数据库交换任务状态。
这次把任务编号固定为 run_1,开始时处于 running。Worker 先调用物流工具,工具返回后再调用生成回复的函数,最后保存答案。为了只看执行控制,物流和回复都返回固定内容,不接模型服务。
我在物流函数里放了一个等待点。Worker 确实进入查询以后,测试才发起取消。这样按下按钮时,任务稳稳停在执行中间,不会因为机器快一点,就把一次“完成后再取消”当成了中途停止。
先试最直接的写法。接口把 Run 改成 cancelled,提交事务,再返回成功。此刻另开连接查库,状态确实已经变了;但执行物流函数的线程还没结束,它连这个字段被改过都不知道。
等我放开等待点,线程按原来的顺序往下走,拿到物流结果,生成回复,执行最后那条无条件更新。刚写进去的 cancelled 又被覆盖成 succeeded,答案也一起存进去了。两次更新各自执行成功,拼在一起却给页面讲了两个相反的结果。
线程还在往下跑。
▲ 接口提前标成 cancelled,工具返回后继续生成回复,又把任务写成 succeeded。
给最后的更新加上状态条件,能挡住这次覆盖,可回复函数依然已经跑过。接上真实模型,这一轮调用仍会消耗时间和额度;换成发消息的工具,消息甚至已经离开自己的系统。只盯住最终那一行状态,前面的动作就漏掉了。
于是需要两处控制。执行过程得有机会读到取消请求,决定后面的步骤是否继续;最终提交结果时,也得重新核对当前状态,防止旧结果把已经结束的任务改回来。
这两处分别管过程和收尾。只做收尾,可能白跑后续工具;只在开头查一次,运行途中收到的取消又赶不上。问题就落到一个很具体的地方:取消这件事,先记在哪里,Worker 又在哪个位置读取?
| AGENT FIELD NOTE 02 | 02 / 05 |
先记下“有人要求停”,再等 Worker 确认
我给 Run 留一个 cancel_requested_at 字段,空值表示尚未收到取消请求,有值时记录第一次请求的时间。对于正在运行的任务,接口只写这个字段,同时追加一条 run.cancel_requested 事件,任务状态暂时保留 running。
页面拿到这样的组合,可以显示“正在停止”。进度条可以换成等待收尾的样式,重复取消按钮也可以收起来,但还不能把任务归进“已取消”的列表。等 Worker 真正提交 cancelled,页面再给出结束提示。
这样刷新页面也能接上。新页面凭原来的任务编号查库,仍能读到取消标记,不用依赖上一个页面里的临时变量。用户关掉网页,已经提交的取消请求也留在记录中,Worker 到检查点时照样能看到。
取消请求的响应也可能丢。按钮转了一会儿,最后报网络错误,客服无法从这个报错判断后台收没收到。所以重试仍操作同一个 run_id,也可以先查询它:标记已存在就继续等,状态已取消就展示结束。为了重试取消再创建一个任务,会把原来那次执行留在原地。
为什么不新增一个 cancelling 状态?这一版用标记表达用户意图,用原来的状态表达执行进度。任务仍在运行,同时有人请求停止,这两个事实可以一起保存。沿用现有状态,领取和完成逻辑也不必为了按钮提示再增加一套分支。
反过来,任务还在排队,尚未被 Worker 领取,接口就能直接取消。测试里另有一种等待人工确认的状态,执行端已经交还控制权,也走直接结束的路径。这个前提很重要:进入这两个状态时,确实没有仍在运行的工具。
一旦领取和取消同时发生,就得重新看谁先修改了记录。取消先提交,领取时读到终态,任务不再开跑;领取先提交,接口读到 running,改走记录请求的路径。判断和写入放在同一个受保护的事务里,不能拿几秒前查到的状态直接决定现在怎么取消。
对于已经成功或失败的任务,我保留原结果,取消接口返回冲突。客服在结果生成后才点到按钮,页面就提示任务已结束,并重新展示当前状态。把成功结果改成取消,会把已经发生的处理藏起来,之后回查更难解释。
重复点击也有一个小细节。任务还在等待停止时,第二次请求保留第一次的时间,不反复追加同一条取消事件。否则用户连点几次,记录里全是“刚刚申请”,原本等了多久反倒看不出来。
已经取消后再点一次,这里的接口也按终态返回冲突,附带当前状态。前端读到 cancelled,照样可以显示“已取消”。冲突说的是这次操作没有再次执行,不能把它统一翻译成“取消失败”,让已经结束的任务重新转起圈。
请求时间和事件一起提交。我还让事件写入故意失败了一次,事务回滚后,取消字段仍为空,没有出现字段说“收到了”、事件里却找不到这次操作的半份记录。
| AGENT FIELD NOTE 03 | 03 / 05 |
物流还没回来,安全点放在哪里?
取消请求已经记住,接下来才轮到 Worker。它在调用工具前检查一次,工具返回后再检查一次,决定是否进入后续步骤。检查到取消标记,就在数据库里结束这次执行,然后退出自己的处理函数。
这里的“安全点”要落在程序位置上。物流调用前,还没有发出这次查询,可以直接停;物流调用返回后,执行端拿回了控制权,也能阻止接下来的回复生成。等待远端返回的那段时间里,普通的数据库字段没有办法替它跳出函数。
可以先用下面这段检查一个更小的问题。它只运行一个等待中的工具,不需要数据库。把代码存成 Python 文件直接执行,两个输出分别是取消是否成功、工具最终返回了什么。
# 吴师兄学大模型:wushixiongai.comfrom concurrent.futures import( ThreadPoolExecutor as Pool,)from threading import Event entered =Event() release =Event()defquery_logistics(): entered.set()ifnot release.wait(5):raiseTimeoutError("工具等待超时")return"包裹正在转运"withPool(max_workers=1)as pool: task = pool.submit(query_logistics)try:assert entered.wait(5)print(task.cancel())finally: release.set()print(task.result())
输出是 False 和“包裹正在转运”。这里用的是线程池的 Future,工具已经开始执行,调用它的 cancel() 没有把线程停掉。把数据库字段改成取消,同样不会向这个线程凭空发送一条能中断函数的指令。
回到带数据库的验证,我让物流工具返回后先保存 tool.returned 事件,再检查取消标记。工具确实查过,就留下这次返回;但这份过程记录不会自动变成给客服的最终回复。
检查点读到了取消,Worker 随即写入 run.cancelled 并退出。生成回复的函数没有被调用,Run 的 output 仍为空。与最初的失败版本放在一起,区别就清楚了:两边都查了一次物流,后续回复从一次变成了零次。
少跑的就是这一步。
▲ 取消请求到达时工具已经在运行;工具返回后,Worker 在检查点停止后续步骤并确认取消。
检查点还有一个间隙。Worker 刚查完“没有取消”,正准备调用下一步,这时取消请求提交了,那一步仍可能发出去。把检查点加密,能缩短等待下一次检查的距离,却给不出“按钮一按,外部绝不再收到请求”的保证。
所以这个按钮的承诺是尽快停止后续处理,执行端在检查点确认。已经进入调用的工具,按工具自己的超时或取消能力收尾。对一个没有取消入口的远端请求,客户端停止等待以后,远端也可能还在处理。
这也决定了等待体验。一个工具卡很久,页面就得保留“正在停止”,同时显示当前卡在哪一步。工具调用配置超时,执行端拿回控制权后再处理取消,才有后续动作;多查几次数据库,对一个始终不返回的函数没有帮助。
执行端本身已经退出,情况又不同。取消标记虽然保住了,却没有活着的 Worker 来确认停止,单靠这个字段,任务会一直挂在等待收尾的位置。页面不能等几秒就自行宣布取消完成;后端得先查明执行者是否还在,再由接管流程处理,这个等待也值得单独监控。
至于已经发出的业务动作,要单独保存它的结果。取消以后停止再做新的步骤,不能把已经发送的消息或已经提交的变更抹掉。这个例子只查物流,保留查询记录就够;业务要撤销已经完成的动作,就得另有撤销流程。
| AGENT FIELD NOTE 04 | 04 / 05 |
取消和成功挤在一起,听谁的?
工具停下来的位置讲清楚了,还有最后一个窗口。回复已经生成,Worker 正准备保存结果,用户这时点击取消。一边想写成功,一边想写取消请求,靠谁先打开页面、谁先打日志,都说不清最后该留哪个结果。
我把决定放在数据库事务里。测试用 SQLite,每次先执行 BEGIN IMMEDIATE 取得写事务,再重新读取 Run。取消请求和结果提交走相同的顺序,取得写权限以后才判断,前面读取过的对象不拿来作最后决定。
负责保存结果的函数里,主体是这几行。transaction 管理开始、提交和异常回滚;event 在同一个连接里插入事件,worker_id 标明这次执行由谁负责。
# 吴师兄学大模型:wushixiongai.comdefcomplete_run(path, output, worker_id):withtransaction(path)as db: run = db.execute("SELECT * FROM runs ""WHERE id='run_1'").fetchone()if run["status"]!="running":returnFalseif run["worker_id"]!= worker_id:returnFalseif( run["cancel_requested_at"]isnotNone): db.execute("UPDATE runs ""SET status='cancelled' ""WHERE id='run_1'")event(db,"run.cancelled")returnFalse db.execute("UPDATE runs ""SET status='succeeded', ""output=? WHERE id='run_1'",(output,),)event(db,"run.succeeded")returnTrue
先让取消请求提交。Worker 随后取得写事务,读到标记,就把任务结束为 cancelled,手里已经算好的回复不再写入 output。这一步可能浪费一份已生成的答案,但能兑现已经受理的停止请求。
再把顺序倒过来。Worker 先提交成功,取消接口后取得写事务,重新查到的已经是终态,返回冲突并保留原答案。这时页面显示“已经完成”,比强行变成取消更准确。
我把这两个先后顺序都安排了一遍。结果取决于哪一个写事务先完成,判断依据在同一份数据库记录里。请求在网络上晚到了、页面消息乱序了,都不会让后来的操作随意覆盖终态。
取消时间在这里负责留记录,不参与抢先后。拿接口机器的时间跟 Worker 的时间比较,会把时钟偏差也带进来;测试直接让写事务排出顺序,第二个操作取得写权限后,读到的就是前一个已经提交的决定。
▲ 取消请求先提交,Worker 确认 cancelled;成功先提交,后到的取消请求保留成功结果。
成功状态和答案也一起提交。让成功事件写入失败后,整个事务回滚,库里仍是 running,output 仍为空。这样就不会向客服显示“成功”,点进去却没有可用回复。
不过事务只包住这段短促的读写,物流和生成回复都在外面。把慢工具放进写事务,其他取消请求也会被挡在锁外,直到工具结束才轮到它登记。取消本来是为了早点停止,结果被自己的锁排到了最后,这就很尴尬。
写锁等待失败,也得把错误原样交给上层,不能顺手返回“请求已收到”。这份代码在取得写事务后才作决定,事务提交完成才把响应交出去。连接设置了五秒等待,超过等待或写入出错就报错,由查询结果或下一次重试确认实际状态。
这份验证只有一个负责执行的 Worker,检查归属字段是为了挡住无关执行者提交。更换 Worker 后如何回收旧执行权,需要另一套领取与接管设计;在这里,所有合法收尾都通过这一个入口,终态一旦提交,迟到结果就被拒绝。
| AGENT FIELD NOTE 05 | 05 / 05 |
取消后还做了什么?
把前面的修改放回完整路径,我再次让 Worker 进入物流函数,确认进入记录已经保存,才提交取消请求。此时另开连接查询,状态仍是 running,取消时间已经有值,工具线程也确实还在等待。这个中间状态正好对应页面上的“正在停止”。
随后放开工具,等 Worker 退出,再读取事件。顺序是工具开始、收到取消请求、工具返回、确认取消。最终结果为空,物流调用一次,回复调用零次。我按这个固定顺序重复跑了二十次,后续回复都被拦住了。
把取消再往前挪,安排在第一次工具检查之前,两个调用计数都变成零。完全不取消的对照组则是物流一次、回复一次,最终有成功答案。两个对照一起看,既能确认取消生效,也能确认正常路径没有被顺手拦掉。
这些计数比只看 cancelled 有用得多。状态可以被一条更新语句写出来,后续工具有没有启动,得去看执行记录。最初那个错误版本就能短暂查到取消,最后却继续生成了回复。
你接到自己的项目里,可以保留同样的等待点,先把一次正在调用工具的任务稳稳停住。点取消后先查中间记录,再放行工具,看后续步骤有没有继续。不用一开始就并发几百个请求,这一个窗口跑清楚,按钮的基本承诺才有依据。
放行以后还停不下来,就继续找调用链里最近的检查点;终态又被改回成功,就查最终提交入口。两个方向对应不同的代码位置,别只在页面上反复点按钮,看它偶尔有没有变灰。
回到开头那句提示,我会让取消接口受理后显示“正在停止”,等执行端确认后再显示“已取消”。已经完成的任务保留结果,正在收尾的任务保留进度,客服不用靠猜来决定要不要再开一次处理。
“已取消”这三个字,我留给 Worker 确认以后再说。
这篇把取消请求和确认停止接了起来。我在训练营 S5 的客服 Agent 后端里,还安排了任务领取、失败重投,每节带代码和验收点。有编程基础、想继续做 Agent 后端的朋友,可以了解2026吴师兄大模型/Agent 1v1求职陪跑营。
▲ 课程安排:模块4
我是吴师兄,我们下篇文章见。