0基础学会Agent Harness工程(13):Background Tasks避免慢操作阻塞
本篇对应的官方文档
- Learn Claude Code:s13 Background Tasks:支撑慢工具后台分派、占位结果和完成通知回填的教学结构。
- OpenAI Function Calling:用于核对 Chat Completions 中 assistant
tool_calls与role="tool"/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 后,仍然直接调用 handler,handler 不返回,循环就不能继续。
假设模型决定做两件事:先运行耗时的依赖安装,同时读取配置文件并检查参数。若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:保护两个字典的跨线程读写;- daemon
Thread:真正执行 handler,结束后写回状态和结果。
观察下图时,重点不是记住四个变量名,而是看“执行实例、执行结果、互斥规则、执行载体”怎样分别落位。只有把这四类职责拆开,查询状态时才不必阻塞真正的工作,完成结果也不会与仍在运行的元数据混成一团。
两个字典分别回答“它在做什么”和“它得到了什么”。把状态和大段输出分开,可以在列表后台工作时避免每次复制完整结果。但它们都是进程内存,与第 12 篇的.tasksJSON 完全不同;重启后bg_0001的状态和输出不会恢复。
是否转入后台由两层规则决定。run_in_background=True是模型通过 Tool Schema 显式提出的请求;若没有该标志,is_slow_operation()再用install、build、test、deploy等关键字做降级启发。
defis_slow_operation(tool_name:str,tool_input:dict)->bool:"""用关键字识别可能长时间运行的 Bash 命令。"""iftool_name!="bash":returnFalsecommand=tool_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_id:
defstart_background_task(block)->str:"""在守护线程中执行工具,并返回后台任务 ID。"""global_bg_counter _bg_counter+=1bg_id=f"bg_{_bg_counter:04d}"arguments=json.loads(block.function.arguments)command=arguments.get("command",block.function.name)defworker():result=execute_tool(block)withbackground_lock:background_tasks[bg_id]["status"]="completed"background_results[bg_id]=resultwithbackground_lock:background_tasks[bg_id]={"tool_call_id":block.id,"command":command,"status":"running",}thread=threading.Thread(target=worker,daemon=True)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 会在进程关闭时被突然停止,打开的文件、事务和其他资源可能没有正常释放。因此daemon=True只是让教学 CLI 退出时不被后台线程拖住,并不代表任务可靠完成。
三、占位结果和完成通知怎样接回messages
后台化最容易混淆的地方不是线程,而是 Chat Completions 消息配对。assistant 已经输出一个带 ID 的 tool call,后续role="tool"结果必须使用对应tool_call_id。若 Harness 什么都不回填,只想等后台结束再说,当前消息组就不完整,模型也无法先继续处理其他事情。
所以第一次回填不是最终输出,而是占位结果:“后台任务bg_0001已启动,结果完成后可用”。这条消息仍用原block.id作为tool_call_id,因为它回答的正是“本次工具调用是否已被 Harness 接受”。
工具调用 ID 在这个时刻已经消费完毕。若真实命令结束后再发一条相同tool_call_id的 tool message,就相当于一个调用返回两次结果,既破坏消息组的一对一关系,也会让历史裁剪和重放无法判断哪条是有效结果。
真实完成是之后发生的环境事件,因此代码把它组装为<task_notification>文本,再以新的role="user"消息注入。这是 Harness 内部通知协议,不是 OpenAI API 新增的标准 message role,XML 标签也只是应用选择的可读包装。
defcollect_background_results()->list[str]:"""取出已完成后台结果,并组装成新的通知。"""withbackground_lock:ready_ids=[bg_idforbg_id,taskinbackground_tasks.items()iftask["status"]=="completed"]notifications=[]forbg_idinready_ids:withbackground_lock:task=background_tasks.pop(bg_id)output=background_results.pop(bg_id,"")notifications.append("<task_notification>\n"f" <task_id>{bg_id}</task_id>\n"" <status>completed</status>\n"f" <command>{task['command']}</command>\n"f" <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:name=block.function.name arguments=json.loads(block.function.arguments)ifshould_run_background(name,arguments):bg_id=start_background_task(block)output=(f"[Background task{bg_id}started] ""Result will be available when complete.")else:output=execute_tool(block)messages.append({"role":"tool","tool_call_id":block.id,"content":str(output),})notifications=collect_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_tasks | messages新增内容 |
|---|---|---|---|
| T0 | 收到慢 Bash tool call | 无 | assistant tool call |
| T1 | 创建bg_0001并启动 worker | running | 占位 tool result |
| T2 | Agent 处理快工具 | running | 其他 tool result |
| T3 | worker 写入输出 | completed | 暂无 |
| T4 | collector 取出结果 | 记录被pop | usertask_notification |
| T5 | 再次调用模型 | 无 | 新 observation 可见 |
这项静态推演能证明占位结果与完成通知分属两个时刻,也能证明原 tool call 只配对一次。它不能证明实际模型一定会选择后台参数,不能证明命令在进程崩溃后会恢复,也不能证明多进程下状态一致。
当两个后台工作几乎同时完成时,当前代码按background_tasks的字典遍历顺序收集,不一定保留真实完成时间顺序。如果业务必须先处理最早完成的事件,应保存completed_at或使用 FIFO queue;Pythonqueue.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()将结果包装为新 observation,agent_loop()再把通知接回messages。原 Tool Schema 与 dispatch map 保持稳定,只在 Bash 参数与执行策略上增加一个交点。
不过,它仍然只能处理“现在已经发起的慢操作”。如果需要每天 09:00 自动检查构建,或在两小时后重新查询某个 Task,当前 Harness 没有时间规则、持久计划和主动唤醒链。第 14 篇将在后台执行之上增加 CronJob、scheduler、queue processor 和一次性/周期性触发。