iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Build on Google AI

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

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

  • 分享至 

  • xImage
  •  

Recap: 昨天把 log 盤點完,裡面有模型 404、有 Day 18 跑 eval 撞到的 429,還有 Day 13 被花費上限擋下、畫面卻只寫「模型拒絕了這個請求」的那次

這幾天欠下的帳都是同一件事:出錯的時候,程式不知道這是哪一種錯
不知道是哪一種,就不知道該不該重試,也不知道該跟使用者說什麼

最便宜的失敗處理:不要發出那個請求

Day 04 寫的第一個測試

def test_resume_too_short_is_rejected_before_calling_model():
    r = client.post("/analyze", json={"resume_text": "太短"})
    assert r.status_code == 422

min_length=20 讓明顯無效的輸入在 pydantic 那層就被擋掉,不會變成一次付費呼叫
今天要處理的是另一種:請求送出去之後才失敗的

先盤點:SDK 本來就會重試嗎

google-genai 的原始碼裡有這段

# Default retry options.
# 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.

看起來預設就會重試 4 次,但往下看真正決定要不要重試的函式

def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict:
  if options is None:
    return {'stop': tenacity.stop_after_attempt(1), 'reraise': True}

沒給 retry_options 就是只打一次
上面那個「重試 4 次」,是你給了一個空的 HttpRetryOptions() 時的預設值

ADK 也一樣,LlmAgent(model="gemini-2.5-flash") 會變成一個 Gemini 物件,它的 retry_options 預設是 None,而且沒有設逾時

所以到今天為止,這個專案的每一次模型呼叫,都是失敗一次就放棄
Day 18 跑 eval 撞到 429,eval 自己包了一層「等 20 秒、再等 60 秒」才跑得完,原因就在這裡

另外一個沒注意到的:llm.py 只接了 ClientError 跟 ServerError
逾時、連不上是 httpx 的例外,不是 APIError,以前根本沒接,會直接變成 500

刻意觸發失敗

要知道重試到底怎麼動,得讓它失敗
404 跟逾時可以用真的 Vertex 觸發,但 429 不是說要就有

所以寫了 scripts/trigger_failures.py:在本機起一個假的模型端點,照劇本回 429、503、403,或故意很慢
用的是真的 google-genai client、真的 HTTP,重試走的是跟正式呼叫同一段程式,而且不花錢

--- SDK 預設(沒給 retry_options)
429 一次就好                打了 1 次  間隔 -              共 0.0s   失敗 → rate_limited

--- 加上 RETRY
429、429、成功              打了 3 次  間隔 ['2.1', '4.6']  共 6.8s   成功
一直 429                    打了 3 次  間隔 ['2.0', '5.0']  共 7.0s   失敗 → rate_limited
503、成功                   打了 2 次  間隔 ['2.9']         共 2.9s   成功
403 花費上限                打了 1 次  間隔 -              共 0.0s   失敗 → spend_cap
400 參數錯                  打了 1 次  間隔 -              共 0.0s   失敗 → bad_request
每次都要 3 秒、逾時 1 秒    打了 3 次  間隔 ['3.8', '5.5']  共 10.4s  失敗 → timeout

最後一行是今天最重要的發現
http_status_codes 裡我只放了 429 跟 5xx,但逾時還是被重試了 3 次

回去看 SDK 的判斷條件

retry = tenacity.retry_if_exception(
    lambda e: (isinstance(e, errors.APIError) and e.code in retriable_codes)
    or isinstance(e, _HTTPX_TRANSIENT_EXC),   # TimeoutException、ConnectError
)

狀態碼可以選,逾時跟連線錯誤是寫死的,一定重試
所以單次逾時設多長,最壞情況就要乘以 3

再用真的 Vertex 打兩個

不存在的模型          共 2.9s   失敗 → model_not_found(ClientError)
逾時 2 秒的長回答     共 14.0s  失敗 → timeout(ReadTimeout)

404 沒有重試,馬上回來
逾時 2 秒的那個,SDK 的 log 印了兩次 Retrying ... as it raised ReadTimeout,總共打了 3 次

