iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Build on Google AI

LOCAL:30 天打造 LINE × Google AI 地方服務 Agent系列 第 8 篇

Day 8|一句「好」不夠:確認綁定具體操作

  • 分享至 

  • xImage
  •  

LOCAL 問:「要幫你整理一則詢問集合地點的訊息嗎?」你回了「好」,活動時間卻剛剛更新。今天讓 Gemini 搭配 Google ADK(Agent Development Kit,用來串起模型、工具與對話流程的 Agent 開發框架)準備確認內容,再由程式核對使用者、內容版本與期限。確認還有效就留下紀錄;資料變了,就帶你看新版。省下的是重新打字,不是省略重要變更。

Day 8 turns a conversational “yes” into confirmation of a specific request. Gemini and ADK prepare the confirmation flow, while application code binds the response to a user, session, request snapshot, catalog version, and expiry. We compare an unchanged request with one whose source changes while the user is deciding. The result is a content-confirmation record; creating the service request follows in Day 9.

先看實測成果:同樣按確認,結果為什麼不同?

這次用 Gemini 搭配 Google ADK 的工具確認(Tool Confirmation),比較「資料沒變」和「等待期間換版」兩個情境。確認回覆由測試程式代替使用者送出,Gemini 與 ADK 則實際執行工具流程;下圖是執行結果的靜態報告,不是已接上後端的網頁按鈕。

情境 原本待確認的內容 送回確認時發生什麼 工具結果
情境一:正常確認 時段 07:30~11:00 的活動時段與集合地點詢問 資料版本未變(v-0337e2296139) confirmation_recorded(已記錄確認)
情境二:等待中換版 同一份舊時段與問題 演練目錄已切換至 08:00~11:00(v-62ccd0ef3ca44e6ca8b7ef2d3302ab28) version_changed(資料已換版,原確認不接受)

註:本例的 v-0337e2296139 與 v-62ccd0ef3ca44e6ca8b7ef2d3302ab28 是 Day 8 adapter 指定的演練版本標籤,不是 Day 7 原始歸檔的 version_id。這裡比較的是兩份已固定的教學快照是否仍是同一版。

實測中的用量與耗時等實驗數據整理如下表:

實測指標(gemini-3.8-flash) 情境一:正常確認 情境二:等待中換版
模型請求次數 3 次 3 次
總 Token 用量 2,277 2,513
情境耗時 9.13 秒 4.76 秒
模型回覆重點 已成功確認草稿內容,下一階段將建立服務單 活動資訊已更新,出示變動並請使用者確認新版

實測環境為 google-adk 2.9.1 與 gemini-3.8-flash,兩情境總計 6 次模型請求、4,790 Token(預算上限 8 次)。全套 34 項離線回歸測試(25 項核心單元測試 + 9 項 ADK 整合測試)全數通過(PASS,0 外部網路存取)。

兩個確認情境的靜態實測報告
圖 1:兩個確認情境的靜態實測報告。上方是程式組成的待確認單,Gemini 負責提出工具呼叫,ADK 串接確認流程;左下為版本未變時的「已記錄確認」,右下為等待中換版後的「資料已換版」。本次確認輸入由測試程式代送,圖中的按鈕只作示意,尚未串接網頁後端。

兩個結果的 execution_allowed 都是 false,表示本篇只處理內容確認。是否確認成功要看各自的狀態與紀錄;真正建立服務請求留到下一篇。

一、昨天核對的是資料,今天確認的是你的意思

Day 7 把新海報整理進 LOCAL,也讓查詢分得出更新前、待核與採用後。接著換使用者出場:活動查到了,想進一步請服務人員協助,要怎麼接?

這次把前篇花壇場次的活動內容整理成兩份內建教學快照,服務窗口也是測試用的。演練一個需求:「請幫我整理一則詢問,問這場活動的集合地點在哪裡。」

這時 LOCAL 最有用的工作,是把活動與問題整理好,讓你少打一段字。下面是確認畫面的文案示例:

請確認這份詢問內容

活動:卦山大縱走・花壇場次
日期:2026-09-19
活動時段:07:30~11:00
詢問內容:請問這場活動的集合地點在哪裡?
接收對象:LOCAL 教學服務窗口

確認內容 修改內容 取消

人看的是活動、日期與問題;程式在背後記住它們對應的版本。今天按「確認內容」會留下確認紀錄,下一篇再讓這份紀錄接到真正的請求建立。

「好」本身沒有問題,問題是系統不知道你對哪一件事說好。 我們先用明確的確認操作保留對象;日後接回 LINE,同樣可以讓自然的對話對應到這份待確認內容。

