iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
AI 自動化

情報進來,內容發出去:30 天拆一條每天真的在跑的 AI 內容產線系列 第 14

Day 14|行動批准後建立待辦,重跑為什麼不能建立兩次?

  • 分享至 

  • xImage
  •  

AI 從會議逐字稿抓出一件待辦,人按了批准,程式去建立。呼叫送出去,畫面一直轉,沒回應。

這時候只有兩個選擇,兩個都不對。不重送,那筆待辦可能根本沒建。重送,可能建成兩筆。

今天做的就是讓「重送」變安全:第二次送出去,對方認得這是同一件事,把第一次的結果還我,不再多建一筆。

我用一個假的待辦服務跑了一遍。同一份批准,兩個程序先後送出。服務收到 2 次請求,只建立 1 次,最後只有 1 筆待辦。兩張收據上的編號一樣。

這篇文章要做到的,就這一行:2、1、1

什麼叫「重送安全」

用生活的例子想。網購按「送出訂單」,畫面一直轉。再按一次。

我要的是兩件事同時成立:只成立一張訂單;第二次按下去也要有回應,不能當作沒按過。

技術上這叫冪等(idempotent):同一個請求送幾次,效果都跟送一次一樣。

「效果一樣」不代表「沒有紀錄」。RFC 9110 定義冪等時特別講了這點:伺服器端的預期作用跟送一次相同,伺服器仍然可以逐次留下日誌。

這句話直接決定了今天怎麼驗收。

先接上第 13 天

第 13 天最後交出一筆「行動候選」:程式從逐字稿裡整理出「某人說要補檢查項目」,驗過來源、驗過格式,停在「可以送人審」。它沒有被批准,也沒有任何工具可以呼叫。

今天補三個零件,讓它能真的變成一筆待辦,而且重送不會變兩筆:

1/ 一份獨立的批准,綁死候選的版本。

2/ 一個「意圖編號」,代表「這一次建立」這個動作。

3/ 一個假的待辦服務,會記住第一次的結果。

為什麼用假的服務?因為今天要驗的是控制邏輯,不是真的去碰誰的待辦清單。假服務沒有網路呼叫,所有狀態都存在本機檔案裡,可以反覆跑、反覆弄壞。

第 12 天也在「去重」:同一份人工決定被重送兩次,領域狀態只套用一次(檔案仍會重寫)。但那次的重送在自己家門口就擋掉了,決定根本沒出門。今天的重送刻意讓它出門,走到待辦服務的門口。因為真正的問題是外部的東西被重試時會不會多建一筆;在家門口擋掉,回答不了外面那一題。

我怎麼驗收:三個數字一起看

我讓假服務把三件事分開數:收到幾次請求、真的執行了幾次「建立」、最後有幾筆待辦。

第一次送完,三個數字是 1、1、1。第二個程序用同一份批准重送,變成 2、1、1。

只看最後一個數字不夠。「最後只有一筆」可能是建了兩筆再蓋掉一筆。三個數字一起看,才能確定:重送真的到了(第一個數字變 2),建立沒有再發生(後兩個數字沒動)。

「沒有重複」是一句話。「2、1、1」是三個可以檢查的數字。

四張紙,各管一件事

從候選變成待辦,中間會經過四份資料。我刻意不把它們合併。

候選是第 13 天的產物,它說「逐字稿裡有這件事、來自哪一版來源」。建立請求說「這次準備建立什麼」:標題、負責人、期限,加上意圖編號。批准說「誰批准了哪一份候選、哪一份請求、可以用什麼能力」。執行收據是送去服務之後拿回來的結果:建立了、重播了、還是衝突。

為什麼不能把「已批准」直接寫回候選裡?候選是從模型輸出驗出來的資料,批准是另一個人做的權限決定,收據是服務回來的觀察結果。三件事混在同一個物件,其中一個要換版本時,另外兩個也跟著壞。

新增的三份(請求、批准、收據)各有一份 schema 當文件契約;執行時跑的是手寫的嚴格格式檢查,不是通用的 JSON Schema 驗證器。

建立請求:空的欄位就讓它空著

節錄自保存的合成請求,另有 candidate_ref 綁定來源版本:

{
  "schema_version": "task-request.v1",
  "fixture_kind": "synthetic-task-request-no-real-provider",
  "request_id": "task-request-synthetic-001",
  "intent_id": "intent-synthetic-001",
  "operation": "create_task",
  "provider": "fake-task-provider.v1",
  "task": {
    "title": "補上通知設定的檢查項目",
    "assignee_person_ref": null,
    "due_at": null
  }
}

