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.aborted、signal.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.3 与 5.4 会看到后两者协作)。
最小示例:20 行写一个 emitter
事件模式听起来抽象,实现出来却小得惊人——核心只是「一张事件名到监听器集合的表」:
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);
}
},
};
}用起来是这样(完整可运行版本在本章实验里):
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 就是这张表的直接实现。
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)传入的原因)。
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 的代码完全不受影响。所以「可取消」不是白来的,干活的代码必须主动配合。配合的标准写法只有两步,以「可被打断的等待」为例:
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
倒计时被中断:用户按下了 ESCsignal.aborted(接住过去),再 addEventListener(接住将来)。稍后看 Pi 源码时你会发现它的合并函数里就是这两步。另一个常见误会是把 abort 当成「强杀」——它杀不死任何代码,只能通知愿意配合的代码。 fetch(signal) 惯例与串联传递
AbortController 能成为「统一」原语,靠的是一条被普遍遵守的惯例:凡是可能长时间运行的异步 API,都接受一个可选的 signal 选项。最重要的例子是 fetch:
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 timeoutPi 中哪里用到了它
Pi 里「按 ESC 停掉一切」正是上面这套机制的原样放大。从源码结构看,整条链是:终端界面捕获 ESC 按键(packages/coding-agent/src/modes/interactive/interactive-mode.ts 第 2596 行起的 onEscape 回调——注意它本身就是一个事件监听器),经过会话层最终调用 Agent 的 abort():
/** 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();
}abort这条 signal 从 agentLoop 的参数(packages/agent/src/agent-loop.ts 第 35 行)一路串联传递到发起模型请求、执行工具的每一层,途中反复出现 if (signal?.aborted) 检查——例如工具即将执行前的第 644–650 行:一旦发现已取消,就返回「Operation aborted」的工具结果而不再动手。这正是「abort 是请求,配合靠自觉」在生产代码里的样子。
多条取消来源合并的需求 Pi 也遇到了(调用方传入的 signal、超时、内部清理各是一条),它写了一个带退订清理的合并函数,核心与本章实验的手写版几乎逐行对应:
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 });
}combineAbortSignals整条链在 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 检查。
实践任务
labs/typescript-basics/10-events-abort目标:跑通本章全部机制——订阅 / 触发 / 退订、abort 三件套、可被中断的倒计时、双来源 signal 链。实验目录:labs/typescript-basics/10-events-abort。
步骤:
进入实验目录,安装依赖并运行:
shcd labs/typescript-basics/10-events-abort npm install npm start对照
expected-output.txt核对五个部分的输出,重点看第 2 部分「取消后才订阅的监听器不会被补发通知」和第 4、5 部分倒计时中断的位置;改一改:把第 4 部分模拟按 ESC 的 500ms 改成 900ms,预测会多响几声滴答,再运行验证;
加练:删掉第 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.ts 的 combineAbortSignals(实验 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.ts 的 signal getter 与 abort()(第 306–314 行);packages/ai/src/utils/abort-signals.ts 的 combineAbortSignals(第 6–41 行);packages/agent/src/agent-loop.ts 的 signal 参数(第 35 行)与执行前检查(第 644–650 行)
自测问题:
- 同样是「模型回复的文字一段段到来」,用事件(emit 一段推一段)和用异步迭代器(for await 一段拉一段)各有什么后果?如果消费方处理得比生产方慢,两种模式分别会发生什么?
controller.abort()之后,一个从未读过signal.aborted、也没订阅"abort"事件的函数会停下来吗?为什么?- 为什么
sleep必须「先查aborted,再addEventListener」?反过来写,在什么时序下会出 bug? - Pi 的
combineAbortSignals为什么需要存在——直接把用户的 signal 一路传下去还不够吗?(提示:取消的来源有几个?)
下一章预告:2.8 Node.js 文件、路径与进程 API——异步的「骨架」已经齐了,接下来看 Node.js 提供的「肌肉」:读写文件、拼路径、跑子进程——Pi 的每一个内置工具都建立在这些 API 上,而且它们大多也接受本章的 signal。