iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
AI Engineering

現代化的 AI 系統設計系列 第 20

[Day20] - Agent 說退款失敗了,真的嗎?Timeout 後如何找回同一筆操作

  • 分享至 

  • xImage
  •  

昨天,我們替客服 Agent 建立了一道執行授權:模型可以建議退款,但只有 Runtime 能讓那一筆固定的退款真正發生。今天,這筆退款已經通過授權。

Agent 呼叫付款 API,準備把訂單 123 的 500 元退回原付款方式。十秒後,Runtime 收到 Timeout。Agent 告訴客服:「退款失敗,我再試一次。」第二次呼叫很快成功,系統也順利把案件關閉。直到客戶來信詢問:為什麼收到兩筆 500 元退款?

這時我們才發現,第一次退款早已完成,只是成功回應在途中遺失。
第二次呼叫並不是從失敗中恢復,而是建立了另一筆真的會改變外部世界的操作。

問題不只是「Timeout 之後能不能重試」。更根本的問題是:

Timeout 後要恢復的不是模型的決策,而是那筆已經授權操作的身分與證據。

https://ithelp.ithome.com.tw/upload/images/20260903/20183613MBkAMhTwP1.png

Runtime 沒有收到答案,不代表客戶沒有收到退款。

再問模型一次,為什麼不叫恢復?

站在人的語言裡,「再退一次 500 元」可能只是重複表達同一個意圖;站在系統裡,它卻可能是兩筆不同的操作。

第一次工具呼叫是 call_A,模型重新思考後產生的卻是 call_B。兩次都寫著訂單 123、退款 500 元,看起來是同一個意圖,在系統裡卻是兩筆不同的操作。若模型順手調整金額、付款目的地或備註,下游更不可能知道它們是不是同一件事。

這就是 Agent 與一般固定流程最值得注意的差異:模型很擅長重新產生一個語意相近的行動,但可靠系統需要延續的是同一筆已被授權的操作

所以,恢復不能從「再問模型該怎麼做」開始。它必須先回答:剛才送出去的,究竟是哪一筆退款?


Timeout 破壞的不是流程,而是我們對事實的認知

呼叫端只看到 Timeout,外部世界卻可能處於不同情況:請求尚未送出、已經受理、仍在處理,或早已完成但回應遺失。

重要的不是背下所有分類,而是接受一個限制:本地沒有收到答案,不能證明外部沒有發生效果。

Stripe 的錯誤處理文件也指出,遇到連線錯誤時,呼叫端可能不知道服務端是否已收到請求;即使收到 500,原始操作仍可能已產生副作用。

因此,Runtime 不能把 Timeout 直接翻譯成「退款失敗」,只能先記成「結果未知」。

「結果未知」不是含糊的錯誤訊息,而是一個需要被保護的正式狀態。在可信證據出現以前,模型、工作流程與操作人員都不能自行把它改寫成成功或失敗。

這個限制也適用於取消。使用者按下停止,只能證明系統提出了取消意圖;它不能讓已送往銀行或付款服務的退款憑空消失。

停止 Agent,不等於停止外部世界。


每一筆副作用,都需要自己的身分

要讓 Timeout 後的流程回到同一筆退款,我們需要在執行前建立一個穩定的 Operation ID:

operation_id: refund:order-123:v1
intent_hash: sha256(canonical_parameters)
observed_outcome: unknown
external_reference: null

這不是特定框架規定的格式,而是一份最小的事實紀錄:

  • operation_id:這是哪一筆業務操作?
  • intent_hash:退款金額、對象與目的地是否仍與原授權一致?
  • observed_outcome:目前有什麼證據能支持結果?
  • external_reference:外部服務是否提供可追查的編號?

Operation ID 應該在退款通過授權時就建立,而不是等到錯誤發生才臨時補上。同一筆效果的所有嘗試都沿用同一個 ID;參數若改變,就不能假裝仍是原本的退款,而必須建立新操作並重新授權。

這裡真正要保護的不是一串識別碼,而是操作身分不可被模型在恢復過程中偷偷換掉

https://ithelp.ithome.com.tw/upload/images/20260903/20183613YhuegooT8Y.png

重新生成相似意圖可能建立新操作;恢復則必須延續原本的 Operation ID。

Day16 談過的 Checkpoint 與 Durable Execution,能讓 Agent 在重啟後找回執行位置,卻不能只靠本地紀錄判斷付款服務是否已退款。Temporal 也明確說明,Activity(執行單元)可能被執行多次,因此會改變外部系統的 Activity 仍應設計成冪等。Temporal Activity Definition

所以我們要保存兩種不同的事實:執行紀錄回答「Agent 從哪裡繼續」,副作用紀錄回答「同一筆外部操作目前有什麼證據」。兩者可以相互關聯,卻不能被壓成同一個模糊的狀態。

Checkpoint 讓 Agent 找回執行位置;Operation ID 與副作用證據,讓系統找回外部事實。


一個能交給 Agent 的寫入工具,至少要有三種能力

許多工具只定義正常路徑:輸入參數、執行動作、回傳結果。但只要它會付款、寄信、建立工單、修改檔案或部署服務,只有執行功能就只完成了契約的一部分。

正式環境中的寫入工具至少還要回答三個問題:

能力 要回答的問題 退款案例
識別 這次是否仍是同一筆操作? 穩定的 Operation ID 與參數雜湊
執行 如何送出或延續這筆操作? 使用相同冪等鍵呼叫退款 API
查證 外部世界最後發生了什麼? 依 Operation ID 或外部編號查詢結果

如果工具只有「執行」,Timeout 後的 Runtime 就只能猜。它不知道該等待、查詢、重送,還是交給人工處理。

