iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Build on Google AI

把履歷修改工程化:從「聽起來有道理」到用 Gemini 打造可驗證的 AI 職涯助理系列 第 24 篇

Day 24 - 模型逾時、429、工具失敗怎麼辦?讓流程能重試,也知道何時停止

  • 分享至 

  • xImage
  •  

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、但欄位沒填」的時候

這個專案兩條路都沒給

  • 路徑 A 的 llm.py:HttpOptions(api_version="v1", timeout=120_000),沒有 retry
  • 路徑 B 的 agent:LlmAgent(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 小時,沒人刪

429 的錯誤訊息是空的

跑 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

假伺服器證明了重試規則是對的,但雲端上真的遇到 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 改成 503
  • app/agent/runner.py:AgentError 帶錯誤碼、本機也有 180 秒上限、工具例外轉成 AgentError
  • app/main.py:忙回 503 + Retry-After,建 session 被 Agent Runtime 配額擋下也回 503,刪除失敗時明確說「還沒刪掉」
  • app/agent/remote.py:雲端的例外也帶錯誤碼
  • 前端:503 跟 504 一樣顯示成黃色的「稍後再試」
  • 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 給的建議,到底好不好?
明天先定義「好」:建立履歷分析跟學習計畫的評估規準


上一篇
Day 23 - 不記錄整份履歷,也要排查問題:Logging 與 Monitoring 實作
下一篇
Day 25 - 怎樣才算好建議?建立履歷分析與學習計畫的評估規準
系列文
把履歷修改工程化:從「聽起來有道理」到用 Gemini 打造可驗證的 AI 職涯助理 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言