二、Gemini 整理意思,ADK 接住等待與回覆

這篇的 Google 元件是 Gemini 與 ADK 的工具確認流程。

如果你第一次看到這幾個名詞,先抓住三件事就好:

  • ADK(Agent Development Kit):Google 提供的 AI Agent 開發框架。可以把它想成把模型、工具、對話狀態與執行流程串在一起的骨架。
  • Session(對話工作階段):一段使用者和 Agent 對話的工作空間,保存這段對話需要的事件與暫時狀態。本篇先用記憶體 Session,所以程式重啟後不會自動保留。
  • Tool Confirmation(工具確認):工具準備繼續做某件需要人確認的事時,先停下來把內容給使用者看,收到確認後才往下走。

Gemini 根據使用者需求提出工具呼叫,整理想詢問的內容;工具讀取目前採用的活動資料,產生確認摘要。[2] 工具函式中的 tool_context: ToolContext 參數,是 ADK 執行工具時自動提供的環境物件,不是模型或使用者填寫的業務資料。本篇透過它的 request_confirmation() 發出確認請求,收到回覆後再從 tool_confirmation 讀回使用者的選擇。[1][7]

流程可以這樣讀:

使用者說明需求 → Gemini 提出工具呼叫 → 程式組成確認內容 → ADK 等待回覆 → 程式重新核對 → 回傳確認結果。

以下節錄本次版本 adk_bridge.py 的確認流程,省略資料準備與記錄部分;註解補上這幾步在做什麼:

from google.adk.tools import ToolContext


def prepare_handoff_draft(
    draft_id: str = "draft-001",
    request_text: str = "請問這場活動的集合地點在哪裡?",
    tool_context: ToolContext | None = None,  # ADK 自動提供,不由模型或使用者填寫
    event_id: str = "evt-60d76a55472c503faa4c",
) -> dict:
    identity = get_current_identity(tool_context)
    confirmation = tool_context.tool_confirmation

    if confirmation is None:
        # 階段一:建立草稿與待確認單(offer),向使用者出示確認請求
        operation = service.get_or_create_operation(
            draft_id=draft_id, event_id=event_id, request_text=request_text, owner=identity
        )
        offer = service.issue_offer(owner=identity, operation=operation)
        ev = operation.displayed_event

        hint = (
            f"【請確認這份詢問內容】\n"
            f"活動:{ev.get('name')}({ev.get('area')}・{ev.get('venue')})\n"
            f"日期:{ev.get('date')}\n"
            f"活動時段:{ev.get('time')}\n"
            f"詢問內容:{request_text}\n"
            f"接收對象:LOCAL 教學服務窗口"
        )
        tool_context.request_confirmation(
            hint=hint,
            payload={
                "confirmation_id": offer["confirmation_id"],
                "draft_id": draft_id,
                "event_id": event_id,
                "operation_fingerprint": offer["operation_fingerprint"],
            },
        )
        return {"status": "awaiting_confirmation", "confirmation_id": offer["confirmation_id"]}

    # 階段二:使用者已回覆確認,重新核對伺服器最新狀態
    confirmation_id = None
    if confirmation.payload and isinstance(confirmation.payload, dict):
        confirmation_id = confirmation.payload.get("confirmation_id")

    if not confirmation_id:
        confirmation_id = lookup_confirmation_id_from_session(tool_context)

    # 找不到原確認請求時就拒絕,不回退(不改用最新待確認單)
    if not confirmation_id:
        return {
            "status": "not_found",
            "execution_allowed": False,
        }

    return service.validate_and_record(
        confirmation_id=confirmation_id,
        actor=identity,
        draft_id=draft_id,
        approved=bool(confirmation.confirmed),
    )

lookup_confirmation_id_from_session() 不是找「最新一張」待確認單,而是利用目前工具呼叫的 function_call_id,從 Session 事件找回原本那次確認請求綁定的 confirmation_id;找不到原確認請求時就明確拒絕,不回退(不改用最新待確認單),避免舊回覆誤套到另一份新內容。

這段分成「提出確認」與「收到確認後重新核對」。第一段產生的摘要,必須來自程式保存的同一份操作內容;第二段讀回伺服器紀錄,檢查目前狀態,再決定是否接受。

活動的時間、資料版本與使用者身分,由程式取得;模型負責把問題整理成容易理解的文字。如此一來,模型就算換一種說法,使用者確認的對象仍然明確。

