手機交到志工手上,畫面卻停在等待狀態,我要先救網路,還是繼續按送出?今天把展示準備做成三份交付:固定版本的執行手冊、0~180 秒雙手機完整劇本,以及可在本機執行的 SQLite 備援。從哪個版本開始、何時停止重試、切換備援時如何說明,都寫進 DEMO.md。成果展示要證明的,不只是順境下的成功,更是突發狀況下如何保護那張單據。
This chapter delivers a version-pinned demo runbook, a 180-second dual-phone storyboard, and a local SQLite fallback. It separates source revisions, deployment settings, data snapshots, and recordings, with explicit stop conditions for each presentation mode. The clean-room container rerun on Google Cloud Shell achieved 155 green tests with exit code 0 under
--network none. Live Cloud Run inspection confirmed revisionlocal-day12-agent-00013-bw5serving 100% traffic, and phone LINE Bot interaction confirmed ticket creation (req-20261011-dcd85c8b18175111) with progress lookup returning the same ticket.
今天交出三份成果:固定版本的展示手冊 DEMO.md、0~180 秒雙手機劇本,以及本機 SQLite 備援 replay_day27.py。在 Google Cloud Shell 以 --network none 重跑固定版本 f8267a8,155 項測試全數通過,結束碼 0,映像摘要與套件清單留存於 out/day27/cleanroom-01/。Cloud Run 流量分配與手機 LINE 對話,也都在今天留下紀錄。
現場一句話:鄉親已按確認,螢幕沒更新,能不能為了趕進度再按一次?
後端決策原則:狀態以單據為準,展示切換保留原操作識別。
Google 技術角色:Cloud Shell 提供乾淨容器;Gemini 與 ADK 呼叫設定列入凍結。
五分鐘入口:切到 Commit f9b7630,執行 python3 -m unittest test_day27_replay -v 與 python3 replay_day27.py --out out/day27/replay-normal。
| 本日交付 | 讀者拿到什麼 | 對照方式 |
|---|---|---|
| 展示手冊 | 前置條件、動作、台詞與切換規則 | DEMO.md 與本篇第四、五節 |
| 版本規範 | 程式、設定、資料與備援素材的綁定方法 | 第三節指令與每次獨立報告目錄 |
| 本機備援 | 同號查回、暫停阻擋、單次認領 | 第六節程式與實際 SQLite 紀錄 |
向利害關係人展示成果時,只有三分鐘可以驗收核心流程,網路卻突然不穩。這時候該如何應對?是盲目重複送出,還是先清楚說明切換到哪個備援?
所謂「版本漂移(Configuration Drift)」,指程式碼、雲端修訂版、模型設定與資料快照脫鉤的現象:簡報寫新介面,雲端跑舊版;或改了程式碼,模型提示詞仍停在上一版。「隨手改一行」常引發骨牌效應:改字更動按鈕資料,換模型影響工具選擇,重匯資料改變回覆。成果展示如同活動報到,名單與票券要對得上,否則失去技術說服力。
凍結(Demo Freeze)是先圈定可展示的版本與行為,再約束變更。 凍結範圍包含程式碼、執行環境、資料快照與展示素材。Git Commit 固定程式碼;模型、提示詞與工具宣告另存設定;資料來源保存雜湊,最後才綁定備援素材。
| 要固定的東西 | 為什麼只看 Commit 不夠 | 本篇採用的方法 |
|---|---|---|
| 程式與測試 | main 會繼續前進 |
使用完整 Commit,記錄測試範圍 |
| 映像與套件 | 標籤與間接套件可能更新 | 保存映像摘要、平臺與套件清單 |
| 雲端與模型設定 | 相同程式可配不同修訂版與提示 | 記錄 Cloud Run revision、模型與設定雜湊 |
| 資料與備援素材 | 新資料改變答案,素材可能落後 | 保留種子快照、單號與素材雜湊 |
Cloud Run 每次部署皆產生獨立修訂版;但流量仍可能指向不同修訂版。核對順序是「程式碼 → 映像摘要 → revision → 實際流量分配 → 本次請求」,確認 status.traffic 確實承接流量,而非看見修訂版存在即認定上線。[2]
檢視 Cloud Run 服務 local-day12-agent:local-day12-agent-00013-bw5 配置 100% 流量,d13 與 d14 標籤指向舊版。另記錄 /healthz 回傳 404,未帶簽章的 /webhook 回傳 401 與追蹤標頭。單憑狀態碼尚不能確認拒絕發生在哪個步驟,404 原因留待 Day 28 核對。
我把 48 小時列為準備目標,讓關鍵路徑有時間彩排。若發現影響資料一致性的缺陷,就建立新候選重驗;凍結的是可追查的選擇,而非掩蓋問題。

