停止生成后为什么还在扣费?API 取消信号、后台任务与账单核对
区分停止显示、取消连接、终止后台任务与账单结算,逐层检查取消信号、重试队列和迟到结果,用本地模拟验证停止按钮的边界。
点击“停止生成”,文字不再出现,但控制台稍后仍有用量或扣费,并不一定说明停止按钮坏了。停止显示、终止当前连接、取消服务器任务和最终结算是四件事,需要分别确认。
本教程处理主动取消的验收问题:用户按下停止后,哪些工作应停止,哪些结果仍可能到达,怎样避免自动重试和旧答案覆盖新答案。它不承诺任何平台取消后免费;示例只在本地模拟,未调用真实付费 API。技术资料核对日期为 2026-10-26。
1. 先确定“停止”到了哪一层
| 层级 | 可以观察的证据 | 仍然不能证明 |
|---|---|---|
| 页面 | 停止追加文字、按钮恢复可操作 | 网络请求已取消 |
| 客户端连接 | 对当前请求发出取消信号,读取操作结束或报取消 | 网关到上游的请求也结束了 |
| 服务端任务 | 自有后端停止调度;上游按文档返回任务取消状态 | 已生成的内容和已执行的动作被撤销 |
| 账单 | 对应请求的最终用量、结算状态和适用规则 | 点击停止的一刻起绝无费用 |
MDN 说明,AbortController.abort() 可以中止 fetch 请求、响应体消费或流;Node.js 文档把它定义为供特定 Promise API 使用的取消通知机制。[1][2] 这描述的是参与取消机制的操作,不是对远端业务和计费系统发出的通用撤销命令。
如果应用只停止逐字渲染,网络仍可能继续接收。相反,应用也可能已收完整答案,只是在慢慢显示;这时停止动画不能撤销此前的生成。先区分“模型正在生成”和“界面正在展示”。
2. 按一次请求追踪取消是否传递
给一项用户任务设置业务任务 ID,给每次实际调用设置尝试编号,并关联平台返回的请求 ID。不要假定前端 ID 就是平台 ID。先使用已有失败记录排查;需要开发验收时,用本地假上游,避免为观察取消而反复发送收费请求。
依次核对:
- 停止按钮。 是否调用了当前请求对应的控制器,而不是只修改 loading 状态?快速开始第二个任务时,按钮不能误取消新任务。
- 响应读取。 取消逻辑是否覆盖收到响应头之后的正文读取?fetch 返回 Response 不表示正文已经读完,取消仍可能使后续读取失败。[3]
- 自己的后端。 浏览器到后端、后端到上游是两段连接。后端是否根据框架的实际生命周期识别取消,并把信号传给支持该选项的出站 SDK 或请求?不同框架的 close、finish 等事件含义不同,不能随便监听一个事件就认定客户端取消。
- 队列和后台任务。 请求若已经变成持久化任务,关闭页面通常不足以取消它。只有服务商或任务系统明确提供取消接口时,才按文档的任务 ID、权限和状态调用;没有接口就记录“无法确认取消”,不要猜测一个 DELETE 路径。
- 重试和后续动作。 主动取消后是否仍有退避计时器、备用模型切换、工具调用或下一批请求待执行?取消标记要在每次重新调度前检查。
取消前已经进入外部系统的写入、付款或发送动作,不会因为本地抛出异常自动回滚。应查动作的执行记录,再按该系统支持的撤销或补偿流程处理。自动回退的边界可参见回退范围与尝试链核对。
3. 在本地演示:信号被触发,任务未必配合
下面不是 HTTP 服务模拟,也不是某平台的取消实现;它只用两个受控任务展示取消契约。保存为 cancel-demo.mjs,用支持 AbortController 的当前 Node.js 运行。没有密钥、网络、定时等待或收费操作。finish() 代表测试者手动让假任务完成。
export function mockTask(signal, cooperative) {
if (!(signal instanceof AbortSignal) || typeof cooperative !== 'boolean') {
throw new TypeError('Expected AbortSignal and boolean');
}
let settled = false;
let resolveResult, rejectResult;
const result = new Promise((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
const settle = (success, value) => {
if (settled) return;
settled = true;
signal.removeEventListener('abort', onAbort);
if (success) resolveResult(value);
else rejectResult(value);
};
const onAbort = () => settle(false, signal.reason);
if (cooperative) {
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
}
return { result, finish: () => settle(true, 'completed') };
}
for (const cooperative of [true, false]) {
const controller = new AbortController();
const task = mockTask(controller.signal, cooperative);
const observed = task.result.then(
value => value,
error => error?.name ?? 'aborted'
);
controller.abort();
task.finish();
console.log(cooperative, await observed);
}
输出为 true AbortError 和 false completed。第一个任务检查了预先取消的状态,并监听后续取消;第二个故意忽略信号,所以仍能完成。重复 finish() 或重复取消不会让同一个 Promise 结算两次。正常完成时也移除监听器,避免只依赖 once 而在“从未取消”的路径保留监听。[2][3]
这个例子不能证明真实服务器会继续生成,更不能计算真实费用。它说明为什么仅创建控制器、调用 abort(),却没有把 signal 接到实际工作上,会得到“按钮已停止,后台仍运行”的结果。示例是单个 JavaScript 事件循环的状态演示,不提供数据库事务、跨进程取消或分布式去重保证。
每个新请求应使用新的控制器。被取消的 AbortSignal 不会恢复;继续把它交给 fetch 会立即拒绝。[3] 不要通过“复用旧信号再重试”判断服务商不可用。
4. 取消后的迟到结果,如何避免覆盖新任务
考虑一个假设场景:任务 A 开始,用户取消 A 并启动 B,A 的旧回调随后才执行。即使网络取消功能正确,也应在展示或保存结果前核对“结果属于哪个任务,以及该任务是否还允许提交”。
前端至少检查结果的任务 ID 是否等于当前任务 ID,以及该任务是否已被用户取消。取消 A 后,不要让 A 的 finally 回调关闭 B 的加载状态,也不要让 A 的错误回调把 B 标成失败。部分文本可保留并标注“已中止、非完整结果”,不能自动当作完整答案写入正式业务数据。
后端还需要原子的状态约束:只有仍处于允许提交状态、且版本匹配的任务才能写入结果。检查状态与写入之间可能发生取消,单独先读一次状态再无条件写入,会留下竞争窗口。具体使用条件更新、事务还是队列机制,要按自己的存储系统设计。
对于已入队但尚未发出的工作,在取出队列及实际发出前核对取消状态;对于正在执行的工作,发出取消请求并记录其确认状态;对于已完成的工作,保留实际完成事实,不把“用户不再需要”改写成“从未执行”。使用停止按钮后,取消状态通常不应进入通用网络错误的自动重试分支。
5. 停止后账单增加,按时间线核对
先区分四个时间:请求发出、用户点击停止、平台任务结束(若可查)、账单入账。入账时间晚于点击停止,不等于全部用量发生在停止之后;平台也可能先预扣、后结算。反过来,暂时没有明细也不能证明上游没有执行。
| 观察到的情况 | 下一步核对 |
|---|---|
| 同一请求 ID 稍后出现最终费用 | 核对完成时间、结算延迟、输入与输出用量及取消计费条款 |
| 停止后出现新的请求 ID | 核对是否自动重试、回退、队列续跑或另一设备调用;编号不同本身不是充分证据 |
| 页面只显示一部分,账单输出用量更多 | 检查渲染缓冲、连接停止位置和上游结束状态;可见字数不是计费 Token |
| 取消操作返回已受理,但任务未到终态 | 按文档查询最终状态;已受理不必然表示已停止 |
| 已无新请求,余额仍变化 | 区分延迟结算、订阅、自动充值与其他任务,不只看总余额 |
如果流在最终用量事件前被取消,本地可能没有拿到完整 usage。将这项记为“未知、待平台核对”,不要填零,也不要以本地收到的字符数精确推算全部账单。流式传输本身的问题可结合流式中断与读取排查定位,但取消是否计费必须以该平台当前规则及这次请求的记录为准。
给支持人员的材料应包含:带时区的四个时间、客户端与版本、请求路径、任务 ID 与尝试编号的关联、是否主动取消、是否还有重试、平台请求 ID、最终账单状态。不要提供完整 Key、Cookie、原始提示词或未经脱敏的网络抓包。可使用最小复现与脱敏工单模板。
6. 上线前验收停止按钮
先在本地假任务上验证“启动前取消、运行中取消、完成后取消、重复取消、忽略信号、取消 A 后开始 B”六种顺序,再核对真实客户端和服务商文档。测试中只要后续排队任务仍被发出、主动取消触发自动重试、旧结果覆盖新任务,或者状态记录把未知上游结果写成成功,就应先修复再开放批量运行。
本地通过后,是否需要真实小额验收由业务负责人决定;本教程没有执行这种测试。若执行,应明确预算、最大输出、一次尝试和无敏感输入,分别核对客户端取消、服务端状态和最终费用。无法观察的上游阶段仍标为未知。
对使用现成客户端的用户,最实用的顺序是:停止后先不要连续点重新生成,保存请求时间与编号,检查是否仍有队列和重试,再向客户端作者或服务商确认停止的边界。撤销 Key 可以限制后续凭据使用,但不能默认终止已经开始的所有任务或退回已发生的费用。
参考资料
- [1] MDN:AbortController.abort():取消请求、正文消费和流的范围;默认取消原因为 AbortError。
- [2] Node.js:AbortController 与 AbortSignal:取消通知、事件只触发一次及监听器清理。
- [3] MDN:AbortSignal:一次性信号、收到响应头后的取消、自定义可取消 API 与正常完成时的监听器清理。