在 LOCAL 裡問完花壇場次的集合地點,你確認好詢問內容、按了送出,想想又按一次:「剛剛到底有沒有收到?」今天讓 Gemini 透過 Google ADK(Agent Development Kit,串接模型與工具的開發框架)建立一張有編號的服務請求,再送同一件事仍取回原單。兩次送出,資料庫為什麼只留一筆?
Day 9 connects a confirmed local-service question to a stored handoff request. Gemini calls an ADK function tool; application code verifies the server-side confirmation and writes to SQLite. We compare a first submission, an identical retry, and a conflicting reuse of the same key. The lesson is how confirmation, database constraints, and tool receipts work together.

圖 1:離線核心實驗的靜態結果報告。先比較兩次送出的單號,再看資料庫筆數;本次確認由測試程式代送。
| 本次操作 | 工具判定 | 請求單號 | 資料筆數 |
|---|---|---|---|
| 第一次送出 | request_created |
req-20260922-7292728324c6bb72 | 1 |
| 同鍵同內容再送一次 | already_created |
req-20260922-7292728324c6bb72 | 1 |
| 同鍵改成另一個問題 | idempotency_conflict |
— | 1 |
| 另一份尚未確認的草稿 | unconfirmed_operation |
— | 1 |
先只看前兩列:第二次送出沒有拿到新單號,資料庫也沒有變成兩筆。這種「同一個操作重送多次,仍指向同一筆建立結果」的特性,就是本篇要處理的冪等(Idempotency)。
上表是離線核心實驗。對照組是一張只做 INSERT 的簡化 SQLite 表,每收到一次相同詢問就新增一列;同樣送兩次後留下 2 筆。受控版本則以同一組送出鍵重送,最終留下 1 筆;資料列狀態為 pending_human_review,也就是本機標記為「待真人處理」,但本篇尚未通知或交給真人。SQLite 是把資料庫保存在檔案裡的引擎,本次實際寫入就在這個本機檔案中。
本機離線回歸共 51 項通過:確認/建單核心 38 項、報告與紀錄 6 項、ADK 介接 7 項。另一份 ADK 替身實驗核對工具參數、回傳與資料庫結果。
| 模型 | 思考等級 | 模型請求 | 工具執行 | 資料筆數 |
|---|---|---|---|---|
gemini-3.8-flash |
LOW(低) | 4 | 2 | 1 |
第一次送出(3.314 秒):
已成功為您建立人工服務請求!
- 請求編號:
req-20260923-a85c810bbe51c99d- 狀態:等待真人受理中
後續請靜候專人為您處理與回覆。
同一組參數重送(3.981 秒):
核對結果如下:
此筆請求先前已經建立過,本次沒有新增第二筆。
- 原請求編號:
req-20260923-a85c810bbe51c99d- 目前狀態:等待真人受理中
用量依本次回傳:輸入 4904、輸出 372、思考未回傳、總計 5276 token(模型計算文字用量的單位)。
我的判讀:
離線核心實驗先回答後端問題:刻意省略查重的簡化對照連續寫入兩次,受控版本則在同一送出鍵重送後只保留一筆。這組 2 對 1 的結果屬於 OFFLINE_CORE,對照組的 2 筆另以同一 run 的 naive.sqlite3 資料列為準。
live(真實 API)Gemini 是另一個 run。這一次兩回合裡,模型都實際呼叫 create_handoff_request,送出的 idempotency_key、confirmation_id、request_text 與 event_id 都和測試入口提供值一致;第一次工具回 request_created,第二次回 already_created,兩次的 request_id 都是 req-20260923-a85c810bbe51c99d,而 live SQLite 最終只有一列。這證明的是本次兩回合的工具呼叫與回條鏈,不代表模型在其他輸入下都會有相同行為。
第二回合回覆有正確說明「沒有新增第二筆」,也保留原單號與 pending_human_review 的狀態。第一回合最後一句「後續請靜候專人為您處理與回覆」則比本篇實作多了一層未來承諾:目前只有 local_sqlite_only,尚未通知或交給真人。這也是為什麼本篇把「建立請求」與「真人已受理」分開。
Day 8 已把「誰確認過哪份內容」記下來。今天沿用花壇場次的教學快照與測試服務窗口,把「請問集合地點在哪裡?」收成一張有編號的請求。
這像是把寫好的便條放進收件盒,再拿到一張回條。有了編號,使用者可以核對送出的結果,服務窗口也有固定的紀錄可接手。
執行時直接載入前篇的 ConfirmationStore,使用同一套核心建立本次確認。確認由測試程式明確代送,實際新增的是本機 SQLite 裡的服務請求。 Day 8 的歷史收據留作證據,當次有效的確認則在這次行程裡重新建立。
已確認的詢問 → Gemini 提出建單 → 程式核對 → 資料庫保存 → 取得請求編號。
Day 2 的同鍵重試原則,就在這裡接回 Agent。search_local_events 先幫忙找活動,今天加入的 create_handoff_request 再把需要協助的問題留下來,成為 Day 1 三項工具中的第二項。
從已下載的 Repo 根目錄執行:
python3 examples/day09/demo.py
這個示範只用 Python 內建功能。它會印出新產生的 REPORT.html 路徑;用瀏覽器打開,先比較前兩列的單號,再看最右邊的筆數。
同一份報告也有一個刻意設計的對照:每次收到詢問就直接新增,完全省略查重。對照組與受控版本各用自己的 SQLite 檔案;同樣送兩次,差別會直接留在資料庫裡。[3]
REPORT.html 是實際結果的靜態報告,重做實驗請再次執行指令。每一輪另開資料夾,舊結果照樣留著。這次比的不是誰的畫面比較漂亮,而是同一件事究竟收了幾次。
如果每收到一個請求就發新單號,程式確實很勤勞,但服務窗口會多一份工作。要認出「又送了一次」,需要比單號更早出現的識別。
| 名稱 | 要回答的問題 |
|---|---|
confirmation_id(確認識別碼) |
使用者看過並確認的是哪份內容? |
idempotency_key(冪等鍵) |
這是不是同一次送出的重試? |
request_id(請求編號) |
後端最後收成哪一張單? |
冪等鍵由應用端替這次送出配置,重送沿用原鍵。它像取件憑單上的號碼:再拿來一次,就去找原本那件;真的要辦另一件事,才另開新的確認與送出鍵。本例單號中的日期以 UTC 產生,所以可能和臺灣當地日期差一天。
本篇把鍵的範圍限定在服務單位+使用者。tenant_id 在這裡只是服務單位識別;不同窗口或不同使用者碰巧使用相同鍵,各自仍能處理自己的詢問。回條還會核對本次對話,避免拿錯人的內容。
本篇用 Google ADK 把 Gemini、Python 工具與對話流程接起來;ADK 會依函式簽名、型別與說明,建立模型可使用的工具介面。[1]
以下是 adk_bridge.py 的真實工具簽名節錄,主體省略;完整檔案可以直接對照:
def create_handoff_request(
idempotency_key: str,
confirmation_id: str,
request_text: str,
event_id: str,
tool_context: ToolContext | None = None,
) -> dict[str, Any]:
...
這四個業務參數分別是送出鍵、確認碼、詢問文字與活動。ToolContext 則是 ADK 執行工具時自動提供的環境物件;本篇用它核對 Session(這一段對話的工作階段)。[2] 服務單位、使用者與權限由可信應用端提供,工具收到四個參數後仍要查原紀錄。[1]
.venv?Python 預設會把套件安裝到整台電腦共用的全域目錄,當不同專案需要的套件版本衝突時,就容易互相干擾。.venv(Virtual Environment,虛擬環境) 本質上只是一個輕量的專屬資料夾,讓每個專案都有自己的獨立套件箱,不需要時直接刪除資料夾即可。
一般教學常使用 source .venv/bin/activate(Windows 為 activate.bat)啟動環境,但在多個終端機視窗切換時容易切錯。本系列採用直接指定直譯器路徑的寫法,可以降低在多個終端機之間切錯環境的機會:
路線一:沿用 Day 5 既有環境(若已跟著做到 Day 5)
PY=examples/day05/.venv/bin/python
$PY examples/day09/verify.py --sdk
$PY examples/day09/run.py
路線二:建立全新 Day 9 獨立環境(從零開始或獨立測試的新讀者)
從 Repo 根目錄執行:
python3 -m venv examples/day09/.venv
examples/day09/.venv/bin/python -m pip install -r examples/day09/requirements.txt
PY=examples/day09/.venv/bin/python
$PY examples/day09/verify.py --sdk
$PY examples/day09/run.py
Day 9 直接沿用 Day 5 已裝好 ADK 的環境,省去重複下載的等待;若你是第一次跟著操作的新讀者,照路線二建立即可。
verify.py --sdk 執行完整離線測試;run.py 則讓真正的 ADK **Runner(推進模型與工具往返的執行器)**搭配固定腳本的模型替身,跑第一次建單與再次送出。替身的回答是預先安排的,適合檢查接線;本次 Gemini 回覆的語意表現另外看真實 API 原文。
準備好 GEMINI_API_KEY,確認本次呼叫範圍後,才執行真實模型:
$PY examples/day09/run.py --live --approve-live
這個入口沿用前篇的模型與 LOW 設定,兩回合總計最多六次模型請求、兩次工具執行;私人設定檔的指定方式見 README。測試入口提供已確認的參數,Gemini 負責提出工具呼叫並依回條回答。
看結果時,順著模型提出的參數 → 工具回條 → 資料庫那一列 → 最後回覆對一次,就知道編號從哪裡來。
我第一次在 Antigravity 的終端機跑 live 實驗時,遇到 403 ... not allowed by policy。在我這次環境裡,問題不是建單程式本身,而是執行環境尚未允許對外連線;我用 curl -I https://www.google.com 交叉檢查時也無法連出,調整該環境的網路權限後才恢復。
這只是本次開發環境的排查紀錄,不代表所有 403 都有相同原因。若一般終端機也失敗,再分別檢查 API、金鑰與帳戶設定;產品名稱與主控台選項可能變動,以當下官方介面與文件為準。
想像兩個送出請求同時進來,各自查到「還沒有這張單」,接著都新增。單獨測兩次沒事,同時送來卻變成兩張;問題在於兩步中間留了空隙。
本篇把查重與新增放進同一筆 SQLite 交易(Transaction):把一組資料庫操作作為一個整體處理,成功才提交;失敗則還原未提交的修改。以下節錄 handoff.py:
conn.execute('BEGIN IMMEDIATE')
BEGIN IMMEDIATE 先取得寫入交易,再查詢、核對與新增;另一個寫入者會等待,或得到資料庫忙碌的結果。本例將忙碌回傳為 storage_busy,與建單成功分開;本篇沒有另外製造 SQLITE_BUSY 來驗證這條錯誤分支。[3]
資料表另有兩條唯一性限制(UNIQUE),要求指定欄位組合在表內保持唯一:
UNIQUE(tenant_id, user_id, idempotency_key),
UNIQUE(tenant_id, user_id, confirmation_id)
第一條保護同一使用者的同一次送出;第二條讓同一份確認只對應一張請求。程式檢查加上資料庫限制,兩層一起守住這個結果。[4]
查到原鍵後,還會比對內容。以下同樣節錄自 handoff.py:
if row['payload_hash'] != digest:
conn.rollback()
return self._error('idempotency_conflict')
payload_hash 是把確認碼、詢問文字、活動、操作類型與目的地固定編碼後,計算出的 SHA-256 內容指紋,用來比對這次送來的參數。[5] Day 8 的 operation_fingerprint 也保存在資料庫,追溯當時確認的完整操作;兩個值分別處理「重送參數是否相同」與「原本看過什麼」。
今天的反例很生活化:沿用原鍵,把「集合地點在哪裡?」換成「附近哪裡可以停車?」。
這次應回 idempotency_conflict,原單編號與內容保持原樣。要送新問題,回到內容確認取得新鍵就好。這裡的衝突由測試程式刻意送入,用來解釋規則,沒有把它寫成模型真的犯過的錯。
還有一個值得區分的情況:單子已建立,使用者晚一點重送,這時確認期限過了。
我的選擇是:目前仍有權限、本人與對話相符、同鍵同內容,就取回原回條;第一次建單才要求確認仍然有效。 前者讀取已發生的結果,後者才會新增資料。這個區別,也替 Day 10 的逾時查回留下接點。
尚未確認的另一份草稿會得到 unconfirmed_operation。服務端先檢查原紀錄確實已確認,再交回 Day 8 核心重核內容、版本與期限;Day 8 的 execution_allowed: false 仍保持原義,執行授權另外由今天的建單服務決定。
本篇的 CONTRACT.md 對照主要行為與測試。這組固定輸入、預期結果和驗證方法,就是今天的 Harness(驗收框架);它讓我們每次改程式,都能重看同一套問題。
核心回歸包含六個請求同時送出;ADK 另核對工具參數、回傳與真實資料列。測試回答「單號和筆數對不對」,我再閱讀模型原文,判斷回覆是否自然、是否把等待受理講成已有人處理。
我先用本機 SQLite,因為打開檔案就能檢查,離線也能帶讀者做完。請求已經保存,確認狀態仍在記憶體。 重開資料庫可讀到原單;還沒執行的確認則需重新建立,整段對話的重啟接續留給後篇。
Day 9 的服務端身分範圍與 SQLite 交易是在這個小型教學應用中使用;後續接 LINE 與 Firestore 時,登入、儲存交易與通知還有各自的接點。今天先把「請求已建立」和「等待真人受理」說準。
Day 1 的 handoff-timeout-001 要求用原鍵核對,Day 2 示範過資料已寫入但回覆遺失。今天把確認、工具與後端接成一條線;下一篇再把故障注入 Agent 流程,並加上 GitHub Actions 的離線 CI(持續整合檢查)。[6]
重送時取回同一張回條,使用者少一個疑問,服務窗口也少一張重複單。 你最想先把這個方法用在活動詢問、客服留言,還是哪一種常被連按兩次的服務?
本篇程式:examples/day09;操作:README;規格:CONTRACT.md。