另外要記得:client 等不及斷線,不代表伺服器那邊停止生成
那 3 次被放棄的請求,模型端有沒有照算 token,從 client 這邊看不到,我也沒辦法確認
逾時重試可能是「付三次錢、一次都沒拿到」

什麼該重試

錯誤 重試? 為什麼
429 配額滿了 要 共用額度,等幾秒常常就好(Day 07 就遇過,重跑就過)
500、502、503、504 要 模型端暫時的狀況
逾時、連不上 SDK 一定會重試 只能靠把逾時設短來控制代價
400 參數錯 不要 同一個請求送幾次都一樣
403 權限、花費上限 不要 要有人去改設定或預算
404 模型不存在 不要 Day 19 的 asia-east1,是設定錯
輸出不符合 schema 不要 有機會重試就好,但那是 prompt 的問題,重試只是把它藏起來

全部收在 app/services/retry.py

RETRY_CODES = [429, 500, 502, 503, 504]

RETRY = types.HttpRetryOptions(
    attempts=3,
    initial_delay=2.0,
    max_delay=10.0,
    exp_base=2,
    jitter=1,
    http_status_codes=RETRY_CODES,
)

TIMEOUT_MS = 60_000

為什麼是 3 次、從 2 秒開始:這是給有人在畫面前等的請求用的
多等 6 秒可以接受;Day 18 那種要等 20 秒、60 秒才恢復的 429,在一個請求裡硬撐只會讓使用者以為當掉了
那種情況應該直接告訴他「一分鐘後再試」

批次的 eval 不一樣,它等得起,所以 evals/injection.py 外面那層 20 秒、60 秒的退避留著

逾時從 120 秒改成 60 秒:Day 13 量到完整流程 6 次呼叫 67 秒,單次 10 幾秒
今天再跑一次完整流程,5 次呼叫 60.9 秒,全部成功,60 秒不會誤殺正常的請求
但最壞情況是 60 × 3 + 重試間隔,大約 3 分鐘

重試放在哪一層

這個專案有好幾層:FastAPI → agent 的一輪對話 → 每一次模型呼叫

一開始想在 FastAPI 那邊包一層「失敗就整輪重來」,寫測試的時候先確認了一件事

def test_failed_turn_leaves_the_message_in_the_session():
    ...
    assert err.failure.kind == "rate_limited"
    assert len(users) == 1

模型一直 429、整輪失敗了,但使用者那句話已經寫進 session
整輪重來的話,歷史裡就有兩句一樣的話;如果失敗發生在第三次工具呼叫之後,前面的工具也會再跑一次
一輪對話不是冪等的,不能隨便重來

而且兩層都重試的話,次數是相乘的:外面 3 次 × 裡面 3 次,最壞是 9 次模型呼叫

所以重試只放在最底層,單次模型呼叫
那一次失敗就是沒發生,重送一次沒有副作用

return LlmAgent(
    name="career_advisor",
    model=model or Gemini(model=s.model, retry_options=RETRY),
    generate_content_config=types.GenerateContentConfig(
        http_options=types.HttpOptions(timeout=TIMEOUT_MS)
    ),
    ...
)

本機的 llm.py、analyzer.py 用同一組 RETRY 跟 TIMEOUT_MS

agent 也拿假端點驗一次(Gemini 有 base_url 可以指過去)

模型 429、429、成功     共 8.1s   成功             假端點被打了 3 次
模型一直 429            共 8.4s   失敗 → rate_limited   假端點被打了 3 次

重試完還是失敗:跟使用者說什麼

Day 13 的問題是:403 一律寫成「模型拒絕了這個請求」
花費上限到了是 403,權限設錯也是 403,使用者分不出來是「等一下就好」還是「今天都不會好」

classify() 把例外分成幾種,每一種有自己的 HTTP 狀態跟說法

if code == 429:
    return Failure(
        "rate_limited", 503,
        f"模型那邊現在請求太多,已自動重試 {RETRIES} 次還是滿的。請一分鐘後再試。",
        retry_after_s=60,
    )
...
if code == 403 and _SPEND_CAP.search(str(e)):
    return Failure("spend_cap", 503, "這個服務的費用上限已經用完,今天之內不會恢復,再試也一樣。")