圖 1:展示版本的四份對照。Commit、執行設定、資料快照與備援素材各有識別;雲端修訂版旁另標實際流量,箭頭代表應核對的關係。
Mac 沒有裝 Docker,容器操作改在 Google Cloud Shell 進行。展示手冊收錄於專案 docs/day27/DEMO.md(亦可由 examples/day27/ 導覽取得),載明各模式操作步驟。Cloud Shell 家目錄會跨工作階段保存,若留快取就不算乾淨環境。本篇以腳本在獨立目錄拉取公開程式,以標準輸入傳入 Dockerfile 達成空建置上下文,僅掛載報告目錄。[3]
固定來源 Commit f8267a8,執行 155 項測試(包含 Day 25 的 47 項、Day 24 的 62 項、Day 19 的 18 項與 Day 22 的 28 項),全數通過且結束碼為 0;這 155 項是容器測試數,和實機連線分開計算。[1]
以下在 Cloud Shell 終端機執行。Git 取得公開程式並切到指定版本;命令不部署服務、不呼叫模型,也不變更正式帳號。新目錄拒絕覆寫舊實驗。
set -euo pipefail
REV=f8267a84fc7456783b2229e42b580d705164fd8d
ROOT="$HOME/local-demo-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir "$ROOT"
git clone https://github.com/jarsing/local-service-agent-for-line.git "$ROOT/repo"
cd "$ROOT/repo"
git checkout --detach "$REV"
test "$(git rev-parse HEAD)" = "$REV"
RUN=out/day27/cleanroom-01
mkdir -p out/day27
mkdir "$RUN"
printf '%s\n' "$REV" > "$RUN/launcher-commit.txt"
set +e
REV="$REV" bash examples/day26/cleanroom.sh "$RUN" \
> "$RUN/run.log" 2>&1
RC=$?
set -e
printf '%s\n' "$RC" > "$RUN/exit-code.txt"
cat "$RUN/run.log"
if [ "$RC" -ne 0 ]; then
printf 'STOP: inspect %s/run.log\n' "$RUN" >&2
exit "$RC"
fi
printf 'Reports: %s/%s\n' "$PWD" "$RUN"
在 Cloud Shell 實作有兩個要注意的細節:第一是該版 REV 預設短碼 36914e5,但 Dockerfile 以 git rev-parse HEAD 完整碼比對,故入口須明示完整碼;第二個細節來自 run.log:Docker 對 ARG BASE 缺少預設值發出警告,補上 ARG BASE=python:3.13-slim 即可消除。[1]
操作時還有一個排查經驗:在舊工作區切換版本時,直接簽出可能遭遇參照不存在的錯誤,須先執行 git fetch origin 同步參照,再簽出。
cleanroom.sh 在建置時下載相依套件,測試容器再以 --network none 執行。啟動步驟保留日誌與結束碼;看見 exit-code.txt 為 0 後,仍逐組確認數量與狀態。[4]
| 原始產物 | 要核對的內容 |
|---|---|
base.txt、image.json |
當次取得的基底摘要、映像識別與平台 |
commit.txt、python.txt、packages.txt |
容器內實際程式、Python 與相依套件 |
day25.log、day24.log、day19_day22.log |
分別執行 47、62、46 項,沒有失敗或跳過 |
demo.json、worktree.txt |
合成資料寫入一筆、重送同號,未忽略的工作區差異為空 |
run.log、exit-code.txt |
整段呼叫結果及失敗位置 |
本日備援程式公開於 Commit f9b7630;展示手冊更新於 docs/day27/DEMO.md(Commit e2ab88c,ci.yml Run 38120465601 通過)。完整檔案清單與雜湊另列於清冊。
三分鐘是展示配額,而非延遲保證。手機 A 扮演鄉親、B 扮演志工,終端機只顯示同一張單的幾個欄位。畫面上保留模式標籤,讓現場讀者知道此刻是即時操作、本機模擬還是離線備援。
純模擬器可驗證流程順序,但驗不到關鍵點:手機 A 按下確認、B 收到卡片後,兩端是否對齊同一個權威 request_id;訊號不穩多按一次,會不會重複建單。因此劇本以雙視窗為對象,將動作、台詞與驗收條件寫明。
起點沿用 Day 26 問句:「我想帶吃全素的長輩吃爌肉飯,可以幫我詢問店家嗎?」系統依工具說明可確認範圍;無法確認全素選項時保留未知,不改口說店家不提供。需要真人詢問時後端顯示摘要等待確認,看清 Gemini 理解問題與後端同意流程各自的分工。
| 時間 | 講者台詞 | 實際要做的動作 | 畫面驗收條件 |
|---|---|---|---|
| 0~30 秒 | 「先看版本,再看今天要交給誰。」 | 展示 Commit、測試日誌與模式標籤 | 日誌可對回固定版本,確認當前執行模式 |
| 30~75 秒 | 「提問是意圖,按確認才是同意。」 | A 提問吃素爌肉飯,Gemini 保留未知,確認建單;B 查看通知 | 確認後建立權威單號,B 收到推播卡片且單號一致 |
| 75~120 秒 | 「已存在的單要留著,新認領先停下。」 | 管理者設 PAUSED,B 點認領遭拒;恢復 SERVING 後認領成功 | 暫停時拋出服務暫停,單據不變;恢復後認領版本遞增 |
| 120~180 秒 | 「他看到的進度,要是同一張單的現在。」 | A 查回進度,再操作先前確認卡;對照後端資料列 | 顯示已被志工受理,重複按確認同號查回,不重複建單 |
這套時序把雙手機與後端資料庫綁在同一張單上。狀態只由後端推進:志工認領成功,單據才進入 human_claimed;鄉親查回看到的,必須是原單當前狀態,不是快取。
實體手機對照:輸入全素爌肉飯需求後,服務判定超出範圍,提供三個入口而非店家推薦;選「留下服務詢問」並確認,於 18:42:24 建立權威單號 req-20261011-dcd85c8b18175111,狀態為待人工覆核;18:43 點「查詢這筆進度」,查回同一張單,呈現相同單號與建立時間。未走到店家查詢的原因,留給 Day 29 總驗收。
展示分為兩條路線。第一條為已驗收的本機 SQLite 規則、斷網備援與單號查回;第二條為雙手機現場通道:線上 Firestore 查回須接受合法前進的 human_claimed 狀態;預錄素材通過離線試播前維持待驗收,不拿未驗收素材充數。[5]

