Skip to content

2.7 事件、回调与取消(AbortController)

本章解决什么问题:Agent 程序里几乎所有重要的事都发生在「将来的某个不确定时刻」:模型的回复一小段一小段地到达、工具跑到一半出错、用户随时可能改变主意按下 ESC。2.5 讲了等待「将来的一个值」(Promise),2.6 讲了消费「将来的一串值」(异步迭代器);本章补上最后两块拼图:事件——「事情发生了,通知我」的通用模式;以及建立在事件之上的统一取消原语 AbortController——用户按一下 ESC,一整条调用链上正在干活的代码就能全部停下。

前置知识2.2 interface、type 与函数类型(函数是可以传来传去的值)、2.5 异步编程:Promise 与 async/await(事件循环、setTimeout)、2.6 异步迭代器与 for await

学习目标:读完本章后你能

  • 分清「数据到来」的三种模式——回调、事件、异步迭代器——并说出各自适用的场合;
  • 亲手实现一个约 20 行的迷你事件发射器(emitter),解释 on / emit / 退订背后的机制;
  • 会用 AbortController 三件套:controller.abort(reason)signal.abortedsignal.addEventListener("abort", ...)
  • 能把 AbortSignal 接入自己写的异步函数,并说出「先查状态、再订阅事件」这个顺序为什么不能反;
  • 说清 Pi 里「按 ESC 停掉一切」为什么靠的是一条 signal 链,而不是什么强制杀死线程的魔法。

建立直觉:三种「事情发生了,通知我」的方式

**回调(Callback)**你其实早就在用了。2.2 讲过函数是普通的值,可以当参数传递;2.5 的 setTimeout(fn, 300) 正是把函数 fn 交给运行时:「300 毫秒后,调用它」。这就是回调——把一个函数留给别人,事情发生时别人来调用它,相当于留了一张联系方式的字条。为什么需要它?因为 2.5 说过 JavaScript 只有一个线程、从不原地傻等,「将来才发生的事」只能靠留下联系方式来衔接。

回调适合一对一、说好了就这一件事的场合。但很多通知不是这样的:终端窗口的尺寸可能变化任意多次;一次模型回复会产生几十上百个「又到了一小段文字」的通知;而且关心同一件事的可能不止一方——界面要刷新、日志要记录、统计要累加。给每个关心的人都单独传一个回调参数,函数签名会爆炸。

于是有了事件(Event)订阅模式:维护一张公开的登记表,谁关心某类事,就调用 on("事件名", 监听函数) 把自己的函数登记进去——这个函数叫监听器(Listener);事情发生时,发布方调用 emit("事件名", 数据),登记表里这个事件名下的监听器被挨个调用。订阅和发布互不认识,只共享一张表。

第三种是上一章的异步迭代器:消费方用 for await 主动「拉取」下一条数据,处理完再要下一条。三者的核心差别在于通知几次、有几个接收方、节奏由谁掌握

回调事件(on / emit)异步迭代器(for await)
通知几次约定好的一次任意多次任意多次,而且有明确的「结束」
接收方一个(传进去的那个函数)任意多个,可随时加入退出通常一个消费者
节奏由谁掌握发送方:事到就调发送方:事到就广播接收方:处理完再要下一条
典型例子setTimeout、Promise 的 then按键、窗口变化、abort 通知消费模型的流式输出

「推」和「拉」值得多看一眼:推模式下接收方来不及处理也得收着;拉模式下发送方要等接收方开口。没有谁更好,只有谁更合适。Pi 三种全都在用:给 Agent Loop 传入的事件回调是回调、取消通知是事件、往界面送流式输出用的是异步迭代器(3.35.4 会看到后两者协作)。

最小示例:20 行写一个 emitter

事件模式听起来抽象,实现出来却小得惊人——核心只是「一张事件名到监听器集合的表」:

ts
type Listener<T> = (payload: T) => void;

export function createEmitter<T>() {
  const listeners = new Map<string, Set<Listener<T>>>();
  return {
    on(event: string, listener: Listener<T>): () => void {
      const set = listeners.get(event) ?? new Set<Listener<T>>();
      listeners.set(event, set);
      set.add(listener);
      return () => {
        set.delete(listener); // 返回「退订」函数
      };
    },
    emit(event: string, payload: T): void {
      for (const listener of listeners.get(event) ?? []) {
        listener(payload);
      }
    },
  };
}

用起来是这样(完整可运行版本在本章实验里):