負責人是 null,期限是 null。第 13 天只知道有個匿名說話者承諾要做這件事,不知道他是誰,逐字稿裡也沒提日期。今天只能原樣保留。不能因為要建待辦,就猜一個人名或日期填進去。

批准:綁死四樣東西

批准不是一張到處能用的通行證。程式在呼叫服務之前,會重算並核對四件事:候選的編號跟內容摘要(內容的雜湊值)對不對、候選引用的來源版本對不對、建立請求的編號跟內容摘要對不對、批准給的能力是不是剛好「建立假待辦」這一項。

任何一項不對,程式停在門外,服務那邊的請求數維持 0。候選換了版本、來源換了版本、請求內容被改、能力被換成「發布」,四種情況都連門都進不去。

批准檔裡有個「誰批准的」欄位,只是給測試回查用的。這次沒有登入、沒有簽章,不能證明是真人或有授權。

意圖編號和內容摘要,是兩件事

這是今天最重要的一段。

意圖編號回答:這是不是同一次「建立」的動作?內容摘要回答:這次送來的內容,跟第一次一樣嗎?

假服務收到請求時,先查有沒有同一個意圖編號的紀錄:

有沒有同一個意圖編號 內容跟第一次比 結果 服務做了什麼
沒有 不用比 建立(created 建一筆,記住這次的內容摘要跟結果
一樣 重播(replayed 不建,把第一次的結果還你
不一樣 衝突(idempotency_conflict 不建,也不蓋掉第一筆,回報衝突

第三列是很多人會漏掉的。拿到同一個意圖編號,不代表永遠回成功。服務得記住第一次的內容摘要,才能在誤用同一個編號、卻送了不同內容時,明確回「這不對」,而不是默默把舊結果還回去。

還有第四種組合:不同的意圖編號、一樣的內容。這時候服務會建兩筆。

這條很容易被當成 bug。兩個不同的意圖編號,代表呼叫的人明確表達了「我要建兩次」。內容剛好一樣,可能是真的要兩筆一樣的待辦。服務不該替他猜。

第 12 天用同一份 AWS 文章擋過「同一個決定識別碼換內容」。同一個原則到了外部寫入這裡,多了一層:識別碼要由呼叫者給,因為只有呼叫者知道這是重試還是再來一筆。老實說,只用內容摘要當去重的鍵很誘人,可以少一個欄位;但省掉的正是「呼叫者的意圖」這個資訊。

一個小坑:欄位順序

JavaScript 物件的欄位順序可以不一樣,內容完全相同。如果直接對原始字串算摘要,這樣的兩份請求會被判成不同內容,第二次就變衝突。

做法沿用第 11 天:先把物件的鍵排序,再算 SHA-256。陣列的順序保留,因為陣列位置可能有意義。專項測試把整份請求的欄位順序打亂,摘要一樣,第二次還是重播,待辦編號還是同一個。

假服務存四份紀錄,不是一個「已處理」

服務的狀態檔裡有四個清單:每一次收到的請求、每一次真的執行的建立、目前存在的待辦、每個意圖編號對到的第一次內容摘要跟結果。

四個清單,才回答得了四個問題:第二次有沒有真的到?有沒有再建?最後有幾筆?重播回的是哪一次的結果?

這四個問題各自有獨立的欄位可查。換成 already_processed: true,稽核時只能回答「處理過」,回答不了「處理成什麼」。

真的跑兩個程序

第一個程序拿著批准跟請求,呼叫假服務,拿到「建立」,狀態寫進檔案,程序結束。第二個程序啟動,讀回那份檔案,原樣重送同一份批准跟請求,拿到「重播」。

兩張收據的待辦編號都是 fake-task-0001。三個數字是 2、1、1。

這裡要老實講邊界:兩個程序是先後跑的,不是同時。狀態檔用「先寫暫存檔再改名」的方式存(避免讀到寫一半的檔案),只有單機、單一寫入者。沒有資料庫交易,沒有處理兩個程序同時進來的情況。

三次送出,三種收據

跨程序那一段只驗到第二次的 2、1、1。下面這三次是同一個程序內連跑的固定序列:

第幾次 意圖編號 內容 收據 請求、建立、待辦
1 同一個 原內容 建立 1、1、1
2 同一個 原內容 重播 2、1、1
3 同一個 改過的內容 衝突 3、1、1

第三次值得多看一眼。內容被改了,而且改動本身有另外拿到一份批准。服務照樣擋:這個意圖編號已經綁著第一次的摘要,內容不同就是衝突。批准解決的是「有沒有資格呼叫」,冪等解決的是「同一件事有沒有做兩次」,兩個問題不能互相抵掉。

另一條路:兩個不同的意圖編號、一樣的內容,結果是 2、2、2。兩筆都建。

這兩組放在一起看,就知道今天不是用「內容像不像」猜重複。是呼叫者先給意圖編號,服務再用摘要檢查這個編號有沒有被誤用。

故意弄壞六次

一條流程會不會壞,不是看它順跑一次,是看它被亂送的時候會不會做錯事。

弄壞什麼 在哪裡停 服務收到幾次請求
沒有批准,或拿第 12 天的身分決定冒充 批准格式檢查 0
候選或來源換版了,沿用舊批准 候選綁定檢查 0
改動已批准的請求內容 請求摘要檢查 0
把能力換成「發布」 能力白名單 0
同一意圖編號、不同內容 服務的冪等紀錄 多 1 次,不建
不同意圖編號、相同內容 服務的建立 多 1 次,建第二筆

前四種根本沒資格呼叫,服務連請求都沒收到。後兩種必須真的走進服務,才驗得到冪等契約有沒有守住。

Day 14 的測試檔這次跑 14 條全過,含正常路徑、這六種弄壞、欄位順序、跨程序,以及保存的結果跟程式重算一致。

別人的服務怎麼做

冪等鍵在付款和任務類 API 很常見,做法卻不一樣。挑三個講:

Stripe 會保存第一次的回應,同一個鍵再送就回那份;參數不一樣就回錯誤;鍵放至少 24 小時後可能被清掉。今天的假服務走的就是這一型。

Google 的 Cloud Tasks 讓呼叫者自己指定任務編號,同一個佇列近期已有這個編號就回「已存在」。副作用一樣只建一次,但第二次回的不是第一次的結果,是一個錯誤碼。

Adyen 的鍵以公司帳戶為範圍,保存七到十四天;兩個一樣的鍵同時進來,可能回暫時性錯誤要求等一下再送。

還有 PayPal、Square、Amazon ECS、Google 的 AIP-155,各有各的規定,連結都在文末。拉出來的共同點只有一個:只建立一次。至於鍵保存多久、作用範圍多大、兩個同時到怎麼辦、第二次回什麼,每家都不同。正式接一個服務的時候,這四件事要自己去查文件,不能只說「我有帶 UUID」。

順帶一句:常看到的 Idempotency-Key 這個 HTTP 標頭,IETF 的草案在 2026-04-18 過期了,沒有變成 RFC。可以參考它的設計,不能當它是標準。

如果要正式做

今天用 JSON 檔當服務狀態,只能一個程序接一個程序跑。正式環境至少要換成資料庫:用「呼叫者範圍加意圖編號」建唯一約束,兩筆第一次紀錄就不可能同時落地;PostgreSQL 的 INSERT ... ON CONFLICT 可以原子地(要嘛整筆成立、要嘛完全不動)處理這個衝突。

唯一約束只回答「第二筆插不插得進去」。內容一不一樣、第一次的結果存哪裡、衝突回什麼,還是要自己定。而且就算本地紀錄寫得再穩,也沒辦法把外部服務的建立跟本地紀錄綁進同一筆交易。外部建成功了、回應卻沒收到,之後怎麼對帳,是第 26 天的題目。

這次證明了什麼,沒證明什麼

證明了:

1/ 同一份批准,兩個程序先後送,服務收到 2 次、建 1 次、留 1 筆,兩張收據同一個編號。

2/ 同一意圖換內容會衝突,不會默默回舊結果;不同意圖同內容會建兩筆。

3/ 欄位順序不同的同一份請求,摘要一樣,還是重播。

4/ 沒批准、換版、改請求、擴權,四種都停在門外,服務請求數是 0。

沒證明的:

▸ 任何真實的待辦服務。今天沒有網路呼叫,服務是本機假的。

▸ 兩個程序同時進來會怎樣。今天只跑了先後。

▸ 恰好一次。單機檔案不證明跨檔交易或當機恢復。

▸ 鍵要保存多久、範圍多大。今天的狀態不過期,正式服務要自己定。

參考資料


上一篇
Day 13|逐字稿裡的一句話,什麼時候才算行動候選?
下一篇
Day 15|從逐字稿包到可審會議報告,整條流程真的接起來了嗎?
系列文
情報進來,內容發出去:30 天拆一條每天真的在跑的 AI 內容產線18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言