:Background Tasks避免慢操作阻塞)
0基础学会Agent Harness工程13Background Tasks避免慢操作阻塞本篇对应的官方文档Learn Claude Codes13 Background Tasks支撑慢工具后台分派、占位结果和完成通知回填的教学结构。OpenAI Function Calling用于核对 Chat Completions 中 assistanttool_calls与roletool/tool_call_id的配对边界。Python threading用于核对Thread、Lock以及 daemon thread 在进程退出时的资源释放风险。Python queue用于对比教学代码的加锁字典与专用多生产者、多消费者队列。本篇主要内容第 12 篇已经用Task、blockedBy、owner和 JSON 持久化记住目标但工具 handler 仍在 Agent Loop 内同步运行一条十分钟的命令会让前台一直等待。本篇增加background_tasks、background_results、Lock和 daemon thread先用占位 tool result 完成当前协议配对再把真实结果作为新的task_notification注入messages最后追踪通知时机、乱序和进程退出等生产边界。下篇预告慢操作已经能离开前台运行但仍需要当前交互先发起它。第 14 篇将加入 Cron Scheduler让未来时间点主动唤醒 Agent。一、任务能跨轮保存为什么慢操作仍会占住循环第 12 篇的代码主线是创建 Task、写入.tasks、检查blockedBy、认领任务并在完成后解锁下游。它解决了目标的生命周期却没有改变工具的执行方式agent_loop()取到一个 tool call 后仍然直接调用 handlerhandler 不返回循环就不能继续。假设模型决定做两件事先运行耗时的依赖安装同时读取配置文件并检查参数。若run_bash()使用subprocess.run()同步执行代码必须先等安装结束才能回填 tool result模型也不可能在等待期间决定读文件。Task 文件虽然记得“正在安装”前台依然被这次调用占住。这里需要区分三个对象Task 是业务目标记录“为什么做、依赖谁、当前到哪一步”tool call 是模型在某一轮提出的结构化行动意图background job 是 Harness 为一次具体慢操作创建的执行实例。一个 Task 可能触发多个 background job一个 background job 也不能代替 Task 的 owner、依赖和完成标准。从生命周期边界观察Task 可以跨进程保留background job 只存在于当前 Python 进程tool call 则属于当前模型请求的协议上下文。三条生命线只在明确节点交接模型通过 tool call 提出动作Harness 据此创建 background job完成后再由应用决定是否更新 Task。若只因为后台命令结束就直接把业务 Task 标记为 completed就跳过了结果校验、副作用确认和下游解锁条件。第 13 篇提出的解法是把“提交”与“取回结果”拆成两次交接。前台只创建线程并立即返回bg_id真实 handler 在后台执行完成后将输出写入结果存储前台在后续循环里收集它把新状态注入给模型。同步与后台执行的差异不在于命令本身而在于何时把控制权还给 Agent Loop。同步路径要等真实输出才能回填后台路径先回填“已提交”让循环继续处理其他动作。后台化并没有让模型在同一时刻并行思考多轮。Agent Loop 依然是单线程的请求、回填与再请求只有工具执行离开了这条主线。这个边界能防止把“后台 I/O”误解为“多个 Agent 并行推理”。图中的控制权变化还带来一个实际判断适合后台化的不是“代码看起来复杂”的工具而是调用方无需立刻拿到最终结果也能继续推进的操作。读取即将用于下一步判断的配置通常应同步完成构建、批量测试和远程部署则更适合返回句柄。若下一步严格依赖真实输出过早后台化只会把清晰的顺序依赖改造成轮询和等待。二、后台执行由哪些状态组成s13_background_tasks.py保留了第 12 篇的 Task System、Prompt 组装、Tool Schema、dispatch 和基础 Agent Loop。本篇只在持久运行层增加四个关键对象background_tasks按bg_id记录原 tool call、命令和running/completed状态background_results保存已完成 handler 的文本输出background_lock保护两个字典的跨线程读写daemonThread真正执行 handler结束后写回状态和结果。观察下图时重点不是记住四个变量名而是看“执行实例、执行结果、互斥规则、执行载体”怎样分别落位。只有把这四类职责拆开查询状态时才不必阻塞真正的工作完成结果也不会与仍在运行的元数据混成一团。两个字典分别回答“它在做什么”和“它得到了什么”。把状态和大段输出分开可以在列表后台工作时避免每次复制完整结果。但它们都是进程内存与第 12 篇的.tasksJSON 完全不同重启后bg_0001的状态和输出不会恢复。是否转入后台由两层规则决定。run_in_backgroundTrue是模型通过 Tool Schema 显式提出的请求若没有该标志is_slow_operation()再用install、build、test、deploy等关键字做降级启发。defis_slow_operation(tool_name:str,tool_input:dict)-bool:用关键字识别可能长时间运行的 Bash 命令。iftool_name!bash:returnFalsecommandtool_input.get(command,).lower()slow_keywords[install,build,test,deploy,compile,docker build,pip install,npm install,cargo build,pytest,make,]returnany(keywordincommandforkeywordinslow_keywords)defshould_run_background(tool_name:str,tool_input:dict)-bool:优先采用显式参数否则回退到启发式判断。iftool_input.get(run_in_background):returnTruereturnis_slow_operation(tool_name,tool_input)显式标志提供可观察意图启发式只是容错。两者都不是可靠的资源调度pytest -q可能几秒结束不含关键字的数据迁移却可能运行数小时。生产系统应让工具元数据声明预期耗时、可后台性和资源限制并由 Harness 最终决定。判断链要观察优先级显式True直接进后台否则只有 Bash 且命中慢关键字才进后台其他工具继续同步。这保留了快操作的简单反馈也避免所有 handler 都无条件地增加异步状态。从决策图进入代码时可以把它读成一项策略函数而不是模型的最终命令。模型只提供偏好Harness 仍应检查工具是否允许后台执行、当前容量是否充足、调用是否具有副作用以及调用方是否能够接受稍后获得结果。这样即使 Tool Schema 暴露了run_in_background系统控制权也没有交给模型。图中的菱形判断最终只输出“采用哪条执行路径”不会改变原 tool call 的名称和参数。继续进入代码时要检查后台分支是否保存了足够的关联信息至少包括新的bg_id、原tool_call_id、命令摘要和当前状态。缺少这些字段之后即使获得一段结果也无法解释它来自哪次调用、应该通知哪段会话。因此分派函数的职责到“创建可追踪执行实例”就结束了线程生命周期、结果写入和通知交付分别由后续组件承担。这样的边界让未来把 Thread 换成进程池或外部队列时Agent Loop 的分支和 tool result 配对仍可保持不变。真正的后台分派发生在start_background_task()。它保存调用信息创建 worker 闭包启动 daemon thread然后立即返回bg_iddefstart_background_task(block)-str:在守护线程中执行工具并返回后台任务 ID。global_bg_counter _bg_counter1bg_idfbg_{_bg_counter:04d}argumentsjson.loads(block.function.arguments)commandarguments.get(command,block.function.name)defworker():resultexecute_tool(block)withbackground_lock:background_tasks[bg_id][status]completedbackground_results[bg_id]resultwithbackground_lock:background_tasks[bg_id]{tool_call_id:block.id,command:command,status:running,}threadthreading.Thread(targetworker,daemonTrue)thread.start()returnbg_idLock保护的是共享字典的复合读写不是将整个 handler 锁住。worker 在锁外执行耗时工具只在更新状态和结果时持锁若把execute_tool()放在with background_lock里其他线程连查状态都要等慢命令结束异步优势会被锁粒度抵消。这段实现还隐含了一个状态不变量background_tasks[bg_id]必须先以running出现worker 才能把它改成completed结果写入与状态切换也应在同一次临界区完成。否则 collector 可能看见“已完成但没有结果”或者 worker 极快结束时访问一个尚未登记的 ID。当前代码先登记再thread.start()正是为了维持这个顺序。Python 文档明确提醒daemon thread 会在进程关闭时被突然停止打开的文件、事务和其他资源可能没有正常释放。因此daemonTrue只是让教学 CLI 退出时不被后台线程拖住并不代表任务可靠完成。三、占位结果和完成通知怎样接回messages后台化最容易混淆的地方不是线程而是 Chat Completions 消息配对。assistant 已经输出一个带 ID 的 tool call后续roletool结果必须使用对应tool_call_id。若 Harness 什么都不回填只想等后台结束再说当前消息组就不完整模型也无法先继续处理其他事情。所以第一次回填不是最终输出而是占位结果“后台任务bg_0001已启动结果完成后可用”。这条消息仍用原block.id作为tool_call_id因为它回答的正是“本次工具调用是否已被 Harness 接受”。工具调用 ID 在这个时刻已经消费完毕。若真实命令结束后再发一条相同tool_call_id的 tool message就相当于一个调用返回两次结果既破坏消息组的一对一关系也会让历史裁剪和重放无法判断哪条是有效结果。真实完成是之后发生的环境事件因此代码把它组装为task_notification文本再以新的roleuser消息注入。这是 Harness 内部通知协议不是 OpenAI API 新增的标准 message roleXML 标签也只是应用选择的可读包装。defcollect_background_results()-list[str]:取出已完成后台结果并组装成新的通知。withbackground_lock:ready_ids[bg_idforbg_id,taskinbackground_tasks.items()iftask[status]completed]notifications[]forbg_idinready_ids:withbackground_lock:taskbackground_tasks.pop(bg_id)outputbackground_results.pop(bg_id,)notifications.append(task_notification\nf task_id{bg_id}/task_id\n statuscompleted/status\nf command{task[command]}/command\nf summary{output[:200]}/summary\n/task_notification)returnnotificationspop()使已收集的结果不会在下一轮再次注入这是一个最小的进程内去重。但它没有持久化 acknowledgement如果已从字典删除还没把消息安全写入会话时进程崩溃通知会丢失。反过来若先写消息后标记已消费中间崩溃又可能重复注入。两次交接的完整时序是assistant 提出慢工具调用Harness 创建bg_id立即回填占位 tool result模型继续处理快操作worker 在后台完成后写入结果下一次收集时再以新的 user message 把 observation 送回模型。这条时序暴露了教学实现的一个重要缺口collect_background_results()只在某轮工具处理后执行。若后台命令在 Agent 已经返回纯文本并退出循环后才完成且之后没有新用户输入前台不会被自动唤醒通知只能留在字典里等下一次交互。第 13 篇实现了“后台完成后可在后续轮次看见”还没实现“完成事件立即主动唤醒 Agent”。增量接回主循环的位置只有两处执行前用should_run_background()选择同步或后台一批 tool result 回填后调用collect_background_results()有完成项时追加 user notification。Tool Schema、Task System、Prompt 组装和chat_completion()都不需要重写。forblockinmessage.tool_calls:nameblock.function.name argumentsjson.loads(block.function.arguments)ifshould_run_background(name,arguments):bg_idstart_background_task(block)output(f[Background task{bg_id}started] Result will be available when complete.)else:outputexecute_tool(block)messages.append({role:tool,tool_call_id:block.id,content:str(output),})notificationscollect_background_results()ifnotifications:messages.append({role:user,content:\n\n.join(notifications),})在六层代码地图中第 13 篇的 worker 和结果存储属于持久运行层同步/后台分支位于工具执行层通知则最终接回循环与状态层的messages。按这张代码坐标读完整文件时可以先定位agent_loop()的分支和回填再追踪start_background_task()如何写两个字典最后看collect_background_results()如何删除已消费项。这条路径比从文件第一行开始逐个复习 Task、Memory 和 Prompt 更容易看见本篇增量。代码地图也说明了为什么本篇没有重写模型交互层后台机制改变的是工具结果“何时可用”并没有改变 assistant 如何提出 tool call。稳定的消息协议让新增能力集中在 Harness 内部如果为了后台执行发明新的模型 role 或跳过原调用配对局部异步会反过来污染整条对话链。四、一组线程和字典为什么还不是可靠任务队列在不配置 API、不调用模型端点的边界下仍可以沿本地控制流推演一条正常路径时刻Harness 动作background_tasksmessages新增内容T0收到慢 Bash tool call无assistant tool callT1创建bg_0001并启动 workerrunning占位 tool resultT2Agent 处理快工具running其他 tool resultT3worker 写入输出completed暂无T4collector 取出结果记录被popusertask_notificationT5再次调用模型无新 observation 可见这项静态推演能证明占位结果与完成通知分属两个时刻也能证明原 tool call 只配对一次。它不能证明实际模型一定会选择后台参数不能证明命令在进程崩溃后会恢复也不能证明多进程下状态一致。当两个后台工作几乎同时完成时当前代码按background_tasks的字典遍历顺序收集不一定保留真实完成时间顺序。如果业务必须先处理最早完成的事件应保存completed_at或使用 FIFO queuePythonqueue.Queue已经实现了线程间交换所需的锁语义。当前教学实现还有八类必须公开的缺口。进程退出会中断工作。daemon thread 不保证清理与结果写回后台状态也没有持久化。没有取消和超时管理。run_bash()有单次 subprocess 超时却没有对 background job 暴露 cancel、deadline 或进程组终止。没有容量上限。每个慢调用都创建新线程缺少并发数、队列长度、CPU、内存和子进程限制。输出可能丢失。collector 只把前 200 个字符注入通知完整结果被pop后没有持久可查的 artifact 地址。通知没有确认机制。结果从存储移到messages的过程不是事务崩溃可导致丢失或重复。完成不会立即唤醒前台。collector 依赖后续循环没有 event loop、消息队列消费者或独立唤醒器。缺少幂等与副作用语义。进程无法确认慢命令是未执行、执行中还是已执行但未回报盲目重试可能重复部署或重复写数据。线程安全不等于多进程安全。threading.Lock只协调当前进程的线程其他进程、机器和重启后 worker 都看不到这把锁。阅读下图时应把左侧每一种失败都对应到一个缺失的持久事实任务是否被可靠接收、由谁持有租约、是否允许重试、结果是否已经交付、同一副作用是否执行过。只增加更多线程无法补齐这些事实反而会扩大并发窗口。这些风险的共同根因是当前方案只将“等待时间”移出前台还没有将任务交给可持久、可重试、可确认的执行系统。生产架构至少需要 durable queue、worker lease、heartbeat、幂等键、取消信号、完整 artifact 存储和 delivery acknowledgement。Task repository 保存业务目标job queue 保存执行实例notification channel 保存可重放事件三者不应只用两个字典模拟。第 13 篇的完整增量可以压缩成一条链should_run_background()选择执行策略start_background_task()创建线程并返回占位结果worker 写入完成状态collect_background_results()将结果包装为新 observationagent_loop()再把通知接回messages。原 Tool Schema 与 dispatch map 保持稳定只在 Bash 参数与执行策略上增加一个交点。不过它仍然只能处理“现在已经发起的慢操作”。如果需要每天 09:00 自动检查构建或在两小时后重新查询某个 Task当前 Harness 没有时间规则、持久计划和主动唤醒链。第 14 篇将在后台执行之上增加 CronJob、scheduler、queue processor 和一次性/周期性触发。