# 長期運作 Agent harness 的 5 種設計模式

> 📖 本站完整內容索引（documentation index）：[llms.txt](/llms.txt)

> 原作者：Google Cloud Tech (@GoogleCloudTech) · 策展與摘要：EasyVibeCoding · 平台：X (Twitter) · 熱度：🔥🔥 · 日期：2026-08-20

> 原始來源：https://x.com/GoogleCloudTech/status/2090248297214525569

## 證據與延伸閱讀

- [# 長期運作 Agent harness 的 5 種設計模式](https://x.com/GoogleCloudTech/status/2090248297214525569)
- [模式 1 Stable prefix 解決快取](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/149c617b639032a2.jpg)
- [模式 2 Background learning](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/dd27df8d738c8011.jpg)
- [模式 3 Persistent workspace](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/7cb5cb816854ce3a.jpg)
- [模式 4 Explicit failure](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/602b146f42b63049.jpg)
- [模式 5 Guard chain 防護鏈](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/a84376b6ac7f58a9.jpg)
- [Google ADK Long Horizon harness 參考實作（非官方支援產品）](https://github.com/google/adk-samples/tree/main/core/python/long-horizon-harness) — 官方 Repository

## 中文摘要

# 長期運作 Agent harness 的 5 種設計模式

任何 Agent 在一次性任務上看起來都可能很厲害。但讓它實際工作一週，就會開始崩潰。

「長期運作」指的是 Agent 能跨越數天、數十個工作階段持續執行，而不是回答一次之後就忘記你。這類工作可能是需要執行三週的 schema migration，或是從一個 on-call 班次交接到下一個班次的 incident。

作者：@Saboo_Shubham_、@secchi_elia、@lavinigam

我們打造了一套並將其開放原始碼。Long Horizon 是一個建構在 Agent Development Kit 上的 Agent harness 參考實作，採用 Apache 2.0 授權，設計目標是讓人閱讀、擷取其中做法，而不是直接安裝。

在發布前，我們先讓它在自己的工作中運作了幾週。在那段期間記錄的幾乎每個 bug 裡，都出現了同一件事：從來沒有任何錯誤被拋出。

1. Prompt cache 從未生效，但 API 帳單看起來完全正常。

1. 每次回覆前先擷取記憶，讓每一輪互動都變慢；但使用者要到下週才會感受到這項功能的好處。

1. 一次 deploy 清掉了 Agent 花了一整個工作階段安裝的工具，而它仍然繼續執行，從頭安裝所有東西，並一路回報進度。

1. 一個 sub-agent 逾時，但 parent agent 卻愉快地回報工作已完成。

1. 一個命令繞過了我們的安全防護，連上雲端 metadata server；由於在防護機制看來什麼都沒出錯，因此完全沒有留下任何 log。

一次性 Agent 出錯時，你會直接看到它停下來。長期運作的 Agent 出錯時，卻會悄悄隱藏問題，然後繼續執行。你不需要龐大的 framework 來解決這件事，只需要五種設計模式，阻止 Agent 悄悄偏離正軌。

以下是我們學到的內容，以及如何將每一種模式套用到自己的技術堆疊。

## 模式 1：穩定的前綴

Prefix caching 理論上是很容易取得的效能提升：只要讓 prompt 的前段保持完全一致，provider 就能從快取提供這段內容，成本和延遲都只需原本的一小部分。

我們啟用它之後，cache hit rate 卻一直維持在 0%。

問題出在 memory preloader。每一輪互動，它都會擷取過去的對話，並直接插入 system prompt 的最前方。由於每一輪擷取到的記憶都可能不同，prefix hash 也就每一輪都會改變，因此快取從未建立起來。

可以把 prefix caching 想成 Docker build：如果修改靠近頂端的一行內容，下面的每一層都會從頭重新建置。

我們的修正方式，是按照每個部分的變動速度排列 prompt：

- 凍結（頂端）：System instructions、persona 和 tool definitions。在每一輪中都保持完全相同的位元組內容。

- 緩慢變動（中間）：使用者個人資料和啟用中的工具。

- 高度變動（尾端）：步驟計數器、runtime 警告和擷取出的記憶。

![Stable Prefix 在 system instruction 與 message tail 儲存 memory 時的快取命中率比較圖](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/149c617b639032a2.jpg)
> 將 Memory 放置於 message tail 相較於放置於 system instruction，能保持各 Turn 的指紋一致（fp a91c），讓 95% 請求從快取讀取（95% served from cache），解決快取無法形成的問題。

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">Stable Prefix: what broke and what fixed it

Memory in the system instruction
Turn 1: [藍色網底區塊] fp a91c
Turn 2: [藍色網底區塊] fp 4d0e
Turn 3: [藍色網底區塊] fp b72f
❌ cache never forms

Memory on the message tail
Turn 1: [藍色區塊與下方黃色標籤] fp a91c
Turn 2: [藍色區塊與下方黃色標籤] fp a91c
Turn 3: [藍色區塊與下方黃色標籤] fp a91c
✅ 95% served from cache</div></details>

同一段文字放在兩個不同位置。在 system prompt 中，它會讓快取每一輪失效；放到對話後方則不會。

把動態記憶移到尾端就是全部的修正。第一輪會暖化快取；之後每一輪都有 95% 的 prompt 直接由快取提供。

不要只是猜測，應該實際測量。如果第二輪回應 metadata 中的 cached token count 仍然是零，就代表 prefix 中仍有內容在變動。進行同樣的稽核後，我們把 prompt 從 70,000 個字元縮減到不到 22,000 個字元。

## 模式 2：背景學習

會隨時間學習的 Agent 必須擷取記憶並將其寫入保存。

一開始，我們在回覆前同步執行這件事。每一輪互動都因此變慢，只為了擷取使用者直到下週才會需要的記憶。

修正方式是 write-behind caching：先將回覆傳給使用者，再以相同的使用者身分在背景執行記憶擷取，讓寫入的內容能放在下一輪會尋找的位置。在 ADK 中，這可以接到 post-response plugin lifecycle。

![Background Learning: what the user waits for 記憶寫入時序比較圖，對比 Write first 與 Reply first 兩種機制在 model call、judge and write memory 及 reply 階段的時間分配差異。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/dd27df8d738c8011.jpg)
> 背景學習最佳化過程的對照圖，比較「Write first」與「Reply first」兩種運作流程在 model call、judge and write memory 與 reply 階段的時間分配與使用者等待時間差異。

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">Background Learning: what the user waits for
Doing the memory write before replying makes every turn slower for a benefit the user will not feel until next week. Shipping the reply first removes that cost from the turn entirely.

上方流程 (Write first)：
- 左側標籤：Write first (帶紅色垂直線)
- 區塊 1 (藍色)：model call
- 區塊 2 (紅色)：judge and write memory
- 區塊 3 (綠色)：reply
- 右上方標註：reply at last

下方流程 (Reply first)：
- 左側標籤：Reply first (帶綠色垂直線)
- 區塊 1 (藍色)：model call
- 區塊 2 (綠色)：reply
- 區塊 3 (黃色虛線框)：judge and write memory
- 下方標註：reply here
- 上方雙向箭頭標註：time the user no longer waits</div></details>

回應會立即返回。使用者收到回覆後，學習工作才會進入第二條執行路徑。

若要讓這套機制在 production 中安全運作，需要三項防護：

1. 保留強的 task 參考。在 Python 的 asyncio 等非同步 runtime 中，沒有被參考的背景 task 可能在寫入途中被 garbage collection，資料會在沒有拋出錯誤的情況下悄悄遺失。

1. 使用隔離的 sibling agent。只提供背景學習 Agent 最少的工具清單，將它的檔案寫入限制在記憶目錄，並且不提供任何 post-response hooks，避免它意外遞迴呼叫自己。

1. 限制執行頻率。每次背景 consolidation pass 之間等待 120 秒，避免一連串快速的使用者訊息觸發數十次重複的擷取工作。

最後，shutdown drain timeout 必須短於 runtime host 的 timeout。在 shutdown 時，我們會等待 4 秒讓執行中的寫入完成。如果把這個值設得高於 framework 的 5 秒清理限制，runtime 就會在寫入途中終止程序，資料最後仍然會遺失。

## 模式 3：持久化 workspace

同一位使用者的兩次訊息之間，可能相隔數小時甚至數天。當 Agent 再次被喚醒時，這段期間建立的所有內容都必須仍然存在。

但我們的系統一開始並不是這樣。一次標準的 backend deploy 清掉了 Agent 花了一整個工作階段安裝的 CLI 工具。它完全不受影響地繼續執行，從頭重新安裝所有東西，並一路回報進度。

標準的 Web 程式碼通常假設 request handler 沒有狀態。但長期運作的 Agent 是一個長生命週期程序，同一位使用者會持續回來使用它。

檔案、已安裝的工具，以及尚未完成的工作，都必須比單次互動存活得更久。工具呼叫應該透過負責管理這些狀態的執行介面，而不是直接對 host 執行。如此一來，同一份工具程式碼在開發環境中可以對本機檔案系統執行，在 production 中則對受管理的沙盒執行，而不需要知道兩者之間的差異。

![Persistent Workspace 機制中 Rebuild 與 Reattach 模式的比較流程圖](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/7cb5cb816854ce3a.jpg)
> Persistent Workspace: reattach instead of rebuild 的架構比較圖，上方顯示 Rebuild 模式在六小時後清除 workspace A 轉為空的 workspace B，導致工具消失且工作重啟；下方顯示 Reattach 模式在六小時後重新連接保持溫熱且已安裝 ruff 的 workspace，使工具保持不變。

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">圖表標題：
Persistent Workspace: reattach instead of rebuild

上方流程（標籤為紅色方框 Rebuild）：
- 左側：
  - Turn 1
  - workspace A (ruff installed)
- 中間標註：6 hours later
- 右側：
  - Turn 2
  - workspace B (empty)
- 右側紅色文字：tools gone, work restarted

下方流程（標籤為綠色方框 Reattach）：
- 左側：
  - Turn 1
- 中間標註：6 hours later
- 右側：
  - Turn 2
- 下方橫跨的綠色區塊：
  - workspace, kept warm
  - ruff installed
- 右側綠色文字：tools still there</div></details>

Code executor 執行一段程式碼後就忘了它。Environment 則是明天的互動預期能找到、且內容仍然完整的檔案系統。

將它的 scope 設定在使用者，而不是對話；讓它保持 warm，並允許後續訊息重新附加回來。重新附加時不要綁定特定版本，避免新的 backend 清掉昨天安裝的工具。

此外，絕對不要用兩個生命週期階段共用的 status code 來判斷 liveness。一個已刪除的 environment 會回傳 502，所以我們原本把任何 5xx 都視為已失效。但啟動中的 environment 也會在 readiness poll 中回傳 502，導致我們在建立健康 environment 幾秒後就把它逐出。

## 模式 4：明確的失敗狀態

當一個 Agent 的輸出成為另一個 Agent 的輸入時，parent 會根據回傳 envelope 的結構判斷 child 是否成功。

一次 evaluation run 發現，我們的 root agent 從一個實際上已逾時的 delegate call 中，回報「全部 20 個測試都通過」。實際上什麼都沒有寫入，也沒有執行任何測試。

問題在於 envelope 的設計讓模型產生了幻覺。Child 無論是逾時、達到步驟上限、暫停等待核准，還是正常完成，回傳的結構都完全相同：把 child 工作期間說過的每一行內容串接起來。這種片段式的旁白，看起來就和完成後的報告一模一樣。

![Explicit Failure: four endings, one envelope 流程圖，說明四種執行終止狀態匯聚至單一封包時導致 Parent agent 誤判的過程。](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/602b146f42b63049.jpg)
> Explicit Failure: four endings, one envelope 流程圖，左側展示四種不同的終止狀態分別為 Timed out、Hit iteration cap、Paused for approval 與 Finished empty，皆標示相同形狀並指向中間的 envelope，其內容標示 summary: string 以及 no status field，接著向右傳遞並標示 read as success 至 Parent agent，最後輸出右側帶引號的文字方塊，底部附有一行說明文字說明失敗時回傳空值看起來與成功時回傳空值完全相同。

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">圖片頂端標題：「Explicit Failure: four endings, one envelope」

左側四個方塊代表不同的結束狀態：
- Timed out (紅色外框)
- Hit iteration cap (紅色外框)
- Paused for approval (黃色外框)
- Finished empty (灰色外框)

左側四個狀態皆指向中間的信封圖示，並標示「same shape」。
信封圖示內文字：
- summary: string
- no status field

信封向右箭頭指向藍色方塊「Parent agent」，箭頭上方標示「read as success」。

Parent agent 向右箭頭指向右側紅色外框方塊：
- "all 20 tests passing"
- nothing ran

圖片底部說明文字：「returning nothing on failure looks exactly like returning nothing on success」</div></details>

每種結束狀態都回傳同一類型的字串，因此 parent 沒有任何可以分支判斷的依據，最後把它們全都當成成功。

不要依賴慣例或空字串。為每個 terminal state 命名，並讓 parent 根據狀態進行分支。我們使用四種狀態：completed、timeout、因步驟上限或 crash 而 halted，以及 child 正在等待人工處理時使用的 pending。

status 欄位可以保護呼叫端程式碼，卻保護不了模型，因為模型讀的是 summary，而不是旁邊的欄位。因此要重寫 summary：提早停止的 child 必須回傳 `INCOMPLETE: the child hit the timeout and did not finish. Do not report this work as done.`

另一個相關問題，是一個看起來很有效率、實際上卻永遠執行下去的迴圈。限制每次 iteration 的工具呼叫次數（我們設定為 200 次），以及每個 session 的 iteration 次數（設定為 50 次），然後在下一個乾淨的邊界停止，而不是在一輪互動中途停止：從 request 移除工具，讓模型寫出純文字的交接內容。如果保留工具，模型就會持續呼叫它們，結果只是把失控迴圈換成錯誤迴圈。

## 模式 5：防護鏈

擁有 shell 存取權的 Agent 可以連到機器能連到的任何地方，包括雲端 provider 的 metadata endpoint，以及該 endpoint 提供的憑證。

我們用字串比對封鎖 `169.254.169.254`。接著，`curl http://2852039166/` 卻直接繞過了 filter，因為那個整數解析後正好是同一個 IP 位址。

比較前一定要先正規化。絕對不要比對結構化值的原始字串表示。該位址有四種有效的表示方式：點分十進位、整數、十六進位，以及 IPv6-mapped。將每個候選值解析成真正的 address object，就能一次排除所有形式。

將 guards 當成 short-circuit expression 來執行：先執行便宜且確定性的檢查，只有必要時才升級到昂貴的檢查。

![Guard Chain 的執行流程與檢查步驟架構圖](https://pub-75d4fe1e4e80421b9ecb1245a7ae0d1a.r2.dev/curated/a84376b6ac7f58a9.jpg)
> 標題為 Guard Chain: cheap checks first, first decision wins 的系統架構流程圖，從左至右展示從 Tool call 經過 Exfiltration check、Policy rules、Ask the human 到 Tool runs 的防護鏈路，並在下方以時間軸標示由 microseconds 到 human seconds 的處理時效。

<details class="chart-data"><summary>展開畫面重點</summary><div class="me-note">Guard Chain: cheap checks first, first decision wins

no model anywhere in this path

Tool call
None
Exfiltration check
regex and address parsing
dict returned
block
no session mode can loosen this

None
Policy rules
declarative deny, ask, allow
dict returned
deny

None
Ask the human
interactive prompt
dict returned
declined

Tool runs

microseconds
human seconds</div></details>

Guards 按照成本由低到高執行。回傳空值會繼續流向下一個 guard；回傳結果則會結束呼叫。

我們的 chain 使用三個階段：

- Exfiltration guard。直接封鎖 metadata IP 位址等危險目的地。任何 session 設定都不能放寬這項限制。

- Policy guard。根據宣告式規則集回傳 allow、ask 或 deny。

- Interactive prompt。最後才詢問使用者，因為人的注意力是你能夠花費的最昂貴資源。

這條路徑中沒有任何模型。它由 parser、宣告式規則和計數器組成：完全可稽核，執行時間以微秒計。這條 chain 最後成為我們最大的子系統，其背後的測試程式碼比 repository 中任何其他部分都多。

如果 guard 什麼都詢問，使用者最後會養成不看內容就核准的習慣。若要安全地放寬常見命令，應該解析命令並解析出實際執行的 binary，而不是對原始文字進行子字串比對。

周遭是否有人，也會改變「詢問」的意義。在聊天情境中，它會提示你；在沒有任何人在場的背景 sub-agent 中，則會變成拒絕。

最後，設計憑證時要假設 guards 早已被攻破，因為它們遲早會被攻破。每位使用者的 secrets 都透過 injection 傳入 environment，絕不放進 prompt。沙盒使用停用對外網路的 template 啟動，這項限制在 platform layer 實施。Artifact 連結會以 signed URL 提供給 client，而在模型收到的內容中只放 placeholder，這樣帶有憑證的 blob 就不會進入回覆。

## 重點整理

讓 Agent 存活並持續運作數週，重點不在於打造龐大的 framework，而在問題燒掉預算或污染狀態之前，先攔截那些悄無聲息的失敗。

如果這週要稽核自己的技術堆疊，可以先從最簡單的檢查開始：測量 prefix cache hit rate。如果它接近零，就代表 prompt 中有某些內容每一輪都在變動，而 latency graph 不會告訴你原因。

你不需要採用整套 harness。挑選任何你喜歡的模式，閱讀實作內容，然後將它接到你已經在打造的系統中：

- Prefix caching 與 prompt 組裝：system_prompt.py 和 reminders.py

- 背景學習 worker：sibling_agent_plugin.py

- 持久化 workspace 介面：environment/base.py

- 具型別的 sub-agent envelope：delegate_runner.py

- 確定性的 guard chain：exfil_guard.py

在 GitHub 上探索 Long Horizon harness。如果你要從頭開始，可以使用 Python 中的 ADK，或透過 agents-cli 驅動它。

---

如果你喜歡這篇文章，別錯過我們上一篇文章〈每位 AI 工程師都應該知道的自我改進 Agent 迴圈 7 條規則〉。也歡迎持續追蹤我們，取得更多內容。

## 媒體內容

**將 Memory 放置於 message tail 相較於放置於 system instruction，能保持各 Turn 的指紋一致（fp a91c），讓 95% 請求從快取讀取（95% served from cache），解決快取無法形成的問題。**

**數據表（1）Memory in the system instruction**

| 項目 | 數值 |
| --- | --- |
| Turn 1 | a91c |
| Turn 2 | 4d0e |
| Turn 3 | b72f |
| 結果=cache never forms |  |

**數據表（2）Memory on the message tail**

| 項目 | 數值 |
| --- | --- |
| Turn 1 | a91c |
| Turn 2 | a91c |
| Turn 3 | a91c |
| 結果=95% served from cache |  |

## 標籤

Agent, 開源專案, Harness, Long Horizon, Agent Development Kit
