半夜在 LINE 問爌肉飯,一句「沒有取得可用結果」,可能讓人誤以為彰化名店今晚集體公休。今天接續既有三個入口,拆開查無資料、暫時查不了與不支援的分流,讓 Gemini 理解需求,後端決定文案與按鈕;讀者也能理解不同失敗的防禦策略。
Day 17 adds an executable recovery flow for LOCAL. Gemini interprets requests while backend checks distinguish empty snapshots, temporary downtime, and unsupported services. Typed outcomes select fixed messages and buttons. The chapter examines retry attempts, backoff delays, and model timeouts in LINE, teaching readers to preserve actions during failures.
蔬食節集章已於 9/30 截止,如圖 2 左側所示。接下來換成彰化人天天遇到的爌肉飯:今年彰化旅行+與白色方塊工作室、旅庫彰化推出《爌肉之城》,踏查 51 間店家,清晨到深夜隨時吃得到。
彰化爌肉飯是一套二十四小時生活時差系統:清晨開門、中午排隊、半夜開鐵捲門,有人挑三層、有人指名圈仔肉。
剛下火車的遊客半夜在 LINE 問:「現在哪裡有開著的爌肉飯?可以幫我預約兩碗帶走嗎?」
LOCAL 快照只有花壇活動與精選蔬食,未收錄爌肉飯與營業時間,亦無預約工具。依設計走向「超出服務範圍」;相近實測見圖 2 右側,原句留到 Day 18 評測。若此時網路斷線或逾時,系統只回一句「這次查詢暫時沒有取得可用結果」:
客人當場困惑:51 間店家今晚全公休了嗎?工程師懂錯誤碼,遊客只看見這行字。同樣沒答案,後端該憑哪個執行事實決定下一步?
Day 14 先用固定字句接住「我要預約」;今天處理換個說法、或問了不支援的事。 像圖 2 右邊「可以幫我預約明天的爌肉飯嗎」,需經由模型選工具、執行與呈現,走回既有入口。[1]
我把「三種查不到」分為三種結果:資料查完是空的、這次查詢沒完成、需求超出目前能力。每種結果都要附上適合的操作。本篇附可執行程式與離線示範;本機、CI 與手機結果分開記在第七節。
現場一句話:半夜哪裡有開著的爌肉飯?可以幫我預約嗎?
只准後端決定的規則:三種狀態的固定文案與按鈕。
Google AI 用到/刻意不用:Gemini 理解需求並選工具;錯誤訊息由程式提供。
五分鐘入口:專案根目錄執行 python3 -m examples.day17.demo --out out/day17/first-run。
這篇不能證明:所有自然語言說法都能被正確分類。
先看 examples/day17/messages.py 的固定回覆。這張表節錄實際字串:
| 結果型別 | 成立條件 | 卡片文字節錄 | 下一步 |
|---|---|---|---|
no_data,查無資料 |
有效查詢完成,快照無符合項目 | 這份快照沒有符合資料 | 換個鄉鎮查詢、重新輸入條件 |
query_unavailable,暫時查不了 |
執行未能取得可採用的結果 | 這次暫時查不了 | 稍後重新查詢、查原本單據 |
unsupported,不支援 |
工具回報需求超出服務範圍 | 這項需求超出 LOCAL 目前的服務範圍。 | 查活動、查蔬食、留下服務詢問 |
這三種狀態在爌肉飯的生活場景格外鮮明:
活動與店家皆用公開快照。 有型別的結果 是把結果限縮成幾種程式能檢查的狀態。模型提出 show_local_help(reason="unsupported"),後端核對紀錄與允許值,由程式組裝卡片與按鈕。
| Google 元件 | 今天的分工與取捨 |
|---|---|
| Gemini API | 理解非固定說法、提出工具呼叫;延續單次嘗試的設定方向 |
| Google ADK | 保留工具宣告、Runner 與回呼檢查,將實際執行結果交回應用程式 |
| Cloud Run | 延伸 Day 12 起的同一服務;修訂版 local-day12-agent-00013-bw5 已部署 |
| Cloud Logging | 沿用錯誤事件,增加固定原因分類,觀察失敗集中在哪條路徑 |
| Firestore | 服務單與已同意偏好保持原值;執行帳本及 Day 15 對話脈絡仍各有保存流程 |
| Vertex AI | 本篇未操作;模型呼叫身分與金鑰分工留到 Day 22 |
Gemini 的價值在於理解非固定說法;而能提供哪些服務、哪次查詢真的完成,則有工具契約與紀錄可查。
examples/day14/main.py 的 HELP_TEXT 已涵蓋「我要預約」等字句,直接顯示 help_card() 省下模型呼叫。[1] 既有按鈕也很明確:d14:events 查活動、d14:places 選區域、d14:enquiry 輸入「新需求:」接回確認。[2] Day 16 的唯讀路徑與授權核對繼續保留。[3][8]
需整理的是自然語言查詢尾端。舊版第 138~151 行把模型逾時、HTTP 429(呼叫太頻繁/超出配額)、HTTP 503(服務暫時無法使用/伺服器過載)、網路失敗、NO_EXECUTED_TOOL、CATALOG_CHANGED 全收進同一個 except Exception,一律顯示 help_card("query_unavailable"),後兩者被當成 ValueError。[1]
執行 show_local_help() 會登記呼叫,但 _finalize() 忽略帶回的 reason。空結果走格式化流程。[1][2] 今天整理成功與失敗路徑;只改卡片文案,分不清前面發生什麼事。
離線故障注入由測試程式指定工具結果或例外,能反覆重現故障,省下等待上游出錯的時間:
python3 -m examples.day17.demo --out out/day17/first-run
示範建立合成資料後走六條路徑:固定「我要預約」、空結果、模擬 503、不支援、區域追問與正常查詢,由腳本指定工具,模型呼叫為零。節錄 demo_results.json:
{
"mode": "SCRIPTED_INTENT_NOT_GEMINI",
"data_origin": "synthetic_sqlite_fixture",
"cases": [
{
"case_id": "no_data",
"plan": {"result": {"status": "no_data", "reason": "empty_snapshot"}}
},
{
"case_id": "unavailable",
"plan": {"result": {"status": "query_unavailable", "reason": "upstream_unavailable"}}
},
{
"case_id": "unsupported",
"plan": {"result": {"status": "unsupported", "reason": "unsupported"}}
}
]
}
接著看 executed_tools:空結果列有 search_local_places,不支援列有 show_local_help,模擬 503 則無工具執行。三種情境若回同一張卡即算失敗。write_audit.json 與前後快照確認帳本新增記錄、服務單與偏好維持原值,杜絕中途寫入副作用。
我用正規化函式將工具格式整理成有限型別。判斷依據依序為:有沒有實際執行、條件是否完整、結果是否有效、最後才是有沒有符合資料。
outcomes.py 第 8~11 行列出今天關注的三種結果;同一個列舉還保留正常查詢、區域追問等狀態。
class QueryState(str, Enum):
NO_DATA = "no_data"
UNAVAILABLE = "query_unavailable"
UNSUPPORTED = "unsupported"
| 工具或執行事實 | 正規化後的處理 |
|---|---|
status="help" 且原因通過 unsupported 白名單 |
QueryState.UNSUPPORTED,固定三入口 |
| 支援的查詢成功,且有效結果為空 | QueryState.NO_DATA,提供改條件操作 |
needs_area,尚待選擇鄉鎮 |
保留區域追問卡 |
| 查到資料、一般說明、原有偏好流程 | 交回各自既有呈現流程 |
| 缺少必要欄位、未知結果格式 | 工具契約錯誤,留下原因 |
店家查詢說「附近」可能需追問鄉鎮;活動查詢亦包著 catalog_result。單看 places=[] 不足判定查無資料,需按步驟判讀內容。
節錄 examples/day17/main.py 流程(第 97~129 行;每天新功能以轉接層接進同一服務,由 day14/main.py 經 legacy_adapter.py 接入,以 examples.day16.main:app 啟動):
try:
...
tools = await _invoke(self.tools_factory, actor)
try:
await asyncio.wait_for(
self._ask_once(text, actor, event_id, tools),
timeout=self.timeout_seconds,
)
except TimeoutError as exc:
raise QueryUnavailable(FailureReason.MODEL_TIMEOUT) from exc
if tools.last is None or len(tools.calls) != 1:
raise ToolContractError("NO_EXECUTED_TOOL")
call = tools.calls[0]
if not isinstance(call, Mapping) or call.get("tool") not in READ_TOOL_FIELDS:
raise ToolContractError("INVALID_EXECUTED_TOOL")
raw = tools.last
if call["tool"] == "search_local_events" or is_events_result(raw):
if self.catalog_is_current is None:
raise ToolContractError("CATALOG_CURRENT_CHECK_REQUIRED")
if await _invoke(self.catalog_is_current, actor) is not True:
raise CatalogChanged()
outcome = normalize_executed_result(raw, self.query_adapter, executed_tool_name=call["tool"])
return await self.present_query_outcome(actor, outcome)
except self.preference_changed_exceptions:
outcome = QueryOutcome(QueryState.PREFERENCE_CHANGED, "preference_changed")
return self._plan(msg.present_query_outcome(outcome), outcome.as_result())
except PermissionError:
raise
except Exception as exc:
return self.query_failure(classify_exception(exc), elapsed=time.monotonic() - started, exc=exc)
初始化在 try 內,讀偏好失敗亦有出口。等模型選工具後,核對呼叫、名稱與結果,活動資料多驗一次目錄版本,交給 normalize_executed_result()。
_ask_once() 先將介面逾時標為 upstream_timeout;外層等待逾時才記 model_timeout,避免工具逾時誤判為模型逾時。
legacy_adapter.py 先檢查 google.genai.errors.APIError 類別與狀態碼,再由 classify_exception() 分類。對外同屬暫時查不了,但內部原因並不都適合重試:逾時與上游故障可稍後再試;tool_contract 偏向工程異常,保留固定操作是防使用者卡死,維運端不應把重試視為修復。
要讓 Gemini 分辨「不支援」,模型指令(day17-service-outcomes-v1)明定:未支援需求呼叫 show_local_help(reason="unsupported"),只准空字串或 unsupported;未執行工具不代表不支援,不得用模型文字冒充工具結果。
present_query_outcome() 核對偏好版本並帶入回覆計畫;PreferenceChanged 優先處理,其他 PermissionError 往外拋交給授權流程,不包裝成暫時查不了。最忌諱的反例是模型沒選工具就回「沒有預約功能」:NO_EXECUTED_TOOL 僅代表契約未完,判為不支援等於自己編造答案;這就像店員沒問廚房就跟客人說賣完了,沒有答案不能拿來證明沒有供應。
既有格式由 legacy_adapter.py 核對,正常結果沿用。空結果保留來源與時效再接新卡片;d14:events 版本不符接相同失敗文案,重按結果取決於最新資料。

