🔁 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 元也成立。客戶被退了兩次。

圖:模型呼叫 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 上差在哪。
Stripe 的錯誤處理文件講連線錯誤時寫得很直接:把結果當成未知,不要假設成功,也不要假設失敗。要知道結果,可以去 Stripe 查那個物件、等 webhook 通知,或用同一把 idempotency key 重送,直到拿到明確的成功或失敗。
create_refund 把這幾種情況都寫成 Failed to create refund,模型手上就只剩「失敗」這個判斷。接下來不管是再送一次,還是跟客戶說沒退成,都是照錯的資訊在做事。
重送危不危險,要看動作本身。查訂單、查物流這種只讀資料的,再查一次就好;把地址改成某個值,改兩次結果一樣;退款、寄信、建立訂單這種新增一筆的,再送一次就多一筆。
同一個動作做兩次、效果跟做一次一樣,叫冪等。前兩種多半本來就是冪等的,不過改地址如果每次都會寄一封通知信,重送一次客戶就收到兩封。第三種要靠下游幫忙:下游記得處理過哪些請求,同一個請求再來就回第一次的結果。Stripe 的 idempotency key 做的就是這件事,它把每把 key 和第一次的結果存起來,同一把 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 就變了。
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,照順序處理:
re_7f3a 就改成 confirmed,回模型「已經退過」。unknown,轉給人處理。這樣走下來會碰到三個編號,要分清楚:
refund-1043-li_2,重送幾次都不變,直接拿來當 idempotency key。re_7f3a,用來查詢和對帳。Day 17 替送到 OpenAI 的請求加的 X-Client-Request-Id 也是編號,但每次送出都產生一個新的,跟著每一次呼叫走。timeout 之後拿它請 OpenAI 查那一次收到沒有,剛好用得上;重送時換了一個,就擋不住重複。
同一把 key 擋得住大部分的重送,下面這幾種還是要另外處理:
500。 它會把這個 500 跟 key 存在一起,同一把 key 重送通常拿到同一個 500;換新 key 重送,Stripe 文件也勸你別這樣做,因為原本那把 key 可能已經產生效果。這時只能等 webhook 或去查。後三種都只能靠查,查不清楚就停在 unknown,轉給人處理。
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,只退一次。這是示意,實作要照自己的工具和環境調整。
| 欄位 | 這個例子的值 | 誰寫、誰讀 |
|---|---|---|
| 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 對外部服務重送。接下來還有另一個問題:任務跑到一半換了模型,哪些進度可以交接過去,哪些供應商專屬的狀態根本搬不動?
payment_intent 和 amount,呼叫 stripe.refunds.create 時沒帶 idempotency key,出任何錯都回 Failed to create refund;2026-02-05 的 commit ed67c1f 從工具包移除。default_tool_error_function 預設回模型「An error occurred while running the tool. Please try again.」;Tools 文件也寫預設會把錯誤告訴模型。只說明框架怎麼提示模型,模型會不會真的再送沒有實測。payment_intent 篩出一筆付款的退款。PayPal-Request-Id 最多 45 天內可以重送。activity_id,就用它在這個 workflow 裡的順序號當 ID。activity_id;同一個目錄的 _model.py 把每次模型請求也排成 Activity。這是我讀原始碼看到的,沒有實際跑。