停止生成之后还会改页面吗:处理 Agent 取消与过期响应

导购页正在搜索“通勤包”,用户马上改成“运动包”。第二次查询先返回,页面刚显示运动包,第一次查询又姗姗来迟,把卡片换回了通勤包。每个接口都成功了,页面却给出了错误的当前结果。

Agent 页面还多一个动作:停止生成。用户点击停止后,如果旧工具的回调仍然改动结果、错误提示或加载状态,这个按钮就没有兑现它的交互承诺。

这一篇用 Vue 3 和 TypeScript 做一个导购搜索演示:AbortController 尽量中止旧工作,任务 ID 决定谁还有资格更新当前页面。 两个机制各有职责,要一起使用。

全部商品、模型决策和查询延迟均为本地固定模拟。模型选择工具被简化为固定分支,没有调用真实模型、商城接口或后台任务系统;公开演示不发送查询网络请求,也不执行任何业务写操作。

机器人只允许当前编号02的商品结果进入页面,将晚到的旧编号01送入回收箱;停止按钮旁仍可能有旧消息抵达。

先把“当前搜索”定义清楚

本例的输入框是待提交条件。修改文字不会自动查询;点击“查询”才创建新任务,并把提交时的条件保存为快照。卡片旁展示实际任务条件,避免把输入草稿误认成结果对应的条件。

每次提交都分配新的递增 ID,即使条件相同也不复用。页面只接受最新任务的结果,开始时清空上次卡片和错误。点击停止,立即退出等待状态并撤销当前任务的页面更新资格。

页面状态包括 idle、running、success、canceled 和 error。成功但列表为空表示没有匹配项;停止表示用户终止接收;工具报错才显示查询失败。这些状态对应不同的恢复动作,不能全部塞进一个“网络异常”。

为了观察竞争条件,演示还提供预设按钮:先启动较慢的通勤查询,再启动较快的运动查询。旧查询特意忽略取消信号,让它真的晚到,检验页面是否仍然守住当前结果。

取消信号负责通知,任务编号负责接收

AbortController 向支持取消的异步操作传递信号。与 fetch 配合时,把 controller.signal 放进请求选项;调用 abort() 可以中止请求、响应体消费或流读取。MDN:AbortController.abort

但普通 Promise 没有一个自动停止一切工作的开关。自定义工具如果没有监听信号,计时器仍可能完成,回调仍可能执行。本例故意同时保留“响应取消”和“忽略取消”两类模拟工具。

因此页面还要维护一个当前任务标记。一次结果返回时,先检查它是否属于当前任务,再检查信号是否已中止。只要任务被替换或停止,就放弃它对结果区域的更新。

这个 ID 是当前控制器内的临时序号,不是用户身份、授权令牌或业务幂等键。它可以解决当前页面的响应覆盖问题,不能代替服务端权限检查,也不能证明某次订单只执行了一次。

每个异步出口都要核对任务

核心实现放在 src/core.ts 的 createSearchController。下面摘出决定结果是否可接受的判断,active 保存当前任务及其控制器,generation 保存递增序号:

typescript
function isCurrent(id: number, signal: AbortSignal): boolean {
  return !disposed && active?.id === id
    && generation === id && !signal.aborted;
}

创建新任务时,先增加序号并替换 active,再中止上一个控制器。这样旧任务在收到取消通知时,已经失去了当前身份。随后发布新任务的等待状态,并用本次条件快照调用工具。

工具结果按 unknown 接收,检查当前身份后再校验商品数组。源码限制最多三件商品,核对 ID、名称、整数分价格及重复 ID;缺少价格直接进入错误状态。Vue 使用普通文本插值,商品名称不会变成动态模板。

更容易漏掉的是另外两个出口:错误处理和清理逻辑。旧任务报错不能清空新结果;旧任务结束时,也不能把仍在工作的最新任务改成“不忙了”。源码的结构如下:

typescript
try {
  const raw = await tool(query, controller.signal, scenario);
  if (!isCurrent(id, controller.signal)) { return; }
  const products = parseProducts(raw);
  publish({ taskId: id, query, status: 'success',
    busy: true, products, message: '当前任务结果已展示。' });
} catch (error: unknown) {
  if (!isCurrent(id, controller.signal)) { return; }
  // 发布当前任务的错误;产品列表置空。
} finally {
  if (isCurrent(id, controller.signal)) {
    active = undefined;
    publish({ ...state, busy: false });
  }
}

这段摘录省略了错误文案构造,完整实现以源码包为准。finally 同样受身份约束,不能无条件写 loading = false。如果之后增加分阶段进度、推荐理由或引用,也要在每个异步更新点做同样的核对。

停止时先失效,再发出取消通知

仅调用 abort() 后等待 Promise 抛错,会让停止按钮的反馈依赖工具实现。忽略取消的工具可能继续等待,页面却还在转圈。

本例先把当前任务失效,立即发布取消状态,再通知工具:

typescript
const previous = active;
++generation;
active = undefined;
publish({ ...state, status: 'canceled', busy: false,
  products: [], message: '已停止接收本次结果。' });
previous.controller.abort();

代码位于确认存在活动任务的分支中。先失效也避免取消监听器同步执行时仍把旧任务认作当前任务。停止后的迟到结果、迟到异常和清理回调都会被身份检查挡住。

新搜索必须使用新的 AbortController。已中止的 signal 不能重新用于下一次请求;MDN 对这种一次性使用语义有明确说明,自定义可取消 API 还应处理一开始就已中止的信号,并在完成后清理监听器。MDN:AbortSignal

模拟工具的正常路径会清理计时器或取消监听器;忽略取消路径只用于复现迟到。它的固定延迟不是实际模型耗时,更不能用来推算生产性能。

接到 Vue 时,把清理入口也接好

页面通过回调把控制器的状态写入 Vue ref,渲染逻辑只读取状态。点击查询调用 start,停止调用 stop;组件卸载时调用 dispose,使后续回调不能继续发布状态。

预设演示本身也有一个安排第二次查询的定时器。切换预设、手动查询、停止或卸载时都要清理它,否则用户已经停止,演示脚本却可能又启动一个新任务。这和工具回调过期是两个不同的入口,都需要处理。

若产品改为条件变化就自动搜索,可以使用 watch 并注册清理。Vue 官方示例用清理回调中止旧请求;onWatcherCleanup 需要 Vue 3.5+,且必须在同步执行期间注册,不能放到 await 后面。Vue:监听器的副作用清理

本例使用明确提交按钮,没有实现输入防抖。防抖可以减少请求次数,但已经发出的旧请求仍然可能晚到,所以不能替代任务身份核对。

用可控的完成顺序验证竞争条件

只测试“点击查询后出现卡片”,无法证明取消逻辑正确。核心测试注入可手动完成的 Promise,明确安排谁先成功、谁先失败,避免依赖机器快慢碰巧制造顺序。

场景 应满足的页面约束
正常查询 显示本次条件对应的演示商品
旧慢新快 新结果出现后,旧成功不能覆盖它
旧任务迟到报错 当前成功结果和提示保持不变
旧任务先结束、新任务仍等待 旧 finally 不能关闭新任务的等待状态
点击停止,工具忽略取消 立即取消,旧结果到达后仍无卡片
当前工具返回缺少价格的数据 拒绝结果,显示校验错误
卸载后任务完成 不再向 Vue 发布任何状态

测试还要覆盖停止后重新查询,以及同一条件连续提交。身份不能靠比较搜索文字判断,否则相同条件的两轮执行就无法区分。

这些断言验证的是本地控制器与固定夹具。它们不覆盖真实浏览器到代理、模型服务、业务工具的完整取消链路,也不能证明后端节省了多少资源。

运行演示,检查停止后的页面

打开取消与过期响应演示 · 下载 Vue / TypeScript 完整源码

解压后可直接打开 index.html。Vue 已打包进本地文件,无需模型密钥或 CDN。也可以在解压目录执行 python3 -m http.server 8080,访问 http://127.0.0.1:8080。这是静态文件预览,查询仍使用浏览器内的模拟工具。

修改源码时,使用 README 中锁定的依赖和命令:

Bash
npm ci
npm run typecheck
npm test
npm run build

先运行正常查询,再观察“旧慢新快”和“旧任务迟到报错”。最后启动忽略取消的慢任务并点击停止,等待旧任务足够长时间后,确认页面仍显示取消且没有商品。仅在刚点击停止的一瞬间截图,无法证明晚到响应已经被挡住。

本次类型检查与 14 组核心测试通过,浏览器完成桌面七个场景及手机竞争场景检查,结果随源码附带;电脑与手机视口还分别核对布局、按钮和结果区域。这里的通过指可复现的模拟路径,没有执行真实模型验证。

真正的业务取消还需要服务端协议

把 mockSearchTool 换成真实请求时,应将 signal 传给 fetch,读取响应体后继续核对任务身份,再校验业务数据。若是流式进度,每条更新也必须带着当前执行的上下文经过同一入口。

浏览器停止等待,不会自动回滚服务端已经发生的写入。查询服务若需要终止后台任务,应设计任务 ID、取消接口与可查询终态,并明确取消能在哪个执行阶段生效。付款、退款和消息发送还需要各自的确认、幂等与结果核对流程,不能照搬本例的本地序号。

前端在本例能保证的范围很具体:被替换或停止的任务,即使之后返回,也不能再改当前结果、错误或等待状态。把这条约束固定在统一控制器中,才能让“停止”和“重新查询”成为可信的页面动作。

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2022-2026 alan_mf
  • 全站访客— 累计浏览— 统计加载中…

请我喝杯咖啡吧~

支付宝收款码 支付宝
微信收款码 微信