iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
AI Engineering

AI Agent 上線要想清楚的事:30 天拆解 Harness 的設計取捨系列 第 18 篇

Day 18|Timeout 之後,可以直接重試嗎?

  • 分享至 

  • xImage
  •  

🔁 timeout 不代表失敗,動作可能已經做了,不能直接再送一次。要再送,Harness 得帶上同一把 key:key 從「做哪一件事」算出來,不管誰重送、送幾次都是同一把;送出前先記一筆,第二次呼叫進來先查,查不到才用這把 key 補送。

昨天在 Day 17|Agentic search 停下來時,怎麼知道找齊了?,我們讓 Agent 改完程式交回時,附上它用哪些線索搜、排除了什麼、哪些還沒驗證。

快速回顧一下:Agent 呼叫工具時,Harness 執行工具、把結果交回模型,出錯也一樣,由模型決定下一步(Day 1 的 loop)。Day 14 替工具的錯誤分了類:還沒送出去的,Harness 自己 retry;送出後 timeout、不知道成功沒有的,留到今天。

今天要接著看的是:請求送出去了卻沒等到回應,能不能再送一次?

假設一家網路商店用 Stripe 收款,客服 Agent 的退款工具用的是 Stripe 之前官方工具包(Agent Toolkit)裡的 create_refund:收付款編號 payment_intent 和退款金額 amount,裡面呼叫 Stripe 的 SDK 建立退款,出了任何錯都只回模型一句 Failed to create refund。

一位客戶在訂單 1043 買了藍牙耳機和耳機殼,付了 1,580 元。耳機殼一直沒寄來,他跟客服說不想等了。Agent 確認還沒出貨、說明會退 380 元,他同意後就呼叫 create_refund,帶上付款編號和 38000(Stripe 的台幣金額以「分」為單位)。

這時 Stripe 剛好出狀況,回得很慢。SDK 送出時會自動帶一把 idempotency key(Stripe 拿它認出重送的是不是同一個請求),等不到回應就用同一把 key 重送;重送兩次也沒拿到結果,工具回模型 Failed to create refund。其實第一個請求已經在 Stripe 建好 380 元的退款,只是回應沒送回來。

模型讀到的是「失敗」。OpenAI Agents SDK 這類框架會把工具的錯誤交回模型決定下一步,預設的錯誤訊息還直接寫「Please try again.」。假設模型照做,用同樣的參數再呼叫一次。對 SDK 來說這是新的呼叫,它產生一把新的 key,Stripe 就當成另一筆退款;同一筆付款可以分次退款,只要總額不超過付款金額,第二筆 380 元也成立。客戶被退了兩次。

timeout 後再叫一次,同一筆退了兩次

圖:模型呼叫 create_refund → SDK 帶 key A 送出,Stripe 建好退款,回應沒回來 → SDK 用 key A 重送兩次也沒拿到結果 → 工具回模型「Failed to create refund」 → 模型再呼叫一次,SDK 換成 key B → Stripe 當成新的一筆,客戶被退了兩次。這是示意,實作要照自己的工具和環境調整。

SDK 自己的重送是安全的,出事的是上一層:模型不記得上一次,也看不到 Stripe 那頭的狀態,能把兩次呼叫認成同一筆退款的,只有包著工具的 Harness。下面先看 timeout 之後知道什麼、SDK 的 key 為什麼沒擋住,再把這筆退款改成同一把 key 走一遍,然後補上同一把 key 不夠用的情況,最後看跑在 workflow engine 上差在哪。

timeout 之後,知道的只有「沒收到回應」

Stripe 的錯誤處理文件講連線錯誤時寫得很直接:把結果當成未知,不要假設成功,也不要假設失敗。要知道結果,可以去 Stripe 查那個物件、等 webhook 通知,或用同一把 idempotency key 重送,直到拿到明確的成功或失敗。

create_refund 把這幾種情況都寫成 Failed to create refund,模型手上就只剩「失敗」這個判斷。接下來不管是再送一次,還是跟客戶說沒退成,都是照錯的資訊在做事。

