异步后台执行
让慢工具脱离当前请求,完成后通过 typed runtime event 回到同一个 Loop。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
异步后台执行
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 后台化发生在安全管道之后,不复制第二套 Loop。
- job 需要查询与取消能力。
- 三态参数要区分未提供、明确 false 和 true。
顺着原文把边界看清
01导读:问题背景与本章目标让 Agent 执行依赖安装或完整测试时,最直观的体验是:模型调用像被暂停了几分钟。问题不在等待本身,而在等待期间 Agent 是否还能完成别的工作。⌄
让 Agent 执行依赖安装或完整测试时,最直观的体验是:模型调用像被暂停了几分钟。问题不在等待本身,而在等待期间 Agent 是否还能完成别的工作。
第 12 章的 Task DAG 描述“要做什么、谁认领、依赖是否满足”,却不会替你执行慢命令。第 13 章增加另一种生命周期:工具调用已经通过参数校验、Hook、权限和审批,可以先返回后台占位,再在完成后通过 typed runtime event 回到同一个 Agent Loop。
本章只后台化 P13 主 Agent 的 PowerShell 工具。Cron、持续 teammate、后台 subagent、输出流文件和交互式提示看门狗不在范围内。
02P13 增加什么P13 在 P12 上增加 background capability,把 EventInbox 接入公共 Loop,并为 P13 主 Agent 暴露后台 job 的查询与取消工具。Task、TODO、Hook、权限、恢复、记忆、动态 Prompt 和上下文压缩全部保留;不复制第二套 Loop。⌄
P13 在 P12 上增加 background capability,把 EventInbox 接入公共 Loop,并为 P13 主 Agent 暴露后台 job 的查询与取消工具。Task、TODO、Hook、权限、恢复、记忆、动态 Prompt 和上下文压缩全部保留;不复制第二套 Loop。
| 角色 | 责任 | 不负责 |
|---|---|---|
BackgroundDispatcher | 判断同步或后台,提交已经获批的调用 | 不做权限判断,不重放原始 JSON |
JobSupervisor | 持有 worker,控制容量、超时、取消、关闭和终态 | 不充当 Task DAG |
query_background_job / cancel_background_job | 按 job_id 查询或取消持久化后台 job | 不暴露给 subagent 与 P01-P12 |
EventInbox | FIFO 传递 typed RuntimeEvent | 不接收普通字典或伪 tool result |
JsonBackgroundJobStore | 在 workspace 内原子保存 job 状态 | 不盲目重放崩溃前的未知副作用 |
对应实现位于 background.ts、events.ts、background-json.ts、loop.ts 和 bootstrap.ts。
工具执行顺序仍只有一条:
PreToolUse
-> hard permission / approval
-> synchronous dispatch or background submission
-> PostToolUse
-> append exactly one matching tool result后台只改变“谁等待结果”,不改变“谁拥有权限”。
03显式参数是三态只有 P13 主 Agent 的 shell schema 增加 runinbackground;P01-P12 和一次性 subagent 仍看到只有 command 的 schema。⌄
只有 P13 主 Agent 的 shell schema 增加 run_in_background;P01-P12 和一次性 subagent 仍看到只有 command 的 schema。
const backgroundShellInputSchema = z.strictObject({
command: z.string().min(1),
run_in_background: z.boolean().nullable().optional().default(null),
});三种输入的语义固定如下:
| 输入 | 决策 |
|---|---|
true | 强制后台 |
false | 强制同步,即使命中慢命令关键词 |
null 或省略 | 才使用启发式 |
判断必须先区分显式布尔值:
export function shouldRunInBackground(input: BackgroundShellInput): boolean {
if (input.run_in_background !== undefined && input.run_in_background !== null) {
return input.run_in_background;
}
const command = input.command.toLowerCase();
return [
"cargo build",
"compile",
"deploy",
"docker build",
"npm install",
"pip install",
"pytest",
].some((marker) => command.includes(marker));
}启发式只是确定性兜底,并不预测任意命令的耗时。工具定义中的 concurrency: "background_eligible" 是第二道门;没有该标记的工具永远直接执行。
04为什么提交必须在权限之后AgentRunner 的唯一工具执行点先解析 JSON 和 Zod schema,再运行 Pre Hook 与 PermissionPolicy,最后才调用可注入的 ToolDispatcher:⌄
AgentRunner 的唯一工具执行点先解析 JSON 和 Zod schema,再运行 Pre Hook 与 PermissionPolicy,最后才调用可注入的 ToolDispatcher:
const result = this.#toolDispatcher === undefined
? await tools.invoke(effective, context)
: await this.#toolDispatcher.dispatch(effective, context);Dispatcher 拿到的是 Hook 改写后、权限实际审查过的 PreparedToolCall。因此可以直接验证:
- Pre Hook 阻断时,不创建 job;
- 权限拒绝时,不创建 job、不启动 executor、不调用 shell handler;
- 权限通过后,才按“容量检查 -> 持久化
running-> 登记 worker”的顺序提交。
把 worker 提前创建再补权限会留下竞态:拒绝结果还没返回,副作用已经开始。本章明确禁止这种软边界。
05占位结果与完成事件OpenAI 工具消息必须保持配对。后台命令尚未完成时,原始 toolcallid 立即得到唯一的占位结果:⌄
OpenAI 工具消息必须保持配对。后台命令尚未完成时,原始 tool_call_id 立即得到唯一的占位结果:
{"job_id":"00000000-0000-4000-8000-000000000301","status":"running","tool_name":"shell"}它闭合当前工具轮,让同一条 assistant 消息中的快工具继续执行。真实结果随后成为 BackgroundJobEvent,由 Loop 注入为普通 user 消息:
{
"runtime_event": {
"event_id": "00000000-0000-4000-8000-000000000302",
"job_id": "00000000-0000-4000-8000-000000000301",
"kind": "background_job",
"result": {"content":"404 passed","error_code":null,"is_error":false},
"source_tool_call_id": "slow-call",
"status": "completed",
"tool_name": "shell"
},
"batch": {"index": 0, "total": 1}
}事件有独立的 event_id,也不会再次伪装成带 tool_call_id 的工具结果。EventInbox 只接受实现 RuntimeEvent 的对象,不能直接塞入字典。即使一次只注入一条事件,runtimeEventMessage() 也会输出 batch 位置,模型因此能区分“单条完成”和“多条结果中的某一条”。
06后台 job 不是 Task 的第四种状态Task 仍然是 pending -> inprogress -> completed。后台 job 表示“一次已经授权的执行尝试”:⌄
Task 仍然是 pending -> in_progress -> completed。后台 job 表示“一次已经授权的执行尝试”:
running -> completed
-> failed
-> timed_out
-> cancelled
restart: running -> interrupted状态不变量是:running 没有结果;每个终态都有 ToolResult;只有 completed 能携带成功结果,其余终态必须携带稳定 error code。
Supervisor 用一个条件迁移发布终态:只有磁盘中的当前状态仍为 running 时,finishRunning() 才返回 job 并发布一个事件。完成、取消和超时竞争时,后到分支返回 undefined,不会重复通知。
07后台 job 的查询与取消JobSupervisor 同时实现 RuntimeEventPump,但模型并不知道后台 job 当前处于什么状态。为了主动检查进度并决定是否取消,P13 主 Agent 在组合根追加 registerBackgroundJobTools();它只在 dependencies.backgroundSupervisor 存在时注册两个工具:⌄
JobSupervisor 同时实现 RuntimeEventPump,但模型并不知道后台 job 当前处于什么状态。为了主动检查进度并决定是否取消,P13 主 Agent 在组合根追加 registerBackgroundJobTools();它只在 dependencies.backgroundSupervisor 存在时注册两个工具:
| 工具 | effect | 输入 | 返回 |
|---|---|---|---|
query_background_job | read | { job_id } | 当前持久化 job payload |
cancel_background_job | write | { job_id } | 取消后的最终 job payload |
输入 schema 只接受 canonical UUID,避免把任意字符串拼进文件路径:
const backgroundJobIdSchema = z
.string()
.regex(CANONICAL_UUID, "job_id must be a canonical UUID");
const backgroundJobIdInputSchema = z.object({ job_id: backgroundJobIdSchema }).strict();查询 running 状态返回 result: null,因为 running 不携带结果:
{
"job_id": "00000000-0000-4000-8000-000000000301",
"status": "running",
"tool_name": "shell",
"source_tool_call_id": "slow-call",
"result": null
}取消工具先调用 supervisor.cancel(),等待 worker 收束并落盘,再重新读取最终状态,因此不会返回“刚刚发起取消但还没迁移”的中间结果:
{
"job_id": "00000000-0000-4000-8000-000000000301",
"status": "cancelled",
"tool_name": "shell",
"source_tool_call_id": "slow-call",
"result": {
"content": "Background job was cancelled",
"error_code": "background_cancelled",
"is_error": true
}
}已知领域错误会转成稳定的 ToolResult error code。未知 ID 对应 background_job_not_found,已终态 job 再次取消对应 background_job_state,容量满载对应 background_capacity。registerBackgroundJobTools() 在组合根只对主 Agent 调用,subagent 和 P01-P12 不暴露这些工具。
08所有异步 task 都有 ownerJobSupervisor 是 P13 后台 coroutine 的唯一 owner:⌄
JobSupervisor 是 P13 后台 coroutine 的唯一 owner:
| 边界 | 默认值 | 行为 |
|---|---|---|
| 活跃 job 容量 | 4 | 满载时返回 background_capacity,不创建第二个 job |
| 单 job 超时 | 120 秒 | abort worker,迁移为 timed_out |
| 关闭时限 | 10 秒 | 拒绝新提交,取消并等待受管 task |
测试通过 JobExecutor 注入手动释放的 worker,不依赖真实长时间 sleep。AgentRunner.close() 按逆序关闭资源,并在一个资源失败时继续尝试剩余资源。
09先落盘,再启动 worker00000000-0000-4000-8000-000000000301.json⌄
状态文件位于 workspace 内:
workspace/
.agent_tutorial/
.background.lock
background/
00000000-0000-4000-8000-000000000301.jsonJsonBackgroundJobStore 沿用前章的持久化纪律。canonical UUID 文件名必须与 payload ID 一致;workspace、状态目录和 job 文件解析后不能通过 symlink/junction 逃逸;读取严格验证 UTF-8、JSON、schema 和状态不变量。proper-lockfile 保护整次读—条件迁移—原子写,同目录临时文件先 fsync 再 rename。
如果进程在命令执行期间退出,下一次构造 JobSupervisor 会把遗留 running 迁移为 interrupted 并发布一次中断事件;第二次重建不会重放。终态由 finishRunning 条件迁移仲裁:谁先写进磁盘谁定终态,晚到一方拿到 undefined 安静跳过发事件——「只发一次」靠磁盘状态不靠内存标记位。另外 P13 的后台任务本质是一个 Promise 而非子进程:只能协作式中断、只能等它自己结束——换成子进程实现时,状态机、事件回灌、条件迁移、崩溃恢复一条规则都不用改。
10事件怎样回到同一个 Loop每次模型请求前,Loop 会先等待恢复完成并批量 drain 所有已就绪 typed events。模型暂时返回无工具文本时,如果仍有 active job,事件泵等待终态事件,然后在同一个 run() 中继续下一次模型请求:⌄
每次模型请求前,Loop 会先等待恢复完成并批量 drain 所有已就绪 typed events。模型暂时返回无工具文本时,如果仍有 active job,事件泵等待终态事件,然后在同一个 run() 中继续下一次模型请求:
let events = this.#eventPump.drainEvents();
if (events.length === 0 && waitForPendingWork && this.#eventPump.hasPendingWork) {
events = await this.#eventPump.waitForEvents();
}整批事件会先验证 RuntimeEvent 契约、按 eventId 去重并 acknowledge,再计算 { index, total } 位置,逐条调用 runtimeEventMessage() 注入普通 user 消息:
for (const [index, event] of injected.entries()) {
this.#history.push(runtimeEventMessage(event, { index, total: injected.length }));
}runtimeEventMessage() 调用 event.toPayload() 后包成普通 user 消息,事件不携带 tool_call_id,也不会被当作第二次工具结果。单条事件也会带 batch: { "index": 0, "total": 1 },让模型明确知道这是本批结果中的第几条。
Turn 1:
assistant -> shell(background=true) + fast(read config)
Loop -> shell placeholder;fast 工具先完成
Turn 2:
assistant -> 暂时回答,Loop 因 active job 等待
worker -> terminal state -> BackgroundJobEvent
Turn 3:
Loop -> 注入普通 runtime-event user message
assistant -> 根据真实后台结果给出最终回答历史顺序是“已配对的占位 tool result -> 中间 assistant 文本 -> 普通 runtime-event user message -> 新 assistant 回复”。事件不会重新发送旧工具结果,消息配对验证仍然通过。
批量事件会在同一个模型请求前连续追加,事件之间没有 assistant 文本,模型一次看到完整结果集合;重复 eventId 只注入一次,避免同一完成事件重放。
11与 Claude Code 的差异参照 learn-claude-code/s13backgroundtasks/README.md 中深入 CC 源码的分析,P13 的后台任务系统在教学简化上与 Claude Code 的后台任务系统存在以下差异:⌄
参照 learn-claude-code/s13_background_tasks/README.md 中深入 CC 源码的分析,P13 的后台任务系统在教学简化上与 Claude Code 的后台任务系统存在以下差异:
后台任务类型。 CC 定义了 7 种后台任务(Task.ts:7-13):local_bash、local_agent、remote_agent、in_process_teammate、local_workflow、monitor_mcp、dream,每种有自己的注册、生命周期和通知机制。P13 只后台化了 P13 主 Agent 的 shell 工具,其余工具和非 shell 场景保持同步执行。
线程模型。 CC 运行在 Node.js/Bun 单线程事件循环中,“后台”只是不 await。ShellCommand.background(taskId) 把 stdout/stderr 重定向到文件,让进程独立运行而不阻塞主循环。P13 用 JobSupervisor 管理受管 worker、容量、超时、取消和关闭。每个后台 shell 调用都在独立 worker 中执行,通过持久化 JsonBackgroundJobStore 保证进程崩溃后可恢复。
三态 vs 布尔。 CC 的 bash 工具 schema 有 run_in_background: boolean 参数(BashTool.tsx:241),模型显式指定。P13 的 backgroundShellInputSchema 使用 boolean().nullable().optional().default(null) 实现三态语义。其中 true 强制后台、false 强制同步、null 或省略才走关键词启发式兜底。P13 的启发式没有相应的 CC 实现痕迹。
pendingToolUseSummary。 CC 在每批工具执行完后启动一个 Haiku side-query(query.ts:1411-1482),后台生成约 30 字符的工具使用摘要(toolUseSummaryGenerator.ts:15)。摘要在主模型流式生成期间完成,用于移动端进度展示。P13 没有 side-query summarizer,后台 job 完成后直接注入包含完整 stdout 的 BackgroundJobEvent。
通知队列。 CC 后台任务完成后通过 enqueueTaskNotification(utils/task/framework.ts:267)或 enqueuePendingNotification(messageQueueManager.ts)入队到共享命令队列。通知格式是结构化 XML <task_notification>,优先级分 next/later;后台任务默认 later,不阻塞用户输入。P13 用 typed RuntimeEvent / EventInbox FIFO 队列注入 BackgroundJobEvent,事件与 tool_call_id 解耦,不区分优先级。
停滞看门狗。 CC 后台 bash 任务有一个输出停滞看门狗(LocalShellTask.tsx L24-25 常量,L59-98 逻辑),45 秒无输出增长后检测交互式提示((y/n) 等),防止后台任务卡在无人响应的交互式对话框。P13 支持全局超时和显式取消,但没有输出停滞检测或交互提示识别。
并发限制。 CC 前台工具调用有 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY(默认 10 个并发安全工具);后台 bash 任务没有硬性限制,它们是独立子进程。P13 的 JobSupervisor 默认容量为 4,超载时返回 background_capacity 错误,不会创建第二个 job。CC 没有类似的容量拒绝机制。
状态能力。 CC 的 LocalShellTaskState 支持停止运行中的后台任务和读取后续输出(重定向到文件的结果可以按需读取下一段)。P13 的后台 job 状态机为 running → completed/failed/timed_out/cancelled/interrupted,支持取消和超时,但无法在 worker 运行中流式读取部分输出;终态结果是一次性完整交付的。
落盘粒度。 CC 的后台 bash 通过重定向 stdout/stderr 到文件实现持久化,重定向文件在 task 生命周期内持续增长。P13 用 JsonBackgroundJobStore 将每个 job 的终态结果写入独立 JSON 文件,proper-lockfile 保护原子迁移,进程重启时将遗留 running 迁移为 interrupted。两种策略侧重点不同:CC 偏流式,P13 偏终态快照。
上述 CC 实现细节来自本仓库的参考快照 learn-claude-code/s13_background_tasks/README.md 及该快照引用的 CC 源码行号。
12从 ai-agent-book 的 Flux 实验中学到什么ai-agent-book/chapter4/async-agent/README.md 是配套《深入理解 AI Agent》实验 4-5 的事件驱动异步框架。它的设计与本教程同一路线:异步工具先返回占位 taskid,任务完成后再以 async.result 新事件注入轨迹。P13 这次补上两处直接借鉴:按 ID 查询/取消后台 job,以及一次批量注入多条已就绪事件。⌄
ai-agent-book/chapter4/async-agent/README.md 是配套《深入理解 AI Agent》实验 4-5 的事件驱动异步框架。它的设计与本教程同一路线:异步工具先返回占位 task_id,任务完成后再以 async.result 新事件注入轨迹。P13 这次补上两处直接借鉴:按 ID 查询/取消后台 job,以及一次批量注入多条已就绪事件。
13Flux 的实验设计Flux 把事件按紧急度分成三类:INTERRUPT 立即取消当前 turn 和后台任务;IMMEDIATE 立即回答但不打断后台;DEFERRED 进入 pending 缓冲,等异步结果到达时一次性批量追加。配套实验先用三个离线演示做可测量验证:并行 vs 串行墙钟对比、打断后恢复、检查点跨会话恢复。随后用四个 LLM 场景验证异步执行、事件批量、打断和按进度取消。它把状态检查点保存到 trajectory + task progress,恢复时把 running 任务标为 suspended,让上层决定重跑还是续跑。⌄
Flux 把事件按紧急度分成三类:INTERRUPT 立即取消当前 turn 和后台任务;IMMEDIATE 立即回答但不打断后台;DEFERRED 进入 pending 缓冲,等异步结果到达时一次性批量追加。配套实验先用三个离线演示做可测量验证:并行 vs 串行墙钟对比、打断后恢复、检查点跨会话恢复。随后用四个 LLM 场景验证异步执行、事件批量、打断和按进度取消。它把状态检查点保存到 trajectory + task progress,恢复时把 running 任务标为 suspended,让上层决定重跑还是续跑。
14P13 的对照与取舍- 查询/取消。 Flux 的 TaskManager 提供 querytask(taskid) 和 canceltask(taskid),Agent 可以在任务运行中查进度、按阈值取消。P13 新增 querybackgroundjob 与 cancelbackgroundjob,同样由 job id 定位状态;但 P13 不保留流式进度,running 时只返回 result: null,取消后返回持久化终态。⌄
- 查询/取消。 Flux 的
TaskManager提供query_task(task_id)和cancel_task(task_id),Agent 可以在任务运行中查进度、按阈值取消。P13 新增query_background_job与cancel_background_job,同样由 job id 定位状态;但 P13 不保留流式进度,running 时只返回result: null,取消后返回持久化终态。这是刻意取舍:不做进度事件——3 分钟每秒报一次就是 180 条 user 消息吃光上下文预算,模型想知道进度就主动查一次,拉取优于推送。 - 批量事件。 Flux 的 queued processing 把非紧急 pending 事件在异步结果到达时批量追加;P13 的 Loop 在每次模型请求前
drainEvents()取走整批已就绪事件,按eventId去重后带batch位置注入。P13 没有单独 pending buffer,也没有用户消息紧急度分类;外部事件统一在下一模型轮次前批量可见。 - 打断。 Flux 支持用户
INTERRUPT取消当前 LLM turn 与全部后台任务;P13 提供显式取消工具和 close 时统一取消,但没有“用户消息打断当前模型请求”的机制。 - 持久化。 Flux 保存完整 trajectory 与 task progress,恢复为
suspended;P13 保存每个 job 的终态 JSON,重启把遗留running迁移为interrupted。P13 更偏终态快照,不能恢复中间进度。 - 安全边界。 Flux 只运行白名单 Python 子进程且不调用 shell;P13 后台化的是已经过 Hook、权限和审批的真实 PowerShell 工具,因此使用受管 worker、容量、超时和关闭边界而不是任意 shell 进程池。
这部分不是照搬 Flux,而是把“后台慢操作可以查询、取消、批量回传”吸收进 P13 的持久化 job 状态机。
15运行第 13 章Set-Location 'F:\笔记\Agent实操\code'⌄
从 code/ 目录运行固定入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run ch13 -- --prompt "后台运行 npm test,同时读取 README.md;拿到结果后再总结"也可以使用统一入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run agent-tutorial -- run --chapter 13 --prompt "后台运行 npm test,同时读取 README.md;拿到结果后再总结"本章回归测试:
- [ch13-background.test.ts](code/chapters/ch13/tests/ch13-background.test.ts):三态判断、typed-only inbox、成功/异常、容量、超时、取消、关闭、重启中断、后台工具查询/取消和批量事件位置;
- [ch13-bootstrap.test.ts](code/chapters/ch13/tests/ch13-bootstrap.test.ts):P13 依赖、后台工具注册、shell schema 和资源接线;
- [ch13-entry.test.ts](code/chapters/ch13/tests/ch13-entry.test.ts):固定入口和四项配置缺失;
AgentRunner集成测试:占位结果、事件回流、批量注入和消息配对。
直接执行聚焦套件:
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch13
npm run typecheck第 13 章解决的是“已经决定执行的慢操作,怎样不挡住同轮快工作,同时仍有可靠终态和关闭边界”。下一章才处理未来时间点生成工作意图的 Cron 状态机。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始