做 Agent 会用到的 Node API(4):取消与 AbortController

本系列 讲实现 Agent harness 时会反复碰到的 Node / JS 运行时能力。默认读者:会一点 JS,但还没碰过「取消进行中的异步任务」

上一篇:(3)异步与流

示例仓库:react-agent-mini

相关前作:150 行搞懂 Agent 主循环 · 权限 + Write


场景:进行到一半,怎么停?

上一篇把主循环写成了「异步生成器管道」:模型边吐字,工具边跑。现实里用户经常要半路叫停

情况 期望
终端里按 Ctrl+C 正在跑的这一轮停掉;空闲时再按才退出程序
写文件弹确认,用户选 N 本轮后续工具别再跑,也不要继续调模型硬干
父会话取消 正在跑的子代理也要跟着停

如果没有统一的「取消开关」,你会落到:

  • 到处设 let cancelled = false,漏改一处就停不干净
  • HTTP 请求还在飞,浪费配额
  • 工具队列继续改磁盘

现代 JS(浏览器和 Node 都有)提供了标准答案:AbortController + AbortSignal


1. 两个角色:控制器 vs 信号

可以想成对讲机:

对象 谁拿着 干什么
AbortController 「想取消的人」(REPL、权限逻辑、宿主) 调用 abort() 发出取消
AbortSignalcontroller.signal 「干活的人」(fetch / callModel / 自定义循环) 听信号:是否已取消、取消时做什么

最小骨架:

ts 复制代码
const ac = new AbortController();

// 把 signal 交给异步工作
doWork({ signal: ac.signal });

// 别处决定取消
ac.abort("interrupt"); // 可选:附带原因

signal 上常用三件事:

ts 复制代码
signal.aborted; // boolean:是不是已经取消了
signal.reason; // abort() 时传入的原因(若有)

signal.addEventListener("abort", () => {
  // 取消瞬间触发一次
});

约定: 创建控制器的一方负责 abort();真正干活的 API 只接收 signal,一般不直接拿着整个 AbortController(除非像 Agent 上下文那样要在工具层也能触发取消)。


2. 和 fetch 的关系(建立直觉)

很多文档用 fetch 举例,因为浏览器/Node 的 fetch 原生支持 signal

ts 复制代码
const ac = new AbortController();

const p = fetch("https://example.com", { signal: ac.signal });

// 用户取消:
ac.abort();

// p 通常会以 AbortError 一类错误失败

要点:

  1. 取消不会自动发生 ------必须有人调用 ac.abort(),或把会自动 abort 的 signal(如超时信号)传进去。
  2. abort() 本身不保证远端服务器立刻停算------主要是本地停止等待、关掉连接、让 Promise 失败。
  3. 业务代码要把 「用户取消」「真正出错」 分开:常看 err.name === 'AbortError'(或 SDK 自定义的 abort 错误名)。

Agent 调模型时,OpenAI 兼容 SDK 同样接收 signal;示例仓库的 callModel 会把它传下去:

ts 复制代码
const stream = await client.chat.completions.create(
  { model, messages, tools, stream: true },
  { signal: params.signal },
);

上一篇的流式管道,加上本篇的 signal,就变成:边收 chunk,边能被掐断


3. Agent 里怎么挂:一轮一个控制器

取消的粒度很重要。示例仓库选择:

每一次用户回合(runTurn)新建一个 AbortController;取消只影响当前轮,不拆掉整个 REPL 进程。

167:173:src/QueryEngine.ts 复制代码
    // 每轮独立 AbortController:用户拒绝写操作 / interrupt 只结束本轮
    const abortController = new AbortController()
    this.#currentAbort = abortController
    const toolUseContext: ToolUseContext = {
      ...this.#toolUseContext,
      abortController,
    }

同时把「当前轮的控制器」记在引擎上,方便外部打断:

92:97:src/QueryEngine.ts 复制代码
  abortCurrentTurn(reason: unknown = 'interrupt'): boolean {
    const ac = this.#currentAbort
    if (!ac || ac.signal.aborted) return false
    ac.abort(reason)
    return true
  }