重送危不危險,要看動作本身。查訂單、查物流這種只讀資料的,再查一次就好;把地址改成某個值,改兩次結果一樣;退款、寄信、建立訂單這種新增一筆的,再送一次就多一筆。

同一個動作做兩次、效果跟做一次一樣,叫冪等。前兩種多半本來就是冪等的,不過改地址如果每次都會寄一封通知信,重送一次客戶就收到兩封。第三種要靠下游幫忙:下游記得處理過哪些請求,同一個請求再來就回第一次的結果。Stripe 的 idempotency key 做的就是這件事,它把每把 key 和第一次的結果存起來,同一把 key 再送就回原本的結果,換一把就當成新的請求。

SDK 已經帶了 key,為什麼還是退兩次?

把開頭兩次呼叫的 key 排出來:

模型第 1 次呼叫 create_refund
  SDK 呼叫 #1:key A 送出 → key A 重送 → key A 重送 → 拋錯
  Stripe:key A → 建立退款 re_7f3a(回應沒送回來)

模型第 2 次呼叫 create_refund
  SDK 呼叫 #2:key B 送出 → 成功
  Stripe:key B → 建立退款 re_9b1c

Stripe 的進階錯誤處理文件說,網路錯誤之後要用相同的 key、相同的參數重送,才拿得到確定的答案。SDK 自己的重送做到了;模型那次呼叫是另一個 SDK 呼叫,key 是新產生的。key 綁在「這一次呼叫」上,上面哪一層再送一次,Stripe 就認不出來。

這支工具 2026 年 2 月已經從工具包移除,現在 Stripe 官方的 MCP server 退款前要人先確認。但這種寫法很常見,開源電商平台 Medusa 今年 9 月也有人回報同一種 bug:退款失敗時刪掉那一筆紀錄,重試再建一筆新的,送出去的 key 就變了。

改成同一把 key,訂單 1043 會怎樣?

Stripe 的進階錯誤處理文件給了兩種產生 key 的方式:用 UUID v4 這種夠隨機的值,或者從使用者那邊的物件推出來,例子是購物車 ID,用來擋重複送出。第一種要在第一次送出前產生、存好,之後每次重送都拿出來用;第二種不用記,同一個購物車算幾次都是同一把。

退款我會用第二種,從「退哪一筆」算出這件事的編號,我叫它 Operation ID,直接拿來當 key:訂單 1043 的耳機殼,就是 refund-1043-li_2。模型第二次呼叫時什麼都不記得,只要帶的還是訂單 1043 和耳機殼,Harness 算出來的就是同一把。編號從這件事本身算出來,不靠哪個 process 記得上一次,執行工具的 worker 可以是 stateless 的(自己不留狀態),換哪一台都算得出同一個。

這也是我不會把 create_refund 原封不動交給模型的原因。它收的是付款編號和金額,同一筆付款退兩次 380 元,可能是重送,也可能是客戶真的要退兩件 380 元的東西,光看參數分不出來。我會包一支收訂單和品項的工具,金額由後端照訂單算,編號從訂單和品項算。只有要做的事變了,例如改退別的品項,才是新的 Operation。

把開頭那兩次呼叫改成這樣,前後並排:

改前:key 每次呼叫都重新產生
  第 1 次呼叫:key A → Stripe 建立 re_7f3a,回應沒回來 → 工具回 Failed
  第 2 次呼叫:key B → Stripe 再建立 re_9b1c → 退了兩次

改後:key 從訂單和品項算出來
  第 1 次呼叫:refund-1043-li_2
    先記一筆 submitted → 送出 → Stripe 建立 re_7f3a,回應沒回來
    改成 unknown → 回模型「結果未知,不要再送」
  第 2 次呼叫:算出同一把 refund-1043-li_2
    看到 unknown → 先查 Stripe → 找到 re_7f3a
    改成 confirmed → 回模型「已經退過」