程式把使用者的確認結果包成工具回覆訊息(FunctionResponse),交回 ADK 接續流程。這份回覆的 id 要對上發出 adk_request_confirmation 請求時的 function_call_id,就像用「對號單」找回原本的工具呼叫;LOCAL 的 confirmation_id 則用來找回伺服器保存的那份待確認內容。[1]

三、確認不是一個布林值,而是一張有對象的單子

只存 confirmed = True 很方便,卻回答不了「誰同意哪一份?」所以程式先建立一張待確認單(本篇程式裡叫 offer),記下這次要確認的內容、版本與對象。

我把確認紀錄分成五個部分:

要記住的事 本篇的安排 為什麼要有它?
誰在確認 使用者與 Session 另一個人的回覆不能套到這份內容。
確認哪件事 草稿 ID、操作類型、接收對象 區分不同詢問,也避免同意被移到其他用途。
看過哪份內容 草稿修訂版與操作指紋 使用者改了問題,也要重新確認。
根據哪版資料 本篇演練目錄的版本標籤與活動 ID 確認時核對資料是否已換版。
何時還有效 建立時間、到期時間、處理狀態 處理久未回覆、取消及重複點擊。

圖中的「操作指紋」是把操作內容按固定的 JSON 編碼規則整理後,計算出的 SHA-256。它涵蓋詢問文字、接收對象、草稿修訂版、資料版本與呈現的活動資訊等內容,用來檢查使用者當時看過的那份操作是否仍相同;身分與授權則由應用程式另外核對。[3]

模型可以協助改寫詢問,但最後供人確認的文字與活動資訊,要一起保存。使用者看到「集合地點」,實際記錄的卻是「取消報名」,就算那張單子有確認勾選也沒有用。

因此,這篇由固定摘要用來確認內容,模型回覆另由事件紀錄檢查。這個做法把內容一致性留給程式,也保留模型整理日常用語的方便。

四、一個看得見的反例:等你回覆時,資料已經換版

最值得演練的,不是隨便填一個不存在的 ID,而是使用者真的會遇到的等待。

先以內建的 07:30~11:00 快照準備詢問內容。ADK 發出確認請求後,測試程式把演練目錄切換成 08:00~11:00,再送回原請求的確認回覆。

兩件事現在分開了:畫面裡的內容仍是原版,系統已經採用新版。確認入口要讀的是當下的伺服器版本,不是相信按鈕夾帶的舊版本。

在真實實測中,當工具回傳 version_changed 阻斷舊確認後,Gemini 給出的回覆是:

「活動資訊已更新,請留意相關時段與內容有所變動。請確認您是否同意依據最新版本的活動資訊繼續處理此詢問?」

模型捕捉到了變動並主動向使用者詢問,沒有盲目繼續。若進一步配合應用端介面,也可以出示期待的介面文案:

活動時段已更新為 08:00~11:00。我保留了你要詢問集合地點的問題,請看過新版後,再按一次「確認內容」。

這比回一句「版本衝突,操作失敗」有用。問題不用重打,重要變更也能被看見。

同樣地,使用者把「問集合地點」改成「問附近停車位置」,就重新建立確認內容,讓舊的確認識別退出待處理狀態。保存原本的操作脈絡,更新真正改動的部分,這才是確認流程該有的體驗。

五、把這件事寫成小規格,再交給程式驗證

這裡開始使用一個很小的規格驅動開發例子。先寫清楚接受條件與反例,再拿它核對 Coding Agent 交付的程式;規格的價值是讓我們知道在測什麼,而非多產生幾頁文件。

本篇核心附在 examples/day08/confirmation.py;以下是其中版本與內容核對的節錄:

if current_catalog_version != record["snapshot"]["catalog_version"]:
    record["status"] = "version_changed"
    return result("version_changed")

if operation_fingerprint(current_operation) != record["fingerprint"]:
    record["status"] = "content_changed"
    return result("content_changed")

這幾行是在確認入口重新檢查伺服器目前的資料與操作內容。完整核心也檢查身分、期限、授權,以及取消與重複確認;順利通過才回傳 confirmation_recorded。

從 Repo 根目錄,分開執行純核心邏輯與需要 ADK 的完整離線驗證:

# 只執行不依賴 ADK 的核心測試與示範
python3 examples/day08/test_confirmation.py
python3 examples/day08/demo.py

# 完整離線流程需要已安裝 ADK 的虛擬環境(沿用 Day 5 環境)
PY=examples/day05/.venv/bin/python
$PY examples/day08/verify.py

