學(xué)會Agent Harness工程(13):Background Tasks避免慢操作阻塞)
0基礎(chǔ)學(xué)會Agent Harness工程13Background Tasks避免慢操作阻塞本篇對應(yīng)的官方文檔Learn Claude Codes13 Background Tasks支撐慢工具后臺分派、占位結(jié)果和完成通知回填的教學(xué)結(jié)構(gòu)。OpenAI Function Calling用于核對 Chat Completions 中 assistanttool_calls與roletool/tool_call_id的配對邊界。Python threading用于核對Thread、Lock以及 daemon thread 在進(jìn)程退出時的資源釋放風(fēng)險。Python queue用于對比教學(xué)代碼的加鎖字典與專用多生產(chǎn)者、多消費(fèi)者隊(duì)列。本篇主要內(nèi)容第 12 篇已經(jīng)用Task、blockedBy、owner和 JSON 持久化記住目標(biāo)但工具 handler 仍在 Agent Loop 內(nèi)同步運(yùn)行一條十分鐘的命令會讓前臺一直等待。本篇增加background_tasks、background_results、Lock和 daemon thread先用占位 tool result 完成當(dāng)前協(xié)議配對再把真實(shí)結(jié)果作為新的task_notification注入messages最后追蹤通知時機(jī)、亂序和進(jìn)程退出等生產(chǎn)邊界。下篇預(yù)告慢操作已經(jīng)能離開前臺運(yùn)行但仍需要當(dāng)前交互先發(fā)起它。第 14 篇將加入 Cron Scheduler讓未來時間點(diǎn)主動喚醒 Agent。一、任務(wù)能跨輪保存為什么慢操作仍會占住循環(huán)第 12 篇的代碼主線是創(chuàng)建 Task、寫入.tasks、檢查blockedBy、認(rèn)領(lǐng)任務(wù)并在完成后解鎖下游。它解決了目標(biāo)的生命周期卻沒有改變工具的執(zhí)行方式agent_loop()取到一個 tool call 后仍然直接調(diào)用 handlerhandler 不返回循環(huán)就不能繼續(xù)。假設(shè)模型決定做兩件事先運(yùn)行耗時的依賴安裝同時讀取配置文件并檢查參數(shù)。若run_bash()使用subprocess.run()同步執(zhí)行代碼必須先等安裝結(jié)束才能回填 tool result模型也不可能在等待期間決定讀文件。Task 文件雖然記得“正在安裝”前臺依然被這次調(diào)用占住。這里需要區(qū)分三個對象Task 是業(yè)務(wù)目標(biāo)記錄“為什么做、依賴誰、當(dāng)前到哪一步”tool call 是模型在某一輪提出的結(jié)構(gòu)化行動意圖background job 是 Harness 為一次具體慢操作創(chuàng)建的執(zhí)行實(shí)例。一個 Task 可能觸發(fā)多個 background job一個 background job 也不能代替 Task 的 owner、依賴和完成標(biāo)準(zhǔn)。從生命周期邊界觀察Task 可以跨進(jìn)程保留background job 只存在于當(dāng)前 Python 進(jìn)程tool call 則屬于當(dāng)前模型請求的協(xié)議上下文。三條生命線只在明確節(jié)點(diǎn)交接模型通過 tool call 提出動作Harness 據(jù)此創(chuàng)建 background job完成后再由應(yīng)用決定是否更新 Task。若只因?yàn)楹笈_命令結(jié)束就直接把業(yè)務(wù) Task 標(biāo)記為 completed就跳過了結(jié)果校驗(yàn)、副作用確認(rèn)和下游解鎖條件。第 13 篇提出的解法是把“提交”與“取回結(jié)果”拆成兩次交接。前臺只創(chuàng)建線程并立即返回bg_id真實(shí) handler 在后臺執(zhí)行完成后將輸出寫入結(jié)果存儲前臺在后續(xù)循環(huán)里收集它把新狀態(tài)注入給模型。同步與后臺執(zhí)行的差異不在于命令本身而在于何時把控制權(quán)還給 Agent Loop。同步路徑要等真實(shí)輸出才能回填后臺路徑先回填“已提交”讓循環(huán)繼續(xù)處理其他動作。后臺化并沒有讓模型在同一時刻并行思考多輪。Agent Loop 依然是單線程的請求、回填與再請求只有工具執(zhí)行離開了這條主線。這個邊界能防止把“后臺 I/O”誤解為“多個 Agent 并行推理”。圖中的控制權(quán)變化還帶來一個實(shí)際判斷適合后臺化的不是“代碼看起來復(fù)雜”的工具而是調(diào)用方無需立刻拿到最終結(jié)果也能繼續(xù)推進(jìn)的操作。讀取即將用于下一步判斷的配置通常應(yīng)同步完成構(gòu)建、批量測試和遠(yuǎn)程部署則更適合返回句柄。若下一步嚴(yán)格依賴真實(shí)輸出過早后臺化只會把清晰的順序依賴改造成輪詢和等待。二、后臺執(zhí)行由哪些狀態(tài)組成s13_background_tasks.py保留了第 12 篇的 Task System、Prompt 組裝、Tool Schema、dispatch 和基礎(chǔ) Agent Loop。本篇只在持久運(yùn)行層增加四個關(guān)鍵對象background_tasks按bg_id記錄原 tool call、命令和running/completed狀態(tài)background_results保存已完成 handler 的文本輸出background_lock保護(hù)兩個字典的跨線程讀寫daemonThread真正執(zhí)行 handler結(jié)束后寫回狀態(tài)和結(jié)果。觀察下圖時重點(diǎn)不是記住四個變量名而是看“執(zhí)行實(shí)例、執(zhí)行結(jié)果、互斥規(guī)則、執(zhí)行載體”怎樣分別落位。只有把這四類職責(zé)拆開查詢狀態(tài)時才不必阻塞真正的工作完成結(jié)果也不會與仍在運(yùn)行的元數(shù)據(jù)混成一團(tuán)。兩個字典分別回答“它在做什么”和“它得到了什么”。把狀態(tài)和大段輸出分開可以在列表后臺工作時避免每次復(fù)制完整結(jié)果。但它們都是進(jìn)程內(nèi)存與第 12 篇的.tasksJSON 完全不同重啟后bg_0001的狀態(tài)和輸出不會恢復(fù)。是否轉(zhuǎn)入后臺由兩層規(guī)則決定。run_in_backgroundTrue是模型通過 Tool Schema 顯式提出的請求若沒有該標(biāo)志is_slow_operation()再用install、build、test、deploy等關(guān)鍵字做降級啟發(fā)。defis_slow_operation(tool_name:str,tool_input:dict)-bool:用關(guān)鍵字識別可能長時間運(yùn)行的 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:優(yōu)先采用顯式參數(shù)否則回退到啟發(fā)式判斷。iftool_input.get(run_in_background):returnTruereturnis_slow_operation(tool_name,tool_input)顯式標(biāo)志提供可觀察意圖啟發(fā)式只是容錯。兩者都不是可靠的資源調(diào)度pytest -q可能幾秒結(jié)束不含關(guān)鍵字的數(shù)據(jù)遷移卻可能運(yùn)行數(shù)小時。生產(chǎn)系統(tǒng)應(yīng)讓工具元數(shù)據(jù)聲明預(yù)期耗時、可后臺性和資源限制并由 Harness 最終決定。判斷鏈要觀察優(yōu)先級顯式True直接進(jìn)后臺否則只有 Bash 且命中慢關(guān)鍵字才進(jìn)后臺其他工具繼續(xù)同步。這保留了快操作的簡單反饋也避免所有 handler 都無條件地增加異步狀態(tài)。從決策圖進(jìn)入代碼時可以把它讀成一項(xiàng)策略函數(shù)而不是模型的最終命令。模型只提供偏好Harness 仍應(yīng)檢查工具是否允許后臺執(zhí)行、當(dāng)前容量是否充足、調(diào)用是否具有副作用以及調(diào)用方是否能夠接受稍后獲得結(jié)果。這樣即使 Tool Schema 暴露了run_in_background系統(tǒng)控制權(quán)也沒有交給模型。圖中的菱形判斷最終只輸出“采用哪條執(zhí)行路徑”不會改變原 tool call 的名稱和參數(shù)。繼續(xù)進(jìn)入代碼時要檢查后臺分支是否保存了足夠的關(guān)聯(lián)信息至少包括新的bg_id、原tool_call_id、命令摘要和當(dāng)前狀態(tài)。缺少這些字段之后即使獲得一段結(jié)果也無法解釋它來自哪次調(diào)用、應(yīng)該通知哪段會話。因此分派函數(shù)的職責(zé)到“創(chuàng)建可追蹤執(zhí)行實(shí)例”就結(jié)束了線程生命周期、結(jié)果寫入和通知交付分別由后續(xù)組件承擔(dān)。這樣的邊界讓未來把 Thread 換成進(jìn)程池或外部隊(duì)列時Agent Loop 的分支和 tool result 配對仍可保持不變。真正的后臺分派發(fā)生在start_background_task()。它保存調(diào)用信息創(chuàng)建 worker 閉包啟動 daemon thread然后立即返回bg_iddefstart_background_task(block)-str:在守護(hù)線程中執(zhí)行工具并返回后臺任務(wù) 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保護(hù)的是共享字典的復(fù)合讀寫不是將整個 handler 鎖住。worker 在鎖外執(zhí)行耗時工具只在更新狀態(tài)和結(jié)果時持鎖若把execute_tool()放在with background_lock里其他線程連查狀態(tài)都要等慢命令結(jié)束異步優(yōu)勢會被鎖粒度抵消。這段實(shí)現(xiàn)還隱含了一個狀態(tài)不變量background_tasks[bg_id]必須先以running出現(xiàn)worker 才能把它改成completed結(jié)果寫入與狀態(tài)切換也應(yīng)在同一次臨界區(qū)完成。否則 collector 可能看見“已完成但沒有結(jié)果”或者 worker 極快結(jié)束時訪問一個尚未登記的 ID。當(dāng)前代碼先登記再thread.start()正是為了維持這個順序。Python 文檔明確提醒daemon thread 會在進(jìn)程關(guān)閉時被突然停止打開的文件、事務(wù)和其他資源可能沒有正常釋放。因此daemonTrue只是讓教學(xué) CLI 退出時不被后臺線程拖住并不代表任務(wù)可靠完成。三、占位結(jié)果和完成通知怎樣接回messages后臺化最容易混淆的地方不是線程而是 Chat Completions 消息配對。assistant 已經(jīng)輸出一個帶 ID 的 tool call后續(xù)roletool結(jié)果必須使用對應(yīng)tool_call_id。若 Harness 什么都不回填只想等后臺結(jié)束再說當(dāng)前消息組就不完整模型也無法先繼續(xù)處理其他事情。所以第一次回填不是最終輸出而是占位結(jié)果“后臺任務(wù)bg_0001已啟動結(jié)果完成后可用”。這條消息仍用原block.id作為tool_call_id因?yàn)樗卮鸬恼恰氨敬喂ぞ哒{(diào)用是否已被 Harness 接受”。工具調(diào)用 ID 在這個時刻已經(jīng)消費(fèi)完畢。若真實(shí)命令結(jié)束后再發(fā)一條相同tool_call_id的 tool message就相當(dāng)于一個調(diào)用返回兩次結(jié)果既破壞消息組的一對一關(guān)系也會讓歷史裁剪和重放無法判斷哪條是有效結(jié)果。真實(shí)完成是之后發(fā)生的環(huán)境事件因此代碼把它組裝為task_notification文本再以新的roleuser消息注入。這是 Harness 內(nèi)部通知協(xié)議不是 OpenAI API 新增的標(biāo)準(zhǔn) message roleXML 標(biāo)簽也只是應(yīng)用選擇的可讀包裝。defcollect_background_results()-list[str]:取出已完成后臺結(jié)果并組裝成新的通知。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()使已收集的結(jié)果不會在下一輪再次注入這是一個最小的進(jìn)程內(nèi)去重。但它沒有持久化 acknowledgement如果已從字典刪除還沒把消息安全寫入會話時進(jìn)程崩潰通知會丟失。反過來若先寫消息后標(biāo)記已消費(fèi)中間崩潰又可能重復(fù)注入。兩次交接的完整時序是assistant 提出慢工具調(diào)用Harness 創(chuàng)建bg_id立即回填占位 tool result模型繼續(xù)處理快操作worker 在后臺完成后寫入結(jié)果下一次收集時再以新的 user message 把 observation 送回模型。這條時序暴露了教學(xué)實(shí)現(xiàn)的一個重要缺口collect_background_results()只在某輪工具處理后執(zhí)行。若后臺命令在 Agent 已經(jīng)返回純文本并退出循環(huán)后才完成且之后沒有新用戶輸入前臺不會被自動喚醒通知只能留在字典里等下一次交互。第 13 篇實(shí)現(xiàn)了“后臺完成后可在后續(xù)輪次看見”還沒實(shí)現(xiàn)“完成事件立即主動喚醒 Agent”。增量接回主循環(huán)的位置只有兩處執(zhí)行前用should_run_background()選擇同步或后臺一批 tool result 回填后調(diào)用collect_background_results()有完成項(xiàng)時追加 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 和結(jié)果存儲屬于持久運(yùn)行層同步/后臺分支位于工具執(zhí)行層通知則最終接回循環(huán)與狀態(tài)層的messages。按這張代碼坐標(biāo)讀完整文件時可以先定位agent_loop()的分支和回填再追蹤start_background_task()如何寫兩個字典最后看collect_background_results()如何刪除已消費(fèi)項(xiàng)。這條路徑比從文件第一行開始逐個復(fù)習(xí) Task、Memory 和 Prompt 更容易看見本篇增量。代碼地圖也說明了為什么本篇沒有重寫模型交互層后臺機(jī)制改變的是工具結(jié)果“何時可用”并沒有改變 assistant 如何提出 tool call。穩(wěn)定的消息協(xié)議讓新增能力集中在 Harness 內(nèi)部如果為了后臺執(zhí)行發(fā)明新的模型 role 或跳過原調(diào)用配對局部異步會反過來污染整條對話鏈。四、一組線程和字典為什么還不是可靠任務(wù)隊(duì)列在不配置 API、不調(diào)用模型端點(diǎn)的邊界下仍可以沿本地控制流推演一條正常路徑時刻Harness 動作background_tasksmessages新增內(nèi)容T0收到慢 Bash tool call無assistant tool callT1創(chuàng)建bg_0001并啟動 workerrunning占位 tool resultT2Agent 處理快工具running其他 tool resultT3worker 寫入輸出completed暫無T4collector 取出結(jié)果記錄被popusertask_notificationT5再次調(diào)用模型無新 observation 可見這項(xiàng)靜態(tài)推演能證明占位結(jié)果與完成通知分屬兩個時刻也能證明原 tool call 只配對一次。它不能證明實(shí)際模型一定會選擇后臺參數(shù)不能證明命令在進(jìn)程崩潰后會恢復(fù)也不能證明多進(jìn)程下狀態(tài)一致。當(dāng)兩個后臺工作幾乎同時完成時當(dāng)前代碼按background_tasks的字典遍歷順序收集不一定保留真實(shí)完成時間順序。如果業(yè)務(wù)必須先處理最早完成的事件應(yīng)保存completed_at或使用 FIFO queuePythonqueue.Queue已經(jīng)實(shí)現(xiàn)了線程間交換所需的鎖語義。當(dāng)前教學(xué)實(shí)現(xiàn)還有八類必須公開的缺口。進(jìn)程退出會中斷工作。daemon thread 不保證清理與結(jié)果寫回后臺狀態(tài)也沒有持久化。沒有取消和超時管理。run_bash()有單次 subprocess 超時卻沒有對 background job 暴露 cancel、deadline 或進(jìn)程組終止。沒有容量上限。每個慢調(diào)用都創(chuàng)建新線程缺少并發(fā)數(shù)、隊(duì)列長度、CPU、內(nèi)存和子進(jìn)程限制。輸出可能丟失。collector 只把前 200 個字符注入通知完整結(jié)果被pop后沒有持久可查的 artifact 地址。通知沒有確認(rèn)機(jī)制。結(jié)果從存儲移到messages的過程不是事務(wù)崩潰可導(dǎo)致丟失或重復(fù)。完成不會立即喚醒前臺。collector 依賴后續(xù)循環(huán)沒有 event loop、消息隊(duì)列消費(fèi)者或獨(dú)立喚醒器。缺少冪等與副作用語義。進(jìn)程無法確認(rèn)慢命令是未執(zhí)行、執(zhí)行中還是已執(zhí)行但未回報盲目重試可能重復(fù)部署或重復(fù)寫數(shù)據(jù)。線程安全不等于多進(jìn)程安全。threading.Lock只協(xié)調(diào)當(dāng)前進(jìn)程的線程其他進(jìn)程、機(jī)器和重啟后 worker 都看不到這把鎖。閱讀下圖時應(yīng)把左側(cè)每一種失敗都對應(yīng)到一個缺失的持久事實(shí)任務(wù)是否被可靠接收、由誰持有租約、是否允許重試、結(jié)果是否已經(jīng)交付、同一副作用是否執(zhí)行過。只增加更多線程無法補(bǔ)齊這些事實(shí)反而會擴(kuò)大并發(fā)窗口。這些風(fēng)險的共同根因是當(dāng)前方案只將“等待時間”移出前臺還沒有將任務(wù)交給可持久、可重試、可確認(rèn)的執(zhí)行系統(tǒng)。生產(chǎn)架構(gòu)至少需要 durable queue、worker lease、heartbeat、冪等鍵、取消信號、完整 artifact 存儲和 delivery acknowledgement。Task repository 保存業(yè)務(wù)目標(biāo)job queue 保存執(zhí)行實(shí)例notification channel 保存可重放事件三者不應(yīng)只用兩個字典模擬。第 13 篇的完整增量可以壓縮成一條鏈should_run_background()選擇執(zhí)行策略start_background_task()創(chuàng)建線程并返回占位結(jié)果worker 寫入完成狀態(tài)collect_background_results()將結(jié)果包裝為新 observationagent_loop()再把通知接回messages。原 Tool Schema 與 dispatch map 保持穩(wěn)定只在 Bash 參數(shù)與執(zhí)行策略上增加一個交點(diǎn)。不過它仍然只能處理“現(xiàn)在已經(jīng)發(fā)起的慢操作”。如果需要每天 09:00 自動檢查構(gòu)建或在兩小時后重新查詢某個 Task當(dāng)前 Harness 沒有時間規(guī)則、持久計(jì)劃和主動喚醒鏈。第 14 篇將在后臺執(zhí)行之上增加 CronJob、scheduler、queue processor 和一次性/周期性觸發(fā)。