iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Build on Google AI

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

Day 12|換了雲端容器,剛才交代的事還在嗎?第一個 Cloud Run 版本

  • 分享至 

  • xImage
  •  

試想,鄉親在 LINE 先問花壇場次的集合點,隨後留下「需要手語志工支援」的詢問,看完內容、按下確認。過一會兒再問「剛才那單有成功嗎?」時,接電話的已經換成另一個雲端行程 (Process) ,它還認得原本那件事嗎?今天把 LINE、Google ADK 與 Firestore 接到 Cloud Run,準備用同一張請求核對答案。

Day 12 connects LINE, an ADK-based activity lookup, and the persistent request service in a Cloud Run application. Gemini interprets search conditions; the application binds confirmation to an operation and reads its receipt from Firestore. The deployment experiment verifies that the same request ID is recovered across Cloud Run revisions.

先看今天要完成的服務

像服務窗口換班,接班的人不需要把上一位志工的腦袋搬過來,只要找得到原詢問、當時的確認與現在進度,就能繼續說明。Day 11 已經把這些紀錄移出單一行程;今天要讓手機使用者也用得到。

以下是今天真實雲端閉環的完整驗收歷程:

使用者做什麼 LOCAL 做什麼 這一步實際看見什麼
問「花壇場次在哪裡集合?」 Gemini 3.8 Flash 理解意圖,工具查採用資料 首次查詢暫不可用時明確提示;第二次查到活動地點與未提供集合點
輸入「需要協助:需要手語志工支援」 保存原文,發起二階段確認卡片 帶有原確認識別與 5 分鐘時限的「確認送出這份詢問」按鈕
點下確認 核對原內容與目前權限,正式寫入 Firestore 實際產生專屬單號 req-20260926-85daad15417821ee 與待處理狀態
部署同一映像的新修訂版 將 100% 流量切換交給全新的修訂版行程 最新修訂版 local-day12-agent-00004-zz6 就緒,行程記憶體全新啟動
送出「剛才那單有成功嗎?」 依持久化參照由 Firestore 查原單 成功讀回同一單號 req-20260926-85daad15417821ee,證明跨行程任務接續

手機 LINE 真實端到端閉環實測
圖 1:LINE 教學帳號的詢問、確認與原單查回。左圖第一次查詢沒有取得工具結果,程式說明並保留原單查詢,第二次查到活動地點;輸入需求後出示確認卡,點確認建立請求 req-20260926-85daad15417821ee。右圖為部署新修訂版之後,查回同一單號與狀態。

我想讓讀者記住的成果是: 換了處理的人,使用者也不必把整件事再說一次。 單號要從資料庫讀回,不能靠模型把上次那句話背出來。

一、地方工作經驗與這份教材的邊界

我們團隊負責「卦山大縱走」的 LINE 數位集章與抽獎系統,所以我選這個熟悉的地方活動作為 LOCAL 的教材背景。正因為扛過真實地方活動的線上服務,我深刻體會到無伺服器環境的維運痛點:在允許縮容至零(Scale to Zero)的設定下,若一段時間沒有流量,閒置實例會被平台回收;下一個請求在全新的實例上冷啟動,如果程式把對話狀態留在記憶體變數裡,換了實例就會失去紀錄,叫鄉親從頭填一次。

我想處理的是一個很具體的問題:鄉親已經交代過的詢問,後端換了行程之後,能不能找回原單,而不是請他再說一次?

這次示範使用專屬的 LOCAL 教學帳號與 9 月 19 日花壇場次的歷史快照,聚焦於查詢、確認、保存詢問與跨行程查回。我們保存的是任務參照與回條單號,而不是把整段聊天記憶背出來;請求建立後的狀態為「待真人處理」,本示範尚未發出外部通知或派遣志工。

雲端演練採用最明確的「修訂版流量切換」來驗證新行程接續,本次不宣稱已觀察到自然縮容至零;Day 11 的模擬器結果是本機邏輯基礎,不能代替真正的雲端權限與網路傳輸。

二、讓 Gemini 選搜尋條件,也保留查詢暫不可用的回覆

收到使用者的自然語言提問後,我把原句交給 ADK 的 LlmAgent。Gemini 3.8 Flash 理解問題並選擇搜尋參數,再呼叫 search_local_events;這次沒有把正確參數先填給它。[3]

真實連線的手機測試中,對話記錄如下:

時間 發生什麼 系統選擇
18:02 工具未取得可核對結果 程式回覆「這次查詢暫時沒有取得可核對的工具結果。原單查詢仍可使用。」不瞎編集合點,保留查原單防線
18:06 工具回傳花壇快照 標示教學資料、明示活動時段與地點,並指出集合時間與集合點未提供
18:09–18:12 使用者留下手語需求並確認 出示確認卡,使用者點擊確認後正式寫入 Firestore,產生單號 req-20260926-85daad15417821ee
18:15(換修訂版後) 送出「剛才那單有成功嗎?」 新行程自 Firestore 讀回同一單號與狀態,未重開確認

