Recap: 昨天整理 log 時,看到 12 筆模型 404;Day 18 跑 eval 時也撞過 429
Day 18 跑 eval 撞到 429 的時候,我在 eval 裡加了「等 20 秒、再等 60 秒」,說正式的重試留到 Day 24
今天就是 Day 24
先查清楚一件事:現在到底有沒有在重試?
google-genai 的原始碼裡,重試的設定長這樣
# By default, the client will retry 4 times with approximately 1.0, 2.0, 4.0,
# 8.0 seconds between each attempt.
_RETRY_ATTEMPTS = 5 # including the initial call.
看到這段註解,第一個反應是「喔,SDK 自己會重試」
往下看實際的程式
def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict:
if options is None:
return {'stop': tenacity.stop_after_attempt(1), 'reraise': True}
retry_options 沒給,就是 stop_after_attempt(1),只試一次
註解說的「預設重試 4 次」,是指「有給 HttpRetryOptions、但欄位沒填」的時候
這個專案兩條路都沒給
llm.py:HttpOptions(api_version="v1", timeout=120_000),沒有 retryLlmAgent(model="gemini-2.5-flash") 只給名字字串,ADK 建出來的 client 一樣沒有 retry所以 Day 18 那個 429 直接失敗,不是重試用完,是根本沒重試
MODEL_RETRY = types.HttpRetryOptions(
attempts=3, # 含第一次,最多重試 2 次
initial_delay=1.0, # 第一次重試前等 1 秒,之後 2 秒
max_delay=8.0,
exp_base=2.0,
jitter=0.5,
http_status_codes=[408, 429, 500, 502, 503, 504],
)
放在 app/retry.py,路徑 A 跟 agent 共用這一份
重試什麼
只重試「等一下可能就好」的狀態碼
400、404 不重試:Day 19 那個模型 404,重試幾次都一樣是 404,只是讓使用者多等幾秒才看到錯誤
重試幾次
SDK 的預設是 5 次,最長要等 1+2+4+8 秒
我只給 3 次,理由是這個 agent 一輪最多呼叫模型 12 次(Day 15),每一次都可能重試,整輪又有 180 秒的上限(Day 21)
而且 429 有兩種:一種是瞬間的尖峰,等 1、2 秒就好;一種是配額用完了,等幾秒根本沒用
Day 18 那次比較像後者,我是在 eval 裡直接加了等 20 秒、60 秒才跑得完
與其在伺服器裡一直等,不如重試兩次還是不行,就早點告訴使用者「現在很忙,等一下再試」
在哪一層重試
這是今天最重要的決定:只重試「一次模型呼叫」,不重試「一整輪對話」
下面用實驗說明
要測重試,就要讓模型回 429
真的去打到 429 要燒配額,所以寫了一個照劇本回應的假 Gemini 伺服器(tests/fake_gemini.py)
Day 19 寫過一個 FakeLlm,但那是換掉整個模型物件,SDK 的重試程式根本不會跑到
這次換掉的是「網路的另一端」:ADK 的 Gemini 有一個 base_url 欄位,指到本機的假伺服器
def make_model(base_url: str | None = None) -> Gemini:
return Gemini(model=get_settings().model, retry_options=MODEL_RETRY, base_url=base_url)
正式環境不給 base_url,測試才給
這樣測到的,是正式的設定、真的 google-genai 重試程式碼
五個情境
| 情境 | 假伺服器回什麼 | 請求次數 | 每次之間隔幾秒 | 結果 |
|---|---|---|---|---|
| A 今天以前(不重試) | 429 → 成功 | 1 | — | 失敗 |
| B 新規則 | 429、429 → 成功 | 3 | 1.37、2.27 | 成功 |
| C 新規則 | 一直 429 | 3 | 1.35、2.37 | 4 秒後失敗 |
| D 新規則 | 404 | 1 | — | 立刻失敗 |
| E 整輪中途失敗再重送 | 工具呼叫 → 500×3,再送一次 | 6 | — | 見下面 |
B 跟 A 是同一個劇本,差別只有 retry_options,一個成功一個失敗
D 證明 404 不會白等
E 的劇本是:模型先叫 verify_evidence,工具跑完要再問模型的時候,模型連續回 500
重試三次都失敗,這一輪失敗;然後使用者再送一次同樣的問題
失敗之後 session 裡的東西
session events: ['USER', 'CALL', 'RESP', 'USER', 'CALL', 'RESP', 'TEXT']
第一輪雖然失敗了,但使用者的訊息、工具呼叫、工具結果,都已經寫進 session 了
重送之後,同一個問題出現兩次,verify_evidence 也叫了兩次
如果在 FastAPI 那層寫「失敗就整輪重來」,使用者什麼都沒做,session 裡就多了一份
模型下一輪看歷史的時候,看到的是自己被問了兩次一樣的問題
一次 generateContent 是沒有狀態的,重來沒有副作用
一整輪是有狀態的,重來就會重複
所以重試只放在模型呼叫那一層,整輪失敗就停下來,交給使用者決定要不要再送
雲端那條路也一樣:FastAPI 打 Agent Runtime 的 stream_query 是一整輪,不重試create_session 也不重試:如果第一次其實建成功了,只是回應丟了,重試會多建一個 session,那一份履歷就在雲端待 24 小時,沒人刪
跑 C 的時候,錯誤訊息印出來是空的
AgentError:
ADK 把 429 包成自己的 _ResourceExhaustedError,它改寫了 __str__
def __str__(self) -> str:
base_message = super().__str__()
return f'{_RESOURCE_EXHAUSTED_POSSIBLE_FIX_MESSAGE}\n\n{base_message}'
開頭是一大段「配額不夠的話可以怎麼辦」的說明,真正的錯誤碼在很後面
Day 23 的 AgentError 是 str(e) 截前 300 個字,剛好只截到說明,429 被截掉了
改成用例外的欄位組訊息,而且把錯誤碼存在 AgentError 上
msg = f"{e.code} {e.status}: {e.message}"
raise AgentError(redact(msg)[:300], code=e.code) from None
「忙」還是「壞」,看數字決定,不靠比對文字
| 狀況 | 回什麼 | 前端顯示 |
|---|---|---|
| 429、503,重試用完 | 503,加 Retry-After: 60 |
黃色:現在太忙,60 秒後再送一次 |
| 404、400 | 502 | 紅色:壞了,再試也一樣 |
| 超過 180 秒 | 504 | 黃色:等太久,可能在冷啟動 |
| 工具丟例外 | 502 | 紅色,附上原因 |
503 跟 502 分開,是因為使用者該做的事不一樣:503 等一下就好,502 等多久都沒用
路徑 A 順便修了一個分類錯誤
429 是 ClientError(4xx 都是),原本的寫法是
except errors.ClientError as e:
raise LlmError(f"模型拒絕了這個請求({e.code})", status_code=502) from e
使用者看到的是「模型拒絕了這個請求」,好像是他的履歷有問題
改成 429 另外處理,回 503「模型目前太忙」
雲端那條路 Day 21 就有 180 秒的整輪上限,本機的 runner 沒有
加了重試之後,本機更需要:每次呼叫都可能多等幾秒,模型如果卡住不回,請求會一直掛著
本機也加上同一個 180 秒,常數移到 runner.py,兩邊共用一個數字
假伺服器加了一個「每次回應前先睡幾秒」的參數,測試把上限調成 1 秒、假伺服器睡 3 秒,確認會變成 504
標題裡的「工具失敗」,實際測了才知道會怎樣
把資源清單的路徑指到不存在的檔案(Day 19 改成找不到就報錯),讓模型去叫 lookup_resources
turn FAILED: FileNotFoundError 找不到資源清單:does\not\exist.json(部署時有沒有打包進 app/_bundled/?) | model requests: 1
工具丟例外,ADK 不會把錯誤交給模型讓它說「目前查不到」,整輪直接失敗
而且丟出來的是原始的 FileNotFoundError,不是 AgentError,到 FastAPI 就變成 500 加一整段 traceback
我們的三個工具都是本機的確定性程式(Day 15 的設計),失敗就代表 bug 或部署漏東西,重試一百次也一樣
所以工具失敗不重試,但要講清楚:runner 把其他例外也轉成 AgentError,回 502,訊息是「FileNotFoundError: 找不到資源清單…」
如果之後有工具會去打外部服務,那個工具自己裡面再決定要不要重試,不在 agent 這層做
重試設定是 agent 的一部分,要重新部署,--update 2 分 42 秒
換成 Gemini 物件之後,雲端打一次確認沒壞
=== 第 1 輪 9.8 秒 / 3521 tokens
=== 第 2 輪 3.2 秒 / 6869 tokens
假伺服器證明了重試規則是對的,但雲端上真的遇到 429 會怎樣,還沒看過
直接試:同時對雲端的 agent 開 30 個對話,每個問一句短的(「不用分析,用一句話告訴我你能幫我做什麼」)
N=30 wall=12.3s results={'ok': 6, 'exception': 24}
20 ClientError: 429 Too Many Requests ...
4 ServerError: 503 UNAVAILABLE. The service is currently unavailable.
撞到了,30 個只成功 6 個
但這些錯誤是從我這邊的呼叫丟出來的例外,不是 agent 裡面的模型回的
等一分鐘再跑一次,這次把錯在哪一步、完整的訊息印出來
N=30 results={'ok': 8, 'exception': 22}
15 create_session | 429 | Quota exceeded for quota metric 'Query Reasoning Engine requests'
and limit 'Query Reasoning Engine requests per minute per region'
7 stream_query | 429 | (同上)
先撞到的不是 Gemini,是 Agent Runtime 的門
去查這個配額
Query Reasoning Engine requests | 1/min/{project}/{region} | 30
每個區域、每分鐘 30 次
而且建 session、送訊息、刪 session 都算一次,一個完整的對話就用掉 3 次,等於一分鐘最多 10 個對話
兩次各成功 6 個、8 個,差不多就是這個數字
再去看 Agent Runtime 的 log:這段時間沒有任何一行 Retrying、沒有 429,模型的請求一共 14 次,剛好是成功的那 14 個對話各一次
Gemini 一次都沒有被逼到極限;請求在進到 agent 之前就被擋掉了
所以今天在模型那一層加的重試,在這個部署下幾乎不會有機會用到
真正會遇到的 429 在更外面一層:FastAPI 呼叫 Agent Runtime 的時候
第一次那 4 個 503,第二次沒有再出現,錯在哪一步也沒記到,我不知道原因;max_instances=1 只有一台,同時塞 30 個請求進去,被擋下來也不奇怪
那這一層要不要重試
被配額擋下來的請求,代表根本沒執行,跟逾時那種「不知道到底成功了沒」不一樣,理論上重試是安全的,create_session 也不會多建一個
但配額是每分鐘算的,要等到下一分鐘才有用;讓使用者的請求在伺服器裡掛一分鐘,不如直接告訴他
所以不重試,但要說對話
create_session 失敗一律回 502「建立對話失敗」,被配額擋下來也這樣說,使用者會以為壞了。改成 429 回 503「現在使用的人太多」AgentError,忙不忙看數字判斷Retry-After 從 30 秒改成 60 秒:配額是每分鐘算的,叫人 30 秒後再試,有一半機會還在同一分鐘裡一分鐘 30 次對一個作品來說夠用,真的要給很多人用,要去申請提高配額;這個數字,Day 29 算成本跟容量的時候會再用到
連刪除都被擋下來
壓測腳本每個對話結束都會刪掉自己的 session
測完去列一下雲端還剩什麼
remaining sessions: 18
user: anon-burst-21 expire: 2026-10-08T16:28:49Z
...
還剩 18 個,每個裡面都是一份(虛構的)履歷,要放到明天才會過期
刪除也算在那每分鐘 30 次裡,大家同時刪,一樣被擋
手動清:一個一個刪,每個間隔 7 秒,遇到 429 等 20 秒再試,18 個全部刪掉
這件事對使用者也一樣
Day 21 的「刪除這次的資料」按鈕,如果剛好遇到配額,後端沒有接這個錯誤,會變成 500
使用者看到一個錯誤,不知道到底刪了沒有,最糟的情況是以為刪了
改成:刪除失敗時明確回「資料還沒刪掉。現在使用的人太多,請 60 秒後再按一次刪除」
「刪除」是 Day 17 對使用者的承諾,失敗的時候講清楚,比成功的時候更重要
app/retry.py:重試規則,兩條路共用app/agent/agent.py:make_model(),agent 的模型帶上重試規則app/services/llm.py:路徑 A 帶上重試規則、429 改成 503app/agent/runner.py:AgentError 帶錯誤碼、本機也有 180 秒上限、工具例外轉成 AgentError
app/main.py:忙回 503 + Retry-After,建 session 被 Agent Runtime 配額擋下也回 503,刪除失敗時明確說「還沒刪掉」app/agent/remote.py:雲端的例外也帶錯誤碼tests/fake_gemini.py、tests/test_retry.py
測試從 91 個變成 105 個
其中重試的測試會真的等退避的秒數,整個測試從 7 秒變成 24 秒,這個代價我接受:要測的就是「真的有等」
版本:google-adk 2.8.0、google-genai 2.22.0、gemini-2.5-flash
第 5 章到這裡結束:部署、存取控制、trace、log、重試
接下來的第 6 章要回答一個一直被推遲的問題:這個 agent 給的建議,到底好不好?
明天先定義「好」:建立履歷分析跟學習計畫的評估規準