圖 1:NO_EXECUTED_TOOL、CATALOG_CHANGED 保留各自原因;成功空結果與 help 另經結果分流,對應 QueryState.NO_DATA、QueryState.UNAVAILABLE、QueryState.UNSUPPORTED。這是架構示意。
Google 文件建議對暫時性錯誤使用指數退避,429、503 是典型例子。[4] 但鄉親等著吃爌肉飯,若中間毫無回應,看不到重試只會焦躁連按。
HttpRetryOptions(attempts=1) 計算含初次在內的總嘗試次數,額外重試為零。在 SDK v2.23.0,啟用重試未指定 attempts 預設嘗試五次。[5] 未提供重試選項的 retry_args(None) 走單次嘗試,行為取決於傳入設定,不能由文件預設值推定。
站在熱騰騰的爌肉攤前,店員若發呆一分鐘不理人,客人早就轉身走了。LINE 聊天室也是如此:多次嘗試加上退避形成長時間空白;單次等待上限 60 秒,預設四次重試等待總和約 15~19 秒(含每次 0~1 秒隨機抖動)。Gemini 文件寫 SDK 預設自動重試四次;[4] 但釘選的 google-genai 2.23.0 原始碼中,沒傳設定時 retry_args(None) 是 stop_after_attempt(1),ADK 2.9.1 的 Gemini 預設亦為 None。文件與原始碼不一致時,升級可能改變行為;明寫 attempts=1,設定才能被測試檢查。
Day 12 的 adk_query.py 已將選項交給 ADK 的 Gemini,Day 15 沿用相同設定。[9] 下面是前篇基線的實際節錄;正式 ADK 路徑仍由 day14/adk_router.py 的 wait_for(..., timeout=18) 管理模型等待,第五節契約核心呈現同一條等待邊界。
model=self.model_override or Gemini(model=self.model_id,
retry_options=types.HttpRetryOptions(attempts=1))
一個代理回合可能包含多次模型請求;attempts 約束 HTTP 嘗試,工具往返由 ADK 回合管理。[9]
模型等待上限設為 18 秒;目前 Webhook 仍於處理完成後回應 HTTP 200,若思考超過兩秒,LINE 可能把 Webhook 記為 request_timeout,但回覆仍在 reply token 有效的 1 分鐘內送出。先回 200 再背景處理留待後續篇章;本日先以單次嘗試防禦重試等待。[6][7]
系統在模型失敗後顯示固定操作,這就是 服務降級 :部分能力暫退,保留仍能做的事。代價是少了自動恢復,好處是策略明確,能決定稍後再查;未來可在降級時附上不經模型的快照按鈕。
查單據與偏好避開模型,依賴資料庫與 LINE。「稍後重新查詢」先給固定提示,重輸才開新查詢核對額度,保留防重送原則,不把文字藏進按鈕。
程式基線為 commit 2fe7736(本機 357 項通過;CI 於 commit 5af4e3f 補齊 ADK 檢驗,Run 36693971656 全數通過),ADK 層使用 google-adk 2.9.1 真 Runner 與腳本模型;前篇修訂版為 local-day12-agent-00012-znc。[8]
| Day 17 證據層 | 本次應核對的問題 | 我的實測紀錄 |
|---|---|---|
| 本機離線故障注入 | 三種狀態是否各有專屬文案按鈕;追問、權限與偏好邊界是否保留 | 67 項通過(0 失敗、0 錯誤、0 跳過);結果檔:verification.json |
| CI 自動化回歸 | 同一版程式能否通過新測例及前篇指定回歸 | Commit b208e16 觸發 7 條工作流全數通過:Day 17(Run 36733198389,離線 67 項+示範 6 路徑)、Day 11~16 回歸及總 CI。改動 day14,回歸比新測試重要。 |
| 手機實機 | 查無資料與不支援兩種情境實際顯示什麼,按鈕能否銜接 | 截圖:圖 2(查無資料與不支援對照);Cloud Run 修訂版 local-day12-agent-00013-bw5。兩句非固定字句,由實機呈現卡片與按鈕;右圖經後端核對 reason="unsupported" 產生。暫時查不了由離線注入驗證。 |
| Cloud Logging | 故障原因與耗時能否對回同次操作及後端資料 | 線上尚未出現暫時查不了真實事件,未附日誌;欄位(DAY14_QUERY_UNAVAILABLE、reason、elapsed_ms)由離線注入驗證。 |