圖 2:0~180 秒的展示與拍攝規格。確認前標示操作提案;確認後才串起權威 request_id、志工通知、暫停、認領與本人查回;本圖為時序設計,實機畫面見圖 3。

圖 3:實體手機 LINE Bot 操作對照。左圖:18:42 按「確認送出這份詢問」後顯示「詢問已保存」,單號 req-20261011-dcd85c8b18175111,狀態為待人工覆核、尚未通知窗口;右圖:18:43 點「查詢這筆進度」,顯示「找到原本的詢問」,單號與建立時間 18:42:24 完全相同,顯示原單進度。
墨菲定律提醒我:系統出故障時的應變,要在出事前先設計好。這正是地方服務最需要的「數位韌性(Digital Resilience)」:最危險的不是畫面停住,而是展示切換不明確,讓人把另一段歷史結果誤認成剛才那張單。
我將故障分成四道防線,切換看證據資格,而非照編號硬闖。失去網路時,直接切換到本機離線狀態機;若環境徹底受限,才考慮使用經離線驗證的預錄素材。
| 防線 | 可展示的能力 | 停止條件與接替方式 |
|---|---|---|
| 雲端即時通道 | Gemini、ADK、Cloud Run、Firestore 與 LINE 接線 | 未取得可確認結果即停止,保留原單號 |
| 本機模擬通道 | 經測試的 Webhook 處理器與模擬事件 | 須標明模擬;無處理器則略過 |
| 離線狀態機 | SQLite 同號、暫停與認領規則 | 使用獨立合成資料庫,不接續線上單據 |
| 預錄備援規格 | 錄製當時實際發生的操作 | 素材須先離線驗收,使用前說明錄製版本 |
每段操作設定 10 秒等待額度。逾時停止重複點擊,後端是否提交由查回確認;計時器無法撤銷已成立的業務結果。
LINE 推播逾時可能已被接受。重試沿用原內容與 X-Line-Retry-Key;平臺接受、手機呈現、志工認領分開留證,避免重複推播。[6]
備援素材規格:A、B 與終端同框連續記錄,顯示日期與版本,對照原單號。公開副本遮蔽個資與金鑰並保留雜湊,不切掉失敗或重試。
展示前在離線環境檢查素材,確認畫面完整。切換時明確宣告:「現在切換至先前記錄的操作,版本顯示在畫面上。」不偽造即時執行。
面對全斷網時,系統韌性體現在受控降級能力。本機 Python 與程式碼須事先備妥;這條路使用 Day 22 的 SecurityStore,無模型與 LINE 傳送器,以合成身分操作真實 SQLite,展示核心狀態機。[7]
下面是本篇 replay_day27.py 的核心。require() 會在條件不成立時拋出例外,操作放在顯式呼叫裡,避免 python -O 移除 assert 時連認領都消失。CLI 另外檢查核心檔案雜湊、拒絕既有目錄,並保存成功或失敗報告。
from examples.day22.circuit_breaker import SecurityStore, Paused
def require(condition: bool, reason: str) -> None:
if condition is not True:
raise RuntimeError(reason)
def exercise(store: SecurityStore) -> dict:
store.set_mode("sample-operator", "SERVING", "demo")
epoch = store.admit("sample-user")
first = store.confirm("sample-user", "demo-confirm", "meal_inquiry", epoch)
rid, version = first["request_id"], first["claim_version"]
store.set_mode("sample-operator", "PAUSED", "incident")
try:
store.claim("sample-volunteer", rid, version)
except Paused as exc:
require(str(exc) == "SERVICE_PAUSED", "WRONG_PAUSE_REASON")
else:
raise RuntimeError("PAUSED_CLAIM_ACCEPTED")
require(store.read("sample-user", rid) == first, "PAUSED_ROW_CHANGED")
store.set_mode("sample-operator", "SERVING", "recovered")
claimed = store.claim("sample-volunteer", rid, version)
require(claimed is True, "CLAIM_FAILED")
second = store.claim("sample-second-volunteer", rid, version)
require(second is False, "SECOND_CLAIM_ACCEPTED")
after = store.read("sample-user", rid)
require(after["state"] == "human_claimed", "WRONG_STATE")
require(after["claimed_by"] == "sample-volunteer", "WRONG_CLAIMANT")
require(after["claim_version"] == version + 1, "WRONG_VERSION")
again = store.confirm("sample-user", "demo-confirm", "meal_inquiry", epoch)
require(again == after, "REPEATED_CONFIRM_CHANGED_REQUEST")
require(len(store.request_snapshot()) == 1, "DUPLICATE_REQUEST")
return after
這是核心函式;完整檔案收錄於 Repo 根目錄與 examples/day27/(Commit f9b7630)。兩次執行使用不同目錄:
python3 replay_day27.py --out out/day27/replay-normal
python3 -O replay_day27.py --out out/day27/replay-optimized
實測在 macOS Python 3.14.2 完成(核心與 Commit f8267a8 相同)。這裡展示兩項防禦:
第一項是最佳化編譯(-O)防禦。Python 加 -O 會移除 assert,檢查若依賴它將失效。本實作以顯式 require() 拋出 RuntimeError,最佳化模式下依然阻擋非法狀態,產出合法報告。
第二項是停止閘門(熔斷)與單次認領防禦。這套狀態機定義志工認領同單規則;今天實機單據仍走 Firestore,尚未接上這套核心:PAUSED 下認領觸發 SERVICE_PAUSED,單據未改;恢復 SERVING 首位志工認領後,次位認領回傳 False 拒絕,版本遞增為 2,認領人維持不變。並行互斥已由 Day 22 覆蓋;切回雲端須找回原單號,不把本機 UUID 填回 Firestore。
5 項單元測試全數通過(日誌留存於證據目錄),驗證正常路徑、輸出保護、核心核對、暫停規則與型別判定。具備隨時停手的能力,才不怕把錯誤一路播下去。