重點在 retry_after_s
等一下再試有用的,HTTP 回應帶 Retry-After;再試也一樣的,就不帶
前端看有沒有這個 header,決定顯示黃色的「約 60 秒後可再試」,還是紅色的錯誤

raise HTTPException(status_code=e.status_code, detail=str(e), headers=e.headers)

順便修正 Day 18 的一個誤會
當時 eval 的註解寫「ADK 的 429 是私有類別,只能看訊息」
其實 _ResourceExhaustedError 繼承 ClientError,e.code 就是 429,現在寫成測試了

反而是今天改完之後,eval 那層真的會壞:AgentError 的訊息換成給人看的中文,裡面沒有 429 這個字了
eval 改成看 e.failure.kind == "rate_limited",不再比對字串

固定流程:後面失敗,前面的照樣交出去

固定流程一次分析要呼叫 5、6 次模型:解析履歷、解析職缺、對照、澄清、改寫、計畫
以前任何一步失敗,整個請求就是一個錯誤,前面花的 token 全部白花

但不是每一步都一樣重要

  • 解析跟對照失敗:沒東西可以給,整個失敗
  • 澄清、改寫、計畫失敗:前面的對照結果還是有用
def failed(key: str, e: LlmError) -> None:
    report.stopped = f"{OPTIONAL_STEPS[key]}沒有完成:{e}"
    report.skipped.append(OPTIONAL_STEPS[key])

哪一步失敗,後面的也不做
429、花費上限這種錯,下一步多半也一樣失敗,繼續做只是讓使用者再多等一輪重試

再加一個總時間的上限

# 正常是 67 秒、6 次呼叫(Day 13)。每次呼叫最壞是 60 秒 × 3 次,
# 不設總上限的話,6 步全部碰到逾時要等十幾分鐘。
BUDGET_S = 120

超過 120 秒就不再開始下一步,已經做完的交出去,畫面上寫清楚少了哪幾塊

工具失敗

agent 的三個工具都是確定性的程式(Day 15),理論上不會壞
但「理論上」不夠,例如資源清單讀不到,lookup_resources 就會丟例外

先看 ADK 遇到工具丟例外會怎樣

      except Exception as tool_error:
        error_response = await _run_on_tool_error_callbacks(...)
        if error_response is not None:
          function_response = error_response
        else:
          raise tool_error

沒有掛 on_tool_error_callback,例外就往外丟,整輪對話直接結束
模型前面做的事全部白做,使用者只看到 502
模型叫了一個不存在的工具名稱,也是一樣的下場

確定性的工具不重試

這是跟模型呼叫最大的不同:同樣的參數再叫一次,結果一樣是失敗
所以工具失敗時不重試,而是把失敗當成工具的回傳值交給模型,讓它決定怎麼跟使用者說

def tool_failed(tool, args, tool_context, error) -> dict:
    n = tool_context.state.get(_TOOL_ERRORS, 0) + 1
    tool_context.state[_TOOL_ERRORS] = n
    return {
        TOOL_ERROR_KEY: f"工具 {tool.name} 執行失敗({type(error).__name__}),這不是查無資料,是系統暫時出錯。",
        "instruction": "不要用同樣的參數再呼叫一次,結果會一樣。...",
    }

回給模型的只有工具名稱跟例外的型別,不放例外訊息
工具的參數是履歷原句(Day 22),例外訊息可能把它們印出來

停止條件

模型不一定聽話,它可能還是一直叫同一個壞掉的工具
所以在 Day 15 的呼叫次數上限旁邊,加一條:一輪裡工具失敗 2 次,下一次就不呼叫模型了

def limit_llm_calls(callback_context, llm_request):
    if callback_context.state.get(_TOOL_ERRORS, 0) >= MAX_TOOL_ERRORS:
        return _notice(TOOL_ERROR_NOTICE, "tool_errors")
    ...

計數放在 temp: 開頭的 state,下一句話重新算

用假端點讓模型一直叫壞掉的工具、一直叫不存在的工具

工具一直丟例外,模型一直叫   共 1.0s  tools=['lookup_resources', 'lookup_resources']  stopped='這一輪有工具連續執行失敗,已中止。…'
                             假端點被打了 2 次
模型叫了不存在的工具         共 1.2s  tools=['search_web', 'search_web']  stopped='這一輪有工具連續執行失敗,已中止。…'
                             假端點被打了 2 次