圖 2:查無資料(左,大村素食缺項)與不支援(右,預約爌肉飯)實機對照。左圖第一則保留原來源與時效,第二則為操作卡;不支援提供三個安全入口。
程式沿用 DAY14_QUERY_UNAVAILABLE,增加固定 reason 與耗時;使用者原文不入日誌。判讀重點:查無資料需有成功依據、降級需按鈕驗證、單據與帳本分開核對。
若要在 GCP 部署驗證,專案根目錄執行:
python3 -m examples.day16.build_context --out /tmp/local-agent-build
它依白名單複製模組(含 Day 17),產生以 python:3.13.5-slim 為基底、非 root appuser 執行的 Dockerfile,並輸出 SOURCE_MANIFEST.json 供來源雜湊稽核。金鑰設定步驟見 Day 12。
回到深夜找爌肉飯的遊客:快照沒收錄、網路暫時查不了、或尚未支援預約,代表三種不同的狀況。地方服務不能用冷冰冰的「查無結果」把人擋在門外,而是說明原因並給出下一步。
下一篇是 Day 18|20 題地方契約評測:從問句、工具到回覆,連失敗一起留下 。預定在 eval/local20.json 收進三種情境與開場複合問句,逐題對照模型選擇、工具執行與回覆。
你的 LINE 服務現在回覆「查不到」時,使用者知道下一步該按哪裡嗎?
前篇:Day 16|傳單裡偷藏指令,系統真的會照做嗎?不可信文件與最小權限。本篇程式位於 examples/day17/(commit b208e16);基線 2fe7736,驗證紀錄見上表。
[1] LOCAL:Day 14 路由、HELP_TEXT 與例外處理,基線 2fe7736
[2] LOCAL:工具執行與結果、既有固定文案與按鈕
[3] Google ADK:Before Tool Callback
[4] Gemini API:Troubleshooting/Retry strategy
[5] Google GenAI Python SDK v2.23.0:重試實作、HttpRetryOptions 型別
[6] Python:asyncio.wait_for 的逾時與取消行為
[7] LINE Messaging API:Send reply message、Webhook error statistics
[8] LOCAL:Day 16 已刊紀錄、GitHub Actions Run 36693971656。
[9] LOCAL:Day 12 ADK 查詢與等待設定、Day 15 模型路由、對話脈絡儲存。