反過來說,只要操作可以被識別與觀察,Runtime 才有機會讓未知狀態逐步收斂:

  1. 先以 Operation ID 或外部參考編號查詢結果。
  2. 若確認成功,沿用既有結果,不再執行。
  3. 若仍在處理,繼續等待事件或稍後查詢。
  4. 若確認未受理,才以相同 Operation ID 與原始參數重送。
  5. 若長時間無法查證,轉入人工處理,不建立新 ID 碰運氣。

https://ithelp.ithome.com.tw/upload/images/20260903/201836139OWiDM67tB.png

只有外部證據能讓結果未知收斂;確認未受理後,也只能沿用同一個 ID 重送。

MCP 的長任務設計也採取相似方向:先建立可持久保存的任務,再回傳可供查詢的任務編號。Tasks 擴充提案傳達的關鍵訊息是:工作已被接受,不等於業務效果已經完成。


冪等、Outbox 與補償,都只是讓同一筆操作收斂

有了操作身分,常見的可靠性機制才知道自己在保護什麼。

機制 在這條主線中的角色
冪等鍵 讓同一個 Operation ID 被重送時,不會產生第二筆效果
Outbox/Inbox 避免本地紀錄與訊息傳送在不同時間點遺失或重複
對帳查證 主動查詢外部狀態,替「結果未知」找回證據
前向恢復 部分步驟完成後,繼續把同一項任務推向合法終態
補償 原效果無法完成時,建立另一筆可追蹤的業務動作加以補救

Stripe 的冪等請求會保存同一個冪等鍵首次開始執行後的結果;同一個鍵若換了參數,服務會拒絕。這正好說明:冪等鍵識別的是同一筆邏輯操作,不是一個可以無限重複使用的重試開關。

Outbox 解決的是另一段問題。它可以在同一本地交易中保存「退款操作已建立」與「退款命令待送出」,避免資料寫進去了,訊息卻沒有送出去。但訊息轉送者仍可能重送,所以接收端依然要用事件編號或 Operation ID 去重。Azure Transactional Outbox

補償也不是把時間倒轉。假設退款已完成,但取消出貨失敗,系統可能需要重新建立應收款、攔截物流或交由客服處理。這些都是新的外部效果,也需要自己的 Operation ID、授權與結果證據。Azure 的補償交易模式AWS Saga 指南都提醒:補償不一定能恢復原始狀態,而且補償本身也可能失敗。

所以這些機制不是一份任選的可靠性工具箱。它們共同服務同一個目標:讓已經建立身分的操作,從未知逐步收斂到有證據的結果。


測試的不是會不會重跑,而是有沒有換掉操作

一般測試常模擬 API 在執行前回傳錯誤,然後確認重試能成功。真正危險的窗口卻是:外部效果已完成,成功回應尚未寫回。

測試時可以故意在這些位置中斷:

注入故障 應該成立的不變量
請求送出後讓回應遺失 狀態進入「結果未知」,不建立新操作
外部完成後讓執行程序崩潰 恢復後先查證,實際退款仍只有一筆
API 只回傳任務編號 保存外部編號,不把「已接受」寫成「已成功」
Outbox 重複傳送訊息 接收端識別同一 Operation ID,不重複退款
模型在恢復時更改金額 參數雜湊不一致,拒絕沿用原授權
補償執行到一半中斷 補償操作本身可以被識別、查證與繼續

近期的 ToolMazeReliabilityBench開始把逾時、暫時性工具異常、部分回應與最終狀態納入 Agent 評估。它們仍是研究訊號,不代表已有通用交易標準;但方向很清楚:Agent 的可靠性不能只看最後回答了什麼,也要檢查外部世界最後被改成什麼。

最後真正值得追蹤的,也不只是重試次數:

  • 是否存在沒有 Operation ID 的外部操作?
  • 同一筆已授權效果,實際產生了幾筆業務操作?
  • 「結果未知」停留多久,最後依靠什麼證據收斂?
  • 模型是否曾在恢復過程中改變原始參數?
  • 補償操作是否能與原操作建立清楚關聯?

Timeout 後,先找回「它是誰」

回到開頭那筆退款。

Runtime 收到 Timeout 時,不應立刻把問題丟回模型,請它重新決定一次。它應該先取回 refund:order-123:v1,確認原始參數與授權仍然有效,再向外部服務查詢這個 Operation ID 的結果。

如果外部證據顯示退款已完成,系統就沿用該結果;如果證明請求未被受理,才以相同身分安全重送;如果始終無法查證,就保留未知並交由人工處理。

這個流程可能不像「自動重試三次」那麼俐落,卻守住了 Agent 改變外部世界時最重要的界線:模型可以提出下一步,但不能用一次新的推理,把一筆尚未查清楚的操作變成另一筆新的副作用。

所以,下次替 Agent 設計付款、寄信、建立工單、修改檔案或部署工具時,不妨先問:

如果它只有執行功能,卻無法識別與查證同一筆操作,我們真的能放心把它交給 Agent 嗎?


AI 你怎麼看?

https://ithelp.ithome.com.tw/upload/images/20260903/201836137rkoqli2FP.png

人類看到 Timeout 後,常會焦慮地按下重試;Agent 只是把這個衝動自動化,而且按得更快。

成熟系統真正自動化的,不應是「再試一次」,而是讓同一筆操作帶著身分與證據繼續前進。可靠性不是讓 Agent 永遠不停下來,而是在不知道發生什麼事的時候,仍然不讓猜測變成第二筆真的退款。


上一篇
[Day19] - Agent 被 Prompt Injection 騙了,為什麼仍不該有能力退款?從執行授權到人工核准
系列文
現代化的 AI 系統設計20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言