ts
const emitter = createEmitter<string>();
const offA = emitter.on("message", (text) => console.log(`  监听器 A 收到:${text}`));
emitter.on("message", (text) => console.log(`  监听器 B 收到:${text}`));

emitter.emit("message", "第一条消息");
offA(); // A 退订
emitter.emit("message", "第二条消息");

真实输出:

  监听器 A 收到:第一条消息
  监听器 B 收到:第一条消息
  监听器 B 收到:第二条消息

三个细节:on 返回的退订函数把监听器从集合里删掉,这是资源不泄漏的关键(长寿命对象上忘了退订的监听器会永远被引用着);emit 一个没人订阅的事件,循环体一次都不执行,安静跳过;泛型参数 T(2.4 讲过)让每个 emitter 的数据类型在编译期就定死。

图加载中…

图 2.7-1 事件订阅模式:一张登记表连接两端
阅读顺序:左边两个入口先看。订阅方与发布方互相不认识,只共享中间那张登记表——on 往表里写,emit 从表里读并逐个调用。本章实验 src/emitter.ts 里的 Map 与 Set 就是这张表的直接实现。

🌱 初学者提示node:events 与 addEventListener:事件 API 的两大家族
不用每次都手写。Node.js 内置了功能更全的版本:import { EventEmitter } from "node:events",提供 on / emit / once / off 等完整方法。另一家族来自 Web 标准:EventTarget,方法名叫 addEventListener / removeEventListener,浏览器和 Node.js 都实现了它。两家机制相同、只是拼法不同——马上要讲的 AbortSignal 就属于 EventTarget 家族,所以订阅它用的是 addEventListener 而不是 on。

取消:一个被低估的难题

现在换一个问题:不是「事情发生了通知我」,而是「别做了,停下」。

设想 Pi 正在工作:一个 fetch(浏览器和 Node.js 都内置的发起网络请求的函数)开着到模型服务的连接、一个工具在读大文件、Agent Loop 准备发起下一轮调用。此刻用户按下 ESC。麻烦在于:Promise 天生没有取消按钮——2.5 讲过它只有 pending / fulfilled / rejected 三态,发起之后,外面的人只能等结果,没有任何标准方法叫停它。

没有统一原语会怎样?历史上就是各家各造:计时器用 clearTimeout、老式网络请求用 xhr.abort()、有的库让你传 isCancelled 标志位轮询……每层代码都得学习下一层的私有取消方式并层层翻译,跨越五六层调用链的「停下」几乎不可能传到底。

AbortController 就是给这个难题定的标准答案。它把「取消」拆成一对对象:

  • AbortController:遥控器。只有一个方法 abort(reason?),握在发起取消的一方手里;
  • AbortSignal:收音机,从 controller.signal 取得,交给干活的一方。它只有两样东西——只读属性 signal.aborted(是否已取消)和一个 "abort" 事件(取消发生那一刻广播一次,signal.reason 里装着 abort(reason) 传入的原因)。
ts
const controller = new AbortController();
const signal = controller.signal;

console.log(signal.aborted);                    // false
signal.addEventListener("abort", () => {
  console.log("abort 事件触发了!");
});
controller.abort(new Error("用户按下了 ESC")); // 打印:abort 事件触发了!
console.log(signal.aborted);                    // true
console.log((signal.reason as Error).message);  // 用户按下了 ESC

注意 abort 的本质:它只是把 aborted 置为 true 并广播一次事件——是请求,不是强制终止。不看 signal 的代码完全不受影响。所以「可取消」不是白来的,干活的代码必须主动配合。配合的标准写法只有两步,以「可被打断的等待」为例:

ts
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {           // 第一步:先查状态(补上错过的通知)
      reject(signal.reason);
      return;
    }
    const onAbort = () => {
      clearTimeout(timer);           // 收拾现场:取消还没到点的计时器
      reject(signal?.reason);
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    signal?.addEventListener("abort", onAbort, { once: true }); // 第二步:再订阅事件
  });
}

有了可取消的 sleep,可取消的倒计时任务就是在循环里反复 await 它——sleep 一失败,await 处抛出异常,整个任务随之终止。本章实验里用 500 毫秒后模拟按下 ESC 的方式运行它,真实输出:

  滴答:5
  滴答:4
  倒计时被中断:用户按下了 ESC
⚠️ 常见误解只订阅事件,不先查 aborted
"abort" 事件只在取消发生的那一刻广播一次,之后才 addEventListener 的监听器不会被补发通知。如果调用你函数时取消早已发生,只靠订阅就会永远等下去。所以顺序固定是:先查 signal.aborted(接住过去),再 addEventListener(接住将来)。稍后看 Pi 源码时你会发现它的合并函数里就是这两步。另一个常见误会是把 abort 当成「强杀」——它杀不死任何代码,只能通知愿意配合的代码。

