大多數代理程式示範都有一條順利路徑:模型選擇工具,工具回傳 200,代理就繼續往下走。到了正式環境就不一樣了。在正式環境中,工具呼叫有時候根本不會回傳任何東西。
這就是一個正確的決定,最後卻變成錯誤結果的地方。
下面是一個示意情境(不是真實事故,但很常見)。
某個代理程式處理客服工單,判定客戶應該退款。它呼叫 POST /refunds。金流服務處理退款、寫入分類帳,並開始送出回應。網路出現小故障。代理的 HTTP 用戶端撞上逾時,拋出錯誤。
從代理的角度來看,這次呼叫失敗了。從金流服務的角度來看,它其實成功了。代理的迴圈看到錯誤後,最自然的反應就是再試一次。
於是客戶拿到了兩筆退款。
模型的推理完全正確。它用正確的參數選了正確的動作。更好的提示、更大的模型、或更聰明的規劃器都無法修正這件事,因為失敗發生在推理結束之後。
逾時只告訴你一件事:你沒有收到答案。它不代表動作沒有發生。把工具錯誤包成「工具失敗了,請決定下一步」的代理框架,會把三種不同情況混成一種:
只有前兩種情況才可以安全地直接重試。
如果代理當時送出了冪等金鑰,第二次退款呼叫就會變成不產生效果的操作,並回傳原本的結果。這個金鑰必須由應用程式在第一次嘗試前產生,並在同一個意圖的每次重試中沿用:
intent = actions.record_intent(
kind="refund",
order_id=order.id,
amount=amount,
idempotency_key=uuid4(), # 只建立一次,並與意圖一起儲存
)
try:
result = payments.refund(
order_id=order.id,
amount=amount,
idempotency_key=intent.idempotency_key,
)
actions.mark_confirmed(intent.id, result)
except Timeout:
actions.mark_unknown(intent.id) # 不是「failed」
重點在於金鑰存放的位置。如果是模型產生的,或每次嘗試都重新產生,它就沒有任何保護效果。
如果「我已經嘗試過退款訂單 4812」這段紀錄只存在於對話歷史裡,那麼一旦當機、上下文被截斷,或工作程序重新啟動,它就會消失。意圖必須在執行前寫入持久化儲存,這樣接手工作的任何程序都看得到。
大多數動作模型只有兩個終態:成功與失敗。可靠的系統至少需要:
submitted:已送出,尚未收到回覆confirmed:下游系統已確認failed:下游系統明確拒絕unknown:無法判定unknown 是最重要的狀態。它應該阻擋自動重試,並改為觸發對帳流程。
當某個動作落入 unknown,就必須有人去查明真相:透過冪等金鑰或訂單 ID 查詢金流服務,和分類帳比對,然後把意圖移到 confirmed 或 failed。這個人可以是背景工作、排程檢查,或者針對高價值動作的人工作業佇列。但不能是代理自己判斷「大概沒問題」或「大概失敗了」。
代理工具使用,本質上就是由 LLM 決定要呼叫哪個程序的遠端程序呼叫(RPC)。所有我們對不可靠網路上的 RPC 已經知道的事情都仍然適用:冪等性、持久化意圖紀錄、明確的不確定性、對帳。
清楚的分工是:
對讀取操作來說,「再試一次」是很合理的預設。對任何會改變狀態的操作來說,只有在你確定第一次沒有真正生效,或者下游系統保證重試不會重複套用時,才是安全的。
如果你已經在付款或訂單流程中使用冪等金鑰,以及 outbox/分類帳模式,那你大多只需要把代理動作導到同一套機制,而不是讓代理直接呼叫 API。若你還沒有,那就是你應該先建立(或採用)的部分,然後再讓代理擁有任何會移動金錢或資料的寫入權限。
你在代理工作流程中,是怎麼處理「我們不知道它有沒有真的發生」這種情況的?你是阻擋重試、做對帳,還是升級給人工處理?