昨天,我們替客服 Agent 建立了一道執行授權:模型可以建議退款,但只有 Runtime 能讓那一筆固定的退款真正發生。今天,這筆退款已經通過授權。
Agent 呼叫付款 API,準備把訂單 123 的 500 元退回原付款方式。十秒後,Runtime 收到 Timeout。Agent 告訴客服:「退款失敗,我再試一次。」第二次呼叫很快成功,系統也順利把案件關閉。直到客戶來信詢問:為什麼收到兩筆 500 元退款?
這時我們才發現,第一次退款早已完成,只是成功回應在途中遺失。
第二次呼叫並不是從失敗中恢復,而是建立了另一筆真的會改變外部世界的操作。
問題不只是「Timeout 之後能不能重試」。更根本的問題是:
Timeout 後要恢復的不是模型的決策,而是那筆已經授權操作的身分與證據。

Runtime 沒有收到答案,不代表客戶沒有收到退款。
站在人的語言裡,「再退一次 500 元」可能只是重複表達同一個意圖;站在系統裡,它卻可能是兩筆不同的操作。
第一次工具呼叫是 call_A,模型重新思考後產生的卻是 call_B。兩次都寫著訂單 123、退款 500 元,看起來是同一個意圖,在系統裡卻是兩筆不同的操作。若模型順手調整金額、付款目的地或備註,下游更不可能知道它們是不是同一件事。
這就是 Agent 與一般固定流程最值得注意的差異:模型很擅長重新產生一個語意相近的行動,但可靠系統需要延續的是同一筆已被授權的操作。
所以,恢復不能從「再問模型該怎麼做」開始。它必須先回答:剛才送出去的,究竟是哪一筆退款?
呼叫端只看到 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;參數若改變,就不能假裝仍是原本的退款,而必須建立新操作並重新授權。
這裡真正要保護的不是一串識別碼,而是操作身分不可被模型在恢復過程中偷偷換掉。

重新生成相似意圖可能建立新操作;恢復則必須延續原本的 Operation ID。
Day16 談過的 Checkpoint 與 Durable Execution,能讓 Agent 在重啟後找回執行位置,卻不能只靠本地紀錄判斷付款服務是否已退款。Temporal 也明確說明,Activity(執行單元)可能被執行多次,因此會改變外部系統的 Activity 仍應設計成冪等。Temporal Activity Definition
所以我們要保存兩種不同的事實:執行紀錄回答「Agent 從哪裡繼續」,副作用紀錄回答「同一筆外部操作目前有什麼證據」。兩者可以相互關聯,卻不能被壓成同一個模糊的狀態。
Checkpoint 讓 Agent 找回執行位置;Operation ID 與副作用證據,讓系統找回外部事實。
許多工具只定義正常路徑:輸入參數、執行動作、回傳結果。但只要它會付款、寄信、建立工單、修改檔案或部署服務,只有執行功能就只完成了契約的一部分。
正式環境中的寫入工具至少還要回答三個問題:
| 能力 | 要回答的問題 | 退款案例 |
|---|---|---|
| 識別 | 這次是否仍是同一筆操作? | 穩定的 Operation ID 與參數雜湊 |
| 執行 | 如何送出或延續這筆操作? | 使用相同冪等鍵呼叫退款 API |
| 查證 | 外部世界最後發生了什麼? | 依 Operation ID 或外部編號查詢結果 |
如果工具只有「執行」,Timeout 後的 Runtime 就只能猜。它不知道該等待、查詢、重送,還是交給人工處理。
反過來說,只要操作可以被識別與觀察,Runtime 才有機會讓未知狀態逐步收斂:

只有外部證據能讓結果未知收斂;確認未受理後,也只能沿用同一個 ID 重送。
MCP 的長任務設計也採取相似方向:先建立可持久保存的任務,再回傳可供查詢的任務編號。Tasks 擴充提案傳達的關鍵訊息是:工作已被接受,不等於業務效果已經完成。
有了操作身分,常見的可靠性機制才知道自己在保護什麼。
| 機制 | 在這條主線中的角色 |
|---|---|
| 冪等鍵 | 讓同一個 Operation ID 被重送時,不會產生第二筆效果 |
| Outbox/Inbox | 避免本地紀錄與訊息傳送在不同時間點遺失或重複 |
| 對帳查證 | 主動查詢外部狀態,替「結果未知」找回證據 |
| 前向恢復 | 部分步驟完成後,繼續把同一項任務推向合法終態 |
| 補償 | 原效果無法完成時,建立另一筆可追蹤的業務動作加以補救 |
Stripe 的冪等請求會保存同一個冪等鍵首次開始執行後的結果;同一個鍵若換了參數,服務會拒絕。這正好說明:冪等鍵識別的是同一筆邏輯操作,不是一個可以無限重複使用的重試開關。
Outbox 解決的是另一段問題。它可以在同一本地交易中保存「退款操作已建立」與「退款命令待送出」,避免資料寫進去了,訊息卻沒有送出去。但訊息轉送者仍可能重送,所以接收端依然要用事件編號或 Operation ID 去重。Azure Transactional Outbox
補償也不是把時間倒轉。假設退款已完成,但取消出貨失敗,系統可能需要重新建立應收款、攔截物流或交由客服處理。這些都是新的外部效果,也需要自己的 Operation ID、授權與結果證據。Azure 的補償交易模式與 AWS Saga 指南都提醒:補償不一定能恢復原始狀態,而且補償本身也可能失敗。
所以這些機制不是一份任選的可靠性工具箱。它們共同服務同一個目標:讓已經建立身分的操作,從未知逐步收斂到有證據的結果。
一般測試常模擬 API 在執行前回傳錯誤,然後確認重試能成功。真正危險的窗口卻是:外部效果已完成,成功回應尚未寫回。
測試時可以故意在這些位置中斷:
| 注入故障 | 應該成立的不變量 |
|---|---|
| 請求送出後讓回應遺失 | 狀態進入「結果未知」,不建立新操作 |
| 外部完成後讓執行程序崩潰 | 恢復後先查證,實際退款仍只有一筆 |
| API 只回傳任務編號 | 保存外部編號,不把「已接受」寫成「已成功」 |
| Outbox 重複傳送訊息 | 接收端識別同一 Operation ID,不重複退款 |
| 模型在恢復時更改金額 | 參數雜湊不一致,拒絕沿用原授權 |
| 補償執行到一半中斷 | 補償操作本身可以被識別、查證與繼續 |
近期的 ToolMaze與 ReliabilityBench開始把逾時、暫時性工具異常、部分回應與最終狀態納入 Agent 評估。它們仍是研究訊號,不代表已有通用交易標準;但方向很清楚:Agent 的可靠性不能只看最後回答了什麼,也要檢查外部世界最後被改成什麼。
最後真正值得追蹤的,也不只是重試次數:
回到開頭那筆退款。
Runtime 收到 Timeout 時,不應立刻把問題丟回模型,請它重新決定一次。它應該先取回 refund:order-123:v1,確認原始參數與授權仍然有效,再向外部服務查詢這個 Operation ID 的結果。
如果外部證據顯示退款已完成,系統就沿用該結果;如果證明請求未被受理,才以相同身分安全重送;如果始終無法查證,就保留未知並交由人工處理。
這個流程可能不像「自動重試三次」那麼俐落,卻守住了 Agent 改變外部世界時最重要的界線:模型可以提出下一步,但不能用一次新的推理,把一筆尚未查清楚的操作變成另一筆新的副作用。
所以,下次替 Agent 設計付款、寄信、建立工單、修改檔案或部署工具時,不妨先問:
如果它只有執行功能,卻無法識別與查證同一筆操作,我們真的能放心把它交給 Agent 嗎?

人類看到 Timeout 後,常會焦慮地按下重試;Agent 只是把這個衝動自動化,而且按得更快。
成熟系統真正自動化的,不應是「再試一次」,而是讓同一筆操作帶著身分與證據繼續前進。可靠性不是讓 Agent 永遠不停下來,而是在不知道發生什麼事的時候,仍然不讓猜測變成第二筆真的退款。