fetch(signal) 惯例与串联传递

AbortController 能成为「统一」原语,靠的是一条被普遍遵守的惯例:凡是可能长时间运行的异步 API,都接受一个可选的 signal 选项。最重要的例子是 fetch:

ts
const controller = new AbortController();
const response = await fetch("https://example.com/big-file", {
  signal: controller.signal, // 任何时刻 controller.abort(),请求立刻中断
});

Node.js 的许多内置 API(文件读写、子进程等,2.8 会见到)同样接受 signal。你自己写的函数也应该照此办理:最后一个参数收一个可选的 signal?: AbortSignal,并把它原样传给你 await 的每一个更深层调用——上面 sleep 就是这么做的。这叫 signal 的串联传递:每一层都只是转交,于是最顶层的一次 abort() 能穿透任意深的调用链,直达最底层正在等待的网络请求和计时器。

还剩最后一块:取消的来源往往不止一个。用户可能按 ESC,请求也可能超时,父任务可能整体被撤销——而干活的代码只想认识一条 signal。办法是合并:新建一个内部 controller,逐条盯住多个来源,任何一条响了就转发。Node.js 甚至内置了现成的 AbortSignal.any([...]) 和「到点自动触发」的 AbortSignal.timeout(ms)。本章实验里手写了一个十几行的合并函数,把「没人按的 ESC」和 500 毫秒超时合并成一条传给倒计时,真实输出:

  滴答:10
  滴答:9
  倒计时被中断:TimeoutError: The operation was aborted due to timeout

Pi 中哪里用到了它

Pi 里「按 ESC 停掉一切」正是上面这套机制的原样放大。从源码结构看,整条链是:终端界面捕获 ESC 按键(packages/coding-agent/src/modes/interactive/interactive-mode.ts 第 2596 行起的 onEscape 回调——注意它本身就是一个事件监听器),经过会话层最终调用 Agentabort()

ts
	/** Active abort signal for the current run, if any. */
	get signal(): AbortSignal | undefined {
		return this.activeRun?.abortController.signal;
	}

	/** Abort the current run, if one is active. */
	abort(): void {
		this.activeRun?.abortController.abort();
	}
earendil-works/pi@c13ffe1第 306–314 行在 GitHub 查看 ↗
取消链的起点:每次运行开始时 Agent 会创建一个 AbortController 存进 activeRun;abort() 按下遥控器,signal 这个 getter 则把对应的收音机交给所有干活的代码。

这条 signal 从 agentLoop 的参数(packages/agent/src/agent-loop.ts 第 35 行)一路串联传递到发起模型请求、执行工具的每一层,途中反复出现 if (signal?.aborted) 检查——例如工具即将执行前的第 644–650 行:一旦发现已取消,就返回「Operation aborted」的工具结果而不再动手。这正是「abort 是请求,配合靠自觉」在生产代码里的样子。

多条取消来源合并的需求 Pi 也遇到了(调用方传入的 signal、超时、内部清理各是一条),它写了一个带退订清理的合并函数,核心与本章实验的手写版几乎逐行对应:

ts
	const controller = new AbortController();
	// …(省略:记录每条订阅,供 cleanup 时退订)
	const abort = (signal: AbortSignal) => {
		if (!controller.signal.aborted) {
			controller.abort(signal.reason);
		}
	};

	for (const signal of activeSignals) {
		if (signal.aborted) {
			abort(signal);
			break;
		}
		const listener = () => abort(signal);
		signal.addEventListener("abort", listener, { once: true });
		listeners.push({ signal, listener });
	}
earendil-works/pi@c13ffe1第 6–41 行在 GitHub 查看 ↗
把多条 AbortSignal 合并成一条。注意循环体内的固定两步:先查 aborted(有一条早已触发就直接转发并停止),再 addEventListener 订阅将来的触发——与本章 CommonMistake 讲的顺序完全一致。

整条链在 5.6 取消与错误处理 有完整拆解,8.5 还会带你在自己的 Mini Harness 里重写一遍。

图解

图加载中…

图 2.7-2 一次 ESC 从按键到全线停止的 signal 链
阅读顺序:从上到下。上半段是「事件」把按键变成一次 abort 调用;下半段是同一条 signal 同时通知三类订阅者——这正是事件模式「任意多个接收方」的价值。图中 agent.abort 对应 packages/agent/src/agent.ts 第 312 行,最下面三个节点分别对应 fetch(signal) 惯例、工具执行与 agent-loop.ts 里的 aborted 检查。