圖 4:故障切換的四道防線。雲端即時、本機模擬、離線 SQLite、預錄素材各有模式標籤與停止條件;只有 SQLite 路徑附本次獨立執行結果,其餘依實際就緒狀態放行。
在地方數位服務的真實場域中,系統設計的核心初衷,是讓鄉親少填一次重複資料、讓志工少猜一次需求意圖。真正的工程成熟度,不只在於展示流程順暢,更在於每筆單據都有可追查的真實依據。
這三分鐘展示背後,是前面 26 天的工程累積:
核心問題是:畫面說有人接手時,後端是否真有其人?Google 技術各司其職:Gemini 產生理解,ADK 安排工具,Cloud Run 承接 Webhook,Firestore 保存狀態,皆留下查核依據。[5]
今天固定展示的操作規範,不代表所有通道皆已接通。DEMO.md 載明版本、動作與停止條件;備援程式留存可查的本機與雲端結果。
下一篇 Day 28|把 LOCAL 交給開發者,整理產品卡、Quickstart 與英文說明,引導開發者執行驗證。總評測留給 Day 29,不臨時新增規則再借舊報告背書。
展示的核心是:畫面停住時,仍知道該查哪張單。 版本可以凍結,完成判定仍要跟著證據走。
[1] 固定公開版本:容器受測基線 f8267a8,包含 Day 26 入口。本日備援程式公開於 Commit f9b7630;展示手冊更新於 Commit e2ab88c,ci.yml Run 38120465601 通過。
[2] Google Cloud:Cloud Run 修訂版與流量。
[3] Google Cloud:Cloud Shell 環境與持久化範圍。
[4] Docker:空建置上下文、None 網路驅動。
[5] LOCAL:Day 25、Day 26。
[6] LINE:重試鍵、接受與送達界線。
[7] 固定核心:SecurityStore;Python:assert 與最佳化模式。