先記一筆,Harness 才知道「這件事送出去過了」,第二次呼叫進來會先查,不會直接送;worker 在送出之後、拿到結果之前掛掉,新的 worker 也要靠這筆紀錄,才知道有一件事懸在那裡。所以一定先標 submitted 再送出:沒標的就一定還沒送過;標了的,不管後來有沒有回應,都當成可能已經生效。

這筆紀錄放在 process 外面,例如業務資料庫,用唯一約束確保同一個 Operation ID 只有一列。模型同時發出兩個一樣的呼叫,送出去的也是同一把 key,重複的那個 Stripe 會擋下來。

timeout 之後,Harness 把這筆標成 unknown,回模型的內容講清楚現在知道什麼:

退款請求已經送出,但沒有收到 Stripe 的回應,不確定有沒有退成。不要再送一次,系統會去查;請先跟客戶說明正在確認,查到結果會再通知。

模型還是可能再呼叫一次。這時 Harness 算出同一個 Operation ID,找到那筆 unknown,照順序處理:

  1. 先去 Stripe 查。建立退款時把 Operation ID 寫進 metadata(Stripe 讓你附在物件上的自訂欄位,進階錯誤處理文件也建議這樣把本地的編號對回 Stripe 的物件),之後列出這筆付款的退款比對 metadata,查到 re_7f3a 就改成 confirmed,回模型「已經退過」。
  2. 查不到、key 也還在 24 小時內,就用同一把 key、同樣的參數重送。第一筆已經成立的話,Stripe 回的是第一筆的結果,不會多退。
  3. key 已經過期,或 Stripe 一直給不出明確的結果,就停在 unknown,轉給人處理。

這樣走下來會碰到三個編號,要分清楚:

  • Operation ID:這件事的編號,例如 refund-1043-li_2,重送幾次都不變,直接拿來當 idempotency key。
  • Attempt:這件事第幾次送出,用來看試過幾次、每次怎麼失敗的。
  • 外部 ID:Stripe 建好的退款編號,例如 re_7f3a,用來查詢和對帳。

Day 17 替送到 OpenAI 的請求加的 X-Client-Request-Id 也是編號,但每次送出都產生一個新的,跟著每一次呼叫走。timeout 之後拿它請 OpenAI 查那一次收到沒有,剛好用得上;重送時換了一個,就擋不住重複。

同一把 key 什麼時候不夠用?

同一把 key 擋得住大部分的重送,下面這幾種還是要另外處理:

  • 參數變了。 同一把 key 第二次卻帶了不同的金額,Stripe 會比對出來、直接回錯誤。所以 Harness 第一次送出前就把參數存下來,之後重送都用存下來的那份。
  • key 過期了。 Stripe 的 key 至少保留 24 小時,之後可能被清掉,清掉後再用同一把 key 就是新的請求;Adyen 寫 7 到 14 天,PayPal 是 45 天。過了期限,同一把 key 重送也不安全,只能去查。
  • Stripe 回 500。 它會把這個 500 跟 key 存在一起,同一把 key 重送通常拿到同一個 500;換新 key 重送,Stripe 文件也勸你別這樣做,因為原本那把 key 可能已經產生效果。這時只能等 webhook 或去查。
  • 查不到。 查不到不代表沒退,索引可能還沒更新,查詢範圍也可能錯了;看到空結果就判定沒退、再送一筆,又回到開頭那種情況。

後三種都只能靠查,查不清楚就停在 unknown,轉給人處理。

跑在 workflow engine 上,還要自己的 key 嗎?

Agent 跑在 workflow engine 上的,重送還會多一層。durable execution 是把流程進度存下來,執行的 process 掛了可以換一個接著跑。Temporal 和 DBOS 都是這類工具,兩邊的文件都要求碰到外部的步驟保持冪等。

Pydantic AI 的 Temporal 整合把每次工具呼叫包成 Temporal 的 Activity(交給 Temporal 排程、失敗會自動重跑的一段程式),模型每次請求也是一個 Activity。工具沒另外設定時一次最多等 60 秒,Temporal 預設 Activity 失敗就重跑。退款等 Stripe 超過 60 秒,整支工具就帶同樣的參數再跑一次;用的如果是開頭那支工具,裡面又是一次新的 SDK 呼叫,key 也換了。

