iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

工具失敗時,Agent 最危險的反應往往不是回覆錯誤,而是把同一個請求又送了一次。

Day 3 我只讓 Agent 查 mock SOP、建立 mock 工單。工具少一點,權限邊界比較看得見;但工具不會因為權限小就永遠正常。

假設 SOP 服務逾時。Agent 有幾種可能:再查一次、直接開工單、把「查不到」說成「查到了」,或一路重送請求。最後一種最麻煩,因為如果它重送的是寫入操作,系統可能出現兩張一模一樣的工單、兩封通知,或兩次帳號異動。

我的專案沒有真實網路服務,所以這篇不會假裝量到某個外部 API 的 timeout。我用會固定拋出 ToolTimeoutError 的 mock SOP source,重現「工具 client 已經判定逾時」後,系統該怎麼走。

這次我想處理的不是讓 Agent 永遠成功,而是它失敗時不要亂做事。

我先分開:什麼可以重試,什麼不該重送

我不把所有例外都交給重試機制。只有被明確分類為暫時性錯誤的唯讀操作才可以重試;格式錯誤、權限拒絕或參數不合法,重送同一個請求通常沒有意義。

class TransientToolError(RuntimeError):
    """A read-only operation that may succeed when repeated."""

class ToolTimeoutError(TransientToolError):
    """A tool client classified an expired request as retryable."""

ToolTimeoutErrorTransientToolError 的子類別,所以 SOP 查詢可以進入重試;ValueError 這類錯誤則會直接往外拋,不會偷偷重送。

這個分類有一個很刻意的限制:create_ticket 不在這個 retry helper 的使用範圍。建立工單是寫入操作。遇到失敗時,我寧可先確認發生了什麼,也不願意讓 Agent 猜「剛剛那次可能沒有成功,再送一次好了」。

我把重試次數與等待時間寫死在後端

今天的唯讀查詢最多嘗試 3 次,等待時間採用 capped exponential backoff:第一次失敗等 0.25 秒,第二次等 0.5 秒,之後不會超過 1 秒。

@dataclass(frozen=True)
class RetryPolicy:
    max_attempts: int = 3
    initial_delay_seconds: float = 0.25
    max_delay_seconds: float = 1.0

    def delay_after_failure(self, failed_attempt: int) -> float:
        if not 1 <= failed_attempt < self.max_attempts:
            raise ValueError("failed_attempt must have another attempt available")
        return min(
            self.initial_delay_seconds * (2 ** (failed_attempt - 1)),
            self.max_delay_seconds,
        )

重試不是寫在 prompt 裡的「請再試一次」,而是包在工具邊界:

try:
    results = run_with_retry(
        lambda: self.knowledge_base.search(query),
        operation_name="search_it_sop",
        policy=self.retry_policy,
        trace=self.trace,
        wait=self.retry_wait,
    )
except RetryBudgetExhausted:
    self.trace.add(
        kind="fallback",
        name="sop_unavailable",
        status="degraded",
        detail="SOP 暫時無法使用,保留問題描述,但不建立工單。",
    )

每次失敗、下一次重試的排程、重試預算耗盡,都會寫入 trace。模型就算一直要求查 SOP,也只能拿到這個工具定義允許的次數。

fallback 不是假裝還有答案

第三次仍然失敗時,我沒有改接一個不知道是否同步的備援知識庫,也沒有直接開單。系統回傳一筆清楚的降級結果:SOP 暫時無法使用,請稍後再試;而且 sop_checked 不會變成 True

return [
    {
        "article_id": "FALLBACK-SOP",
        "title": "SOP 暫時無法使用",
        "content": "請稍後再試;系統不會在無法查核流程時自動建立工單。",
    }
]

後面的 create_ticket 仍會被 SOP-first 規則擋下來。這是我在這個 mock 場景選擇的 graceful degradation:少做一點,明確說明現在做不到什麼,不拿不完整的資訊假裝處理完成。

若真實服務有正式的備援來源,fallback 仍要有自己的資料新鮮度、權限與信任檢查。把另一個 URL 當成「第二次試試看」不算設計完成。

timeout 和 retry 是兩道不同的限制

timeout 決定一個工具請求等多久;retry 決定超時或暫時失敗後,要不要再發一次、最多發幾次。

我的 mock 不會真正連網,因此這裡的 timeout 由 ToolTimeoutError 模擬。若接 HTTP 或資料庫 client,還要在 client 層設定 connect/read timeout,並只把可安全重試的逾時映射成 ToolTimeoutError。這個類別不是取消請求的魔法;已送出的請求是否真的停止,要看底層 client 和外部服務是否支援取消。

這個差別很重要。只設 timeout,Agent 仍可能立刻重送;只設 retry 次數,單次請求仍可能卡太久。兩邊都要有上限。

寫入不重試,但我還是加上 idempotency

「不自動重試寫入」只能減少風險,不能保證不重複。網路中斷時,客戶端可能不知道第一個 create_ticket 到底有沒有被伺服器接受;模型也可能在同一次執行中再次要求同樣的工具。

