第 十三 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

从串行到异步:AI Agent 架构演进中的“慢操作”填坑指南

💡 shell 三态 run_in_background;占位结果 + typed 完成后事件回流。
工具调用可以先返回后台占位,再在完成后通过 typed RuntimeEvent 回到同一个 Agent Loop。后台只改变谁等待结果,不改变谁拥有权限。
本章进度
0%
1 本章要掌握的目标
  • shell 三态:run_in_background=true 强制后台 / false 强制同步 / null 启发式。
  • 后台提交必须在权限之后:Pre 阻断或权限拒绝时不创建 job。
  • 原始 tool_call_id 立即得到唯一占位结果,真实结果作为 BackgroundJobEvent 回流。
  • 后台 job 状态机 running → completed/failed/timed_out/cancelled,含 interrupted 恢复。
  • 先落盘再启动 worker;重启把遗留 running 迁移为 interrupted 且不重放。
2 核心知识点
三态参数

true 强制后台;false 强制同步;null/省略才用启发式(cargo build/compile/deploy/docker build/npm install/pip install/pytest 等关键词)。工具定义中的 concurrency: background_eligible 是第二道门。

const backgroundShellInputSchema = z.strictObject({
  command: z.string().min(1),
  run_in_background: z.boolean().nullable().optional().default(null),
});

function shouldRunInBackground(input) {
  if (input.run_in_background !== undefined && input.run_in_background !== null)
    return input.run_in_background;
  return markers.some(m => input.command.toLowerCase().includes(m));
}
为什么提交必须在权限之后

Pre Hook 阻断时,不创建 job;权限拒绝时,不创建 job、不启动 executor、不调用 shell handler;权限通过后才按『容量检查→持久化 running→登记 worker』提交。

parse + validate → Pre Hook → PermissionPolicy → dispatcher
  → 容量检查 → 持久化 running → 登记 worker → 返回占位
占位结果与完成事件

后台命令未完成时,原始 tool_call_id 立即得到占位:{job_id, status:running, tool_name}——它是普通成功 ToolResult,只说「已受理」,不说「已完成」。真实结果包成 typed RuntimeEvent(kind=background_job,必带 eventId)注入为 role=user 消息:后台结果与原 tool_call 已无配对关系,硬塞成第二条 tool 消息会直接破坏协议。

占位: {"job_id":"...","status":"running","tool_name":"shell"}
事件: {"runtime_event":{"event_id":"...","kind":"background_job",
        "status":"completed","result":{"content":"404 passed"}},
       "batch":{"index":0,"total":1}}
后台 job 不是 Task 第四状态

Task 说「这件事该做」,后台 job 说「这次执行怎么样了」——一个 Task 可对应零/一/多次执行尝试。job 六状态:running 唯一活状态,五终态不可变。终态由 finishRunning 条件迁移仲裁:谁先写进磁盘谁定终态,晚到一方拿到 undefined 安静跳过发事件——「只发一次」靠磁盘状态,不靠内存标记位。

running -> completed / failed / timed_out / cancelled
restart:  running -> interrupted(重启不重放,恢复幂等)
事件怎样回到同一个 Loop

每轮模型请求前 drain events;模型暂时返回无工具文本而仍有 active job 时,事件泵等待终态事件,在同一个 run() 中继续下一次模型请求。事件按 eventId 去重并带 batch 位置。

let events = pump.drainEvents();
if (!events.length && waitForPendingWork && pump.hasPendingWork)
  events = await pump.waitForEvents();
for (const [i, ev] of injected.entries())
  history.push(runtimeEventMessage(ev, { index: i, total: injected.length }));
拉取优于推送;不做清单

刻意不做进度事件:3 分钟每秒报一次 = 180 条 user 消息吃光上下文预算。模型想知道进度就调 query_background_job 查一次——这正是查询工具存在的意义。同样刻意不做的:排队(满了直接报 background_capacity,否则「提交成功」变成谎言)、抢占式取消(JS 杀不死 Promise,协作式发信号+等+超时报错)、子 Agent 后台、自动重试。P13 的后台任务本质是 Promise 不是子进程——架构可平移,换的只是 BackgroundOperation 内部实现。

3 机制流程
1
schedule + 权限通过

PreToolUse → 权限 → 后台提交(不在之前)。

2
落盘 running + 启动 worker

容量 4、单 job 超时 120s、关闭时限 10s。

3
返回占位结果

闭合当前工具轮,快工具继续。

4
终态事件回流

BackgroundJobEvent → EventInbox → 普通 user 消息。

5
query/cancel

query_background_job / cancel_background_job 按 job_id。

4 术语表
BackgroundDispatcher判断同步或后台;本身不做权限判断,只提交已获批的调用。
JobSupervisor后台 worker 唯一 owner:容量 4、单 job 超时 120s、协作式取消、close 超时报 background_close_timeout。
RuntimeEvent外部异步结果进 Loop 的唯一接口:必有 eventId + toPayload();EventInbox 只收 typed 对象。
条件迁移finishRunning:磁盘上还是 running 才写终态;竞速输家拿 undefined,事件恰好发一次。
interrupted进程异常退出遗留 running 的恢复终态,error_code=background_interrupted;连续重启也只收到一份通知。
background_capacity容量满载时的错误码;不排队。
5 QA 测试环节(自测题)
已完成 0 / 6 · 答对 0
Q1. run_in_background 的三态语义是?
Q2. 权限拒绝时后台 job 会?
Q3. 后台命令未完成时,原始 tool_call_id 得到?
Q4. 重启后遗留的 running job 会?
Q5. 同一条 assistant 消息里快工具与慢工具的关系是?
Q6. 取消工具返回什么?
6 验证与实验
  • npm run test:ch13:34 个测试文件 / 285 个用例(本章新增 3 个测试文件共 14 个用例);P13 主 Agent 工具序列 15 个(原 13 + query/cancel 两个 job 工具)。
  • 验证三态判断、typed-only inbox、竞速不双写、事件只发一次、重启中断恢复;用 ManualExecutor/CoordinatingModel 离线跑,不需要 API Key。
  • npm run ch13 -- --prompt "后台运行 npm install,同时读取 README.md;拿到结果后再总结"
  • 背景 shell 只给 P13 主 Agent(子 Agent 硬编码 false,P01–P12 无此参数)。