AGAgent 学习路线
第 13 / 20
CHAPTER 13 · GPT 生成学习页

异步后台执行

让慢工具脱离当前请求,完成后通过 typed runtime event 回到同一个 Loop。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

异步后台执行

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • 后台化发生在安全管道之后,不复制第二套 Loop。
  • job 需要查询与取消能力。
  • 三态参数要区分未提供、明确 false 和 true。
1参数校验与权限
2提交后台 job
3主 Loop 继续响应
4完成事件回注
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

15 个小节17 组代码21 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

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_jobjob_id 查询或取消持久化后台 job不暴露给 subagent 与 P01-P12
EventInboxFIFO 传递 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_jobread{ job_id }当前持久化 job payload
cancel_background_jobwrite{ 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_capacityregisterBackgroundJobTools() 在组合根只对主 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.json

JsonBackgroundJobStore 沿用前章的持久化纪律。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_bashlocal_agentremote_agentin_process_teammatelocal_workflowmonitor_mcpdream,每种有自己的注册、生命周期和通知机制。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 后台任务完成后通过 enqueueTaskNotificationutils/task/framework.ts:267)或 enqueuePendingNotificationmessageQueueManager.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_jobcancel_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 状态机。

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始