本系列 讲实现 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() 发出取消 |
AbortSignal (controller.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 一类错误失败
要点:
- 取消不会自动发生 ------必须有人调用
ac.abort(),或把会自动 abort 的 signal(如超时信号)传进去。 abort()本身不保证远端服务器立刻停算------主要是本地停止等待、关掉连接、让 Promise 失败。- 业务代码要把 「用户取消」 和 「真正出错」 分开:常看
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. 信号往下传:query → callModel
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 可能误伤父的其它逻辑)。做法是:
- 子新建自己的
AbortController - 监听父的
signal:abort时,子也abort(带上同样的 reason) - 若父已经 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 如何挂到会话上。
你可以带走什么?
AbortController负责按取消;signal负责通知干活的人。- Agent 宜「每一用户回合一个 controller」,取消粒度清晰。
signal要传到callModel/fetch等支持取消的 API ,主循环再自己查aborted做兜底。- Ctrl+C、权限拒绝、父取消子代理 ,都应汇到同一次
abort(reason)。 - 取消是正常终止 (
reason: 'aborted'),不要当成未捕获崩溃。
仓库与延伸
- GitHub :react-agent-mini
- 相关前作 :权限 + Write
- 源码 :QueryEngine.ts · query.ts · turnInterrupt.ts · subagent.ts · canUseTool.ts
欢迎 Star、Issue 和 PR。
本文为「做 Agent 会用到的 Node API」系列第 4 篇;示例基于 react-agent-mini。