[02:14:07] agent-3 task=refactor-auth ERROR 429 rate_limit_error
[02:14:07] agent-1 task=add-pagination ERROR 429 rate_limit_error
[02:14:08] agent-2 task=fix-flaky-test ERROR 429 rate_limit_error
[02:14:09] agent-3 retrying (1/5)...
[02:14:09] agent-1 retrying (1/5)...
[02:14:09] agent-2 retrying (1/5)...
...
[02:14:31] agent-3 giving up. task marked FAILED
當時有三個問題同時發生:所有錯誤看起來都一樣,無論是 429(速率限制)、529/503(供應商過載)、400(我的提示詞過大),還是網路重設,都透過同一個 except Exception: retry() 路徑處理。代理們會同步重試,四個代理同時達到限制,然後同步等待相同的固定時間,再同步再次觸發限制,這就是典型的「雷鳴般的羊群效應」(thundering herd)。
「放棄」意味著工作丟失。一個已經進行了 40 分鐘的重構任務會被標記為失敗,下次執行時必須從頭開始,這會消耗更多的 token 和速率限制。在一個艱難的兩週內,我統計發現 31% 的失敗任務根本不是真正的失敗,它們只是暫時性的 API 錯誤,卻被我的系統轉化為永久性錯誤。
讓這個問題變得有趣的是:我不能簡單地「增加更多配額」。限制是按帳戶計算的,所有代理都共用這些限制,而且無論我喜不喜歡,中斷總是會發生。因此,系統必須能夠優雅地降級,而不僅僅是更努力地重試。
以下是最終設計的樣貌:
flowchart LR
A[Agent call] --> B{Circuit open?}
B -- yes --> P[Checkpoint & park task]
B -- no --> C[Call model API]
C -- ok --> D[Continue task]
C -- error --> E[Classify error]
E -- fatal --> F[Fail fast, explain why]
E -- retryable --> G{Retry budget left?}
G -- yes --> H[Jittered backoff] --> C
G -- no --> I[Trip breaker] --> P
以下所有程式碼都是 Python 3.13,已精簡至核心部分。
1. 重試前先分類
將錯誤視為單一類別是價值最高的改變。我定義了錯誤類型:
```python
from enum import Enum
class ErrKind(Enum):
RATE_LIMIT = "rate_limit" # 429: we're too fast
OVERLOADED = "overloaded" # 529/503: they're too busy
TRANSIENT = "transient" # timeouts, resets, 500/502
FATAL = "fatal" # 400/401/403/413: retrying won't help
def classify(status: int | None, exc: Exception | None) -> ErrKind:
if status == 429:
return ErrKind.RATE_LIMIT
if status in (503, 529):
return ErrKind.OVERLOADED
if status is None or status in (500, 502, 504):
return ErrKind.TRANSIENT # includes connection resets
return ErrKind.FATAL
```
這個 FATAL 類別比看起來更重要。在此之前,一個超出上下文視窗的提示詞會被重試五次,這五次都是相同且保證失敗的嘗試,每次都消耗了其他代理所需的速率限制。現在,一個 400 錯誤會立即失敗並顯示明確原因,自我修復代理可以對此做一些有用的事情,例如拆分任務。
2. 帶有重試預算而非重試次數的抖動退避機制
固定次數的重試(例如「嘗試 5 次」)是同步重試問題的根源。我改用了帶有完全抖動的指數退避機制,並且當 API 回傳 retry-after 時,我會遵守它:
```python
import random
BASE = {
ErrKind.RATE_LIMIT: 2.0,
ErrKind.OVERLOADED: 10.0,
ErrKind.TRANSIENT: 1.0
}
CAP = 120.0
def backoff(kind: ErrKind, attempt: int, retry_after: float | None) -> float:
if retry_after is not None:
Server knows best, but add a little jitter so agents don't sync up.
return retry_after + random.uniform(0, 2)
ceiling = min(CAP, BASE[kind] * 2 ** attempt)
return random.uniform(0, ceiling) # full jitter
```
更大的概念是重試預算。每個任務不是「每次呼叫 5 次嘗試」,而是獲得一個等待的時間預算,例如整個任務總共 8 分鐘。一個運行順暢的任務可以承受一次長時間的過載;而一個一直與錯誤搏鬥的任務則會更快耗盡預算並被暫停。
```python
import time
class RetryBudget:
def __init__(self, seconds: float):
self.remaining = seconds
def spend(self, wait: float) -> bool:
if wait > self.remaining:
return False
self.remaining -= wait
time.sleep(wait)
return True
```
這使得行為變得可預測。我知道任務等待的最壞情況時間,無論它發出多少次呼叫。
3. 整個代理群共用一個斷路器
抖動機制可以分散重試,但四個代理獨立退避仍然意味著四個代理獨立地探測一個明顯已停機的供應商。所以我增加了一個斷路器,而且關鍵是讓它共用。所有代理都讀寫一個小型狀態檔案(對於我的規模來說,一個受鎖定保護的 JSON 檔案就足夠了,不需要 Redis)。
```python
import json, time
from pathlib import Path
from filelock import FileLock
STATE = Path("breaker.json")
LOCK = FileLock("breaker.json.lock")
THRESHOLD, COOLDOWN = 6, 300 # 6 failures -> open for 5 min
def breaker_open() -> bool:
with LOCK:
s = json.loads(STATE.read_text()) if STATE.exists() else {}
return time.time() < s.get("open_until", 0)
def record(ok: bool) -> None:
with LOCK:
s = json.loads(STATE.read_text()) if STATE.exists() else {"fails": 0}
s["fails"] = 0 if ok else s.get("fails", 0) + 1
if s["fails"] >= THRESHOLD:
s["open_until"] = time.time() + COOLDOWN
s["fails"] = 0
STATE.write_text(json.dumps(s))
```
當斷路器開啟時,所有代理在五分鐘內都不會呼叫 API。冷卻期過後,第一個醒來的代理會作為探測器。如果它成功,計數會重設,其他代理也會繼續。如果它失敗,斷路器會重新開啟。這將我的「錯誤風暴」日誌從數百行減少到寥寥數行。
4. 檢查點並暫停而非崩潰
這是真正挽救工作的部分。當任務耗盡重試預算或遇到開啟的斷路器時,它不會失敗。它會寫入一個檢查點並進入 PARKED(暫停)狀態:
```python
def park(task, reason: str) -> None:
task.checkpoint = {
"branch": task.branch, # work is already committed here
"completed_steps": task.done_steps,
"next_step": task.current_step,
"notes": task.summary_so_far(), # short, human-readable
}
task.status = "PARKED"
task.park_reason = reason
task.save()
```
協調器會以不同於 FAILED 的方式處理 PARKED 任務:一旦斷路器關閉,暫停的任務會優先恢復,從 next_step 開始,並將筆記注入提示詞中。由於每個代理在每個有意義的步驟後都會提交到其分支,因此「恢復」的成本很低,代理只需讀取檢查點和差異,而不是整個歷史記錄。以下是現在同一個夜晚的日誌:
[02:14:07] agent-3 429 rate_limit -> wait 3.1s (budget 476s left)
[02:14:08] agent-1 429 rate_limit -> wait 0.7s (budget 480s left)
[02:14:11] agent-2 529 overloaded -> wait 14.2s
[02:14:40] breaker OPEN for 300s (6 consecutive failures)
[02:14:40] agent-3 PARKED at step 4/7 (breaker open)
[02:14:41] agent-1 PARKED at step 2/5 (breaker open)
[02:19:41] agent-2 probe OK -> breaker CLOSED
[02:19:42] agent-3 RESUMED from step 4/7
數據顯示,運行約六週後:
✅ 因暫時性 API 錯誤導致的任務失敗:從 31% 降至所有失敗的 3% 以下。
✅ 因致命錯誤(例如提示詞過大)而浪費的重試次數:幾乎為零。
✅ 每個暫停任務的平均額外實際時間:約 7 分鐘,而之前是完全重啟。
⚠️ 早上仍有意外:雖然仍非零,但現在它們是真正的錯誤,這才是我希望醒來時看到的。
經驗教訓:
1. 大多數「代理失敗」都是披著偽裝的基礎設施失敗。在指責模型的推理能力之前,請檢查有多少失敗是由網路或供應商問題引起的。對我來說,幾乎有三分之一是這樣。修復底層問題比我那個月嘗試的任何提示詞調整都有效。
2. 永遠不要重試一個你沒有分類的錯誤。一概而論的重試會將廉價、即時的失敗(錯誤請求)變成緩慢、昂貴的失敗,並且會竊取健康工作的容量。如果你只從這篇文章中做一件事,那就做錯誤分類器。
3. 預算時間,而不是嘗試次數。「5 次重試」根據退避策略會產生截然不同的結果。每個任務的等待時間預算提供了一個你可以推斷的硬性上限,並使長任務和短任務表現公平。
4. 平行代理需要共用的失敗狀態。每個代理單獨表現禮貌是不夠的。如果它們不共用斷路器,它們將會集體衝擊一個正在掙扎的 API。共用狀態可以非常簡單,一個鎖定的 JSON 檔案對於少數代理來說運作良好。
5. 設計時要考慮暫停,而不僅僅是成功或失敗。PARKED 狀態是真正的突破。長時間運行的自主工作需要第三種結果:「我安全地停止了,並且我確切知道從哪裡重新開始。」這只有在代理頻繁提交並在執行過程中留下簡短筆記時才有效。
接下來:
我目前正在處理幾件事:優先級感知節流,當配額緊張時,讓短而高價值的任務優先執行,並暫停大型重構。低風險步驟的備用模型,例如總結差異或編寫提交訊息,不需要最強大的模型,因此它們不應該爭奪相同的限制。在我的遠端控制儀表板上顯示斷路器觸發,這樣我就可以一目了然地看到一個安靜的夜晚是「無事可做」還是「供應商停機」。
總結:
如果你正在無人值守地運行代理,即使是一個循環中的 Claude Code 會話,你的可靠性上限很可能在於錯誤處理,而不是你的提示詞。分類錯誤、帶有抖動的退避、共用斷路器,以及暫停而非崩潰。請在 Dev.to 上關注我,獲取更多關於運行全自動程式碼系統的建構日誌,並留言:API 中斷以何種最奇怪的方式破壞了你的代理設定?我很樂意比較戰鬥經驗。