回合结束(finally)清掉 #currentAbort,避免空闲时误 abort 到上一轮的残骸。


4. 信号往下传:querycallModel

query 从上下文取出 signal,交给模型调用,并在循环里反复检查:

147:159:src/query.ts 复制代码
    const abortSignal = params.toolUseContext.abortController?.signal

    try {
      for await (const chunk of deps.callModel({
        messages: outbound,
        tools: params.tools,
        systemPrompt: params.systemPrompt,
        signal: abortSignal,
      })) {
        if (abortSignal?.aborted) {
          trace('query.turn_end', { reason: 'aborted', turn: turnCount })
          return { reason: 'aborted' }
        }

两层防护:

作用
signal 传给 SDK 尽量让底层 HTTP/流真正停掉
每收到一块后看 aborted 即使错误形态不一致,主循环也能干净 return { reason: 'aborted' }
catch 里认 AbortError SDK 以抛错表示取消时,不当成「Agent 崩溃」

工具跑完后也会再查一次 aborted(例如用户在权限确认里拒绝并 abort),避免再进下一轮模型调用。


5. 谁来按「取消」?三个入口

5.1 Ctrl+C(SIGINT)

终端里 Ctrl+C 会触发进程的 SIGINT。示例把「有回合在跑 → abort;空闲 → 退出 REPL」拆开:

23:32:src/entrypoints/turnInterrupt.ts 复制代码
export function installTurnInterrupt(
  options: TurnInterruptOptions,
): TurnInterruptHandle {
  const target = options.target ?? process
  const event = options.event ?? 'SIGINT'
  const handler = () => {
    if (!options.abortCurrentTurn()) {
      options.onIdleInterrupt()
    }
  }

这样第一次 Ctrl+C 只取消当前 Agent 回合;没有进行中的回合时,再交给「空闲中断」逻辑结束会话。比「一律 process.exit」友好得多。

5.2 用户拒绝写操作

REPL 里对写工具问 y/N,选否时:

58:60:src/permissions/canUseTool.ts 复制代码
    // 拒绝后 abort:orchestration 不再跑后续工具,本轮 query 随信号结束
    context.abortController?.abort('user_reject')
    return { behavior: 'deny', message: REJECT_MESSAGE }

这里 abort 的 reason'user_reject',和 Ctrl+C 的 'interrupt' 区分开,方便日志与测试断言。

工具编排看到 signal.aborted 后,会给还没跑的 tool_use 补上「已跳过」的 tool_result,避免消息历史上留下孤儿 tool_use

5.3 父取消 → 子代理跟着取消

子代理不能 和父共用同一个 AbortController 实例(否则子自己 abort 可能误伤父的其它逻辑)。做法是:

  1. 子新建自己的 AbortController
  2. 监听父的 signalabort 时,子也 abort(带上同样的 reason)
  3. 若父已经 aborted,创建子时立刻跟着重置
38:50:src/utils/subagent.ts 复制代码
  const parentAbort = parent.abortController
  const childAbort = new AbortController()
  if (parentAbort) {
    if (parentAbort.signal.aborted) {
      childAbort.abort(parentAbort.signal.reason)
    } else {
      parentAbort.signal.addEventListener(
        'abort',
        () => {
          childAbort.abort(parentAbort.signal.reason)
        },
        { once: true },
      )
    }
  }

{ once: true }:只传一次,避免重复监听。

于是:取消是一条链,不是散落的布尔旗标。


6. 和「超时杀进程」有啥不同?

上一篇讲 Bash 时,超时是 setTimeout + killTree,那是「操作系统进程」层面的停法。

本篇的 AbortController 主要管:

  • 异步 JS 任务(HTTP 流、主循环、工具编排)
  • 协作式取消(代码主动看 aborted / 听 abort 事件)

两者可以并存:

text 复制代码
Ctrl+C
  → abortCurrentTurn()
  → signal 取消 callModel
  → query return aborted
  →(若 Bash 还在跑)仍可能需要工具自己响应取消或依赖超时杀树

不是所有 IO 都自动吃 signal接了 signal 的 API 才能被标准取消 ;自己写的循环要记得查 aborted 或监听 abort。Bash 那条线若还没把 signal 接到杀进程上,取消体验会弱一截------这是实现完整度问题,心智模型仍是:「取消信号往下传,每一层决定自己怎么停」。


7. 一张总览

text 复制代码
SIGINT / 用户拒绝 / 宿主调用
        │
        ▼
QueryEngine.abortCurrentTurn()
        │  ac.abort(reason)
        ▼
toolUseContext.abortController.signal
        ├─► callModel({ signal })     // 停流式请求
        ├─► query 循环检查 aborted    // return { reason: 'aborted' }
        ├─► runTools 跳过剩余工具     // 补齐 tool_result
        └─► 子代理 childAbort 联动    // addEventListener('abort')

常见坑

建议
建了 AbortController 却不往下传 signal 取消只存在于变量里,干活的人听不见
整进程共用一个 controller 一次取消伤及后续所有回合;宜「每轮一个」
把取消当未处理异常打日志 / 崩 UI 识别 AbortError / aborted,当成正常终止原因
子代理与父共用同一个 controller 生命周期缠在一起;应派生 + 事件链
只 abort、不补 tool_result 历史里 tool_use 无配对,下一轮模型易混乱
空闲时 Ctrl+C 也只 abort 应区分「有 turn」与「无 turn」两种行为

和主循环的关系

text 复制代码
runTurn
  → new AbortController(本轮)
  → query(听 signal)
      → callModel(signal)
      → runTools(看 aborted)
  → return aborted | completed | ...
Ctrl+C → abortCurrentTurn → 同上 signal

主循环的「停」,不是靠把进程杀掉了事,而是 一条可传递的取消信号。学 AbortController,是在学 Agent 的急停装置。


本系列下一篇预告

(5)CLI 与 REPL 胶水 ------readline、标准输入输出、把 query 流接到终端,以及 interrupt 如何挂到会话上。


你可以带走什么?

  1. AbortController 负责按取消;signal 负责通知干活的人。
  2. Agent 宜「每一用户回合一个 controller」,取消粒度清晰。
  3. signal 要传到 callModel / fetch 等支持取消的 API ,主循环再自己查 aborted 做兜底。
  4. Ctrl+C、权限拒绝、父取消子代理 ,都应汇到同一次 abort(reason)
  5. 取消是正常终止reason: 'aborted'),不要当成未捕获崩溃。

仓库与延伸

欢迎 Star、Issue 和 PR。


本文为「做 Agent 会用到的 Node API」系列第 4 篇;示例基于 react-agent-mini。

相关推荐
苏灿烤鱼1 小时前
今日 GitHub 热门|Agent 云端电脑登顶,+1,891 项目却只排第三(08.06)
github·agent·资讯
苏灿烤鱼1 小时前
GitHub Trending 榜首|GitHub #1 拆解|为什么「持久工作台」比临时沙箱更值得关注?(08.06)技术拆解
人工智能·typescript·agent
用户2181697049301 小时前
Flutter(十)Container Center Align
前端
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 12 篇:并发特性与性能优化
前端·vue.js·react.js
小四的小六1 小时前
端侧模型上线翻车实录:性能优化做完之后,用户还是不满意
openai·ai编程·webview
神奇霸王龙1 小时前
Agent 5 场景屠夫:跨厂商基座横评
人工智能·windows·ai·dubbo·agent·ai编程·阿里
小p1 小时前
nextjs学习9: Next.js 渲染与缓存
前端·后端
乘风gg1 小时前
AI 时代,你的编程能力在第几层?我敢说,大多数人卡在第一层
前端·ai编程·claude
东风破_1 小时前
React Router v7 实战:用路由配置、懒加载与嵌套路由搭建一个完整的 SPA
前端