Temporal 的文件寫,Activity 可能已經把外部的事做完,worker 卻在回報 Temporal 之前掛掉,歷史裡沒有完成紀錄,Activity 就會再跑一次;Activity 不是冪等的話,付款的情境可能重複扣款。它建議用 workflow 的 Run ID 加 Activity ID 當 key。Run ID 是這次 workflow 執行的編號;Activity ID 是 Activity 在這個 workflow 裡的編號,Python SDK 沒另外指定的話,就照排進來的順序編 1、2、3。

假設退款工具照這個建議帶 key,兩種重送並排:

Temporal 重跑同一個 Activity(等 Stripe 超過 60 秒)
  Activity 2 第 1 次:key = Run ID + 2 → 建立退款,回應沒回來
  Activity 2 第 2 次:key = Run ID + 2 → Stripe 認得,回第一次的結果

模型再呼叫一次工具(工具回了 Failed)
  Activity 2:key = Run ID + 2 → 建立退款,回應沒回來
  Activity 3:模型再發一次請求
  Activity 4:key = Run ID + 4 → Stripe 當成新的請求,再退一次

Temporal 重跑時編號不變,這把 key 擋得住。模型再呼叫一次,Pydantic AI 排的是新的 Activity,拿到下一個編號,key 就變了。Activity ID 只說得出這是 workflow 裡第幾個 Activity,看不出兩次退的是同一筆,所以業務自己的 Operation ID 還是要留。

DBOS 把每個步驟的結果 checkpoint 到 Postgres,還沒 checkpoint 的步驟恢復時會重跑,所以一樣要求步驟冪等。它保證只執行一次的,是步驟只做資料庫操作、跟 checkpoint 放在同一個 transaction 裡的情況;退款要呼叫 Stripe,我判斷不在這個範圍裡。

Temporal 的文件也提醒,一般不要在 Activity 裡自己寫 retry:Activity 需要的 timeout 會被拉長、算不到失敗次數,在 Temporal 的介面上也不好除錯。我會講清楚哪一層負責哪種重送:SDK 管連線層,workflow engine 管 process 掛掉,Harness 管這件事的 key 和結果未知怎麼處理,不再每一層各包一個通用的 retry。

編號跟著退哪一筆走,第二次呼叫先查

圖:模型呼叫退款工具 → Harness 從訂單和品項算出 refund-1043-li_2,存一筆 submitted 再送 → Stripe 沒回應,標成 unknown,回模型「結果未知,不要再送」 → 模型又呼叫一次,Harness 算出同一個編號 → 先查 Stripe,查不到就用同一把 key 重送 → Stripe 回第一筆的結果,改成 confirmed,只退一次。這是示意,實作要照自己的工具和環境調整。

Harness 最少要存什麼?

欄位 這個例子的值 誰寫、誰讀
operation_id refund-1043-li_2,從訂單和品項算出來 工具第一次被呼叫時建立;之後每次呼叫、每個 worker 都算出同一個,也是送給 Stripe 的 idempotency key
params 付款 pi_7Kd2、金額 38000(380 元) 建立時由後端照訂單算好、固定;之後重送都用這份
state new → submitted → unknown → confirmed 工具送出前後更新;對帳程式查到結果後改成 confirmed
submitted_at 第一次送出的時間 判斷 key 還在不在 Stripe 的 24 小時內
refund_id re_7f3a 收到回應或查到之後寫入;回覆客戶、對帳時用

下面是包在 Stripe 外面、交給模型用的退款工具。tool_ok() 回成功的工具結果,tool_unknown() 回一個標成錯誤、內容寫明「結果未知」的工具結果。