以前這兩種都是一個 502,現在是一段說明,而且只呼叫了 2 次模型

真的模型:把「壞了」講成「查不到」

假模型只驗得了機制,真的模型拿到錯誤會怎麼跟使用者說,要用真的跑
把 lookup_resources 弄壞,問「我想補 Docker,請推薦學習資源,排一個四週計畫」

第一版的回傳值只寫「請告訴使用者這一部分目前無法用工具驗證」,模型的回覆

很抱歉,目前沒有查到關於 Docker 的學習資源。

「工具壞了」被講成「查不到」
使用者讀到這句,會以為真的沒有 Docker 的資源,而不是等一下再問就好
這跟 Day 09 說的「沒寫到不等於不會」是同一件事:沒查到不等於沒有

把回傳值改成明講「這不是查無資料,是系統暫時出錯,不可以說成查不到或沒有」,再跑三次

很抱歉,目前系統暫時無法查詢到 Docker 的學習資源。請稍後再試。

很抱歉,查詢 Docker 學習資源的功能目前發生系統錯誤,暫時無法完成。您可以稍後再試。
由於目前無法查詢到學習資源,我也無法為您規劃四週的學習計畫。

很抱歉,因為系統暫時出錯,目前無法為您查詢 Docker 相關的學習資源,您可以稍後再試。
不過,我可以為您規劃一個四週的 Docker 學習計畫……(計畫有經過 validate_learning_plan)

三次都講對了
但三次只能說「這次講對了」,不能說「以後都會講對」,所以跟 Day 18 的 unverified_quotes 一樣,不靠模型的措辭
runner 從工具的回傳值裡認出 tool_error,放進 tool_errors 回給前端,畫面直接標示「這一輪有工具執行失敗,不代表查不到」

前端

  • 有 Retry-After:黃色提示「暫時無法完成(約 60 秒後可再試)」,問題放回輸入框
  • 沒有 Retry-After:紅色錯誤,再按一次也一樣
  • 固定流程沒做完:前面的結果照樣顯示,上面一條「這次分析沒有做完:少了履歷改寫、學習計畫」
  • agent 有工具失敗:回覆下面標示是哪個工具、不代表查不到

卡住的地方

真的打 Vertex 的逾時測試,第一次跑花了 36.9 秒,而且是 ConnectTimeout
逾時 2 秒、3 次、間隔 2 跟 4 秒,算起來應該 12 秒左右

第二次跑就是正常的 14.0 秒、3 次 ReadTimeout
HttpOptions.timeout 管的是單次 HTTP 請求,取得憑證之類的事不一定算在裡面,我猜是第一次取 token 比較慢,但沒有再追下去
能確定的是:「逾時 × 次數」是 SDK 管得到的部分,不是使用者實際會等的上限
這也是固定流程要另外設 BUDGET_S、雲端那條路 Day 21 的 180 秒要留著的原因

還沒做的

  • 雲端的 agent 還沒重新部署,Agent Runtime 上跑的還是沒有重試、沒有工具錯誤處理的版本
  • 重試發生了幾次,現在只有 SDK 自己的 log 看得到(google_genai._api_client 的 INFO),沒有進結構化的紀錄。要知道「每天有多少請求是靠重試救回來的」,這個數字要記下來
  • 429 的重試沒有讀伺服器回的等待時間,只照自己的退避

測試從 83 個變成 109 個
SDK 的重試用假端點驗,走真的 HTTP;agent 的工具失敗用 Day 18 的假模型跑真的 ADK Runner;全部不打模型

版本:google-adk 2.8.0、google-genai 2.22.0、gemini-2.5-flash

明天

第 5 章到這裡結束:agent 上了雲端,看得到它做了什麼,出錯的時候也知道該不該再試
但「沒出錯」不等於「建議是好的」

明天開始第 6 章,先回答一個一直跳過的問題:怎樣才算好建議?


上一篇
Day 23 - 不記錄整份履歷,也要排查問題:Logging 與 Monitoring 實作
系列文
把履歷修改工程化:從「聽起來有道理」到用 Gemini 打造可驗證的 AI 職涯助理 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言