完整離線測試仍需要 ADK 套件,只是不會使用模型金鑰;這裡沿用 Day 5 的虛擬環境,新環境的準備方式見 README。若需執行真實 Gemini API 實測(需事先設定 GEMINI_API_KEY,嚴格限制單情境最多 4 次、總上限 8 次模型請求):

# 真實模型呼叫(需事先設定 GEMINI_API_KEY)
$PY examples/day08/run.py --live

demo.py 使用合成人物與版本,對照「資料沒變」與「等待中換版」。verify.py 同時驗證核心邏輯與 ADK 事件暫停/接續;而 run.py --live 則使用真實 Gemini 與 ADK 跑完兩種情境並記錄原始 Token 與用量。

本篇小規格:CONTRACT.md,可對照核心測試與 ADK 介接測試一起閱讀。

小規格先守住以下行為:

案例 預期行為
本人、同內容、同資料版本、期限內 留下這份內容的確認紀錄(confirmation_recorded)。
等待期間資料換版 原確認不接受,回報需要確認新版(version_changed)。
草稿問題或呈現內容改了 原確認不接受,因操作指紋不符需重新確認(content_changed)。
另一位使用者或另一個 Session 回覆 保留原本待確認內容,不接受錯配回覆(wrong_actor)。
正好到期 原確認不接受,需要另行提出新的確認(expired)。
同一份確認重送 回傳既有確認結果與收據,不重複新增(already_confirmed)。

這裡說的 Harness(驗收框架),就是把輸入、時鐘、目前版本與預期狀態固定下來,讓每次修改都用同一套條件重新檢查。一般回歸先由 Python 測試跑,模型整合再看 ADK 的真實事件。測試裡的同意輸入是明確布林選擇,不用模型猜一句話到底算不算接受。

六、按鈕是入口,後端才是最後核對的位置

確認畫面可以先提醒使用者;真正接收確認的函式仍須獨立檢查。同一條規則應同時適用於畫面操作與直接送來的請求。[4]

本篇的 ConfirmationStore 接受呼叫端提供的身分與授權結果。離線測試中的 demo-user-a 是模擬身分,用來檢查兩個使用者是否被分開;它不等於完成真實登入驗證。

後續接到 LINE 時,使用者身分要來自已驗簽的事件,由服務端建立 Session 對應。確認內容、目前採用版本與期限,也從服務端記錄取得,不交給模型或前端欄位決定。[5]

ADK 的 Session 可以保存對話中的工作狀態,但本篇的確認規則另外放在應用層。對話怎麼串接可以改,誰確認過哪一份內容的判定仍有自己的入口。[6]

今天先留下確認紀錄,輸出明確標示 execution_allowed: false。它是 Day 9 的前置資料:下一篇建立請求時,仍要核對權限、有效內容與操作識別,才會真正寫入後端。

七、取捨:先讓小流程清楚,再細化哪些改動值得重問

這一版以整份演練目錄的版本標籤判定資料是否改變,而不是只看單一欄位。做法容易理解;將來接入多場活動的目錄,其他場次更新也可能讓使用者重新確認。

將來可以縮小到所引用活動或欄位的版本,減少沒有必要的重新確認。但先把目前的版本綁定、操作內容與確認結果看清楚,才有基準比較哪種做法更省事。

本篇先在單一行程中,用記憶體 Session 完成演練。若服務在等待確認時重啟,記憶體裡的對話狀態就會遺失;如何保存狀態、在重啟後接續確認,留給後面的持久化篇。ADK 官方目前仍將工具確認標為實驗性功能,也列有 Session 服務限制;今天對照已安裝版本驗證,不把它直接當成跨服務的交易機制。[1]

八、今天讓「好」有對象,下一篇讓請求有編號

Day 7 教 LOCAL 管理資料版本;Day 8 把版本帶到使用者確認,讓一段自然對話能對應到清楚的操作內容。

這篇要帶走的方法是:Gemini 幫忙整理,ADK 串起確認,程式保存並核對同意的對象與資料版本。 成功路徑讓人少打一段字,資料異動時則保留問題、只請人重看必要變更。

下一篇接上 create_handoff_request:內容確認之後,怎麼真的建立一筆請求、拿到編號,而且重送時仍然是同一筆?

你最常遇到哪種「好」說得太快的情境:活動時間更新、詢問內容改了,還是同時聊著好幾件事?

程式與參考資料

前篇:Day 7|看懂不等於可信:來源、版本與不知道。系列與公開程式:local-service-agent-for-line。

本篇程式與驗證入口位於:


上一篇
Day 7|看懂不等於可信:來源、版本與不知道
下一篇
Day 9|按兩次送出,會不會多一筆?受控建單與冪等
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言