def refund_item(order_id, line_item_id, customer_id, db, stripe):
    op_id = f"refund-{order_id}-{line_item_id}"           # 從「退哪一筆」算出來
    op = db.get_or_create(op_id, customer_id)              # 唯一約束;新建時由後端算好金額、存下參數
    if op.state == "confirmed":
        return tool_ok(f"這筆已經退過:{op.refund_id},{op.amount_text}。")
    if op.state in ("submitted", "unknown"):
        found = stripe.find_refund(op.payment_intent, op_id)   # 列出這筆付款的退款,比對 metadata
        if found:
            db.confirm(op_id, found)
            return tool_ok(f"這筆已經退過:{found.id},{op.amount_text}。")
        if not op.key_still_valid():                       # 超過 Stripe 的 24 小時
            return tool_unknown("退款結果還在確認,不要再送;已轉人工處理。")
    db.mark_submitted(op_id)                               # 先記「送過了」,再送
    try:
        refund = stripe.create_refund(op.payment_intent, op.amount,
                                      metadata={"op_id": op_id}, idempotency_key=op_id)
    except (Timeout, ConnectionError, ServerError):
        db.mark_unknown(op_id)
        return tool_unknown("退款請求已送出,但沒收到 Stripe 的回應,不確定有沒有退成。"
                            "不要再送;系統會去查,請先跟客戶說正在確認。")
    db.confirm(op_id, refund)
    return tool_ok(f"已退款 {op.amount_text},退款編號 {refund.id}。")
誰呼叫 什麼時候 結果
模型 客戶同意退 380 元 建立 refund-1043-li_2、標 submitted、送出;Stripe 沒回應,標 unknown,回模型「結果未知,不要再送」
模型 沒照做,又呼叫一次 算出同一個編號,先查:查到 re_7f3a 就回「已經退過」;查不到、key 還有效,用同一把 key 重送,Stripe 回第一筆的結果
對帳程式 定時掃 unknown 和停太久的 submitted 查 Stripe 或收 webhook,改成 confirmed;超過 24 小時還沒結果就轉人工
新的 worker 原本的 worker 送出後就掛了 讀到 submitted 的紀錄,跟模型再呼叫一次走同一條路

可以先從哪裡做起?

假設你的 Agent 已經會呼叫改變外部狀態的工具,退款、寄信、建立訂單都算,工具出錯也會交回模型。

第一步,把「沒送出去」和「結果未知」分開回。 連線都還沒建立就失敗的,Harness 可以自己 retry;送出後沒等到回應的,回模型「結果未知、不要再送」,不要回「失敗」。只做這一步,模型就不會被叫去再試一次,但結果還是得有人查。

第二步,從這件事算出 key。 從業務內容算出 Operation ID,當成 idempotency key 帶給下游,送出前存一筆紀錄。之後模型或 worker 再送,下游都認得出是同一筆。

第三步,補上對帳。 定時查 unknown 的紀錄,過了下游 key 的期限還沒結果就轉人工。可以用一個測試確認:讓假的下游收下請求、但把回應丟掉,看第二次呼叫會不會送出新的一筆。

操作不多、下游支援 key 也查得到結果的,業務資料庫加一張表就夠了。流程要跨好幾個步驟、等很久、常常要從中斷處接回來,再考慮 Temporal 這類 workflow engine,但業務的 Operation ID 還是自己留。要人核准的工具也一樣:核准決定該不該退,核准之後送出的那一步 timeout,還是會碰到今天的問題。

Day 19 看換模型時,確認過的結果怎麼交接;Day 23 補核准綁定,Day 25 補重複 Trigger,Day 27 再決定不同失敗該走哪種恢復。

你可以怎麼確認自己讀懂了?

回到那筆 380 元的退款,可以試著問自己:如果工具一開始就拿 refund-1043-li_2 當 key,模型再送一次,還會退兩次嗎?那還需要回頭查嗎?

第一題不會。24 小時內同一把 key、同樣的參數,Stripe 回的是第一筆的結果。第二題還是要:Stripe 回 500 時,同一把 key 只會拿到同一個 500;key 過了期限,再送就是新的一筆;換成不支援 key 的服務,連重送這條路都沒有。這幾種情況只能查。

