本系列讲实现 Agent harness 时会反复碰到的 Node / JS 运行时能力。默认读者会一点 JS但还没碰过「取消进行中的异步任务」。上一篇3异步与流示例仓库react-agent-mini相关前作150 行搞懂 Agent 主循环 · 权限 Write场景进行到一半怎么停上一篇把主循环写成了「异步生成器管道」模型边吐字工具边跑。现实里用户经常要半路叫停情况期望终端里按CtrlC正在跑的这一轮停掉空闲时再按才退出程序写文件弹确认用户选N本轮后续工具别再跑也不要继续调模型硬干父会话取消正在跑的子代理也要跟着停如果没有统一的「取消开关」你会落到到处设let cancelled false漏改一处就停不干净HTTP 请求还在飞浪费配额工具队列继续改磁盘现代 JS浏览器和 Node 都有提供了标准答案AbortControllerAbortSignal。1. 两个角色控制器 vs 信号可以想成对讲机对象谁拿着干什么AbortController「想取消的人」REPL、权限逻辑、宿主调用abort()发出取消AbortSignalcontroller.signal「干活的人」fetch/callModel/ 自定义循环听信号是否已取消、取消时做什么最小骨架constacnewAbortController();// 把 signal 交给异步工作doWork({signal:ac.signal});// 别处决定取消ac.abort(interrupt);// 可选附带原因signal上常用三件事signal.aborted;// boolean是不是已经取消了signal.reason;// abort() 时传入的原因若有signal.addEventListener(abort,(){// 取消瞬间触发一次});约定创建控制器的一方负责abort()真正干活的 API 只接收signal一般不直接拿着整个AbortController除非像 Agent 上下文那样要在工具层也能触发取消。2. 和fetch的关系建立直觉很多文档用fetch举例因为浏览器/Node 的fetch原生支持signalconstacnewAbortController();constpfetch(https://example.com,{signal:ac.signal});// 用户取消ac.abort();// p 通常会以 AbortError 一类错误失败要点取消不会自动发生——必须有人调用ac.abort()或把会自动 abort 的 signal如超时信号传进去。abort()本身不保证远端服务器立刻停算——主要是本地停止等待、关掉连接、让 Promise 失败。业务代码要把「用户取消」和「真正出错」分开常看err.name AbortError或 SDK 自定义的 abort 错误名。Agent 调模型时OpenAI 兼容 SDK 同样接收signal示例仓库的callModel会把它传下去conststreamawaitclient.chat.completions.create({model,messages,tools,stream:true},{signal:params.signal},);上一篇的流式管道加上本篇的 signal就变成边收 chunk边能被掐断。3. Agent 里怎么挂一轮一个控制器取消的粒度很重要。示例仓库选择每一次用户回合runTurn新建一个AbortController取消只影响当前轮不拆掉整个 REPL 进程。// 每轮独立 AbortController用户拒绝写操作 / interrupt 只结束本轮 const abortController new AbortController() this.#currentAbort abortController const toolUseContext: ToolUseContext { ...this.#toolUseContext, abortController, }同时把「当前轮的控制器」记在引擎上方便外部打断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. 信号往下传query→callModelquery从上下文取出 signal交给模型调用并在循环里反复检查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里认AbortErrorSDK 以抛错表示取消时不当成「Agent 崩溃」工具跑完后也会再查一次aborted例如用户在权限确认里拒绝并 abort避免再进下一轮模型调用。5. 谁来按「取消」三个入口5.1 CtrlCSIGINT终端里 CtrlC 会触发进程的SIGINT。示例把「有回合在跑 → abort空闲 → 退出 REPL」拆开export function installTurnInterrupt( options: TurnInterruptOptions, ): TurnInterruptHandle { const target options.target ?? process const event options.event ?? SIGINT const handler () { if (!options.abortCurrentTurn()) { options.onIdleInterrupt() } }这样第一次CtrlC 只取消当前 Agent 回合没有进行中的回合时再交给「空闲中断」逻辑结束会话。比「一律process.exit」友好得多。5.2 用户拒绝写操作REPL 里对写工具问y/N选否时// 拒绝后 abortorchestration 不再跑后续工具本轮 query 随信号结束 context.abortController?.abort(user_reject) return { behavior: deny, message: REJECT_MESSAGE }这里 abort 的reason是user_reject和 CtrlC 的interrupt区分开方便日志与测试断言。工具编排看到signal.aborted后会给还没跑的tool_use 补上「已跳过」的tool_result避免消息历史上留下孤儿tool_use。5.3 父取消 → 子代理跟着取消子代理不能和父共用同一个AbortController实例否则子自己 abort 可能误伤父的其它逻辑。做法是子新建自己的AbortController监听父的signalabort时子也abort带上同样的 reason若父已经 aborted创建子时立刻跟着重置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 时超时是setTimeoutkillTree那是「操作系统进程」层面的停法。本篇的 AbortController 主要管异步 JS 任务HTTP 流、主循环、工具编排协作式取消代码主动看aborted/ 听abort事件两者可以并存CtrlC → abortCurrentTurn() → signal 取消 callModel → query return aborted →若 Bash 还在跑仍可能需要工具自己响应取消或依赖超时杀树不是所有 IO 都自动吃signal。接了 signal 的 API 才能被标准取消自己写的循环要记得查aborted或监听abort。Bash 那条线若还没把 signal 接到杀进程上取消体验会弱一截——这是实现完整度问题心智模型仍是「取消信号往下传每一层决定自己怎么停」。7. 一张总览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 无配对下一轮模型易混乱空闲时 CtrlC 也只 abort应区分「有 turn」与「无 turn」两种行为和主循环的关系runTurn → new AbortController本轮 → query听 signal → callModel(signal) → runTools看 aborted → return aborted | completed | … CtrlC → abortCurrentTurn → 同上 signal主循环的「停」不是靠把进程杀掉了事而是一条可传递的取消信号。学 AbortController是在学 Agent 的急停装置。本系列下一篇预告5CLI 与 REPL 胶水——readline、标准输入输出、把query流接到终端以及 interrupt 如何挂到会话上。你可以带走什么AbortController负责按取消signal负责通知干活的人。Agent 宜「每一用户回合一个 controller」取消粒度清晰。signal要传到callModel/fetch等支持取消的 API主循环再自己查aborted做兜底。CtrlC、权限拒绝、父取消子代理都应汇到同一次abort(reason)。取消是正常终止reason: aborted不要当成未捕获崩溃。仓库与延伸GitHubreact-agent-mini相关前作权限 Write源码QueryEngine.ts · query.ts · turnInterrupt.ts · subagent.ts · canUseTool.ts欢迎 Star、Issue 和 PR。本文为「做 Agent 会用到的 Node API」系列第 4 篇示例基于 react-agent-mini。