在 18:02 的那次嘗試中,查詢未取得通過核對的工具結果,程式因此回覆暫不可用,並保留原單查詢入口。這和「工具成功查詢,但沒有符合條件的資料」是不同結果。系統沒有憑空捏造集合點,也沒有把失敗假裝成成功;這是受控的降級。

工具回傳後,手機上的時間、地點、來源與未知欄位由程式組合。這個版本讓 Gemini 負責理解搜尋條件;確認與單號則維持可以直接核對的格式。每次查詢至多兩次工具呼叫、三次模型呼叫。固定的「查詢原單」按鈕直接走查回服務,省下一次無必要的模型請求。

先提出確認,再正式建立請求

手機上的確認分成兩步。收到志工需求時,先保存待確認內容,此時尚未建立正式請求;按鈕的 postback 帶回原 confirmation_id,後端從已驗簽事件核對本人、原內容與期限,再交給建單服務。[4][5]

節錄自程式核心(tasks.py):

args = self.original.prepare(
    actor, operation,
    ttl_seconds=300,
    approved=False,
    idempotency_key=send_key,
)

approved=False 表示提出確認時尚未獲得使用者同意。即使點下按鈕,產生的確認紀錄中 execution_allowed 仍維持 false,由後端建單服務核對目前權限後才寫入 Firestore。查原單則直接取回既有請求,不會重新要求一次同意。我寧願把現在的進度說準,也不讓一張正確的單號,搭上一句尚未安排的承諾。

三、LINE Webhook 接收與 Cloud Run 容器契約

在 Cloud Run 上執行容器,平台依即時流量調度執行個體。容器內部監聽平台指定的環境變數 $PORT(預設為 8080),並綁定 0.0.0.0。本篇使用 FastAPI 實作,提供接收 LINE 訊息的 /webhook 與平台健康檢查的 /healthz 端點。[1]

LINE → Cloud Run 驗簽與路由 → ADK/Gemini 查詢,或原確認與建單服務 → Firestore → LINE 回覆。

關於回應時限,需要特別說明:LINE 官方文件指出,Webhook 伺服器若未於 2 秒 內回應 HTTP,將在統計中記為 request_timeout。[6] 目前本篇採少量測試白名單的同步示範,模型流程、資料操作與 Reply 都在 HTTP 200 前完成。模型流程可能接近或超過此時限,因此手機收到回覆,不能代替 Webhook 延遲的檢查。後續篇章支援較慢任務時,再將快速接收與持久工作處理切開。

--min-instances 0 讓服務在完全沒有流量時允許縮容至零。在今天的驗證中,我們先採用最明確、可重複驗證的工程手段:先在修訂版 A 建立請求,接著部署相同映像的新修訂版 B 切換 100% 流量,再從手機查回原單。

這樣就把兩個問題清晰拆開:第一,資料能不能被全新行程接續;第二,平台在什麼時機點回收行程。

四、金鑰不進映像:Secret Manager 集中管理

在本地端開發時,我們習慣建立 .env 檔案存放密鑰。但當程式打包上雲端時,把 .env 包進 Docker 映像檔或是明碼寫在設定檔中,是常見的安全隱患。

本篇選擇自己撰寫 Dockerfile,是為了把相依安裝放在建置階段,並配置非 root 的專用使用者(appuser,UID 10001),讓容器不在高權限身分下運作。建置階段使用獨立的上下文,並透過 pip check 檢查已安裝套件宣告的相依條件後記錄其 digest(本例為 sha256:2ab1848373...),部署時直接鎖定這組不變的指紋。程式匯入與整合行為另由測試核對。

金鑰與身分管理則交給 Google Cloud 的身分與秘密管理機制:

  1. 人的部署身分和容器執行身分分開:本機操作者擁有管理權限,但雲端容器的執行身分(Runtime SA)嚴格遵循最小權限原則(least privilege)。我們建立了專用服務帳戶 local-day12-runtime,只授予讀寫 Firestore 的 roles/datastore.user [8] 與存取 5 組機密的 roles/secretmanager.secretAccessor [7],不給予專案的 Owner 或 Editor,將資料影響面控制在專用測試專案內。
  2. 機密集中保存與版本釘版:把 LINE 金鑰、Gemini API Key 與隨機產生的識別密鑰,各自存入 Secret Manager [9] 獨立項目中。掛載時建議明確指定具名的數字版本(如 :1),不用 latest,避免非預期的金鑰輪替導致修訂版行為飄移。
  3. 平台安全掛載:當 Cloud Run 容器啟動時,平台將解密後的數值注入到容器環境變數中,映像與 Dockerfile 內沒有密碼明文。程式仍須注意避免把環境變數印入日誌。

Google Cloud Secret Manager 密碼管理清單
圖 2:本次建立的 Secret Manager 項目清單,畫面未顯示秘密值。服務帳戶能讀哪些項目,需另外由 IAM 與 Cloud Run 秘密引用設定核對。

五、部署至 Cloud Run

部署時使用下列命令範例:

gcloud run deploy "$SERVICE" --project "$PROJECT_ID" --region "$REGION" \
  --image "$IMAGE_DIGEST" --service-account "$RUNTIME_SA" \
  --allow-unauthenticated --ingress all \
  --min-instances 0 --max-instances 2 \
  --memory 512Mi --cpu 1 --concurrency 4 --timeout 60s \
  --env-vars-file "$ENV_FILE" --set-secrets "$SECRETS_BINDINGS" \
  --startup-probe='httpGet.path=/healthz,httpGet.port=8080,timeoutSeconds=2,periodSeconds=5,failureThreshold=24'

上列命令是給讀者參考的示範設定(示範並行 4、逾時 60 秒)[2];本次採用的修訂版截圖顯示為本次實際設定值(並行 80、逾時 300 秒)。實驗的對應值以保存之修訂版設定為準。專案 gen-lang-client-0769172331 為 AI Studio 建立之測試專案。

命令中特別以 /healthz 作為啟動探針(startup-probe),設定檢查預算為 5 × 24 = 120 秒。在容器冷啟動並探針成功之前,平台不會將任何真實流量導向該實例;超出預算仍未就緒則關閉容器。這不代表 LINE 會等 120 秒,也不能代替後端業務的端到端檢查。

LINE Webhook 需要可連入的 HTTPS 端點,所以這裡允許外部呼叫,業務簽章驗證照樣保留。/healthz 回應成功,只代表 HTTP 行程能工作;還要從 LINE 問一次、真的讀寫 Firestore,才知道這條服務有沒有接起來。

Google Cloud Run 服務管理主控台
圖 3:Cloud Run 修訂版管理畫面。local-day12-agent-00004-zz6 已就緒並配置 100% 流量;這張圖呈現修訂版配置,沒有呈現自然縮容至零的監控紀錄。

六、換新行程後,我要核對哪些關鍵紀錄?

驗收一個雲端 Agent 服務時,我們依序檢視六個關鍵狀態:搜尋(意圖解析) → 請求(原文留存) → 同意(驗簽與時限) → 建單(原子寫入) → 替換(流量交棒) → 查回(原單讀回)。

這次的驗證重點是 同一映像的新修訂版任務接續 。先在修訂版 A 建立請求,保存手機畫面與原文件;接著部署相同映像的新修訂版 B,待平台就緒後透過流量分配將 100% 流量交棒給修訂版 B。專案、命名空間、身分金鑰與資料庫保持不變。

接著在手機送出一則「剛才那單有成功嗎?」。應用程式的查回入口讀取持久化 Session 參照,呼叫原 Day 11 的 lookup(),這一步不重新要求確認,也不向模型索取單號。

實測記錄對照表:

核對項目 修訂版 A(建立請求) 修訂版 B(查回請求)
K_REVISION local-day12-agent-00003-2kh local-day12-agent-00004-zz6(承接 100% 流量)
request_id req-20260926-85daad15417821ee req-20260926-85daad15417821ee(完全一致)
同一操作對應的請求文件數 1 筆 1 筆(查回未重複新增)

兩次訊息處理的行程識別(boot_id)與操作識別(operation_id)已由程式記錄於 Cloud Run 日誌(PROCESS_STARTED 與 BUSINESS_RESULT 事件);兩版修訂版各自獨立啟動,修訂版不同,記憶體也不共用。容器的 PID 很可能同樣是 1,所以不能只拿兩個 PID 當作雲端重啟證明。

我的判讀與負向防護

  1. 修訂版切換證明記憶體已換:修訂版從 00003-2kh 切換到 00004-zz6,兩次請求由不同行程處理;新行程能從 Firestore 讀回同一單號,證明狀態已獨立於單一行程之外。
  2. 查詢暫不可用時保留原單入口:這次回覆沒有補造集合點,也沒有宣告查詢成功,而是保留原單查詢防線;底層原因以當次紀錄判讀。
  3. 縮容至零與修訂版切換分開認定:本次驗證完成的是新修訂版接續任務;平台的自然縮容至零留待監控指標另外紀錄。
  4. 負向路徑由離線測試把關:錯簽章與非白名單不進業務、原確認過期不新增單據、同事件重送不重複發起確認、撤權後不回傳私人舊回條——這四項在本地端由 test_core.py 的具名單元測試把關;本次雲端實測則專注走通正常路徑,讓邊界防護與線上主線各自有對應的檢驗依據。

七、原本那件事,終於有一條可以回頭找的路

Day 9 讓同一把鍵對應同一筆請求,Day 10 處理拿不到回條的情況,Day 11 再保存跨行程接續的依據。Day 12 把這些能力帶到 Cloud Run 與 LINE,讓使用者實際看到「你剛才交代的那件事還在」。

這次先集中在一條教學服務。店家資料按 Day 14 引入;真人接收與完整交付自動化仍各有篇章。服務的正常查詢、確認與查回能在手機上串起來,前面那些工程選擇才真的變成使用者少走的一段路。

接下來再整理 LINE 裡的狀態與操作:使用者看見原單之後,要怎麼一眼知道現在做到哪裡、下一步能按什麼?

程式與參考資料

前篇:Day 11|服務重啟了,剛才交代的事還在嗎?。本篇完整程式碼與架構說明在 examples/day12/。


上一篇
Day 11|服務重啟了,剛才交代的事還在嗎?重送、背景工作與重啟恢復
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言