timeout 不代表失敗,動作可能已經做了,不能直接再送一次。要再送,Harness 得帶上同一把 key:key 從「做哪一件事」算出來,不管誰重送、送幾次都是同一把;送出前先記一筆,第二次呼叫進來先查,查不到才用這把 key 補送。

今天處理的是 Agent 對外部服務重送。接下來還有另一個問題:任務跑到一半換了模型,哪些進度可以交接過去,哪些供應商專屬的狀態根本搬不動?

參考資料

  1. Stripe|Agent Toolkit 的 createRefund.ts(2026-02 移除前的版本):工具收 payment_intent 和 amount,呼叫 stripe.refunds.create 時沒帶 idempotency key,出任何錯都回 Failed to create refund;2026-02-05 的 commit ed67c1f 從工具包移除。
  2. OpenAI Agents SDK|src/agents/tool.py:default_tool_error_function 預設回模型「An error occurred while running the tool. Please try again.」;Tools 文件也寫預設會把錯誤告訴模型。只說明框架怎麼提示模型,模型會不會真的再送沒有實測。
  3. Stripe|Refunds:同一筆付款可以退好幾次,總額不能超過原本付款的金額。
  4. Stripe|Error handling:連線錯誤時把結果當成未知;查物件、等 webhook、用同一把 key 重送三種確認方式。
  5. Stripe|Idempotent requests:保存每把 key 第一次的結果、比對參數、至少保留 24 小時,清掉後再用同一把 key 是新請求。
  6. Stripe|Advanced error handling:網路錯誤後用相同的 key 和參數重送;兩種產生 key 的方式;500 的結果會跟 key 存在一起,不建議換新 key 重送;用 metadata 帶本地編號對帳。
  7. Stripe|MCP:現在的官方 MCP server 對退款這類寫入動作,要人先確認才執行。
  8. Medusa|Issue #16681:退款失敗時刪掉那筆紀錄,重試用新紀錄的 ID 當 key,第一次其實成功的話客戶會被退兩次;附模擬 timeout 的重現步驟,是 bug 回報,不是事故報告。
  9. Stripe|List all refunds:可以用 payment_intent 篩出一筆付款的退款。
  10. Adyen|API idempotency:key 在第一次送出後 7 到 14 天內有效。
  11. PayPal|REST requests:同一個 PayPal-Request-Id 最多 45 天內可以重送。
  12. Pydantic AI|Temporal:工具呼叫走 Temporal Activity,沒設定時 start-to-close timeout 是 60 秒。
  13. Temporal|Retry Policies:Activity 失敗預設自動重跑,次數不設上限。
  14. Temporal|Activity Definition:外部工作做完、回報前 worker 掛掉時 Activity 會重跑;建議冪等,用 Run ID 加 Activity ID 當 key,這組值在重跑之間不變;不建議在 Activity 裡自己寫 retry 的三個理由。
  15. Temporal|sdk-python 的 _workflow_instance.py(commit d67e609):排 Activity 時沒指定 activity_id,就用它在這個 workflow 裡的順序號當 ID。
  16. Pydantic AI|temporal/_function_toolset.py(commit 6bc07cf):每次工具呼叫都排一個新的 Activity,沒有指定 activity_id;同一個目錄的 _model.py 把每次模型請求也排成 Activity。這是我讀原始碼看到的,沒有實際跑。
  17. DBOS|Architecture:步驟結果 checkpoint 到 Postgres,還沒 checkpoint 的步驟恢復時會重跑,所以步驟要冪等。
  18. DBOS|Why Postgres is a Good Choice for Durable Workflow Execution:步驟只做資料庫操作、跟 checkpoint 同一個 transaction 時才保證只執行一次;外部 API 不在範圍內是我照這個條件推的。

上一篇
Day 17|Agentic search 停下來時,怎麼知道找齊了?
下一篇
Day 19|做到一半換模型,任務接得下去嗎?
系列文
AI Agent 上線要想清楚的事:30 天拆解 Harness 的設計取捨 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言