所以我讓 HelpdeskWorkflow 用本次執行 ID、申請人與標準化後的工單內容產生 idempotency key。相同執行中的相同工單請求,第二次不新增資料,而是回傳第一次的結果:

if idempotency_key and idempotency_key in self._tickets_by_idempotency_key:
    ticket = asdict(self._tickets_by_idempotency_key[idempotency_key])
    ticket["idempotency_status"] = "replayed"
    return ticket

第一次回傳 idempotency_status="created";重複請求回傳 "replayed",工單編號相同。這個 mock store 的做法很小,卻把一個原則留下來:寫入 API 必須自己能辨識重複請求,不能期待 Agent 永遠只呼叫一次。

真實系統還要決定 key 的有效時間、保存位置,以及相同 key 卻不同 payload 時要怎麼拒絕。這些不能交給 LLM 判斷。

我把失敗流程放進本機頁面

uvicorn app.web:app --reload

開啟 http://127.0.0.1:8000 後,按下「SOP 逾時降級」。它不需要 API key,會重現三次 SOP 查詢逾時、用完重試預算、進入 sop_unavailable fallback,最後不建立工單。

頁面上的 trace 會留下每次失敗、等待下一次嘗試、停止重試與降級結果。它不是在測真實網路品質,而是固定驗證:工具壞掉時,系統沒有把失敗擴大成另一個寫入操作。

image

我測了什麼

這次新增測試確認:

  • ToolTimeoutError 最多重試到預算上限,並採用 capped exponential backoff。
  • 非暫時性錯誤不會被重試。
  • SOP 逾時後回傳 fallback,且建立工單仍被擋下來。
  • 同一個 idempotency key 不會建立第二張 mock 工單。
  • FastAPI 的「SOP 逾時降級」路由回傳 stopped=True 且沒有工單。
python -m unittest discover -s tests -v

目前共有 32 個測試通過。這仍不是網路故障的完整模型:我還沒有 circuit breaker、分散式 idempotency store,也沒有真的接外部服務。但至少現在 Agent 不會把一次 SOP 查詢失敗,變成不斷重送或假裝開單成功。

下一篇我會先用 Promptfoo 打這個 Agent 一拳。正常開單、越權開單、工具不可用,以及「不得假裝成功」都會變成回歸案例。

本日程式碼

day-04-tool-failure


上一篇
Day 3|給 Agent 一把萬用 API Key 後,我才發現它什麼都能做
下一篇
Day 5|我還沒讓 Agent 上線,先用 Promptfoo 打它一拳
系列文
從 LLM、Agent 到 Guardrails:30 天打造可控、安全、可驗證的 AI Agent6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
noalowo
iT邦新手 4 級 ‧ 2026-09-18 13:54:15

想請教 idempotency key 的設計。我看了 day-04 的 _ticket_idempotency_key,key 是 execution_id、申請人,加上 title / description 做 strip() 之後的 hash。

文章提到要防的情況之一是「模型在同一次執行中再次要求同樣的工具」,但 LLM 第二次呼叫時很可能不是逐字重送,例如把「VPN 連不上」改寫成「VPN 無法連線」,或是把 priority 從 medium 調成 high。這樣 key 就不同了,結果還是會開出第二張單。

想問你怎麼看這個落差?會考慮讓 key 只由程式端可控的欄位組成,或是在同一次執行裡直接限制 create_ticket 只能成功一次嗎?還是這部分打算留給下一篇 Promptfoo 的回歸案例去抓?

謝謝你的提問!
你抓到一個重要落差。Day 4 目前的 idempotency key 只能處理完全相同的寫入重送,不能辨識語意相同、但模型改寫過參數的第二次呼叫;它不該被當成完整的「一個執行只開一張單」保證。
比較合理的下一步是把兩件事拆開:

  1. idempotency key:由後端建立、綁定一次使用者的開單意圖,處理網路重送與相同請求重播。
  2. write budget:同一次 execution 預設只允許 create_ticket 成功一次。第二次即使 payload 改了,也不再寫入;若內容不同,回傳 conflict 或要求使用者確認,而不是默默新增另一張單。
    priority 也不應由模型自由提高。後端需要把它限制在原始使用者授權的範圍內,例如使用者未要求高優先級時,Agent 不能自行從 medium 升成 high。
    Promptfoo 很適合加這種 regression case:第一次開單後,要求 Agent 用改寫過的 title、description 或不同 priority 再開一次,預期 trace 必須顯示第二次寫入被 write_budget 或 idempotency_conflict 擋下。但 Promptfoo 是防回歸的測試,真正的保護仍要在後端流程規則與工單 API 上實作。
noalowo iT邦新手 4 級 ‧ 2026-09-18 13:59:57 檢舉

noalowo iT邦新手 4 級 ‧ 2026-09-18 14:05:21 檢舉

期待 Promptfoo 那篇看你怎麼把這些寫成回歸案例

我要留言

立即登入留言