你睡覺時它還在幫你幹活:agent 從一次對話變成一套系統(AI Agent 架構全解析 5/8)
到上一篇為止,我們手上的 agent 已經算是個能幹的助手:有核心迴圈能一輪一輪推進、能拆解複雜任務、有記憶跟韌性撐著不會一出錯就崩掉。但它骨子裡還是「你問一句、它做一輪」的東西——你在螢幕前敲鍵盤它才動;你關掉對話,它腦中那份計畫也跟著蒸發。這對聊天夠用,對一個要真的幫你做事的系統遠遠不夠。
真正在幹活的工程師不是這樣工作的:你手上同時有好幾件事在跑,一個建置在背景轉、一份報告排在明早自動出、兩個分支各改各的互不干擾。這一篇要談的,就是怎麼把 agent 從「一次對話」升級成「一套持續運行的系統」。它用到四塊:讓工作狀態活得比單一 turn 久的 task system、把耗時工作丟出主迴圈的 background execution、讓 agent 被時鐘自動叫醒的 scheduling,以及讓平行任務各用一塊隔離工作區的 worktree isolation。這四塊分別對應到 Claude Code 你可能用過、卻沒細想過原理的功能。一塊一塊拆。
一、task system:讓計畫活得比一次對話久
先講一個你一定踩過的坑。你讓 agent 做多步驟的事,它在腦中(那個 context window 裡)列了張清單:先做 A、再做 B、B 好了才能做 C。這張清單只要 turn 一結束、process 一被關掉就消失得乾乾淨淨,沒辦法跨對話存活,也沒辦法強制「B 沒好就不准動 C」的順序。它只是模型腦中的一段文字,不是系統會當真的東西。
task system 要解決的就是這個。做法很直接:把每個工作單元寫成磁碟上一筆持久記錄,而不是留在記憶體裡。你可以把它想成一套工單系統,或一張待辦資料庫的表——每張工單有 id、主旨、狀態(pending → in_progress → completed),還有欄位記著「被誰認領了」「卡在哪些單還沒好」。這讓它跟腦中清單有本質差別:跨 turn、跨 session、甚至當機重開都還在,而且同一張單只會被一個 worker 認領。
機制上,一個 task 就是磁碟上一筆 JSON 記錄,blockedBy 和 blocks 兩個對稱欄位是相依關係的邊。真正的關鍵是那道叫 claim(認領)的關卡:它在把某張單指派給某個 worker 前先做兩件檢查——這張單是不是已被別人認走?它卡著的前置單是不是全都完成了?兩關都過才給認領。而且這道檢查要加鎖,因為多個 worker 同時搶時,「先檢查再設定」必須是原子的,不然兩個 worker 會同時覺得自己搶到了。
1 | def claim(self, tid, owner): # tasks.py:認領關卡 |
效果是:就算十個 worker 在同一瞬間搶同一張單,靠那把鎖也只有一個拿到 ok: True;還沒解除阻擋的單誰都認領不到。順序就這樣被資料本身強制住,不靠模型自律。
對應到 Claude Code,這就是它的持久 task 機制。每張 task 存成 ~/.claude/tasks/{taskListId}/{id}.json,一個檔一張單,另有個 .highwatermark 記著已發出的最大 id、保證號碼永不重複使用;它用 proper-lockfile 讓認領序列化。你在 Claude Code 裡看到那份會跨對話留著、勾掉一項少一項的任務清單,底下就是這套在撐。
踩雷區: 相依關係最容易出兩種事。一是相依循環——A 卡 B、B 又卡 A,兩張單互相等永遠沒人能動,要嘛保證圖無環、要嘛在建立時加循環檢查。二是卡在 in_progress 的孤兒單:worker 認領完就當掉,ownership 永遠掛在死掉的 worker 身上,所以 worker 離場時一定要清掉它的 ownership,不然單子會慢慢卡死。
二、background execution:把慢工作丟去背景跑
task system 讓工作狀態活得久,但 agent 幹活時還有另一個問題:慢。安裝依賴、建置、跑整套測試、記憶整併,動輒好幾分鐘。而基本的 agent loop 是同步的:呼叫一個工具就死等它回傳,才敢再呼叫模型。對「讀一個檔案」這種毫秒級操作沒差,但對一個要跑五分鐘的建置,整個 agent 就這樣被凍住五分鐘什麼都做不了。
background execution 的想法跟你在後端塞一個 job queue 一模一樣。碰到明知會跑很久、又可以邊做別的邊等的工作,你不會讓主流程卡在那,而是丟進背景 job,主流程立刻拿到一個 handle 繼續走,等它跑完再通知回來。它要做的是:啟動後立刻回傳 handle、追蹤 running / completed / failed / killed 狀態、再在稍後某個 turn 把完成訊息送回迴圈。
1 | def start(self, fn): # background.py:把工作丟出主 loop,立刻回傳 |
效果是:呼叫端拿到 task id 就繼續走,慢工作在旁邊的 thread 自己跑,跑完把結果推進 queue。這裡有個很容易搞錯的細節——被背景化的那個工具呼叫,當下就立刻回傳了一個正常的 tool_result(內容大概是「已在背景啟動 task 3」),真正的完成結果是稍後以一則全新、獨立的 notification 訊息抵達的,不是給原本那個 tool_use_id 的延遲回覆。
那完成訊息怎麼回到迴圈?靠一個叫 drain_into 的動作,在每個 turn 開始、呼叫模型之前,把 queue 裡已完成的 notification 併進下一個 user turn 的最前面。所以模型是在「下一輪開場」才知道「剛才那個背景建置跑完了」。漂亮之處在於 agent loop 幾乎沒改:「一個工具呼叫對一個工具結果」的規則照舊,背景執行只是工具的一個執行選項(schema 加個 run_in_background 旗標),不是特殊的工具型別。
對應到 Claude Code,這就是你用過的背景任務。BashTool 支援 run_in_background,LocalShellTask 追蹤背景 shell 指令,連記憶整併都靠 DreamTask 在背景做。完成訊息透過 enqueueTaskNotification 送進一個共享 queue,還分 now、next、later 三種優先級。另外 Sleep 是非阻塞的等待,不佔住一個 shell process 乾等。
踩雷區: 背景執行提升吞吐量,但失敗方式更陰。第一,process 洩漏——背景工作可能活得比 session 還久,session 關了它還在跑,這在無人值守排程裡特別要命,一定要註冊清理機制、加 kill 路徑跟並行數上限,不然殭屍 process 會把資源吃光。第二,互動式提示卡住:背景指令在等你輸入 yes/no,但沒人在螢幕前,它就無聲地卡著——要偵測像提示的輸出並通知模型去 kill 或改非互動方式重跑。第三,完成訊息遺失:跑完的 task 從沒把訊息送回迴圈等於白跑,讓它一律走同一個共享 queue、送出後標記已通知。
三、scheduling:讓時鐘來啟動 agent
background execution 解決了「工作能不擋主流程地跑」,但有個前提沒被回答:誰來啟動它? 到目前為止一切都還是 user 輸入觸發的——你不敲字什麼都不動。可是很多工作的本質就是「稍後才跑」或「重複跑」:每天早上的一份摘要、十分鐘後的一則提醒、每小時輪詢一次某個狀態。這些不該由你手動去點。
scheduling 就是給 agent 裝一個 cron。核心概念是把「時鐘」跟「迴圈」徹底分開。一個 schedule 本身只是一筆資料——要跑的 prompt、一個 fire 時間、選擇性的重複間隔。有個 scheduler 在獨立的 daemon thread 上默默看時間,時間到了它不直接呼叫模型,只把那個 prompt 丟進 pending queue。真正跑 turn 的前景 driver 會等到沒有任何 turn 在進行時才把 queue 排空,把每個排程 prompt 當成一個像 user 輸入一樣的新 turn 交給同一套 agent loop 去跑。一次性的 fire 完就刪掉自己,週期性的則重新裝填到下一個間隔。
這裡藏著一個排程特有、新手常忽略的問題:答案送去哪? 一般對話裡答案顯示在螢幕上因為你在看;但排程觸發的 turn 跑起來時螢幕前根本沒人,答案不主動送出去就等於白算。所以每筆排程資料多帶一個 channel 欄位,記著這答案該投遞到哪(console、聊天平台、email),fire 時 channel 跟著 prompt 一起進 queue。還有個貼心約定叫 [SILENT]:排程任務跑完,如果模型判斷「這次沒什麼值得吵使用者」(例如巡檢一切正常),就讓答案以 [SILENT] 開頭,投遞層看到直接跳過,但完整文字照樣留檔。這對每小時輪詢的 task 太重要了——你不會想每小時被通知一次「一切正常」。
對應到 Claude Code,這就是 CronCreate / CronList / CronDelete 那組工具。一個 cron 項目存著 id、cron 表達式、prompt、recurring、durable;fire 出來的 prompt 用 priority: 'later' 進 queue,等沒 turn 在跑時排空;設 durable: true 會寫進 .claude/scheduled_tasks.json,重啟後還在。另有一個 RemoteTriggerTool 用託管 trigger 讓工作不靠本地 process 也能 fire。這一篇不少讀者自己在跑無人值守排程(每日摘要、定時發文那類),對這套體感應該特別強——相對地,系列裡走常駐 server 路線的 Hermes Agent 就能真正在無人看管下 fire。
踩雷區(無人值守最痛的一段): 排程在沒人盯著時的失敗是無聲的——沒人會即時發現它沒跑或跑錯。三個最常見的坑:一,durable 不等於永遠開機,本地 durable schedule 只保證「重啟後那筆設定還在」,你機器睡著、關機那段時間它根本不會 tick、不會 fire;要離線也能觸發,得改用 remote trigger 或 OS 層的 timer。二,重複 fire:一次很快的 tick 可能在同一個 cron 分鐘內比對到不只一次,導致同一件事跑兩遍,要追蹤上一次 fire 的分鐘來去重。三,一堆 schedule 擠在同一時刻一起 fire:對週期性 task 加一點有決定性的 jitter 把它們錯開。
四、worktree isolation:平行任務各開一塊工作區
前面三塊湊起來,你的 agent 已經能持久記狀態、能背景跑、能被時鐘叫醒。接下來很自然會想同時派好幾個 agent 平行幹活。但這時冒出一個檔案層級的問題:單一工作目錄是共用的可變狀態。兩個 agent 如果同時往同一個檔案寫東西,其中一個很可能無聲地蓋掉另一個的成果。
三層職責要分清楚:task system 決定「有哪些工作要做」,subagent 決定「工作怎麼拆」,而 worktree isolation 決定「檔案寫入到底發生在哪裡」。它的解法你在團隊協作時天天在用——每個人開自己的 branch、在自己的工作目錄裡改,改完再合,過程中誰也踩不到誰。worktree isolation 就是把這套搬給平行的 agent:每個工作單元配一份私有 checkout 和一條自己的 branch,那個 agent 的檔案工具跟 shell 工具全部在這份 checkout 裡解析路徑。
實作上有個關鍵眉角:綁定工作目錄不能用全域的 os.chdir。同一個 process 裡可能有好幾個 agent 在跑,你一 chdir 就把別人的工作目錄也一起改了。正確做法是用 context variable 做「每個 context 各自的 cwd」,工具去讀 get_cwd() 而不是全域 process cwd;從 loop 外面用一個 cwd_override context manager 包住一個 turn,loop 跟 subagent 一行都不用改,只有「工具看到的工作目錄」變了。收尾時 remove 有個保護——除非你明確強制,否則它拒絕移除有變更的 worktree、留下來讓你 review,乾淨的就自動清掉。另外 slug 會被拿去組路徑,所以組路徑或下 git 指令之前要先驗證,擋掉 ../ 這類路徑穿越。
對應到 Claude Code,這正是它的 git worktree 隔離。AgentTool 可以帶 isolation: 'worktree',worktree 開在 .claude/worktrees/<slug>、branch 命名為 worktree-<slug>。subagent 用 runWithCwdOverride 搭 AsyncLocalStorage 做 context-local 的 cwd(跟上面的 context variable 同一個道理,換成 JS 機制),session 層級模式才用 process.chdir。ExitWorktreeTool 除非 discard_changes 為 true,否則有變更時拒絕拆除。
踩雷區: worktree 給你的是真正的檔案系統隔離跟乾淨的 diff,代價是磁碟空間、建置時間、還有事後那道 merge 手續。最實際的長期問題是陳舊 worktree 堆積——沒清乾淨的 worktree 會一份一份佔著磁碟,跑久了很可觀,所以只清掃你認得的臨時 worktree、有變更的留著審。記住那個總原則:共用目錄比較簡單,但它無法安全地支援平行的寫入者,一旦要平行就得隔離。
從一次對話,到一套會自己跑的系統
這四塊疊起來,你的 agent 已經不是那個「你問一句它做一輪」的助手了,而是一套能在你睡覺時持續運行、能背景做事、能排程、能平行不打架的系統。下面這張表方便你回頭查各塊解決什麼、對應到 Claude Code 的哪個機制:
| 這一塊 | 解決什麼 | 類比 | Claude Code 對應 |
|---|---|---|---|
| task system | 工作狀態活得比一次對話久,順序被資料強制 | 工單系統/待辦資料庫 | ~/.claude/tasks/ 下的持久 JSON task |
| background execution | 慢工作不阻塞主迴圈 | 丟進背景 job queue | BashTool 的 run_in_background |
| scheduling | 時鐘取代 user 成為觸發源 | cron | CronCreate 那組工具、RemoteTriggerTool |
| worktree isolation | 平行寫入者互不踩踏 | 各開一條 branch 與工作目錄 | isolation: 'worktree'、.claude/worktrees/ |
不過你有沒有注意到一件事:這一整篇雖然讓 agent 能平行做事,但那些平行的 agent 彼此還是各跑各的、靠 task 圖跟檔案隔離間接協調,沒有真正「講話」。當你要讓好幾個 agent 針對同一個目標分工、互相把成果交接、甚至一個當協調者去指揮其他幾個——那就是另一個層級的題目了。
第 6 篇會進到多代理協作:subagent 怎麼被生出來、context 怎麼在它們之間傳遞而不互相污染、一個 leader 怎麼把工作切給幾個 delegate 再把結果收回來。前面鋪的 task system 跟 worktree isolation,到那篇會變成多代理協調的地基。我們下一篇見。