实践任务

🛠 实践任务迷你 emitter 与可取消的倒计时labs/typescript-basics/10-events-abort

目标:跑通本章全部机制——订阅 / 触发 / 退订、abort 三件套、可被中断的倒计时、双来源 signal 链。实验目录:labs/typescript-basics/10-events-abort

步骤

  1. 进入实验目录,安装依赖并运行:

    sh
    cd labs/typescript-basics/10-events-abort
    npm install
    npm start
  2. 对照 expected-output.txt 核对五个部分的输出,重点看第 2 部分「取消后才订阅的监听器不会被补发通知」和第 4、5 部分倒计时中断的位置;

  3. 改一改:把第 4 部分模拟按 ESC 的 500ms 改成 900ms,预测会多响几声滴答,再运行验证;

  4. 加练:删掉第 4 部分 countdown 的第三个参数(不传 signal),观察 ESC 照按、倒计时照数到底——体会「abort 是请求,不是强杀」。

预期现象npm start 输出与 expected-output.txt 逐字一致(本实验不打印耗时,输出完全确定);第 3 步改成 900ms 后第 4 部分应响四声滴答。

如何判断成功:三处修改的结果都与你的预测一致,并能口头回答——sleep 里「先查 aborted 再订阅」的顺序反过来会出什么 bug?

常见错误tsx: command not found 说明还没在实验目录里 npm install;改完代码忘了保存再运行,看到的还是旧输出。

对应源码位置packages/agent/src/agent.ts 第 306–314 行(abort 起点)、packages/ai/src/utils/abort-signals.tscombineAbortSignals(实验 src/combine.ts 是它的简化版)。

本章小结

  • 「数据到来」有三种模式:回调(一次、一个接收方)、事件(多次、多个接收方、发送方推)、异步迭代器(多次、接收方拉、有明确结束);场合不同,选择不同。
  • 事件订阅的机制只是一张「事件名 → 监听器集合」的登记表:on 登记并返回退订函数,emit 逐个调用;Node.js 内置 node:events 的 EventEmitter,Web 标准这边是 EventTarget 的 addEventListener
  • Promise 没有取消按钮,AbortController 是统一的取消原语:controller 是遥控器(abort(reason)),signal 是收音机(aborted 属性 + 一次性的 "abort" 事件 + reason)。
  • abort 是请求不是强杀;接入 signal 的固定两步是先查 aborted、再 addEventListener,顺序不能反。
  • 惯例:长时间运行的异步函数最后收一个可选 signal 并层层转交(fetch 等内置 API 都遵守);多条取消来源用合并(AbortSignal.any / Pi 的 combineAbortSignals)归成一条。
  • Pi 按 ESC 取消一次运行,走的正是「按键事件 → agent.abort() → 一条 signal 串联到网络请求与工具执行」这条链。

关键术语:回调(Callback)、事件(Event)、监听器(Listener)、订阅与退订、EventEmitter、EventTarget、AbortController、AbortSignal、signal.aborted"abort" 事件、reason、signal 串联传递、AbortSignal.timeout / AbortSignal.any

关键源码索引packages/agent/src/agent.tssignal getter 与 abort()(第 306–314 行);packages/ai/src/utils/abort-signals.tscombineAbortSignals(第 6–41 行);packages/agent/src/agent-loop.tssignal 参数(第 35 行)与执行前检查(第 644–650 行)

自测问题

  1. 同样是「模型回复的文字一段段到来」,用事件(emit 一段推一段)和用异步迭代器(for await 一段拉一段)各有什么后果?如果消费方处理得比生产方慢,两种模式分别会发生什么?
  2. controller.abort() 之后,一个从未读过 signal.aborted、也没订阅 "abort" 事件的函数会停下来吗?为什么?
  3. 为什么 sleep 必须「先查 aborted,再 addEventListener」?反过来写,在什么时序下会出 bug?
  4. Pi 的 combineAbortSignals 为什么需要存在——直接把用户的 signal 一路传下去还不够吗?(提示:取消的来源有几个?)

下一章预告2.8 Node.js 文件、路径与进程 API——异步的「骨架」已经齐了,接下来看 Node.js 提供的「肌肉」:读写文件、拼路径、跑子进程——Pi 的每一个内置工具都建立在这些 API 上,而且它们大多也接受本章的 signal

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)