<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
	<channel>
		<title>Blog E</title>
		<description>Attitude is everything</description>
		<link>https://www.evanlin.com/</link>
		<atom:link href="https://www.evanlin.com/feed.xml" rel="self" type="application/rss+xml" />
		
			<item>
				<title>[Workshop][Gemini CLI] Build with AI 2026 實戰筆記：用 Gemini CLI + 官方 MCP，從零到上線一隻 Google Drive LINE Bot</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260514235640672.png&quot; alt=&quot;image-20260514235640672&quot; /&gt;&lt;/p&gt;

&lt;p&gt;(活動：&lt;a href=&quot;https://developers.google.com/community/gdg&quot;&gt;Build with AI 2026 @ Google Taipei 101&lt;/a&gt; / 簡報：&lt;a href=&quot;https://speakerdeck.com/line_developers_tw/20260514-build-with-ai-2026-build-line-bot-with-gemini-cli&quot;&gt;SpeakerDeck&lt;/a&gt; / 教材：&lt;a href=&quot;https://github.com/kkdai/BwAI-2026&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/BwAI-2026&lt;/code&gt;&lt;/a&gt; / 範例：&lt;a href=&quot;https://github.com/kkdai/bwai2026-sample&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/bwai2026-sample&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;h1 id=&quot;前情提要當-cli-變成會思考的同事&quot;&gt;前情提要：當 CLI 變成「會思考的同事」&lt;/h1&gt;

&lt;p&gt;2026 年 Google I/O 之後，Gemini CLI 已經不只是另一個包了 LLM 的 terminal 玩具，而是一個&lt;strong&gt;可以掛 MCP、會自己 plan、會自己跑 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt;、會在不懂的時候停下來問你&lt;/strong&gt;的開發工具。&lt;/p&gt;

&lt;p&gt;這次在 &lt;strong&gt;Build with AI 2026&lt;/strong&gt; 的工作坊裡，我把這套工具流壓縮成兩個 hands-on session：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Workshop 1：環境準備 + 兩個必裝的官方 MCP&lt;/strong&gt; —— 讓 Gemini CLI 接上 Google 的官方知識與 Maps Platform。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Workshop 2：對 Gemini CLI 講一句話，把 LINE Bot 部署上 Cloud Run&lt;/strong&gt; —— 不再手敲那串又長又痛苦的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run deploy ...&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;整份教材已開源在 &lt;a href=&quot;https://github.com/kkdai/BwAI-2026&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/BwAI-2026&lt;/code&gt;&lt;/a&gt;，範例專案在 &lt;a href=&quot;https://github.com/kkdai/bwai2026-sample&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/bwai2026-sample&lt;/code&gt;&lt;/a&gt;，活動投影片放在 &lt;a href=&quot;https://speakerdeck.com/line_developers_tw/20260514-build-with-ai-2026-build-line-bot-with-gemini-cli&quot;&gt;SpeakerDeck&lt;/a&gt;。這篇是現場 walkthrough 的完整文字版，含我們當天在台上撞到的三個雷。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;為什麼是-gemini-cli--mcp先看時間軸&quot;&gt;為什麼是 Gemini CLI + MCP？先看時間軸&lt;/h2&gt;

&lt;p&gt;過去一年 Gemini API 與其生態的更新節奏非常密：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;時間&lt;/th&gt;
      &lt;th&gt;新東西&lt;/th&gt;
      &lt;th&gt;對工作流的影響&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;2025/08&lt;/td&gt;
      &lt;td&gt;Gemini YouTube Video Understanding&lt;/td&gt;
      &lt;td&gt;直接 URL 餵影片給模型&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2025/11&lt;/td&gt;
      &lt;td&gt;Gemini File Search&lt;/td&gt;
      &lt;td&gt;Managed RAG，不用自己接 vector DB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2025/12&lt;/td&gt;
      &lt;td&gt;Google Search Grounding (Vertex)&lt;/td&gt;
      &lt;td&gt;模型答案能 grounded 到 search 結果&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2025/12&lt;/td&gt;
      &lt;td&gt;Maps Grounding &amp;amp; Maps Platform Assist MCP&lt;/td&gt;
      &lt;td&gt;地圖場景原生上身&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2026/02&lt;/td&gt;
      &lt;td&gt;Google Developer Knowledge API + MCP Server&lt;/td&gt;
      &lt;td&gt;官方文件變成可被 LLM 查詢的工具&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2026/03&lt;/td&gt;
      &lt;td&gt;Gemini 3 Flash + Tool Combo&lt;/td&gt;
      &lt;td&gt;單次 call 串多個 grounding tool&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;核心觀察&lt;/strong&gt;：Google 把每個新能力都做成 &lt;strong&gt;MCP Server&lt;/strong&gt;，意思是 Gemini CLI 只要 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp add&lt;/code&gt; 一行，就能把 IDE 從「會寫 code 的 LLM」升級成「會用 Google 官方資源寫 code 的 LLM」。&lt;/p&gt;

&lt;p&gt;這次 workshop 我選了兩個對 LINE Bot 開發者最有感的 MCP 來示範。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;workshop-1環境準備與官方-mcp-安裝&quot;&gt;Workshop 1：環境準備與官方 MCP 安裝&lt;/h1&gt;

&lt;h2 id=&quot;為什麼建議用-cloud-shell-開場&quot;&gt;為什麼建議用 Cloud Shell 開場&lt;/h2&gt;

&lt;p&gt;現場工作坊最怕的就是 &lt;em&gt;「老師我這邊 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 跳 Python 3.11 找不到」&lt;/em&gt; 這種環境議題。我把整套示範直接放在 &lt;strong&gt;Google Cloud Shell&lt;/strong&gt;：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 預裝好。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini&lt;/code&gt; CLI 預裝好（最新版 Cloud Shell image 已內建）。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud auth&lt;/code&gt; 跟 Cloud Shell 帳號自動連動，省掉 OAuth dance。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;進 &lt;a href=&quot;https://console.cloud.google.com/&quot;&gt;https://console.cloud.google.com/&lt;/a&gt;，&lt;strong&gt;先確認專案是你新建的&lt;/strong&gt;（不要不小心開到公司正式環境），然後右上角點開 Cloud Shell：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# 驗證兩個工具都在&lt;/span&gt;
gcloud &lt;span class=&quot;nt&quot;&gt;--version&lt;/span&gt;
gemini &lt;span class=&quot;nt&quot;&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;[!TIP]
如果你想在本機跑也可以，依照 &lt;a href=&quot;https://github.com/google/gemini-cli&quot;&gt;Gemini CLI 官方安裝指南&lt;/a&gt; 就行，但 workshop 現場我們統一用 Cloud Shell 來避免「每個人環境一個樣」的悲劇。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;mcp-是什麼三句話講完&quot;&gt;MCP 是什麼？三句話講完&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt; 是 Anthropic 提的開放協定，讓 LLM client 跟 &lt;em&gt;外部能力提供者&lt;/em&gt; 用統一格式對話。&lt;/li&gt;
  &lt;li&gt;Gemini CLI 是 MCP &lt;strong&gt;client&lt;/strong&gt;，你可以 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini mcp add ...&lt;/code&gt; 掛任何符合 MCP 規格的 server。&lt;/li&gt;
  &lt;li&gt;Google 自家現在已經把好幾個 API 包成官方 MCP server，掛上去等於給你的 AI 助手裝上「Google 內部知識庫」。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;mcp-1google-developer-knowledge&quot;&gt;MCP #1：Google Developer Knowledge&lt;/h2&gt;

&lt;p&gt;這個 MCP 把 Google 全家桶的官方文件（Cloud / Android / Web / Firebase / Workspace…）變成 Gemini 可呼叫的工具。比 web search 強的地方在於：&lt;strong&gt;它返回的是經過官方索引的 chunk，附正確 source URL&lt;/strong&gt;，不會被陳年 blog 帶偏。&lt;/p&gt;

&lt;h3 id=&quot;設定步驟&quot;&gt;設定步驟&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;到 &lt;a href=&quot;https://console.cloud.google.com/marketplace/product/google/developerknowledge.googleapis.com&quot;&gt;Google Cloud Console&lt;/a&gt; 啟用 &lt;strong&gt;Developer Knowledge API&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;到「憑證」建立一支 &lt;strong&gt;API Key&lt;/strong&gt;，並把它限制為只能呼叫 Developer Knowledge API（最小權限原則）。&lt;/li&gt;
  &lt;li&gt;在 Cloud Shell 跑：&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gemini mcp add &lt;span class=&quot;nt&quot;&gt;-t&lt;/span&gt; http &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;X-Goog-Api-Key: YOUR_API_KEY&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  google-developer-knowledge &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  https://developerknowledge.googleapis.com/mcp &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--scope&lt;/span&gt; user
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--scope user&lt;/code&gt; 表示這個 MCP 對你所有 project 都有效，下次換 repo 不用再裝一次。&lt;/p&gt;

&lt;h3 id=&quot;驗證&quot;&gt;驗證&lt;/h3&gt;

&lt;p&gt;進入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini&lt;/code&gt; 互動模式，先打：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/mcp list
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;應該看到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google-developer-knowledge&lt;/code&gt; 狀態為 &lt;strong&gt;Connected&lt;/strong&gt;。然後丟一個典型問題：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;請幫我查詢 Google Cloud Run 的最新部署限制（Deployment Limits），並列出前三項。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;正確行為：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Gemini 會 call 出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google-developer-knowledge&lt;/code&gt; tool。&lt;/li&gt;
  &lt;li&gt;回答內容引用自 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cloud.google.com/run/quotas&lt;/code&gt; 等官方頁面。&lt;/li&gt;
  &lt;li&gt;最後附 reference URL。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;mcp-2google-maps-platform-code-assist&quot;&gt;MCP #2：Google Maps Platform Code Assist&lt;/h2&gt;

&lt;p&gt;這支 MCP 專門幫你寫 Google Maps 整合用的 code —— 包含 Maps JavaScript API、Places API、Routes API 的最新呼叫姿勢。對「想做地圖功能但又懶得翻三份 doc」的開發者極友善。&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gemini mcp add &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; user &lt;span class=&quot;nt&quot;&gt;-t&lt;/span&gt; http &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  maps-code-assist-mcp &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  https://mapscodeassist.googleapis.com/mcp
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;驗證-1&quot;&gt;驗證&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;我想在網頁中嵌入一個 Google 地圖，請幫我寫出一段基本的 JavaScript code，
中心點設在台北 101。
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;期待的行為：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Gemini call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;maps-code-assist-mcp&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;產出的 code &lt;strong&gt;不會用到已被 deprecated 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;new google.maps.Map()&lt;/code&gt; 同步 loader&lt;/strong&gt;，而是會用現在官方推薦的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;importLibrary&lt;/code&gt; async pattern。&lt;/li&gt;
  &lt;li&gt;會主動提醒你要去拿 Maps JavaScript API Key 並做 referer 限制。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果你看到它還在生 2020 年的舊寫法，那就是 MCP 沒掛好 —— 重新 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mcp list&lt;/code&gt; 看狀態。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;workshop-2把-line-bot-部署到-cloud-run&quot;&gt;Workshop 2：把 LINE Bot 部署到 Cloud Run&lt;/h1&gt;

&lt;p&gt;這部分用範例專案 &lt;a href=&quot;https://github.com/kkdai/bwai2026-sample&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/bwai2026-sample&lt;/code&gt;&lt;/a&gt;。它是一隻 &lt;strong&gt;LINE Bot 檔案備份小幫手&lt;/strong&gt;：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;使用者把圖片 / 影片 / 音訊 / PDF 丟進 LINE 對話框。&lt;/li&gt;
  &lt;li&gt;Bot 自動把檔案存到 &lt;em&gt;使用者自己&lt;/em&gt; 的 Google Drive，依 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YYYY-MM&lt;/code&gt; 分資料夾。&lt;/li&gt;
  &lt;li&gt;支援 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/recent_files&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/search_files &amp;lt;keyword&amp;gt;&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/disconnect_drive&lt;/code&gt; 等指令。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;技術棧：&lt;strong&gt;Go + LINE Messaging API SDK + Google Drive API + Firestore（存 OAuth token）+ Cloud Run&lt;/strong&gt;。&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;git clone https://github.com/kkdai/bwai2026-sample
&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;bwai2026-sample
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;部署流程總覽&quot;&gt;部署流程總覽&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[階段一] 拿 LINE 金鑰（Channel Secret + Access Token）
      ↓
[階段二] GCP 專案設定（啟用 Run / Build / Firestore / Artifact / Drive API）
      ↓
[階段三] 設定 OAuth Consent Screen + Gemini CLI 登入
      ↓
[階段四] 對 Gemini CLI 講一句中文，部署到 Cloud Run
      ↓
[階段五] 回 LINE Developers Console 填 Webhook URL
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;階段一line-金鑰&quot;&gt;階段一：LINE 金鑰&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;到 &lt;a href=&quot;https://manager.line.biz/&quot;&gt;LINE Official Account Manager&lt;/a&gt; 建立官方帳號。&lt;/li&gt;
  &lt;li&gt;到後台「設定 → Messaging API」&lt;strong&gt;啟用 Messaging API&lt;/strong&gt;，建立 Provider。&lt;/li&gt;
  &lt;li&gt;回 &lt;a href=&quot;https://developers.line.biz/console/&quot;&gt;LINE Developers Console&lt;/a&gt; 對應的 Channel：
    &lt;ul&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Basic settings&lt;/code&gt; → 拿 &lt;strong&gt;Channel Secret&lt;/strong&gt;。&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Messaging API&lt;/code&gt; → 點 &lt;strong&gt;Issue&lt;/strong&gt; 拿 &lt;strong&gt;Channel Access Token (long-lived)&lt;/strong&gt;。&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;超重要&lt;/strong&gt;：回 OA Manager 把「自動回應訊息」&lt;strong&gt;停用&lt;/strong&gt;，否則你的 code 永遠搶不到要回的訊息。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;階段二gcp-專案開通&quot;&gt;階段二：GCP 專案開通&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# 切到 workshop 用的乾淨專案&lt;/span&gt;
gcloud config &lt;span class=&quot;nb&quot;&gt;set &lt;/span&gt;project your-cool-project-id

&lt;span class=&quot;c&quot;&gt;# 一口氣啟用整套服務&lt;/span&gt;
gcloud services &lt;span class=&quot;nb&quot;&gt;enable&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  run.googleapis.com &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  cloudbuild.googleapis.com &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  firestore.googleapis.com &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  artifactregistry.googleapis.com &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  drive.googleapis.com

&lt;span class=&quot;c&quot;&gt;# 建 Firestore（拿來存 per-user OAuth token + state 防偽造）&lt;/span&gt;
gcloud firestore databases create &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;asia-east1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;firestore-native
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;[!NOTE]
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--type=firestore-native&lt;/code&gt; 這個值在第三個踩坑會講為什麼很容易寫錯。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;階段三oauth-consent-screen--gemini-cli-登入&quot;&gt;階段三：OAuth Consent Screen + Gemini CLI 登入&lt;/h2&gt;

&lt;p&gt;因為 Bot 要代表「使用者本人」上傳檔案到他的 Google Drive，這條路一定要走 OAuth。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;進 &lt;a href=&quot;https://console.cloud.google.com/apis/credentials/consent&quot;&gt;OAuth 同意畫面&lt;/a&gt;：
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;User Type&lt;/strong&gt;：External。&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;應用程式名稱&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;My LINE Bot&lt;/code&gt;（或你想叫的名字）。&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;支援電子郵件 / 開發者聯絡信箱&lt;/strong&gt;：填你自己的 Gmail。&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;填完後 &lt;strong&gt;務必點「發布應用程式」&lt;/strong&gt; —— 不發布的話只有在 Test Users 名單裡的帳號能用。&lt;/li&gt;
  &lt;li&gt;建立 OAuth 客戶端 ID：
    &lt;ul&gt;
      &lt;li&gt;類型選 &lt;strong&gt;網頁應用程式 (Web Application)&lt;/strong&gt;。&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;已授權的重新導向 URI&lt;/strong&gt;：暫時填 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://placeholder/oauth/callback&lt;/code&gt;，等階段四拿到 Cloud Run URL 再回來改。&lt;/li&gt;
      &lt;li&gt;存下 &lt;strong&gt;Client ID&lt;/strong&gt; 與 &lt;strong&gt;Client Secret&lt;/strong&gt;。&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;本機跑：
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud auth application-default login
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
    &lt;p&gt;這會把 ADC（Application Default Credentials）寫到本機，Gemini CLI 跑 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 時就會用這份憑證，不會半路彈出瀏覽器要你 re-auth。&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;階段四用-gemini-cli-部署到-cloud-run重頭戲&quot;&gt;階段四：用 Gemini CLI 部署到 Cloud Run（重頭戲）&lt;/h2&gt;

&lt;p&gt;工作坊現場最讓參與者「哇」一聲的就是這段。&lt;/p&gt;

&lt;p&gt;進入專案目錄後，啟動 Gemini CLI 互動模式：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gemini
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;然後就講一句話：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;幫我使用 gcloud 部署到 Cloud Run，如果需要任何資料請停下來問我。
參考 repo https://github.com/kkdai/bwai2026-sample，
region 用 asia-east1，環境變數會用到
ChannelSecret、ChannelAccessToken、GOOGLE_CLIENT_ID、
GOOGLE_CLIENT_SECRET、GOOGLE_REDIRECT_URL。
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Gemini CLI 接下來會：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;自己 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ls&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cat Dockerfile&lt;/code&gt;&lt;/strong&gt; 確認專案結構。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;生出 plan&lt;/strong&gt;：先用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PENDING&lt;/code&gt; 佔位部署 → 拿到 URL → 補 OAuth redirect → 更新 env vars。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;執行前停下來問你確認&lt;/strong&gt;（這是 CLI 的 confirm 模式，預設打開，不會自己 yolo）。&lt;/li&gt;
  &lt;li&gt;跑出大概長這樣的指令：&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud run deploy linebot-backup-service &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--source&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;.&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; asia-east1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--set-env-vars&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GOOGLE_CLOUD_PROJECT=your-cool-project-id,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
ChannelSecret=YOUR_LINE_SECRET_XXXX,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
ChannelAccessToken=YOUR_LINE_TOKEN_XXXX,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
GOOGLE_CLIENT_ID=PENDING,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
GOOGLE_CLIENT_SECRET=PENDING,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
GOOGLE_REDIRECT_URL=PENDING&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--allow-unauthenticated&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;3 ～ 5 分鐘後拿到 Service URL，例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://linebot-backup-service-xxxxx.a.run.app&lt;/code&gt;。&lt;/p&gt;

&lt;h3 id=&quot;補上真實的-oauth-設定&quot;&gt;補上真實的 OAuth 設定&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;回 Console 把剛才填的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://placeholder/oauth/callback&lt;/code&gt; 改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://linebot-backup-service-xxxxx.a.run.app/oauth/callback&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;把真實 Client ID / Secret 貼給 Gemini CLI，請它幫你 update：&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud run services update linebot-backup-service &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; asia-east1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--update-env-vars&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;&quot;GOOGLE_REDIRECT_URL=https://linebot-backup-service-xxxxx.a.run.app/oauth/callback,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
GOOGLE_CLIENT_ID=real-client-id.apps.googleusercontent.com,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
GOOGLE_CLIENT_SECRET=real-secret-xxxx&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;階段五把-line-webhook-對到-cloud-run&quot;&gt;階段五：把 LINE Webhook 對到 Cloud Run&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;回 &lt;a href=&quot;https://developers.line.biz/console/&quot;&gt;LINE Developers Console&lt;/a&gt; → Messaging API tab。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Webhook URL&lt;/strong&gt;：填 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://linebot-backup-service-xxxxx.a.run.app/callback&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;按 &lt;strong&gt;Verify&lt;/strong&gt;，期待看到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Success&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Use webhook&lt;/strong&gt; 切到開啟。&lt;/li&gt;
  &lt;li&gt;最後回 OA Manager 再確認「自動回應訊息」是關的、「Webhook」是開的。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;打開 LINE 把 Bot 加好友，丟一張圖、跑一次 OAuth、看 Drive 裡多了一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LINE Bot Uploads/2026-05/...&lt;/code&gt; 的資料夾 —— 整套流程就跑通了。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;常用維運指令&quot;&gt;常用維運指令&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th style=&quot;text-align: left&quot;&gt;功能&lt;/th&gt;
      &lt;th style=&quot;text-align: left&quot;&gt;指令&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;重新部署&lt;/td&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run deploy linebot-backup-service --source . --region asia-east1&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;改環境變數&lt;/td&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run services update linebot-backup-service --update-env-vars &quot;KEY=VALUE&quot;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;即時 log&lt;/td&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud beta run services logs tail linebot-backup-service&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;查服務狀態&lt;/td&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run services describe linebot-backup-service --region asia-east1&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;整個維運其實也可以丟給 Gemini CLI：「&lt;strong&gt;幫我看一下 linebot-backup-service 最近 5 分鐘的 log，找出 5xx&lt;/strong&gt;」就行。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;工作坊現場踩坑紀錄&quot;&gt;工作坊現場踩坑紀錄&lt;/h2&gt;

&lt;h3 id=&quot;踩坑一billing-沒開第一次-deploy-就紅字&quot;&gt;踩坑一：Billing 沒開，第一次 deploy 就紅字&lt;/h3&gt;

&lt;p&gt;第一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run deploy&lt;/code&gt; 直接噴：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;FAILED_PRECONDITION: Billing account for project [your-cool-project-id] is not found.
Please ensure that you have linked an active billing account.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：工作坊參與者多半開新專案來做，新專案預設沒有綁定 Billing。Cloud Run、Cloud Build、Artifact Registry 都需要計費才能跑 —— 即使是免費額度內，也要有「綁過卡的 billing account」掛在 project 上。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# 看 project 現在的 billing 狀態&lt;/span&gt;
gcloud beta billing projects describe your-cool-project-id

&lt;span class=&quot;c&quot;&gt;# 列出有哪些可用的 billing account&lt;/span&gt;
gcloud beta billing accounts list

&lt;span class=&quot;c&quot;&gt;# 綁定&lt;/span&gt;
gcloud beta billing projects &lt;span class=&quot;nb&quot;&gt;link &lt;/span&gt;your-cool-project-id &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--billing-account&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;0X0X0X-0X0X0X-0X0X0X
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;不能或不想綁卡的話，現場我們改用「&lt;strong&gt;已有 billing 的 sandbox project&lt;/strong&gt;」當示範。&lt;/p&gt;

&lt;h3 id=&quot;踩坑二firestore-type-參數名稱&quot;&gt;踩坑二：Firestore type 參數名稱&lt;/h3&gt;

&lt;p&gt;教材初版（連 AI 第一次猜的也是）寫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--type=native&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--type=native-mode&lt;/code&gt;：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;ERROR: argument --type: Invalid choice: &apos;native-mode&apos;.
  Valid choices: [&apos;firestore-native&apos;, &apos;datastore-mode&apos;]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud firestore databases create&lt;/code&gt; 在 2024 年某次更新後，把 type 參數值改成更明確的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firestore-native&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;datastore-mode&lt;/code&gt;。舊文件、舊回答（包括 LLM 訓練語料）會給你舊值。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud firestore databases create &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;asia-east1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;firestore-native
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這個雷剛好示範了為什麼要裝 &lt;strong&gt;Google Developer Knowledge MCP&lt;/strong&gt; —— 掛上它之後 Gemini 會去查官方最新文件，不會丟給你過時的 type 值。&lt;/p&gt;

&lt;h3 id=&quot;踩坑三忘記啟用-drive-apioauth-過了卻寫不進去&quot;&gt;踩坑三：忘記啟用 Drive API，OAuth 過了卻寫不進去&lt;/h3&gt;

&lt;p&gt;部署完、Webhook 對好、走完 OAuth 同意畫面拿到 token，&lt;strong&gt;結果第一張圖上傳就 500&lt;/strong&gt;。看 log：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;googleapi: Error 403: Google Drive API has not been used in project
your-cool-project-id before or it is disabled.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：階段二的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud services enable ...&lt;/code&gt; 那串裡如果漏掉 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;drive.googleapis.com&lt;/code&gt;，OAuth 是可以過的（因為 Consent Screen 跟 Drive API 是兩件事），但你的 server 拿著 access token 去打 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;drive.googleapis.com&lt;/code&gt; 時會被擋。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;解法（最快）&lt;/strong&gt;：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud services &lt;span class=&quot;nb&quot;&gt;enable &lt;/span&gt;drive.googleapis.com
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;解法（根本）&lt;/strong&gt;：把所有要用到的 API 一次都 enable，列在教材的 checklist 裡，現場跟著跑就不會漏。階段二那串指令我特別把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;drive.googleapis.com&lt;/code&gt; 寫進去，就是為了堵這個雷。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;[!TIP]
一個 debug 的好習慣：&lt;strong&gt;只要 server 拿著正確 token 卻被 403&lt;/strong&gt;，先去 &lt;a href=&quot;https://console.cloud.google.com/apis/library&quot;&gt;API Library&lt;/a&gt; 確認對應 API 是 enabled，再去看 OAuth scope，最後才是看 IAM。順序錯會浪費很多時間。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;為什麼這套組合值得學&quot;&gt;為什麼這套組合值得學？&lt;/h2&gt;

&lt;p&gt;工作坊跑完，我問現場參與者最有感的是哪個 moment，得到的回答幾乎一致：&lt;strong&gt;「對著 Gemini CLI 講中文就把服務部署上去」那一刻&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;那為什麼會有感？拆開來看：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;以前 DevOps 卡的是 &lt;em&gt;記得哪個指令&lt;/em&gt;，現在卡的是 &lt;em&gt;表達清楚你想做什麼&lt;/em&gt;&lt;/strong&gt;。後者門檻低很多，新人三天上手 vs. 三個月才敢碰 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MCP 把官方知識前置注入 Gemini&lt;/strong&gt;。你不再需要先自己 RTFM、再翻譯成 prompt 給 LLM；MCP 等於讓 LLM 自己有 RTFM 的能力。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;錯誤訊息回到工具自己面前&lt;/strong&gt;。以前報錯要 Google + StackOverflow，現在直接貼回 CLI，它讀完錯誤再決定下一步 —— 形成完整的 plan-act-observe 迴圈。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;整套工作流 reproducible&lt;/strong&gt;。教材、範例、prompt 都在 GitHub repo 裡，任何人 clone 下來照著做，結果應該一致。&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;想再深入建議的進階閱讀&quot;&gt;想再深入？建議的進階閱讀&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;官方教材：&lt;a href=&quot;https://github.com/kkdai/BwAI-2026&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/BwAI-2026&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;範例專案：&lt;a href=&quot;https://github.com/kkdai/bwai2026-sample&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/bwai2026-sample&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;投影片：&lt;a href=&quot;https://speakerdeck.com/line_developers_tw/20260514-build-with-ai-2026-build-line-bot-with-gemini-cli&quot;&gt;SpeakerDeck&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Gemini CLI：&lt;a href=&quot;https://github.com/google/gemini-cli&quot;&gt;github.com/google/gemini-cli&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;MCP 規格：&lt;a href=&quot;https://modelcontextprotocol.io/&quot;&gt;modelcontextprotocol.io&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;延伸：&lt;a href=&quot;https://www.evanlin.com/gemini-cli-developer-mcp/&quot;&gt;用 Gemini CLI + Developer Knowledge MCP&lt;/a&gt;、&lt;a href=&quot;https://www.evanlin.com/map-mcp-grounding/&quot;&gt;Map MCP Grounding&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;後記來-line-一起做東西吧&quot;&gt;後記：來 LINE 一起做東西吧&lt;/h2&gt;

&lt;p&gt;這次工作坊也是我們 LINE Taiwan DevRel 招募的場合之一。如果你看完這篇覺得：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;想長期玩 LINE Messaging API + Google Cloud + Gemini 的整合。&lt;/li&gt;
  &lt;li&gt;喜歡邊寫 production code 邊把流程做成可被別人複製的教材。&lt;/li&gt;
  &lt;li&gt;每週能投入三天以上，且有意願在實習結束後轉正。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;歡迎私訊我或寄信來聊聊，我們有&lt;strong&gt;一週三天的彈性實習方案&lt;/strong&gt;，做得好就有轉正成為長期夥伴的機會。&lt;/p&gt;

&lt;p&gt;最後感謝所有來現場一起 hands-on 的開發者 —— 願意把週末花在「用新工具打通整條 pipeline」的人，永遠是社群最值得敬佩的那一群。下一場見！&lt;/p&gt;
</description>
				<pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/bwai2026-gemini-cli-mcp/</link>
				<guid isPermaLink="true">https://www.evanlin.com/bwai2026-gemini-cli-mcp/</guid>
			</item>
		
			<item>
				<title>[Gemini實戰][RAG] Gemini API File Search 多模態大升級全解析：Embedding 2、Metadata 過濾、頁級引用，附 LINE Bot 開源實作</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260511221639333.png&quot; alt=&quot;image-20260511221639333&quot; /&gt;&lt;/p&gt;

&lt;p&gt;(圖片來源: &lt;a href=&quot;https://blog.google/innovation-and-ai/technology/developers-tools/expanded-gemini-api-file-search-multimodal-rag/&quot;&gt;Google Blog - Gemini API File Search is now multimodal: build efficient, verifiable RAG&lt;/a&gt;)&lt;/p&gt;

&lt;h1 id=&quot;前情提要rag-終於不用自己拼樂高了&quot;&gt;前情提要：RAG 終於不用自己拼樂高了&lt;/h1&gt;

&lt;p&gt;過去這幾年只要做 RAG（Retrieval-Augmented Generation），開發者腦袋裡浮現的元件清單大概都長這樣：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;一個 chunker（langchain？自己寫？）&lt;/li&gt;
  &lt;li&gt;一個 embedding model（OpenAI text-embedding-3？Cohere？BGE？）&lt;/li&gt;
  &lt;li&gt;一個向量資料庫（ChromaDB、FAISS、pgvector、Pinecone…挑哪個都要打架）&lt;/li&gt;
  &lt;li&gt;一個檢索 + rerank 的流程&lt;/li&gt;
  &lt;li&gt;然後才是 LLM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;更別說多模態 RAG 還要再多一層：圖片怎麼 embed？要不要先 OCR？要不要切兩個 store 文字一個圖片一個？文圖混搜怎麼算分？光是這幾題就能耗掉一個 sprint。&lt;/p&gt;

&lt;p&gt;這幾天 Google 在開發者部落格丟出 &lt;a href=&quot;https://blog.google/innovation-and-ai/technology/developers-tools/expanded-gemini-api-file-search-multimodal-rag/&quot;&gt;Expanded Gemini API File Search for multimodal RAG&lt;/a&gt;，把上面這條冗長的 pipeline 變成「&lt;strong&gt;呼叫一個 managed API&lt;/strong&gt;」的事，而且&lt;strong&gt;圖片是原生支援的&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;本文會做兩件事：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;把新功能拆開講清楚，包含背後的 &lt;strong&gt;Gemini Embedding 2&lt;/strong&gt; 在做什麼。&lt;/li&gt;
  &lt;li&gt;用一隻&lt;strong&gt;已開源&lt;/strong&gt;的 LINE Bot（&lt;a href=&quot;https://github.com/kkdai/linebot-multimodal-rag&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/linebot-multimodal-rag&lt;/code&gt;&lt;/a&gt;）當作活生生的示範，看新功能怎麼在實際 production code 裡組合起來 — 順便把我除錯時撞到的兩個典型坑分享給大家避雷。&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;新功能三大重點&quot;&gt;新功能三大重點&lt;/h2&gt;

&lt;p&gt;依照官方部落格，這次升級的核心就三件事：&lt;/p&gt;

&lt;h3 id=&quot;1-真多模態檔案搜尋native-multimodal-file-search&quot;&gt;1. 真．多模態檔案搜尋（Native Multimodal File Search）&lt;/h3&gt;

&lt;p&gt;過去的 File Search 是純文字檢索，圖片只能靠 OCR 變成文字才能進 store。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“File Search now processes images and text together. Powered by the Gemini Embedding 2 model, the tool understands native image data.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;現在你可以&lt;strong&gt;直接把圖片丟進 File Search Store&lt;/strong&gt;，跟文字一起被索引。背後的引擎是 &lt;strong&gt;Gemini Embedding 2&lt;/strong&gt; —— 文字、圖片、影片、音訊、文件&lt;strong&gt;共用同一個向量空間&lt;/strong&gt;，所以你可以「拿圖找文字」、「拿文字找圖」、或者「拿圖找圖」，不用自己對齊空間。&lt;/p&gt;

&lt;p&gt;對我們做產品的人來說，這代表：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;文圖混搜不再是研究題目&lt;/strong&gt;，是一個 API call。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;不用維護兩個 store&lt;/strong&gt;（一個給文字 chunks、一個給 CLIP-style image embeddings）。&lt;/li&gt;
  &lt;li&gt;科學圖表、UI screenshot、報表、相簿…這些&lt;strong&gt;以前 OCR 之後損失大半語意的東西&lt;/strong&gt;，現在能保留原始視覺資訊去檢索。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;2-custom-metadata-與-server-side-過濾&quot;&gt;2. Custom Metadata 與 Server-side 過濾&lt;/h3&gt;

&lt;p&gt;每一份你丟進 store 的檔案，現在都可以掛上 key-value 標籤：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;user_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;string_value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;U1234abcd...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;department&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;string_value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Legal&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;status&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;string_value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Final&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;查詢時用 &lt;a href=&quot;https://google.aip.dev/160&quot;&gt;google.aip.dev/160&lt;/a&gt; filter 語法（跟 GCP 大部分 list API 一樣的格式）：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;metadata_filter&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;department=&quot;Legal&quot; AND status=&quot;Final&quot;&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;過濾是在 &lt;strong&gt;Google 那邊先做&lt;/strong&gt; 的，不是先撈一堆再丟掉。少了 noise 之後，&lt;strong&gt;速度跟精準度都會上升&lt;/strong&gt;，這對 multi-tenant SaaS 來說根本是救命符 —— 一個 store 配 metadata filter 就能切租戶，不用為了隔離開 N 個 store。&lt;/p&gt;

&lt;p&gt;我的 LINE Bot 就直接靠這個做 &lt;strong&gt;per-user 資料隔離&lt;/strong&gt;：每筆檔案上傳時掛上 LINE 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;user_id&lt;/code&gt;，查詢時 filter 一帶，使用者 A 永遠不可能在問答中看到使用者 B 的資料。&lt;/p&gt;

&lt;h3 id=&quot;3-頁級引用page-level-citations&quot;&gt;3. 頁級引用（Page-level Citations）&lt;/h3&gt;

&lt;p&gt;回應裡的每個被引用片段，現在會帶&lt;strong&gt;頁碼&lt;/strong&gt;。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“captures the page number for every piece of indexed information.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;對企業客戶來說這超關鍵。”AI 跟我說合約第 X 頁有提到 Y” vs. “AI 跟我說合約有提到 Y” —— 前者可以直接被法務 / 稽核接受，後者還要花人力翻書驗證。頁碼解開了「LLM 答案無法溯源」的最後一哩。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;多模態的引擎gemini-embedding-2&quot;&gt;多模態的引擎：Gemini Embedding 2&lt;/h2&gt;

&lt;p&gt;新功能的核心是這顆 &lt;a href=&quot;https://deepmind.google/models/gemini/embedding/&quot;&gt;Gemini Embedding 2&lt;/a&gt; 模型。把它的規格 quote 出來方便你做選型決策：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260511221801984.png&quot; alt=&quot;image-20260511221801984&quot; /&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;項目&lt;/th&gt;
      &lt;th&gt;規格&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;支援輸入&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;文字、圖片、影片、音訊、文件&lt;/strong&gt;（同一個 embedding space）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Input token 上限&lt;/td&gt;
      &lt;td&gt;8,192 tokens&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Output 維度&lt;/td&gt;
      &lt;td&gt;128 ～ 3,072（用 Matryoshka Representation Learning，小維度也能保有相近精度）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;多語支援&lt;/td&gt;
      &lt;td&gt;100+ 語言&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;幾個關鍵 benchmark（recall@1）：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Text-to-Image Search&lt;/strong&gt;：TextCaps &lt;strong&gt;89.6&lt;/strong&gt; / Docci &lt;strong&gt;93.4&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Image-to-Text Search&lt;/strong&gt;：TextCaps &lt;strong&gt;97.4&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Multilingual (MTEB)&lt;/strong&gt;：mean &lt;strong&gt;69.9&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Video-Text Matching&lt;/strong&gt;：Vatex ndcg@10 &lt;strong&gt;68.8&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Speech-Text Retrieval&lt;/strong&gt;：MSEB mrr@10 &lt;strong&gt;73.9&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;幾個重點觀察：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Matryoshka 不是 buzzword&lt;/strong&gt;：你可以先用 3072 維存進去，跑檢索時切到 768 維跑得快還能維持品質。儲存 / 算分成本可以分階段優化。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;跨模態分數高得很真實&lt;/strong&gt;：97.4% recall@1（image→text）代表如果你有一張圖、要找對應的描述文字，幾乎一次就找對。這對「拿手機拍個產品標籤，找對應使用手冊頁面」這類使用情境直接就能落地。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;100+ 語言&lt;/strong&gt;：對台灣 / 日韓 / 東南亞市場是很現實的差異點。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;開發者真正會在意的事價格與接入成本&quot;&gt;開發者真正會在意的事：價格與接入成本&lt;/h2&gt;

&lt;p&gt;從 &lt;a href=&quot;https://dev.to/googleai/multimodal-rag-with-the-gemini-api-file-search-tool-a-developer-guide-5878&quot;&gt;Multimodal RAG with the Gemini API File Search tool: a developer guide&lt;/a&gt; 這篇官方教學文裡，有兩段是真的對成本敏感的開發者該畫起來：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“Fully managed, with no vector database overhead.”&lt;/p&gt;

  &lt;p&gt;“Storage and query-time embeddings are free. You only pay for indexing and tokens.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;翻成白話：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;不用付向量資料庫的錢&lt;/strong&gt;，也不用付運維它的人月。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;儲存免費&lt;/strong&gt;、&lt;strong&gt;查詢時的 embedding 計算也免費&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;你只有兩筆要付：&lt;strong&gt;初次 indexing 時的 embedding 費用&lt;/strong&gt;、以及&lt;strong&gt;生成回答時消耗的 LLM tokens&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;對個人 side project 跟早期 startup 都是友善的成本曲線 —— 你不需要在第一天就決定「我能不能負擔向量 DB 的 baseline」。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;標準工作流程4-個-sdk-call-接完一條-rag&quot;&gt;標準工作流程：4 個 SDK call 接完一條 RAG&lt;/h2&gt;

&lt;p&gt;整理 dev.to guide 後的最小可行流程：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 1. 建立 store（指定多模態 embedding model）
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;store&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file_search_stores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;display_name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;my-multimodal-rag&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;embedding_model&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;models/gemini-embedding-2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 2. 上傳檔案 + 自訂 metadata
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;operation&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file_search_stores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upload_to_file_search_store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;file_search_store_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;report-q1.pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;display_name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Q1 Report&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;custom_metadata&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;department&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;string_value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Finance&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;year&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;string_value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;2026&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 上傳是 long-running operation，要 poll：
# operation = client.operations.get(operation)
&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 3. 把 file_search 當 tool 餵給 generate_content
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-3-flash-preview&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;去年第一季的營收成長率是多少？&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;tools&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Tool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file_search&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FileSearch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;file_search_store_names&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;metadata_filter&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;department=&quot;Finance&quot; AND year=&quot;2026&quot;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;))],&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 4. 取引用（含頁碼）
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;citation&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;candidates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;grounding_metadata&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;grounding_chunks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;citation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;web&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uri&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;citation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;web&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 或對應的 file/page 欄位
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;要附上引用圖片給使用者，還有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;client.file_search_stores.download_media()&lt;/code&gt; 可以呼叫。&lt;/p&gt;

&lt;p&gt;不誇張，&lt;strong&gt;整套多模態 RAG 不到 30 行 code&lt;/strong&gt;。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;演示案例把這些新功能塞進一隻-line-bot&quot;&gt;演示案例：把這些新功能塞進一隻 LINE Bot&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260511221916359.png&quot; alt=&quot;image-20260511221916359&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260511221851736.png&quot; alt=&quot;image-20260511221851736&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;

&lt;p&gt;光看 SDK 範例很抽象，所以我把它做成一隻可以上工的 LINE Bot，開源在 &lt;a href=&quot;https://github.com/kkdai/linebot-multimodal-rag&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/linebot-multimodal-rag&lt;/code&gt;&lt;/a&gt;：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;使用者在 LINE 對話框丟 &lt;strong&gt;PDF / 圖片 / 文字檔&lt;/strong&gt; → Bot 進 File Search Store 索引。&lt;/li&gt;
  &lt;li&gt;使用者打字問 → Gemini 從這位使用者&lt;strong&gt;自己上傳&lt;/strong&gt;的資料裡找答案。&lt;/li&gt;
  &lt;li&gt;使用者丟一張圖問 → 一樣可以做圖找文字的檢索。&lt;/li&gt;
  &lt;li&gt;部署目標：GCP Cloud Run + Cloud Build 自動部署。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;架構非常直觀（重點欄位）：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;元件&lt;/th&gt;
      &lt;th&gt;角色&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;LINE Webhook&lt;/td&gt;
      &lt;td&gt;FastAPI 接收訊息事件&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GCS&lt;/td&gt;
      &lt;td&gt;持久化原檔（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uploads/{user_id}/{message_id}.{ext}&lt;/code&gt;）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Gemini File Search Store&lt;/td&gt;
      &lt;td&gt;唯一的索引層（managed）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Custom metadata &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;user_id&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;多租戶隔離&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;FastAPI BackgroundTasks&lt;/td&gt;
      &lt;td&gt;避開 LINE reply token 30 秒上限&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;對照前面講的三大新功能：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;多模態&lt;/strong&gt;：使用者丟圖、丟 PDF，都進同一個 store，搜尋時都吃同一條 pipeline。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Custom metadata&lt;/strong&gt;：每個 LINE user 的檔案都掛 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;user_id&lt;/code&gt; 標籤，查詢時 filter，做到 server-side 強制隔離。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Page-level citations&lt;/strong&gt;：未來要在 LINE 訊息裡顯示「答案出自 XX.pdf 第 5 頁」，直接消費 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grounding_metadata&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;整個 repo 大概不到 600 行 Python，就把一個「&lt;strong&gt;自己的私人多模態知識庫聊天 Bot&lt;/strong&gt;」做完了。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;部署實戰commit--自動上線&quot;&gt;部署實戰：commit → 自動上線&lt;/h2&gt;

&lt;p&gt;開源範例光跑得起來不夠，要在工作坊現場示範就得是「改 code 推 GitHub 就自動部署」的水準。這次我請 &lt;a href=&quot;https://docs.anthropic.com/en/docs/claude-code&quot;&gt;Claude Code&lt;/a&gt; 當副駕駛幫我把 CI/CD 接起來。&lt;/p&gt;

&lt;p&gt;我只丟了一句：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;「幫我建立 Cloud Build 連接 GitHub，commit 到 main 後就 trigger build 部署到 Cloud Run。」&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude Code 自己先掃 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cloudbuild.yaml&lt;/code&gt;、現有 Cloud Run 設定、Secret Manager、Artifact Registry，列了一份「目前的問題」，然後&lt;strong&gt;停下來問我關鍵決策&lt;/strong&gt;：要保留現有 service 名稱還是改 yaml？GitHub 要不要授權？等我回答完，它一口氣把缺的資源建起來：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# 建 Artifact Registry repo&lt;/span&gt;
gcloud artifacts repositories create linebot &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--repository-format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;docker &lt;span class=&quot;nt&quot;&gt;--location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;asia-east1

&lt;span class=&quot;c&quot;&gt;# 機密搬家：從現役 service 抽到 Secret Manager（透過 stdin，不留 shell history）&lt;/span&gt;
gcloud run services describe linebot-gemini-file-search &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;asia-east1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;value(...)&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  | gcloud secrets create LINE_CHANNEL_SECRET &lt;span class=&quot;nt&quot;&gt;--data-file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;-

&lt;span class=&quot;c&quot;&gt;# 給 Cloud Build / Compute SA 部署需要的角色&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;role &lt;span class=&quot;k&quot;&gt;in &lt;/span&gt;run.admin iam.serviceAccountUser artifactregistry.writer &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
            secretmanager.secretAccessor storage.objectAdmin logging.logWriter&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do
  &lt;/span&gt;gcloud projects add-iam-policy-binding your-cool-project-id &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--member&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;serviceAccount:660825558664-compute@developer.gserviceaccount.com&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;roles/&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$role&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--condition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;None
&lt;span class=&quot;k&quot;&gt;done&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# 建 trigger&lt;/span&gt;
gcloud builds triggers create github &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;linebot-multimodal-rag-main &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--repo-owner&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;kkdai &lt;span class=&quot;nt&quot;&gt;--repo-name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;linebot-multimodal-rag &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--branch-pattern&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;^main$&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--build-config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;cloudbuild.yaml
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;唯一不能自動化的是 &lt;strong&gt;GitHub OAuth 授權&lt;/strong&gt; —— Claude Code 直接坦白告訴我「這步只能去 Console 點」，附上 URL 跟逐步指引。一分鐘點完回來，trigger 就跑通了。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;踩坑紀錄兩個跟新功能直接相關的雷&quot;&gt;踩坑紀錄：兩個跟新功能直接相關的雷&lt;/h2&gt;

&lt;h3 id=&quot;踩坑一寫死的-model-id-過時&quot;&gt;踩坑一：寫死的 Model ID 過時&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cloudbuild.yaml&lt;/code&gt; 跟 code 預設值都寫了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.1-flash&lt;/code&gt;，但翻了一下 &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/models&quot;&gt;Gemini API 目前的 model id 清單&lt;/a&gt;：根本沒這個 model。Gemini 3 Flash 正確的 ID 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-flash-preview&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;為什麼會發生&lt;/strong&gt;：multimodal RAG 是一個非常新的功能，相關文件、教學、範例都還在大量誕生中，命名也微調過。Repo 初版很容易寫到一個「看起來像但其實不存在」的 id。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：全 repo 改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-flash-preview&lt;/code&gt;，順便確認 embedding model 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;models/gemini-embedding-2&lt;/code&gt;（對的，沒踩到雷）。Push 之後 Cloud Build 自動 trigger、三分鐘新 revision 上線。&lt;/p&gt;

&lt;h3 id=&quot;踩坑二神祕的upload-has-already-been-terminated&quot;&gt;踩坑二：神祕的「Upload has already been terminated」&lt;/h3&gt;

&lt;p&gt;這個雷直接踩在 File Search Store 新支援的「&lt;strong&gt;圖片上傳&lt;/strong&gt;」這條 path 上 —— 也是最值得分享的一個，因為它示範了「新 API 的錯誤訊息有時候很委婉」。&lt;/p&gt;

&lt;p&gt;我從 LINE 傳了一張 JPG 給 Bot 點「存入資料庫」，結果：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;❌ 存入失敗：400 Bad Request. {&apos;message&apos;: &apos;Upload has already been terminated.&apos;, &apos;status&apos;: &apos;Bad Request&apos;}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;完全看不出原因。Cloud Logging 也只有同一行錯誤，沒有 stack trace。上 &lt;a href=&quot;https://discuss.ai.google.dev/&quot;&gt;Google AI Developers Forum&lt;/a&gt; 翻一輪，發現好幾種 file type（.md / .xlsx / 大 CSV）都有人遇過類似回報。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;真正的元兇&lt;/strong&gt;藏在這段看起來無辜的程式碼：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# app/gemini_service.py（修改前）
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mimetypes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;guess_extension&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.bin&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tempfile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NamedTemporaryFile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tmp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;tmp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;write&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;tmp_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tmp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;在 Python 3.13 之前，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mimetypes.guess_extension(&quot;image/jpeg&quot;)&lt;/code&gt; &lt;strong&gt;回傳的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.jpe&lt;/code&gt;，不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.jpg&lt;/code&gt;&lt;/strong&gt;。原因是標準函式庫的 MIME 表裡 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.jpe&lt;/code&gt; 字典序排在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.jpg&lt;/code&gt; 前面，這個怪癖存在了將近二十年。&lt;/p&gt;

&lt;p&gt;Gemini File Search Store 看到副檔名 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.jpe&lt;/code&gt; 不認得，但 API 回的訊息又用「Upload has already been terminated」這種非常容易誤導人的講法 —— 一開始我還以為是上傳大小超過、或被併發掐住、或是 SDK 內部有 race。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;修法&lt;/strong&gt;：副檔名直接從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;display_name&lt;/code&gt; 取（handlers 已經正確設成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image_&amp;lt;id&amp;gt;.jpg&lt;/code&gt;），備援用一張顯式 MIME 對照表：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# app/gemini_service.py（修改後）
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_MIME_TO_EXT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;image/jpeg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.jpg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;image/png&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.png&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;image/webp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.webp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;application/pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# ...
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.&quot;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;display_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;display_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rsplit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)[&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lower&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_MIME_TO_EXT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mimetypes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;guess_extension&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.bin&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;[BG Store] uploading display_name=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;display_name&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;!r}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; mime=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; &quot;&lt;/span&gt;
      &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;size=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; tmp_suffix=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;順手把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;except&lt;/code&gt; 那邊也補上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;traceback.format_exc()&lt;/code&gt;，這樣下次出事 Cloud Logging 就會有完整堆疊。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;這個故事的 takeaway&lt;/strong&gt;：當你在用「新 GA 沒多久的 API」上跑新 modality 時，請務必：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;在客戶端先確認你產生的檔名 / 副檔名是 API 預期的格式&lt;/strong&gt;，不要相信 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mimetypes&lt;/code&gt; 標準庫幫你猜的。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;把 stack trace 寫進 log&lt;/strong&gt;，不然 forum 上那種「換一個檔案就好了」的玄學討論你救不了自己。&lt;/li&gt;
  &lt;li&gt;從 &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/file-search&quot;&gt;Gemini File Search 官方支援格式清單&lt;/a&gt; 對照你產生的副檔名一致。&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;總結multimodal-rag-的入場費史上最低&quot;&gt;總結：multimodal RAG 的入場費，史上最低&lt;/h2&gt;

&lt;p&gt;這次的 Gemini API File Search 升級，把一條過去要做 3 個月才能上線的功能線壓縮成「&lt;strong&gt;幾十行 code + 一個 managed API&lt;/strong&gt;」就能跑：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;多模態天生支援&lt;/strong&gt;：文字、圖片、影片、音訊、文件共享同一個 embedding 空間，再見 OCR 過渡層。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Custom metadata + server-side filter&lt;/strong&gt;：multi-tenant SaaS 不用糾結 store 切多少個了。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Page-level citations&lt;/strong&gt;：企業合規場景終於有原生 grounding。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;管錢友善&lt;/strong&gt;：storage / query embedding 都不用錢，只付 indexing + LLM tokens。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Embedding 2 的跨模態分數&lt;/strong&gt;：97.4% recall@1 不是 demo 數字，是直接能撐住產品的等級。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果你想直接看一個 production-shaped 的端到端範例：&lt;a href=&quot;https://github.com/kkdai/linebot-multimodal-rag&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kkdai/linebot-multimodal-rag&lt;/code&gt;&lt;/a&gt; 整個 repo PR welcome，也歡迎拿去改成你自己領域的 RAG 應用 —— Notion 知識庫、員工手冊問答機、相簿管家、研究論文索引…大概只有想像力會限制你。&lt;/p&gt;

&lt;p&gt;想開始的話，建議的閱讀順序：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Google 官方部落格：&lt;a href=&quot;https://blog.google/innovation-and-ai/technology/developers-tools/expanded-gemini-api-file-search-multimodal-rag/&quot;&gt;Expanded Gemini API File Search for multimodal RAG&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Gemini Embedding 2 規格頁：&lt;a href=&quot;https://deepmind.google/models/gemini/embedding/&quot;&gt;deepmind.google/models/gemini/embedding&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;開發者實作指南：&lt;a href=&quot;https://dev.to/googleai/multimodal-rag-with-the-gemini-api-file-search-tool-a-developer-guide-5878&quot;&gt;Multimodal RAG with the Gemini API File Search tool: a developer guide&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;我的開源範例：&lt;a href=&quot;https://github.com/kkdai/linebot-multimodal-rag&quot;&gt;github.com/kkdai/linebot-multimodal-rag&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;歡迎大家一起來試試看這個很強大的 Multimodal RAG 的支援吧！&lt;/p&gt;
</description>
				<pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-multimodal-rag/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-multimodal-rag/</guid>
			</item>
		
			<item>
				<title>[GCP 實戰][BwAI] AI 賦能開發：使用 Gemini CLI 快速部署 LINE Bot 雲端備份神器</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/預覽程式 2026-05-05 12.38.54.png&quot; alt=&quot;預覽程式 2026-05-05 12.38.54&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&gt;

&lt;p&gt;在即將到來的 &lt;strong&gt;Build With AI 2026&lt;/strong&gt; 的工作坊中，我們帶來了一個非常實用的專案：&lt;strong&gt;LINE Bot 檔案備份機器人&lt;/strong&gt;。它可以讓你把 LINE 聊天室裡的圖片、檔案直接丟上 Google Drive，還會按月份自動幫你建資料夾整理得服服貼貼。&lt;/p&gt;

&lt;p&gt;傳統上，要把這樣一個包含 OAuth 授權、Firestore 資料庫、Cloud Run 容器部署的專案放上雲端，新手往往會對著落落長的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 指令發愁。&lt;/p&gt;

&lt;p&gt;但這次不一樣，我們有秘密武器：&lt;strong&gt;Gemini CLI&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;這篇文章將紀錄我們如何把 AI 當作 DevOps 工程師，透過「講話」的方式完成整個複雜的部署流程，當然，也包含了過程中踩到的各種真實坑洞。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;準備工作召喚-ai-助手&quot;&gt;準備工作：召喚 AI 助手&lt;/h2&gt;

&lt;p&gt;在開始之前，除了基本的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 安裝與登入，你只需要安裝 &lt;a href=&quot;https://github.com/google/gemini-cli&quot;&gt;Gemini CLI&lt;/a&gt;。&lt;/p&gt;

&lt;p&gt;準備好以下「機密參數」（本文皆已 Mock 處理）：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;PROJECT_ID&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;your-cool-project-id&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;LINE Channel Secret&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YOUR_LINE_SECRET_XXXX&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;LINE Access Token&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YOUR_LINE_TOKEN_XXXX&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;進入專案資料夾後，我只對 Gemini CLI 說了一句話：&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;「幫我使用 gcloud 部署到 Cloud Run，如果需要任何資料，請停下來問我。參考 repo…」&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;接下來，就是見證奇蹟（與修 bug）的時刻。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;實戰部署流程ai-帶路&quot;&gt;實戰部署流程：AI 帶路&lt;/h2&gt;

&lt;p&gt;Gemini CLI 非常聰明地分析了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Dockerfile&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.go&lt;/code&gt;，馬上列出了一套作戰計畫。&lt;/p&gt;

&lt;h3 id=&quot;第一步環境檢測與-api-啟用&quot;&gt;第一步：環境檢測與 API 啟用&lt;/h3&gt;
&lt;p&gt;AI 首先幫我確認了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 當前的專案設定，並一鼓作氣啟用了必要的服務：&lt;/p&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud services &lt;span class=&quot;nb&quot;&gt;enable &lt;/span&gt;firestore.googleapis.com &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  cloudbuild.googleapis.com &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  run.googleapis.com &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  artifactregistry.googleapis.com
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;第二步建立-firestore-資料庫-遇到第一個坑&quot;&gt;第二步：建立 Firestore 資料庫 (遇到第一個坑)&lt;/h3&gt;
&lt;p&gt;我們的 Bot 需要記錄 OAuth 的 State 防偽造標記，所以需要 Firestore。
AI 嘗試執行了指令，但我們馬上遇到錯誤。&lt;em&gt;(詳見後文踩坑紀錄)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;修正後，正確的指令是：&lt;/p&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud firestore databases create &lt;span class=&quot;nt&quot;&gt;--location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;asia-east1 &lt;span class=&quot;nt&quot;&gt;--type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;firestore-native
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;第三步先上車後補票的-cloud-run-部署&quot;&gt;第三步：先上車後補票的 Cloud Run 部署&lt;/h3&gt;
&lt;p&gt;這是一個經典的「雞生蛋、蛋生雞」問題：Google OAuth 需要知道你的 Cloud Run 網址 (Redirect URI)，但你的 Cloud Run 部署又需要填寫 OAuth 的 Client ID 和 Secret。&lt;/p&gt;

&lt;p&gt;Gemini CLI 的策略很棒：&lt;strong&gt;先用佔位符部署！&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud run deploy linebot-backup-service &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--source&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;.&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; asia-east1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--set-env-vars&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GOOGLE_CLOUD_PROJECT=your-cool-project-id,ChannelSecret=YOUR_LINE_SECRET_XXXX,ChannelAccessToken=YOUR_LINE_TOKEN_XXXX,GOOGLE_CLIENT_ID=PENDING,GOOGLE_CLIENT_SECRET=PENDING,GOOGLE_REDIRECT_URL=PENDING&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--allow-unauthenticated&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;部署成功後，我們拿到了一串香噴噴的網址：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://linebot-backup-service-xxxxx.a.run.app&lt;/code&gt;。&lt;/p&gt;

&lt;h3 id=&quot;第四步完成-google-oauth-設定與環境變數更新&quot;&gt;第四步：完成 Google OAuth 設定與環境變數更新&lt;/h3&gt;
&lt;p&gt;有了網址，我就可以去 Google Cloud Console 的「API 與服務」完成設定：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;建立 &lt;strong&gt;OAuth 同意畫面&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;建立 &lt;strong&gt;網頁應用程式&lt;/strong&gt; 的憑證。&lt;/li&gt;
  &lt;li&gt;把剛剛的網址加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/oauth/callback&lt;/code&gt; 填入「已授權的重新導向 URI」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;拿到真實的 ID 與 Secret 後，我直接把資訊貼給 Gemini CLI，它便自動幫我更新了服務：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud run services update linebot-backup-service &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; asia-east1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--update-env-vars&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GOOGLE_REDIRECT_URL=https://[YOUR_URL]/oauth/callback,GOOGLE_CLIENT_ID=real-client-id.apps.googleusercontent.com,GOOGLE_CLIENT_SECRET=real-secret-xxxx&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;大功告成！最後只要去 LINE Developers Console 把 Webhook 填上就好。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;部署過程中的血淚踩坑紀錄&quot;&gt;部署過程中的血淚踩坑紀錄&lt;/h2&gt;

&lt;p&gt;看起來行雲流水，但其實中間 AI 和我一起撞了幾個牆。這也是使用 CLI 工具最真實的體驗。&lt;/p&gt;

&lt;h3 id=&quot;踩坑一忘記綁定信用卡的-390001-錯誤&quot;&gt;踩坑一：忘記綁定信用卡的 390001 錯誤&lt;/h3&gt;

&lt;p&gt;在執行第一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run deploy&lt;/code&gt; 時，終端機直接噴了滿臉紅字：&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAILED_PRECONDITION: Billing account for project is not found...&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：Cloud Run 和 Cloud Build 需要專案啟用計費功能（Billing Enabled）。這是一個全新的測試專案，我忘記綁定帳單了。
&lt;strong&gt;解法&lt;/strong&gt;：AI 立刻幫我檢查了專案狀態 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud beta billing projects describe&lt;/code&gt;)，並詢問我是要切換到有計費的專案，還是去修復它。我乖乖去 Console 綁定信用卡後，部署才得以繼續。&lt;/p&gt;

&lt;h3 id=&quot;踩坑二指令參數的語法演進&quot;&gt;踩坑二：指令參數的語法演進&lt;/h3&gt;

&lt;p&gt;在建立 Firestore 時，AI 一開始給的指令是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--type=native-mode&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--type=native&lt;/code&gt;，結果 gcloud 不領情：&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ERROR: argument --type: Invalid choice: &apos;native-mode&apos;&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 的 CLI 參數會隨著版本更迭。
&lt;strong&gt;解法&lt;/strong&gt;：仔細看 gcloud 的錯誤提示，現在正確的參數值是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firestore-native&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;datastore-mode&lt;/code&gt;。修改為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--type=firestore-native&lt;/code&gt; 後順利通關。&lt;/p&gt;

&lt;h3 id=&quot;踩坑三那個隱形的drive-api&quot;&gt;踩坑三：那個隱形的「Drive API」&lt;/h3&gt;

&lt;p&gt;當一切部署完畢，我們在測試「上傳到 Google Drive」時，卻發生了權限錯誤。
&lt;strong&gt;原因&lt;/strong&gt;：這是一個幫你把檔案傳到 Drive 的 Bot，但我們在第一步啟用 API 時，竟然忘記啟用主角：&lt;strong&gt;Google Drive API&lt;/strong&gt;！沒有它，就算 OAuth 授權成功，程式一樣會被擋在門外。
&lt;strong&gt;解法&lt;/strong&gt;：我只對終端機輸入了神祕的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;3.&quot;&lt;/code&gt; (暗示第三個檢查點)，AI 立刻心領神會，補上了這關鍵的一擊：&lt;/p&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud services &lt;span class=&quot;nb&quot;&gt;enable &lt;/span&gt;drive.googleapis.com
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;總結&quot;&gt;總結&lt;/h2&gt;

&lt;p&gt;透過 Gemini CLI，原本枯燥且容易出錯的基礎設施建置工作，變成了一場「雙人結隊程式設計」。&lt;/p&gt;

&lt;p&gt;AI 可以幫你記住冗長的 gcloud 參數、幫你梳理部署邏輯（先用 PENDING 部署再更新），甚至在你遇到報錯時，能根據錯誤訊息快速調整策略。&lt;/p&gt;

&lt;p&gt;這就是 &lt;strong&gt;Build With AI 2026&lt;/strong&gt; 想傳達的核心精神：讓 AI 處理繁瑣的 DevOps 雜活，開發者就能把更多精力放在核心業務邏輯的創新上。&lt;/p&gt;

&lt;p&gt;如果你還在手敲又長又臭的 gcloud 指令，強烈建議你把 Gemini CLI 裝起來試試看！&lt;/p&gt;
</description>
				<pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/bwai2026-linebot-file/</link>
				<guid isPermaLink="true">https://www.evanlin.com/bwai2026-linebot-file/</guid>
			</item>
		
			<item>
				<title>[GCP 實戰] 打造持久化 AI 助手：在 GCE 部署 Hermes Agent 並接軌 Telegram</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260502161538962.png&quot; alt=&quot;image-20260502161538962&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&gt;

&lt;p&gt;在解決了 LINE Bot 的 Vertex AI 遷移後，我開始思考：能不能有一個「更具主動性」且「擁有長期記憶」的 AI 助手？這時候我盯上了 &lt;a href=&quot;https://github.com/nousresearch/hermes-agent&quot;&gt;NousResearch 開源的 &lt;strong&gt;Hermes Agent&lt;/strong&gt;&lt;/a&gt; 。&lt;/p&gt;

&lt;p&gt;不同於一般的 Chatbot，Hermes 被設計成一個「會呼吸的操作系統」，它能自己執行 Shell 指令、寫 Python 腳本、管理長期記憶，甚至能透過不同的 Gateway (Telegram, Discord) 隨時與你保持聯繫。&lt;/p&gt;

&lt;p&gt;為了讓它能 24/7 待命，我選擇將它部署在 &lt;strong&gt;Google Compute Engine (GCE)&lt;/strong&gt; 上。這篇文章將紀錄從零開始的部署過程，以及我在配置最新的 &lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt; 模型時踩過的那些坑。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;環境參數預備&quot;&gt;環境參數預備&lt;/h2&gt;

&lt;p&gt;在開始之前，請確保你手手上這這些必要的參數：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;PROJECT_ID&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YOUR_PROJECT_ID&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;LOCATION&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;global&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;GOOGLE_API_KEY&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YOUR_GOOGLE_API_KEY&lt;/code&gt; (Google AI Studio 取得)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;第一步建立-gce-實例&quot;&gt;第一步：建立 GCE 實例&lt;/h2&gt;

&lt;p&gt;Hermes Agent 需要一點運算能力來處理工具調用（Tool Use），建議使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e2-medium&lt;/code&gt; 規格。&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud compute instances create hermes-agent-vm &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--zone&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;us-central1-a &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--machine-type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;e2-medium &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--image-family&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;ubuntu-2204-lts &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--image-project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;ubuntu-os-cloud &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--boot-disk-size&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;30GB &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--metadata&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;startup-script&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;#!/bin/bash
        apt-get update
        apt-get install -y git curl python3-pip python3-venv nodejs npm
    &apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;第二步安裝-hermes-agent&quot;&gt;第二步：安裝 Hermes Agent&lt;/h2&gt;

&lt;p&gt;SSH 進入 VM 後，直接使用官方提供的一鍵安裝腳本。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;進入 VM&lt;/strong&gt;：
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud compute ssh hermes-agent-vm &lt;span class=&quot;nt&quot;&gt;--zone&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;us-central1-a
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;執行安裝&lt;/strong&gt;：
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl &lt;span class=&quot;nt&quot;&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
&lt;span class=&quot;nb&quot;&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;第三步配置-gemini-25-flash-sop-實戰&quot;&gt;第三步：配置 Gemini 2.5 Flash (SOP 實戰)&lt;/h2&gt;

&lt;p&gt;這是整場演習最容易踩坑的地方。Hermes 預設可能會指向不存在或過期的模型標識符。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;建立配置文件&lt;/strong&gt;：
在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.hermes/config.yaml&lt;/code&gt; 中，我們必須精確指定 &lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt;，且 &lt;strong&gt;不可帶 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google/&lt;/code&gt; 前綴&lt;/strong&gt;。&lt;/p&gt;

    &lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;gemini&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;gemini-2.5-flash&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;terminal&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;backend&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;local&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;gateway&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;telegram&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;auxiliary&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# ⚠️ 非常重要：Hermes 內部會硬編碼輔助模型，必須手動覆寫&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;title_generation&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;gemini&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;gemini-2.5-flash&quot;&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;summarization&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;gemini&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;gemini-2.5-flash&quot;&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;設定 API Key&lt;/strong&gt;：
在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.hermes/.env&lt;/code&gt; 中寫入密鑰與權限設定：&lt;/p&gt;
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GOOGLE_API_KEY=你的_API_KEY&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.hermes/.env
&lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GATEWAY_ALLOW_ALL_USERS=true&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.hermes/.env
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;第四步接軌-telegram-與背景持久化&quot;&gt;第四步：接軌 Telegram 與背景持久化&lt;/h2&gt;

&lt;p&gt;為了不讓 SSH 斷線後 Agent 就消失，我們使用 &lt;strong&gt;Systemd&lt;/strong&gt; 來管理。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;建立 Systemd 服務&lt;/strong&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/etc/systemd/system/hermes.service&lt;/code&gt;)：
    &lt;div class=&quot;language-ini highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nn&quot;&gt;[Unit]&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;Description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Hermes Agent Gateway&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;After&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;network.target&lt;/span&gt;
    
&lt;span class=&quot;nn&quot;&gt;[Service]&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;simple&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;root&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;Environment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;HOME=/root&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;Environment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;PYTHONUNBUFFERED=1&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;ExecStart&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/usr/local/lib/hermes-agent/venv/bin/hermes gateway run&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;Restart&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;always&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;RestartSec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;10&lt;/span&gt;
    
&lt;span class=&quot;nn&quot;&gt;[Install]&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;WantedBy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;啟動服務&lt;/strong&gt;：
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;systemctl &lt;span class=&quot;nb&quot;&gt;enable &lt;/span&gt;hermes
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;systemctl restart hermes
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;遷移過程中的血淚踩坑為什麼我的-agent-沒反應&quot;&gt;遷移過程中的血淚踩坑：為什麼我的 Agent 沒反應？&lt;/h2&gt;

&lt;p&gt;即使配置正確，我還是遇到了「Agent 讀取訊息但死不回覆」的窘境。查了日誌 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;journalctl -u hermes&lt;/code&gt;) 才發現幾個深坑：&lt;/p&gt;

&lt;h3 id=&quot;踩坑一gemini-30-的-404-幽靈&quot;&gt;踩坑一：Gemini 3.0 的 404 幽靈&lt;/h3&gt;

&lt;p&gt;我在配置時試圖追求最新，用了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-flash-preview&lt;/code&gt;。結果日誌噴出一堆 &lt;strong&gt;404 Model Not Found&lt;/strong&gt;。
&lt;strong&gt;原因&lt;/strong&gt;：Hermes 內部的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;auxiliary_client.py&lt;/code&gt; 硬編碼了許多 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-flash-preview&lt;/code&gt; 作為預設值。當這些輔助功能（如生成標題）報錯時，會連帶影響整個 Gateway 的回覆邏輯。
&lt;strong&gt;解法&lt;/strong&gt;：手動在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.yaml&lt;/code&gt; 中顯式定義所有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;auxiliary&lt;/code&gt; 模型為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-2.5-flash&lt;/code&gt;，或者直接用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sed&lt;/code&gt; 去補丁源代碼。&lt;/p&gt;

&lt;h3 id=&quot;踩坑二模型標識符的前綴混淆&quot;&gt;踩坑二：模型標識符的前綴混淆&lt;/h3&gt;

&lt;p&gt;在不同的 SDK 中，有人用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google/gemini-2.5-flash&lt;/code&gt;，有人用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-2.5-flash&lt;/code&gt;。
&lt;strong&gt;經驗&lt;/strong&gt;：在 Hermes 的 Gemini Provider 中，&lt;strong&gt;直接使用短名稱 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-2.5-flash&lt;/code&gt; 是最保險的&lt;/strong&gt;。加了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google/&lt;/code&gt; 反而會讓 API 路由出錯。&lt;/p&gt;

&lt;h3 id=&quot;踩坑三systemd-與已經運行的進程衝突&quot;&gt;踩坑三：Systemd 與「已經運行的進程」衝突&lt;/h3&gt;

&lt;p&gt;當你手動跑過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hermes gateway&lt;/code&gt; 後再啟動服務，系統會報 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Gateway already running (PID xxxx)&lt;/code&gt;。
&lt;strong&gt;解法&lt;/strong&gt;：在 Systemd 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecStart&lt;/code&gt; 之前，可以加一條 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecStartPre=/usr/bin/pkill -9 -f hermes || true&lt;/code&gt;，確保每次啟動都是乾淨的環境。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;總結&quot;&gt;總結&lt;/h2&gt;

&lt;p&gt;現在，我的專屬 Hermes Agent 已經穩定的跑在 GCE 上，並且透過 Telegram 隨時待命。它不僅能幫我查資料，還能直接在雲端 VM 上幫我跑一些簡單的運算腳本。&lt;/p&gt;

&lt;p&gt;這次部署讓我學到：&lt;strong&gt;面對更新極快的模型，官方文檔（或 MCP 工具查詢）才是唯一的真理&lt;/strong&gt;。不要盲目追求最新版本號，確保標識符與當前 API 環境匹配才是穩定運行的關鍵。&lt;/p&gt;

&lt;p&gt;如果你也想要一個 24 小時不睡覺的 AI 數位替身，趕快照著這份 SOP 弄一台吧！&lt;/p&gt;
</description>
				<pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-cli-hermes-agent/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-cli-hermes-agent/</guid>
			</item>
		
			<item>
				<title>[GCP 實戰] OpenAB 部署筆記：在 GCE 上打造支援 Telegram 的 Gemini ACP 橋接器</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260502171732526.png&quot; alt=&quot;image-20260502171732526&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&gt;

&lt;p&gt;最近為了讓 AI 編碼助理（如 Claude Code 或 Gemini CLI）能直接在聊天平台上使用，我開始研究 &lt;strong&gt;&lt;a href=&quot;https://openabdev.github.io/openab/&quot;&gt;OpenAB&lt;/a&gt; **。這是一個強大的橋接器，能將 Slack、Discord 或 Telegram 對接到符合 **ACP (Agent Client Protocol)&lt;/strong&gt; 標準的 CLI 工具。&lt;/p&gt;

&lt;p&gt;這篇文章紀錄了我將 &lt;a href=&quot;https://openabdev.github.io/openab/&quot;&gt;OpenAB&lt;/a&gt; 部署在 Google Cloud 上的完整實戰過程，特別是如何繞過認證限制、處理 Telegram 的 HTTPS 需求，以及解決容器化部署中的路徑與權限問題。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;OpenAB 參考文件&lt;/strong&gt;： &lt;a href=&quot;https://openabdev.github.io/openab/&quot;&gt;https://openabdev.github.io/openab/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;OpenAB Repo&lt;/strong&gt;: &lt;a href=&quot;https://github.com/openabdev/openab&quot;&gt;https://github.com/openabdev/openab&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;部署決策為什麼選-gce-而不是-cloud-run&quot;&gt;部署決策：為什麼選 GCE 而不是 Cloud Run？&lt;/h2&gt;

&lt;p&gt;雖然 Cloud Run 是我的首選，但在處理 OpenAB 時，&lt;strong&gt;Google Compute Engine (GCE)&lt;/strong&gt; 才是最佳解決方案。原因有二：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Stateful Session (狀態化對話)&lt;/strong&gt;：OpenAB 會為每個對話 thread 啟動一個子進程（如 Gemini CLI）。這些進程必須長期駐留以維持對話上下文。Cloud Run 的自動縮減機制會殺死這些進程，導致對話中斷。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;認證持久化&lt;/strong&gt;：AI CLI 的 Token 需要儲存在本地磁碟。GCE 配合 Persistent Disk 能保證重啟後登入狀態不消失。&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;實戰步驟step-by-step-部署過程&quot;&gt;實戰步驟：Step-by-Step 部署過程&lt;/h2&gt;

&lt;h3 id=&quot;第一步撰寫自動化啟動腳本-startup-script&quot;&gt;第一步：撰寫自動化啟動腳本 (Startup Script)&lt;/h3&gt;

&lt;p&gt;為了讓部署標準化，我們撰寫了一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;setup-openab.sh&lt;/code&gt;。其核心任務是安裝 Docker、建立持久化目錄，並動態生成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.toml&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;最關鍵的部分是 &lt;strong&gt;自定義 Docker Image&lt;/strong&gt;。由於 OpenAB 官方鏡像不一定包含所有 AI 工具，我們透過 Dockerfile 現場安裝 Node.js 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@google/gemini-cli&lt;/code&gt;：&lt;/p&gt;

&lt;div class=&quot;language-dockerfile highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; ghcr.io/openabdev/openab:latest&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; root&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;apt-get update &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-y&lt;/span&gt; curl &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;    curl &lt;span class=&quot;nt&quot;&gt;-fsSL&lt;/span&gt; https://deb.nodesource.com/setup_20.x | bash - &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;    apt-get &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-y&lt;/span&gt; nodejs &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt;    npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; @google/gemini-cli
&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; 1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;第二步使用-gcloud-建立-gce-實例&quot;&gt;第二步：使用 gcloud 建立 GCE 實例&lt;/h3&gt;

&lt;p&gt;我們選擇 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e2-medium&lt;/code&gt; 規格，並透過 Metadata 傳遞敏感資訊（如 Bot Token），避免寫死在腳本中。&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud compute instances create openab-server &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;your-project-id &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--zone&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;asia-east1-b &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--machine-type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;e2-medium &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--image-family&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;debian-11 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--image-project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;debian-cloud &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--metadata-from-file&lt;/span&gt; startup-script&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;setup-openab.sh &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--metadata&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;tg_bot_token&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;YOUR_BOT_TOKEN
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;第三步配置-gemini-api-key&quot;&gt;第三步：配置 Gemini API Key&lt;/h3&gt;

&lt;p&gt;不同於 Kiro 需要互動式登入，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-cli&lt;/code&gt; 可以直接讀取環境變數。我們將 API Key 注入到 OpenAB 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.toml&lt;/code&gt; 中，讓它在背景自動運作：&lt;/p&gt;

&lt;div class=&quot;language-toml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nn&quot;&gt;[agent]&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;command&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gemini&quot;&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;args&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;[&quot;--acp&quot;]&lt;/span&gt;
&lt;span class=&quot;nn&quot;&gt;env&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;GEMINI_API_KEY&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;AIzaSy...&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;第四步使用-cloudflare-tunnel-解決-https-需求&quot;&gt;第四步：使用 Cloudflare Tunnel 解決 HTTPS 需求&lt;/h3&gt;

&lt;p&gt;Telegram Webhook 強制要求 &lt;strong&gt;HTTPS&lt;/strong&gt;。與其設定複雜的 Nginx + SSL，我選擇使用 &lt;strong&gt;Cloudflare Quick Tunnel&lt;/strong&gt;：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;在 VM 執行：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cloudflared tunnel --url http://localhost:8080&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;取得隨機生成的 HTTPS 網址。&lt;/li&gt;
  &lt;li&gt;註冊 Webhook：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl &quot;https://api.telegram.org/bot&amp;lt;TOKEN&amp;gt;/setWebhook?url=&amp;lt;CF_URL&amp;gt;/webhook/telegram&amp;amp;secret_token=&amp;lt;SECRET&amp;gt;&quot;&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;遷移過程中的血淚踩坑技術總結&quot;&gt;遷移過程中的血淚踩坑：技術總結&lt;/h2&gt;

&lt;p&gt;在部署過程中，我們來回除錯了幾次，以下是總結出的三大「坑」：&lt;/p&gt;

&lt;h3 id=&quot;踩坑一鏡像來源的混淆&quot;&gt;踩坑一：鏡像來源的混淆&lt;/h3&gt;

&lt;p&gt;一開始我嘗試從 Docker Hub Pull &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openabdev/openab&lt;/code&gt; 但一直失敗。最後才發現該專案目前的穩定鏡像是放在 &lt;strong&gt;GitHub Container Registry (GHCR)&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;解法&lt;/strong&gt;：必須使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ghcr.io/openabdev/openab:latest&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;踩坑二硬編碼的配置路徑&quot;&gt;踩坑二：硬編碼的配置路徑&lt;/h3&gt;

&lt;p&gt;OpenAB 的 Dockerfile 內部預期設定檔路徑在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/etc/openab/config.toml&lt;/code&gt;。我最初掛載到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/app/config.toml&lt;/code&gt; 導致容器啟動後立即閃退報錯。&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;解法&lt;/strong&gt;：修正 Docker Volume 掛載路徑為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/etc/openab/config.toml&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;踩坑三security-secret-token-驗證失敗&quot;&gt;踩坑三：Security Secret Token 驗證失敗&lt;/h3&gt;

&lt;p&gt;即便 URL 正確，Telegram 訊息仍被 Gateway 拒絕。日誌顯示 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;invalid or missing secret_token&lt;/code&gt;。&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;原因&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openab-gateway&lt;/code&gt; 為了防止非法請求，會生成一個內部校驗碼。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;解法&lt;/strong&gt;：必須從 Gateway 容器中提取該 Token，並在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;setWebhook&lt;/code&gt; 時作為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secret_token&lt;/code&gt; 參數傳遞。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;總結完美的-ai-橋接方案&quot;&gt;總結：完美的 AI 橋接方案&lt;/h2&gt;

&lt;p&gt;透過這套架構，我成功在 GCP 上建立了一個完全自託管、安全且高效率的 AI 助理。它不依賴昂貴的訂閱，而是直接利用 Gemini 的 API 能力，並透過 Telegram 作為互動介面。&lt;/p&gt;

&lt;p&gt;如果你也想在雲端架設一個專屬的 ACP 橋接器，這套 GCE + Docker + Cloudflare Tunnel 的組合方案將是最平衡穩定選擇。&lt;/p&gt;
</description>
				<pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-openab/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-openab/</guid>
			</item>
		
			<item>
				<title>[GCP 實戰] LINE Bot 遷移大作戰：從 AI Studio 轉向 Vertex AI 解決 429 額度危機</title>
				<description>&lt;p&gt;&lt;img src=&quot;https://www.evanlin.com/images/image-20260421011411264.png&quot; alt=&quot;image-20260421011411264&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&gt;

&lt;p&gt;最近我們部署在 Google Cloud Run 上的 LINE 名片助理機器人 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linebot-namecard-python&lt;/code&gt;) 突然罷工了。透過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud logging read&lt;/code&gt; 查了一下日誌，迎面而來的是這個無情的錯誤：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google.api_core.exceptions.ResourceExhausted: 429 Your billing account has exceeded its monthly spending cap.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;這是一個慘痛的教訓：我們當初為了快速開發，直接使用了 Google AI Studio 提供的 API Key（走 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google.generativeai&lt;/code&gt; 套件）。隨著流量增加，我們很快就撞上了 &lt;strong&gt;Google AI Studio Tier 1 的 429 限制（Rate Limit / Quota 爆炸）&lt;/strong&gt;，結果默默把每月的免費額度給打爆了。&lt;/p&gt;

&lt;p&gt;為了徹底解決這個問題，我們決定將所有依賴 AI Studio Gemini API Key 的應用程式，全面轉換成 &lt;strong&gt;Google Cloud Vertex AI&lt;/strong&gt;，直接走 GCP 的企業級架構與計費系統。&lt;/p&gt;

&lt;p&gt;這篇文章就來分享這次遷移的完整過程，以及途中發現「Vertex AI 的資訊實在太少，導致模型設定常常踩坑」的各種血淚經驗。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;身份驗證升級推薦使用-workload-identity&quot;&gt;身份驗證升級：推薦使用 Workload Identity&lt;/h2&gt;

&lt;p&gt;在遷移的過程中，最重要的一環就是身分驗證。過去我們習慣在環境變數塞一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GEMINI_API_KEY&lt;/code&gt;，簡單粗暴但充滿資安隱患。&lt;/p&gt;

&lt;p&gt;來到 GCP 的世界，很多人第一直覺是去開一個 Service Account（服務帳戶），然後把 JSON 金鑰載下來放進程式裡。&lt;strong&gt;但強烈建議不要走 Service Account 金鑰這條路！&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;搭配 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud cli&lt;/code&gt;，其實可以很快速地為 Cloud Run 設定 &lt;strong&gt;Workload Identity&lt;/strong&gt;。程式碼中不需要任何金鑰，只要在執行環境中賦予該 Identity 對應的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Vertex AI User&lt;/code&gt; 權限，Google Cloud SDK 就會自動取得憑證。這樣不僅大幅降低金鑰外洩的風險，管理起來也更輕鬆。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;程式碼升級從-ai-studio-轉向-vertex-ai&quot;&gt;程式碼升級：從 AI Studio 轉向 Vertex AI&lt;/h2&gt;

&lt;p&gt;要將專案從 Google AI Studio SDK 遷移到 Vertex AI，主要有三個步驟：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;替換依賴套件&lt;/strong&gt;：
在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requirements.txt&lt;/code&gt; 中，移除舊的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google.generativeai&lt;/code&gt;，換成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google-cloud-aiplatform&lt;/code&gt;。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;更新環境變數設定&lt;/strong&gt;：
在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.py&lt;/code&gt; 中，我們不再需要 API Key，而是改用 GCP 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PROJECT_ID&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LOCATION&lt;/code&gt;：
    &lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;PROJECT_ID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;PROJECT_ID&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;LOCATION&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;LOCATION&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;global&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# 預設使用 global
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;核心程式碼改寫 (gemini_utils.py)&lt;/strong&gt;：
Vertex AI 的 SDK 介面雖然類似，但對於多模態（如圖片）的處理稍微嚴格一點。我們需要將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PIL.Image&lt;/code&gt; 轉換成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vertexai.generative_models.Part&lt;/code&gt; 格式：&lt;/p&gt;

    &lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;vertexai&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;vertexai.generative_models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GenerativeModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;io&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BytesIO&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;PIL.Image&lt;/span&gt;
   
&lt;span class=&quot;c1&quot;&gt;# 初始化 Vertex AI
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;vertexai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PROJECT_ID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LOCATION&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
   
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;pil_to_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PIL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;img_byte_arr&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BytesIO&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;save&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;img_byte_arr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;JPEG&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;img_byte_arr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getvalue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
   
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;generate_json_from_image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PIL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GenerativeModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;gemini-3-flash-preview&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;generation_config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;response_mime_type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# ⚠️ 注意這裡：必須轉換成 Part 物件
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;img_part&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pil_to_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;image/jpeg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;img_part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;    &lt;/div&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;遷移過程中的血淚踩坑模型都很笨還是-vertex-ai-資訊太少&quot;&gt;遷移過程中的血淚踩坑：模型都很笨？還是 Vertex AI 資訊太少？&lt;/h2&gt;

&lt;p&gt;在遷移的過程中，有一種強烈的感覺：&lt;strong&gt;「模型好像都很笨（我是說在座的所有 models），還是其實是 Vertex AI 官方給的資訊太少了？」&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;許多在 AI Studio 上理所當然的設定，搬到 Vertex AI 後卻頻頻報錯。以下整理了幾個最容易踩到、讓人來來回回除錯的深坑：&lt;/p&gt;

&lt;h3 id=&quot;踩坑一殘留的舊-sdk-導致-cloud-run-啟動失敗&quot;&gt;踩坑一：殘留的舊 SDK 導致 Cloud Run 啟動失敗&lt;/h3&gt;

&lt;p&gt;滿心歡喜地用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run services update&lt;/code&gt; 更新了環境變數，結果 Cloud Run 部署失敗，容器連啟動都啟動不了。&lt;/p&gt;

&lt;p&gt;查了日誌才發現：&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ModuleNotFoundError: No module named &apos;google.generativeai&apos;&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：雖然 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini_utils.py&lt;/code&gt; 已經改寫好了，但主程式 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app/main.py&lt;/code&gt; 裡面還殘留著 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import google.generativeai as genai&lt;/code&gt; 以及 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;genai.configure(...)&lt;/code&gt; 的初始化程式碼。既然 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requirements.txt&lt;/code&gt; 已經移除了這個套件，容器啟動時自然會找不到模組而崩潰。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：全面 grep 專案，徹底移除所有舊版 SDK 的引用，然後使用 Cloud Build 重新打包 Docker image 再次推送。&lt;/p&gt;

&lt;h3 id=&quot;踩坑二region-必須選-global-才有最多跟最新模型&quot;&gt;踩坑二：Region 必須選 global 才有最多跟最新模型&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;https://www.evanlin.com/images/Google%20Chrome%202026-04-21%2001.12.46.png&quot; alt=&quot;Google Chrome 2026-04-21 01.12.46&quot; /&gt;&lt;/p&gt;

&lt;p&gt;程式碼清乾淨、容器也順利啟動了，但當我在 LINE 傳送圖片時，機器人卻拋出了 500 錯誤。日誌顯示：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google.api_core.exceptions.NotFound: 404 Publisher Model ... was not found or your project does not have access to it.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;這是我這次遇到最大的坑！很多人（包含我）直覺上會把 Vertex AI 的 Region 設定跟 Project 或 Cloud Run 的 Region 綁在一起（例如設定為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;asia-east1&lt;/code&gt; 台灣區）。&lt;/p&gt;

&lt;p&gt;但在 Vertex AI 的世界裡，&lt;strong&gt;Region 很容易跟 Project Region 搞混&lt;/strong&gt;。如果你想要使用最新、最完整的模型（例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-flash-preview&lt;/code&gt;），你&lt;strong&gt;必須將 Vertex AI 的 LOCATION 設定為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;global&lt;/code&gt;&lt;/strong&gt;。如果硬是設定成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;asia-east1&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;us-central1&lt;/code&gt;，常常會遇到 404 找不到模型的窘境。&lt;/p&gt;

&lt;h3 id=&quot;踩坑三model-名稱選錯造成來來回回錯誤&quot;&gt;踩坑三：Model 名稱選錯，造成來來回回錯誤&lt;/h3&gt;

&lt;p&gt;在 Google AI Studio，你可以很隨意地用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-1.5-flash&lt;/code&gt; 這個 alias 甚至省略後綴。但在 Vertex AI，模型名稱的規定非常嚴格且混亂。&lt;/p&gt;

&lt;p&gt;如果你名稱選錯（例如少加了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-002&lt;/code&gt;，或是預覽版名稱拼錯），API 不是直接報錯，就是默默地 Fallback 跑到舊版的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.5&lt;/code&gt; 模型去執行，導致你覺得「這模型怎麼變笨了？」，結果查了半天才發現根本叫錯了模型。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;最終解法與建議&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.py&lt;/code&gt; 的預設 region 改為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;global&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;呼叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vertexai.init(project=&quot;line-vertex&quot;, location=&quot;global&quot;)&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;確保使用的模型名稱與 GCP 官方文件完全一致，例如直接指定 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-flash-preview&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;總結vertex-ai-帶來的改變&quot;&gt;總結：Vertex AI 帶來的改變&lt;/h2&gt;

&lt;p&gt;經過一番折騰，名片機器人終於滿血復活，並且順利升級到最新的 Gemini 3 Flash Preview 模型。從 AI Studio 遷移到 Vertex AI 後，帶來了幾個顯著的好處：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;擺脫 Quota 焦慮&lt;/strong&gt;：徹底解決 AI Studio Tier 1 的 429 爆炸問題，直接透過 GCP 帳單扣款，適合生產環境。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;安全性大躍進&lt;/strong&gt;：捨棄了明文 API Key 與 Service Account 金鑰，擁抱 Workload Identity，架構更安全現代化。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;穩定性&lt;/strong&gt;：享受企業級的 SLA 保障。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;雖然一開始覺得 Vertex AI 資訊太少、模型名稱與 Region 設定讓人頭痛，但只要掌握了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;global&lt;/code&gt; region 以及 Workload Identity 這兩個訣竅，後續的維護其實是非常香的。&lt;/p&gt;

&lt;p&gt;完整程式碼已更新至 &lt;a href=&quot;https://github.com/kkdai/linebot-namecard-python&quot;&gt;GitHub&lt;/a&gt;，如果你也有專案正準備從 AI Studio 搬家到 Vertex AI，希望這篇踩坑紀錄能幫你少走一點彎路！&lt;/p&gt;
</description>
				<pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/aistudio-to-vertexai/</link>
				<guid isPermaLink="true">https://www.evanlin.com/aistudio-to-vertexai/</guid>
			</item>
		
			<item>
				<title>[Gemini 3.1] Gemini-3.1-flash TTS 實戰：更簡單、更強大的朗讀摘要功能</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/Finder 2026-04-16 21.43.57.png&quot; alt=&quot;Finder 2026-04-16 21.43.57&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&gt;

&lt;p&gt;在上一篇實戰中，我們利用 Gemini 3.1 Flash Live 實現了語音辨識，並透過 Gemini 2.5 Live API 的「側擊」方式勉強達成了朗讀摘要（TTS）功能。&lt;/p&gt;

&lt;p&gt;但就在 2026 年 4 月，Google 正式發佈了 &lt;a href=&quot;https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-1-flash-tts/&quot;&gt;&lt;strong&gt;Gemini 3.1 Flash TTS&lt;/strong&gt;&lt;/a&gt;。這是一個專門為語音輸出設計的原生模型，不再需要掛載 Live WebSocket，直接透過標準的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generate_content&lt;/code&gt; 流程就能輸出高品質音訊。&lt;/p&gt;

&lt;p&gt;身為開發者，有更優雅、更原生的方案當然要立刻跟上。這篇文章就來分享如何把 LINE Bot 的朗讀摘要功能升級到 Gemini 3.1 Native TTS，以及過程中踩到的「異步大坑」。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;技術升級從-live-api-轉向-native-tts&quot;&gt;技術升級：從 Live API 轉向 Native TTS&lt;/h2&gt;

&lt;p&gt;之前的朗讀功能是利用 Gemini 2.5 Live API 模擬出來的，雖然可用，但有幾個缺點：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;複雜度高&lt;/strong&gt;：需要管理 WebSocket 連線生命週期。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;模型限制&lt;/strong&gt;：必須使用特定的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;native-audio&lt;/code&gt; 模型，且主要支援在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;us-central1&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;回傳格式固定&lt;/strong&gt;：採樣率通常固定在 16kHz。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Gemini 3.1 Flash TTS&lt;/strong&gt; 的出現改變了這一切：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;模型名稱&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.1-flash-tts-preview&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;介面一致&lt;/strong&gt;：使用熟悉的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generate_content_stream&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;動態參數&lt;/strong&gt;：支援從回傳的 MIME type 自動偵測採樣率（通常提升到了 24kHz，音質更好）。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;核心程式碼進化toolstts_toolpy&quot;&gt;核心程式碼進化（tools/tts_tool.py）&lt;/h2&gt;

&lt;p&gt;新的實作變得更加簡潔，重點在於 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;response_modalities=[&quot;audio&quot;]&lt;/code&gt; 這個設定：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;text_to_speech&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;tuple&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;api_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GOOGLE_AI_API_KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;http_options&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;api_version&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;v1beta&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
                &lt;span class=&quot;c1&quot;&gt;# 加入在地化指令，讓語氣更自然
&lt;/span&gt;                &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;請使用台灣用語的繁體中文，以親切且自然的語氣朗讀以下摘要內容。## Transcript:&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;response_modalities&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;audio&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;speech_config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SpeechConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;voice_config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VoiceConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;prebuilt_voice_config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PrebuiltVoiceConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;voice_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Zephyr&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;pcm_chunks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;sample_rate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;24000&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# 預設值
&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# ⚠️ 這裡就是那個差點讓我修到天亮的大坑
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;response_stream&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;aio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-3.1-flash-tts-preview&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inline_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                        &lt;span class=&quot;n&quot;&gt;pcm_chunks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inline_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;c1&quot;&gt;# 從 MIME type 動態取得採樣率（例如 audio/L16;rate=24000）
&lt;/span&gt;                        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inline_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                            &lt;span class=&quot;n&quot;&gt;sample_rate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parse_rate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inline_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;TTS Error: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;pcm_bytes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pcm_chunks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;duration_ms&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pcm_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sample_rate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;# 後續同樣透過 ffmpeg 轉成 m4a 傳給 LINE...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;踩過的坑那個消失的-await&quot;&gt;踩過的坑：那個消失的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;await&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;這次升級遇到一個非常隱晦的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TypeError&lt;/code&gt;，在遠端部署後一直噴出：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TypeError: &apos;async for&apos; requires an object with __aiter__ method, got coroutine&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;-錯誤寫法&quot;&gt;❌ 錯誤寫法&lt;/h3&gt;
&lt;p&gt;當初照著範例寫，直覺地以為可以直接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async for&lt;/code&gt; 一個 method：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# 這是錯的！
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;aio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(...):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;-正確解法&quot;&gt;✅ 正確解法&lt;/h3&gt;
&lt;p&gt;在 Google GenAI Python SDK 的非同步版本中，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generate_content_stream&lt;/code&gt; 本身是一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async&lt;/code&gt; 函式，它會&lt;strong&gt;回傳&lt;/strong&gt;一個 iterator。所以你必須先 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;await&lt;/code&gt; 拿到那個 iterator，然後再對它進行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async for&lt;/code&gt;。&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# 正確做法：分兩步
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response_stream&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;aio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(...)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這個細節在一般的同步程式碼或某些舊版 SDK 中不一定存在，但在處理 3.1 Flash TTS 的非同步串流時，這是能否成功跑起來的關鍵。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;在地化調整讓-bot-說台灣話&quot;&gt;在地化調整：讓 Bot 說「台灣話」&lt;/h2&gt;

&lt;p&gt;雖然摘要本身已經是繁體中文，但 TTS 模型在朗讀時，有時會帶有非本土的腔調或用語。我們透過 Prompt Engineering 解決了這個問題：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;「請使用&lt;strong&gt;台灣用語&lt;/strong&gt;的繁體中文，以&lt;strong&gt;親切且自然&lt;/strong&gt;的語氣朗讀…」&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;加入這行指令後，Gemini 輸出的音訊在語調起伏和斷句上更接近台灣使用者的習慣，大大提升了「朗讀摘要」的親和力。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;總結native-tts-帶來的改變&quot;&gt;總結：Native TTS 帶來的改變&lt;/h2&gt;

&lt;p&gt;從 Live API 遷移到 Native TTS 之後：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;連線更穩定&lt;/strong&gt;：不再需要維持一個長時間的 WebSocket。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;音質提升&lt;/strong&gt;：原生支援 24kHz 採樣率。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;維護容易&lt;/strong&gt;：程式碼量減少了約 30%，邏輯更直接。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;這次經驗也提醒了我，即使是看似成熟的 SDK，在處理 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async&lt;/code&gt; 模式時仍要仔細檢查回傳值類型。&lt;/p&gt;

&lt;p&gt;如果你也想讓你的 LINE Bot 開口說話，Gemini 3.1 Flash TTS 絕對是目前的最佳選擇。&lt;/p&gt;

&lt;p&gt;完整程式碼已更新至 &lt;a href=&quot;https://github.com/kkdai/linebot-helper-python&quot;&gt;GitHub&lt;/a&gt;，我們下次見！&lt;/p&gt;
</description>
				<pubDate>Wed, 15 Apr 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-flash-tts/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-flash-tts/</guid>
			</item>
		
			<item>
				<title>[Gemini 3.1] Flash Live 語音辨識實戰：讓 LINE Bot 聽懂你說的話</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260328203306501.png&quot; alt=&quot;image-20260328203306501&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&gt;

&lt;p&gt;Google 在 2026 年 &lt;a href=&quot;https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-1-flash-live/&quot;&gt;3 月底發佈了 &lt;strong&gt;Gemini 3.1 Flash Live&lt;/strong&gt;&lt;/a&gt;，主打「讓音訊 AI 更自然、更可靠」。這個模型專門針對即時雙向語音對話設計，低延遲、可中斷、支援多語言。&lt;/p&gt;

&lt;p&gt;剛好手邊有一個 LINE Bot 專案（&lt;a href=&quot;https://github.com/kkdai/linebot-helper-python&quot;&gt;linebot-helper-python&lt;/a&gt;）——它已經能處理文字、圖片、URL、PDF、YouTube，唯獨語音訊息完全不理：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;用戶傳送語音訊息
Bot：（沉默）
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這次就把語音支援補進去，順便分享幾個踩過的坑。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;設計決策flash-live-還是標準-gemini-api&quot;&gt;設計決策：Flash Live 還是標準 Gemini API？&lt;/h2&gt;

&lt;p&gt;第一個問題：Gemini 3.1 Flash Live 是為&lt;strong&gt;即時串流&lt;/strong&gt;設計的，但 LINE 的語音訊息是&lt;strong&gt;預錄好的 m4a 檔案&lt;/strong&gt;，不是即時音訊流。&lt;/p&gt;

&lt;p&gt;用 Flash Live 處理預錄檔案，就像用直播攝影機拍照——技術上可行，但工具選錯了。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;決定用標準 Gemini API&lt;/strong&gt;——直接把音訊 bytes 當 inline data 傳進去，一次呼叫拿到轉錄文字。更簡單、更適合這個場景。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260328203340798.png&quot; alt=&quot;image-20260328203340798&quot; /&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;架構設計&quot;&gt;架構設計&lt;/h2&gt;

&lt;h3 id=&quot;整合思路&quot;&gt;整合思路&lt;/h3&gt;

&lt;p&gt;這個 repo 已經有完整的 Orchestrator 架構，會依照訊息內容自動路由到不同 Agent（Chat、Content、Location、Vision、GitHub）。語音訊息的目標很明確：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;把語音轉成文字，然後當成一般文字訊息丟進 Orchestrator——讓現有的所有功能自動支援語音輸入。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;使用者說「幫我搜尋附近的加油站」→ 轉錄成文字 → Orchestrator 判斷是地點查詢 → LocationAgent 處理。不需要為語音另外實作邏輯。&lt;/p&gt;

&lt;h3 id=&quot;完整流程&quot;&gt;完整流程&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;用戶傳送 AudioMessage（m4a）
    │
    ▼ handle_audio_message()
    │
    ├─ ① LINE SDK 下載音訊 bytes
    │       get_message_content(message_id) → iter_content()
    │
    ├─ ② Gemini 轉錄
    │       tools/audio_tool.py → transcribe_audio()
    │       model: gemini-3.1-flash-lite-preview
    │
    ├─ ③ Reply #1：「你說的是：{transcription}」
    │       reply_message()（消耗 reply token）
    │
    └─ ④ Reply #2：Orchestrator 路由
            handle_text_message_via_orchestrator(push_user_id=user_id)
            ↓
            push_message()（reply token 已用掉，改用 push）
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;為什麼要兩段回覆&quot;&gt;為什麼要兩段回覆？&lt;/h3&gt;

&lt;p&gt;回覆分成兩則，讓使用者&lt;strong&gt;立刻看到轉錄結果&lt;/strong&gt;，不用等 Orchestrator 處理完才知道 Bot 有沒有聽懂自己說什麼。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;核心程式碼詳解&quot;&gt;核心程式碼詳解&lt;/h2&gt;

&lt;h3 id=&quot;step-1音訊轉錄工具toolsaudio_toolpy&quot;&gt;Step 1：音訊轉錄工具（tools/audio_tool.py）&lt;/h3&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;TRANSCRIPTION_MODEL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gemini-3.1-flash-lite-preview&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;transcribe_audio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;audio_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;audio/mp4&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
    Transcribe audio bytes to text using Gemini.
    LINE 語音訊息固定是 m4a，MIME type 固定填 audio/mp4。
    &quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;vertexai&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GOOGLE_CLOUD_PROJECT&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GOOGLE_CLOUD_LOCATION&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;us-central1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;audio_part&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;audio_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mime_type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;aio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TRANSCRIPTION_MODEL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;audio_part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;請將以上語音內容完整轉錄成文字，保留原語言，不要加任何說明或前綴。&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;設計原則：函式本身不 catch exception，讓上層 handler 統一處理錯誤回覆。&lt;/p&gt;

&lt;h3 id=&quot;step-2handler-主流程mainpy&quot;&gt;Step 2：handler 主流程（main.py）&lt;/h3&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;handle_audio_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MessageEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;Handle audio (voice) messages — transcribe and route through Orchestrator.&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;replied&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 追蹤 reply token 是否已使用
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# 下載音訊
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;message_content&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_message_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;audio_bytes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;iter_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;audio_bytes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# 轉錄
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;transcription&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;transcribe_audio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;audio_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# 空轉錄（無聲或太短）
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;transcription&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextSendMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;無法辨識語音內容，請重新錄製。&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# Reply #1：讓使用者確認轉錄結果（消耗 reply token）
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextSendMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;你說的是：&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transcription&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;replied&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# Reply #2：送進 Orchestrator，用 push_message（token 已用掉）
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;handle_text_message_via_orchestrator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transcription&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;push_user_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Error handling audio for &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exc_info&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;error_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;LineService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;format_error_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;處理語音訊息&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;error_msg&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TextSendMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;replied&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;# reply token 已消耗，改用 push
&lt;/span&gt;            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;push_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error_msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error_msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;step-3讓-orchestrator-支援外部傳入文字&quot;&gt;Step 3：讓 Orchestrator 支援外部傳入文字&lt;/h3&gt;

&lt;p&gt;原本的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;handle_text_message_via_orchestrator&lt;/code&gt; 直接讀 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.message.text&lt;/code&gt;，AudioMessage 沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.text&lt;/code&gt;，所以加兩個 optional 參數：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;handle_text_message_via_orchestrator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MessageEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;           &lt;span class=&quot;c1&quot;&gt;# ← 外部傳入文字（語音轉錄）
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;push_user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# ← 設定時改用 push_message
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;msg&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;orchestrator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;process_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;response_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;format_orchestrator_response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;reply_msg&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TextSendMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;push_user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;push_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;push_user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;error_msg&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TextSendMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LineService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;format_error_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;處理您的問題&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;push_user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;push_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;push_user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error_msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reply_token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;error_msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text is not None&lt;/code&gt;（而非 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text or ...&lt;/code&gt;）是刻意的——萬一語音轉錄出空字串，要讓空字串通過（然後被上層的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if not transcription.strip()&lt;/code&gt; 攔掉），不是 fallback 到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.message.text&lt;/code&gt;。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;踩過的坑&quot;&gt;踩過的坑&lt;/h2&gt;

&lt;h3 id=&quot;-坑-1partfrom_text-不接受-positional-argument&quot;&gt;❌ 坑 1：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Part.from_text()&lt;/code&gt; 不接受 positional argument&lt;/h3&gt;

&lt;p&gt;最先遇到的 TypeError：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# ❌ 錯誤（TypeError: Part.from_text() takes 1 positional argument but 2 were given）
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;請將以上語音內容完整轉錄成文字，保留原語言，不要加任何說明或前綴。&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# ✅ 正確
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;請將以上語音內容完整轉錄成文字，保留原語言，不要加任何說明或前綴。&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;在這個版本的 SDK 中，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Part.from_text()&lt;/code&gt; 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text&lt;/code&gt; 是 keyword argument，或者直接用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Part(text=...)&lt;/code&gt; 建構子更保險。&lt;/p&gt;

&lt;h3 id=&quot;-坑-2line-reply-token-只能用一次&quot;&gt;❌ 坑 2：LINE reply token 只能用一次&lt;/h3&gt;

&lt;p&gt;LINE 的 reply token 是&lt;strong&gt;一次性&lt;/strong&gt;的。一旦呼叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;reply_message()&lt;/code&gt;，token 就失效了。&lt;/p&gt;

&lt;p&gt;這個專案的語音流程會呼叫兩次：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Reply #1（顯示轉錄文字）→ &lt;strong&gt;消耗 token&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;Reply #2（Orchestrator 結果）→ &lt;strong&gt;token 已失效，會收到 LINE 400 error&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;解法是讓 Orchestrator handler 支援 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_message&lt;/code&gt; 模式（透過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_user_id&lt;/code&gt; 參數），Reply #2 改走 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_message&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;錯誤處理也要注意：如果 Reply #1 成功後 Orchestrator 才拋例外，except block 裡也不能再用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;reply_message&lt;/code&gt;，同樣要改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_message&lt;/code&gt;。這就是程式碼裡 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;replied&lt;/code&gt; flag 的用途。&lt;/p&gt;

&lt;h3 id=&quot;-坑-3gemini-flash-live-不適合預錄檔案&quot;&gt;❌ 坑 3：Gemini Flash Live 不適合預錄檔案&lt;/h3&gt;

&lt;p&gt;不是真正的「坑」，但值得說清楚：&lt;/p&gt;

&lt;p&gt;Gemini 3.1 Flash Live 是為&lt;strong&gt;即時雙向串流&lt;/strong&gt;設計，有連線建立和串流協定的開銷。LINE 語音訊息是完整的預錄 m4a，一次性處理即可。&lt;/p&gt;

&lt;p&gt;直接用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;client.aio.models.generate_content()&lt;/code&gt; 傳 inline audio bytes，更簡單，延遲也不差。Flash Live 留給真正需要即時對話的場景。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;效果展示&quot;&gt;效果展示&lt;/h2&gt;

&lt;h3 id=&quot;場景-1語音指令查詢&quot;&gt;場景 1：語音指令查詢&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;用戶傳送：[語音] 幫我搜尋台北車站附近的咖啡廳

Bot Reply #1：你說的是：幫我搜尋台北車站附近的咖啡廳
Bot Reply #2：[LocationAgent 回覆附近咖啡廳清單]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;場景-2語音問問題&quot;&gt;場景 2：語音問問題&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;用戶傳送：[語音] Gemini 和 GPT-4 有什麼差別

Bot Reply #1：你說的是：Gemini 和 GPT-4 有什麼差別
Bot Reply #2：[ChatAgent 搭配 Google Search Grounding 回覆比較結果]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;場景-3語音發-url&quot;&gt;場景 3：語音發 URL&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;用戶傳送：[語音] 幫我摘要這篇文章 https://example.com/article

Bot Reply #1：你說的是：幫我摘要這篇文章 https://example.com/article
Bot Reply #2：[ContentAgent 抓取並摘要文章]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;語音轉錄出來的文字直接進 Orchestrator，現有的 URL 偵測、意圖判斷全部照常運作，零額外邏輯。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;傳統文字輸入-vs-語音輸入&quot;&gt;傳統文字輸入 vs 語音輸入&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;文字輸入&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;語音輸入&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;輸入格式&lt;/td&gt;
      &lt;td&gt;TextMessage&lt;/td&gt;
      &lt;td&gt;AudioMessage（m4a）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;前處理&lt;/td&gt;
      &lt;td&gt;無&lt;/td&gt;
      &lt;td&gt;Gemini 轉錄&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;reply token&lt;/td&gt;
      &lt;td&gt;直接用&lt;/td&gt;
      &lt;td&gt;Reply #1 消耗，Reply #2 改 push&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Orchestrator&lt;/td&gt;
      &lt;td&gt;直接路由&lt;/td&gt;
      &lt;td&gt;轉錄文字後路由&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;支援功能&lt;/td&gt;
      &lt;td&gt;全部&lt;/td&gt;
      &lt;td&gt;全部（無需額外設定）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;錯誤處理&lt;/td&gt;
      &lt;td&gt;reply_message&lt;/td&gt;
      &lt;td&gt;replied flag 判斷 reply/push&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;分析與展望&quot;&gt;分析與展望&lt;/h2&gt;

&lt;p&gt;這次整合最讓我滿意的是&lt;strong&gt;幾乎不用改 Orchestrator 本身&lt;/strong&gt;。只要在輸入端把語音轉成文字，後面所有的路由邏輯、Agent 呼叫、錯誤處理全都自動繼承。&lt;/p&gt;

&lt;p&gt;Gemini 的多模態音訊理解在這個場景裡表現很穩——繁體中文、台語腔調、夾雜英文的句子基本上都能準確轉錄。&lt;/p&gt;

&lt;p&gt;未來可以延伸的方向：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;多語言自動偵測&lt;/strong&gt;：轉錄時告訴 Gemini 保留原語言，日文語音→日文轉錄，再由 Orchestrator 決定要不要翻譯&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;群組語音支援&lt;/strong&gt;：目前只限 1:1，群組的語音訊息暫時忽略&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;長錄音摘要&lt;/strong&gt;：超過一定長度的錄音直接走 ContentAgent 做摘要，而非當指令處理&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;延伸-朗讀摘要讓-bot-說話&quot;&gt;延伸：🔊 朗讀摘要——讓 Bot 說話&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;../images/預覽程式 2026-03-28 20.33.53.png&quot; alt=&quot;預覽程式 2026-03-28 20.33.53&quot; /&gt;&lt;/p&gt;

&lt;p&gt;語音辨識讓 Bot「聽懂」使用者說的話。這件事做完之後，自然就有了下一個問題：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Bot 能不能說話回應？&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Gemini Live API 有一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;response_modalities: [&quot;AUDIO&quot;]&lt;/code&gt; 的設定，可以直接輸出音訊 PCM 串流。我把它接上了另一個場景——&lt;strong&gt;朗讀摘要&lt;/strong&gt;。&lt;/p&gt;

&lt;h3 id=&quot;功能設計&quot;&gt;功能設計&lt;/h3&gt;

&lt;p&gt;每次 Bot 摘要完一個 URL、YouTube 或 PDF，訊息底下都會出現「🔊 朗讀」的 QuickReply 按鈕。使用者按下去，Bot 把摘要文字送進 Gemini Live TTS，把 PCM 音訊轉成 m4a，然後用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AudioSendMessage&lt;/code&gt; 傳回去。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;URL 摘要完成
    │
    ▼ [🔊 朗讀] QuickReply 按鈕
    │
用戶按下按鈕 → PostbackEvent
    │
    ▼ handle_read_aloud_postback()
    │
    ├─ ① 從 summary_store 取出摘要文字（10 分鐘 TTL）
    │
    ├─ ② Gemini Live API → PCM 音訊
    │       model: gemini-live-2.5-flash-native-audio
    │       response_modalities: [&quot;AUDIO&quot;]
    │
    ├─ ③ ffmpeg 轉檔：PCM → m4a
    │       s16le, 16kHz, mono → AAC
    │
    └─ ④ AudioSendMessage 傳給使用者
            original_content_url: /audio/{uuid}
            duration: {ms}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;核心程式碼toolstts_toolpy&quot;&gt;核心程式碼（tools/tts_tool.py）&lt;/h3&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;LIVE_MODEL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gemini-live-2.5-flash-native-audio&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;text_to_speech&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;tuple&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;vertexai&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VERTEX_PROJECT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;us-central1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;response_modalities&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;AUDIO&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;aio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;live&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LIVE_MODEL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;session&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;session&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;send_client_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;turns&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]),&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;turn_complete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;pcm_chunks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;session&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;receive&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;server_content&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;server_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model_turn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;server_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model_turn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inline_data&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inline_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                        &lt;span class=&quot;n&quot;&gt;pcm_chunks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inline_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;server_content&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;server_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;turn_complete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;break&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;pcm_bytes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pcm_chunks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;duration_ms&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pcm_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;32000&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 16kHz × 16-bit mono
&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# PCM → m4a（temp file 模式，避免 moov atom 問題）
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tempfile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NamedTemporaryFile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.pcm&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;write&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pcm_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;pcm_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;m4a_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pcm_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.pcm&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.m4a&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ffmpeg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-y&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-f&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;s16le&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-ar&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;16000&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-ac&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
         &lt;span class=&quot;s&quot;&gt;&quot;-i&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pcm_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-c:a&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;aac&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;m4a_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;capture_output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m4a_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;rb&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;read&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;duration_ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;朗讀功能踩的坑&quot;&gt;朗讀功能踩的坑&lt;/h2&gt;

&lt;h3 id=&quot;-坑-4模型名稱完全不同&quot;&gt;❌ 坑 4：模型名稱完全不同&lt;/h3&gt;

&lt;p&gt;Gemini Live TTS 的第一個嘗試是：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;LIVE_MODEL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gemini-3.1-flash-live-preview&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;照著語音辨識用的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.1-flash-lite-preview&lt;/code&gt; 推導的，結果直接 1008 policy violation：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Publisher Model `projects/line-vertex/locations/global/publishers/google/
models/gemini-3.1-flash-live-preview` was not found
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;列出 Vertex AI 可用模型才發現，Live/native audio 的模型命名規則完全不同：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# ✅ 正確
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LIVE_MODEL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gemini-live-2.5-flash-native-audio&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Gemini 3.1 在 Vertex AI 上&lt;strong&gt;沒有 Live 版本&lt;/strong&gt;。Live/native audio 功能目前是 2.5 世代，命名格式是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-live-{version}-{variant}-native-audio&lt;/code&gt;，跟一般模型的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-{version}-flash-{variant}&lt;/code&gt; 完全是兩套邏輯。&lt;/p&gt;

&lt;h3 id=&quot;-坑-5google_cloud_locationglobal-讓-live-api-失聯&quot;&gt;❌ 坑 5：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GOOGLE_CLOUD_LOCATION=global&lt;/code&gt; 讓 Live API 失聯&lt;/h3&gt;

&lt;p&gt;換了正確的模型名稱之後，錯誤訊息還是一樣：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Publisher Model `projects/line-vertex/locations/global/...` was not found
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這次 model 名稱正確了，但 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locations/global&lt;/code&gt; 很奇怪——我們明明設定了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;us-central1&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;追查 Google GenAI SDK 的原始碼發現：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# _api_client.py
&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;location&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;location&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;env_location&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;location&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;api_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;location&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;global&apos;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# ← 這裡
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;location or env_location&lt;/code&gt;——如果傳進去的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;location&lt;/code&gt; 是空字串，就會 fallback 到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;global&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;問題根源是 Cloud Run 的環境變數：&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;GOOGLE_CLOUD_LOCATION&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;global&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GOOGLE_CLOUD_LOCATION&lt;/code&gt; 被設成了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;global&quot;&lt;/code&gt; 字串。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;os.getenv(&quot;GOOGLE_CLOUD_LOCATION&quot;, &quot;us-central1&quot;)&lt;/code&gt; 拿到的不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;us-central1&quot;&lt;/code&gt;，而是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;global&quot;&lt;/code&gt;——然後 SDK 乖乖連到 global endpoint，但 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-live-2.5-flash-native-audio&lt;/code&gt; 在 global 沒有 BidiGenerateContent 支援。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Endpoint&lt;/th&gt;
      &lt;th&gt;標準 API&lt;/th&gt;
      &lt;th&gt;Live API&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;global&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;✅ 可用&lt;/td&gt;
      &lt;td&gt;❌ 模型不在這裡&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;us-central1&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;✅ 可用&lt;/td&gt;
      &lt;td&gt;✅ &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-live-2.5-flash-native-audio&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;解法：Live API 的 location 直接硬寫，不從 env var 讀：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# ❌ 受 GOOGLE_CLOUD_LOCATION=global 影響
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VERTEX_LOCATION&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GOOGLE_CLOUD_LOCATION&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;us-central1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# ✅ 硬寫，不受 env var 干擾
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VERTEX_LOCATION&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;us-central1&quot;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# Live API 需要 regional endpoint
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;語音辨識-vs-朗讀摘要&quot;&gt;語音辨識 vs 朗讀摘要&lt;/h2&gt;

&lt;p&gt;兩個功能用了完全不同的 Gemini API：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;語音辨識&lt;/th&gt;
      &lt;th&gt;朗讀摘要&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;方向&lt;/td&gt;
      &lt;td&gt;音訊 → 文字&lt;/td&gt;
      &lt;td&gt;文字 → 音訊&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;API&lt;/td&gt;
      &lt;td&gt;標準 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generate_content&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Live API &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BidiGenerateContent&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;模型&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.1-flash-lite-preview&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-live-2.5-flash-native-audio&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Location&lt;/td&gt;
      &lt;td&gt;跟著 env var&lt;/td&gt;
      &lt;td&gt;硬寫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;us-central1&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;輸出格式&lt;/td&gt;
      &lt;td&gt;text&lt;/td&gt;
      &lt;td&gt;PCM → ffmpeg → m4a&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;LINE 訊息類型&lt;/td&gt;
      &lt;td&gt;輸入：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AudioMessage&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;輸出：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AudioSendMessage&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;總結&quot;&gt;總結&lt;/h2&gt;

&lt;p&gt;Gemini 3.1 Flash Live 的發布讓音訊 AI 更值得認真對待。這次把語音辨識和朗讀摘要都接進了 LINE Bot：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;語音辨識&lt;/strong&gt;：標準 Gemini API，預錄 m4a 一次轉錄，接進現有 Orchestrator&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;朗讀摘要&lt;/strong&gt;：Gemini Live TTS，摘要文字轉 PCM，ffmpeg 轉 m4a，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AudioSendMessage&lt;/code&gt; 傳回&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;最麻煩的不是功能本身，而是&lt;strong&gt;找到正確的模型名稱&lt;/strong&gt;和&lt;strong&gt;定位 SDK 的 location 邏輯&lt;/strong&gt;——這兩個都沒有在文件顯眼的地方寫清楚，只能靠列出可用模型、讀 SDK 原始碼才找到答案。&lt;/p&gt;

&lt;p&gt;完整程式碼在 &lt;a href=&quot;https://github.com/kkdai/linebot-helper-python&quot;&gt;GitHub&lt;/a&gt;，歡迎參考。&lt;/p&gt;

&lt;p&gt;我們下次見！&lt;/p&gt;
</description>
				<pubDate>Sat, 28 Mar 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-flash-live-voice/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-flash-live-voice/</guid>
			</item>
		
			<item>
				<title>[Gemini] Tool Combo 實戰：在單次 API 呼叫中結合 Maps Grounding 與 Places API 打造 LINE 聚會地點小幫手</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260327164715459.png&quot; alt=&quot;image-20260327164715459&quot; /&gt;&lt;/p&gt;

&lt;p&gt;參考文章：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://blog.google/innovation-and-ai/technology/developers-tools/gemini-api-tooling-updates/&quot;&gt;Gemini API tooling updates: context circulation, tool combos and Maps grounding for Gemini 3&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.google.com/maps/documentation/places/web-service/nearby-search&quot;&gt;Google Places API (New) - searchNearby&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/kkdai/linebot-spot-finder&quot;&gt;GitHub: linebot-spot-finder&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;完整程式碼 &lt;a href=&quot;https://github.com/kkdai/linebot-spot-finder&quot;&gt;GitHub&lt;/a&gt; (聚會小幫手 LINE Bot Spot Finder)&lt;/li&gt;
&lt;/ul&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&gt;

&lt;p&gt;LINE Bot + Gemini 的組合已經很常見，不管是用 Google Search Grounding 讓模型查即時資訊，還是用 Function Calling 讓模型呼叫自訂邏輯，單獨使用都很成熟。&lt;/p&gt;

&lt;p&gt;但如果你想在&lt;strong&gt;同一個問題裡&lt;/strong&gt;同時做到「地圖定位情境」和「查詢真實評分」呢？&lt;/p&gt;

&lt;p&gt;以餐廳搜尋來說，傳統做法通常長這樣：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;用戶: &quot;幫我找附近評價4星以上的熱炒店&quot;

方案 A（只用 Maps Grounding）：
Gemini 有地圖情境，但評分資訊是 AI 自行描述，不保證準確。

方案 B（只用 Places API）：
可以拿到真實評分，但沒有地圖情境，Gemini 不知道用戶在哪裡。

要兩者兼得，通常需要分兩次 API 呼叫，或是自己手動串接。
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;AI 能查地圖、也能呼叫外部 API，但要在一次呼叫裡同時做到這兩件事&lt;/strong&gt;——在 Gemini API 的舊架構下一直是個尷尬的空白。&lt;/p&gt;

&lt;p&gt;直到 2026 年 3 月 17 日，Google 發布了 &lt;a href=&quot;https://blog.google/innovation-and-ai/technology/developers-tools/gemini-api-tooling-updates/&quot;&gt;Gemini API Tooling Updates&lt;/a&gt;（作者：Mariano Cocirio），這個問題才有了官方解法。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;什麼是-tool-combinations&quot;&gt;什麼是 Tool Combinations？&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260327163136077.png&quot; alt=&quot;image-20260327163136077&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Google 在這次&lt;a href=&quot;https://blog.google/innovation-and-ai/technology/developers-tools/gemini-api-tooling-updates/&quot;&gt;更新中宣布了&lt;/a&gt;三個核心功能：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Tool Combinations（工具組合）&lt;/strong&gt;
開發者現在可以在&lt;strong&gt;單次 Gemini API 呼叫&lt;/strong&gt;中同時掛上 built-in 工具（如 Google Search、Google Maps）以及自訂 Function Declarations。模型自行決定要呼叫哪個工具、何時呼叫，最後整合結果生成回答。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Maps Grounding&lt;/strong&gt;
Gemini 現在可以直接感知地圖資料，不再只是文字描述「位置」，而是真正具備空間情境——知道用戶在哪裡、附近有什麼。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Context Circulation&lt;/strong&gt;
讓多輪工具呼叫之間的情境能自然流通，模型在第二次呼叫時能完整記憶第一次的工具呼叫結果。&lt;/p&gt;

&lt;p&gt;這次改動的關鍵在於：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# 舊的做法（兩個工具不能並存）
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Tool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;google_search&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GoogleSearch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Tool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;function_declarations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MY_FN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 新的做法（同一個 Tool 物件，兩者共存）
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Tool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;google_maps&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GoogleMaps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;function_declarations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MY_FN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;一行改動，打開了全新的組合方式。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;專案目標&quot;&gt;專案目標&lt;/h2&gt;

&lt;p&gt;這次我用 Tool Combinations 改造了既有的 &lt;strong&gt;linebot-spot-finder&lt;/strong&gt;，讓它從「只能 Maps Grounding 粗略回答」升級到「Google Maps 情境 + Places API 真實資料」：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;用戶傳送 GPS 位置後輸入：「請找評價 4 顆星以上、適合多人聚餐的熱炒店，列出名稱、地址和評論摘要。」&lt;/p&gt;

  &lt;p&gt;Bot（舊版 Maps Grounding）：「附近有幾間熱炒店，評價都不錯。」（AI 自行描述，可能不準）&lt;/p&gt;

  &lt;p&gt;Bot（新版 Tool Combo）：「老王熱炒｜台北市信義區市民大道100號｜評分 4.6（312則）｜評論：份量大、CP值高，適合聚餐；服務效率高，上菜快。」&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;差別在於：Gemini 現在同時收到地圖情境（你在哪裡）和 Places API 的&lt;strong&gt;真實結構化資料&lt;/strong&gt;（評分數字、評論文字），回答因此從「模糊描述」變成「有根據的資訊」。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;架構設計&quot;&gt;架構設計&lt;/h2&gt;

&lt;h3 id=&quot;整體訊息流程&quot;&gt;整體訊息流程&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;LINE User 傳送 GPS 位置
    │
    ▼
handle_location()  →  session.metadata 儲存 lat/lng
    │
    └──► 回傳 Quick Reply（餐廳 / 加油站 / 停車場）

LINE User 傳送文字問題（e.g. &quot;找評價4星以上的熱炒店&quot;）
    │
    ▼
handle_text()
    │
    ├── session 有 lat/lng？
    │       是 → tool_combo_search(query, lat, lng)   ← 本文重點
    │       否 → fallback: Gemini Chat + Google Search
    │
    └──► 回傳自然語言答覆
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;tool-combo-agentic-loop&quot;&gt;Tool Combo Agentic Loop&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;tool_combo_search(query, lat, lng)
         │
         ▼
  Step 1: generate_content()
  tools = [google_maps + search_nearby_restaurants]
         │
         ▼
  response.candidates[0].content.parts 裡有 function_call？
       ╱                              ╲
      是                               否
      │                                │
      ▼                                ▼
  _execute_function()           直接回傳 response.text
  → _call_places_api()
    （Places API searchNearby）
    回傳評分、地址、評論
      │
      ▼
  收集成單一 Content(role=&quot;user&quot;)
  加入 history
      │
      ▼
  Step 3: generate_content(contents=history)
  Gemini 整合地圖情境 + Places 資料
      │
      ▼
  回傳 final.text
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;為什麼-latlng-不放在-function-declaration-裡&quot;&gt;為什麼 lat/lng 不放在 Function Declaration 裡？&lt;/h3&gt;

&lt;p&gt;這是設計上一個重要決策。&lt;/p&gt;

&lt;p&gt;如果把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lat&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lng&lt;/code&gt; 加進 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SEARCH_NEARBY_RESTAURANTS_FN&lt;/code&gt; 的 parameters，Gemini 會自己填入座標——但它填的是從對話推斷的「大概位置」，不是用戶實際 GPS 座標，誤差可能高達數公里。&lt;/p&gt;

&lt;p&gt;正確做法是讓 Python dispatcher 從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;session.metadata&lt;/code&gt; 取出精確座標，&lt;strong&gt;注入&lt;/strong&gt;進去：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_execute_function&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;search_nearby_restaurants&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_call_places_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# ← 從 session 注入，不讓 Gemini 猜
&lt;/span&gt;            &lt;span class=&quot;n&quot;&gt;keyword&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;keyword&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;min_rating&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;min_rating&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;4.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;核心程式碼詳解&quot;&gt;核心程式碼詳解&lt;/h2&gt;

&lt;h3 id=&quot;step-1定義-function-declaration&quot;&gt;Step 1：定義 Function Declaration&lt;/h3&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;SEARCH_NEARBY_RESTAURANTS_FN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FunctionDeclaration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;search_nearby_restaurants&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;用 Google Places API 搜尋附近餐廳，回傳評分、地址與用戶評論。&quot;&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;lat/lng 由系統自動帶入，不需要提供。&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;parameters&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OBJECT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;properties&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;keyword&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;STRING&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;餐廳類型或關鍵字，例如：熱炒、火鍋、義式&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;min_rating&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NUMBER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;最低評分門檻（1–5），預設 4.0&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;radius_m&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;INTEGER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;搜尋半徑（公尺），預設 1000&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;description 裡明確告訴模型「lat/lng 由系統帶入」，避免模型在 args 裡自己填座標。&lt;/p&gt;

&lt;h3 id=&quot;step-2places-api-呼叫&quot;&gt;Step 2：Places API 呼叫&lt;/h3&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;httpx&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;PLACES_API_URL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;https://places.googleapis.com/v1/places:searchNearby&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;PLACES_FIELD_MASK&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;places.displayName,&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;places.rating,&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;places.userRatingCount,&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;places.formattedAddress,&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;places.reviews&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_call_places_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;keyword&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;min_rating&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;4.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;radius_m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;includedTypes&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;restaurant&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;maxResultCount&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;locationRestriction&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;circle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;center&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;latitude&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;longitude&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;radiusMeters&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;radius_m&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;PLACES_API_URL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;X-Goog-Api-Key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GOOGLE_MAPS_API_KEY&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;X-Goog-FieldMask&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PLACES_FIELD_MASK&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;10.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raise_for_status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;restaurants&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;place&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;places&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;rating&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;place&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;rating&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rating&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;min_rating&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;reviews&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;place&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;reviews&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[])[:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{}).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;restaurants&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;place&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;displayName&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;address&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;place&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;formattedAddress&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;rating&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rating&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;rating_count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;place&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;userRatingCount&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;reviews&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;reviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;restaurants&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;restaurants&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;step-3tool-combo-主函式agentic-loop&quot;&gt;Step 3：Tool Combo 主函式（Agentic Loop）&lt;/h3&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;tool_combo_search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;vertexai&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GOOGLE_CLOUD_PROJECT&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GOOGLE_CLOUD_LOCATION&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;us-central1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;http_options&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HttpOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;api_version&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;v1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;enriched_query&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;用戶目前位置：緯度 &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;，經度 &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;。&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
        &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;請用台灣用語的繁體中文回答，不要使用 markdown 格式。&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
        &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;問題：&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;tool_config&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;tools&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Tool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;google_maps&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GoogleMaps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;                      &lt;span class=&quot;c1&quot;&gt;# ← Maps grounding
&lt;/span&gt;                &lt;span class=&quot;n&quot;&gt;function_declarations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SEARCH_NEARBY_RESTAURANTS_FN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# ← Places API
&lt;/span&gt;            &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;# ── Step 1 ──────────────────────────────────────────────────────
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TOOL_COMBO_MODEL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;enriched_query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tool_config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;candidates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;（無法取得回覆）&quot;&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;history&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;enriched_query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;candidates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;# ── Step 2：處理 function_call ──────────────────────────────────
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;function_response_parts&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;candidates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;function_call&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;fn&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;function_call&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_execute_function&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{}),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;function_response_parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;function_response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FunctionResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                        &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;function_response_parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;history&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;function_response_parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# ── Step 3 ────────────────────────────────────────────────────
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TOOL_COMBO_MODEL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;history&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tool_config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;final&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;（無法取得回覆）&quot;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;（無法取得回覆）&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;踩過的坑&quot;&gt;踩過的坑&lt;/h2&gt;

&lt;h3 id=&quot;-坑-1partfrom_function_response-不接受-id-參數&quot;&gt;❌ 坑 1：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Part.from_function_response()&lt;/code&gt; 不接受 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; 參數&lt;/h3&gt;

&lt;p&gt;這是這次最容易踩的坑，而且錯誤只在&lt;strong&gt;真實模型呼叫時&lt;/strong&gt;才會爆，單元測試幾乎不會發現。&lt;/p&gt;

&lt;p&gt;原本參考官方範例這樣寫：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# ❌ 錯誤——TypeError 在 runtime 發生
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_function_response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;       &lt;span class=&quot;c1&quot;&gt;# ← 這個參數不存在！
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fn_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;from_function_response&lt;/code&gt; 的實際簽名是：&lt;/p&gt;
&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Optional&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;完全沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; 參數。每次模型真的觸發 function_call，程式就會在這行噴 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TypeError&lt;/code&gt;，然後靜默進入 Step 3 的 except，回傳錯誤訊息，Places API 的結果從來沒有真正傳回給 Gemini。&lt;/p&gt;

&lt;p&gt;正確寫法是直接建構 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;types.FunctionResponse&lt;/code&gt;：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# ✅ 正確
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;function_response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FunctionResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fn_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;python -c &quot;from google.genai import types; help(types.Part.from_function_response)&quot;&lt;/code&gt; 可以立刻確認參數清單。&lt;/p&gt;

&lt;h3 id=&quot;-坑-2include_server_side_tool_invocationstrue-讓-pydantic-爆炸&quot;&gt;❌ 坑 2：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;include_server_side_tool_invocations=True&lt;/code&gt; 讓 Pydantic 爆炸&lt;/h3&gt;

&lt;p&gt;看到官方文件範例加了這個參數覺得應該加：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# ❌ 錯誤
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;tools&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[...],&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;include_server_side_tool_invocations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# ← 安裝的 SDK 版本不支援
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google-genai 1.49.0&lt;/code&gt; 這個欄位還不在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenerateContentConfig&lt;/code&gt; 的 model fields 裡，Pydantic 會直接噴 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;extra_forbidden&lt;/code&gt; 驗證錯誤。直接拿掉就好，功能完全正常。&lt;/p&gt;

&lt;h3 id=&quot;-坑-3textquery-是-searchtext-的參數不是-searchnearby-的&quot;&gt;❌ 坑 3：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;textQuery&lt;/code&gt; 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;searchText&lt;/code&gt; 的參數，不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;searchNearby&lt;/code&gt; 的&lt;/h3&gt;

&lt;p&gt;想說「有 keyword 就帶進 Places API」，直覺把它加進 request body：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# ❌ 錯誤——對 searchNearby endpoint 是無效欄位
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;keyword&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;textQuery&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;keyword&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;searchNearby&lt;/code&gt; 只接受 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;includedTypes&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locationRestriction&lt;/code&gt; 等欄位；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;textQuery&lt;/code&gt; 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;searchText&lt;/code&gt; endpoint 的參數。帶了這個欄位不會報錯（某些版本下），但 keyword 完全不生效。&lt;/p&gt;

&lt;p&gt;正確的做法是把 keyword 留在 Function Declaration 的 description 裡給 Gemini 參考，讓模型把意圖轉譯到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;enriched_query&lt;/code&gt; 上，讓 Maps Grounding 去處理關鍵字語意，Places API 只負責回傳真實評分資料。&lt;/p&gt;

&lt;h3 id=&quot;-坑-4responsecandidates0-沒有-guard&quot;&gt;❌ 坑 4：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;response.candidates[0]&lt;/code&gt; 沒有 guard&lt;/h3&gt;

&lt;p&gt;模型在遇到安全過濾、RECITATION、或其他非正常終止時，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;candidates&lt;/code&gt; 可能是空 list，這時直接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;response.candidates[0]&lt;/code&gt; 就是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IndexError&lt;/code&gt;。&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# ❌ 沒有 guard
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;history&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parts&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Part&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;enriched_query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;candidates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# ← 如果 candidates 是空的就爆
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# ✅ 加 guard
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;candidates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;（無法取得回覆）&quot;&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;history&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;demo-展示&quot;&gt;Demo 展示&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260327163200329.png&quot; alt=&quot;image-20260327163200329&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;場景-1找評價-4-顆星以上的聚餐熱炒店&quot;&gt;場景 1：「找評價 4 顆星以上的聚餐熱炒店」&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;用戶傳送：GPS 位置（台北市信義區，25.0441, 121.5598）

用戶輸入：「請找評價 4 顆星以上、適合多人聚餐的熱炒店，列出名稱、地址和評論摘要。」

[Step 1: Gemini 收到 query + 地圖情境]
  → 偵測到需要餐廳資料，emit function_call:
    search_nearby_restaurants(keyword=&quot;熱炒&quot;, min_rating=4.0)

[Step 2: Python 呼叫 Places API]
  → lat=25.0441, lng=121.5598 從 session 注入
  → 回傳 3 間評分 ≥ 4.0 的餐廳，含評論文字

[Step 3: Gemini 整合 Maps 情境 + Places 資料]
  → 「老王熱炒｜信義區市民大道100號｜⭐ 4.6（312則）
      評論摘要：份量大、CP值高，朋友聚餐首選；服務快，菜色新鮮。
     ...（共3間）」
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;場景-2有沒有-cp-值高的日式料理&quot;&gt;場景 2：「有沒有 CP 值高的日式料理？」&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;用戶輸入：「附近有沒有 CP 值高的日式料理？」

[Step 1: Gemini]
  → function_call: search_nearby_restaurants(keyword=&quot;日式料理&quot;, min_rating=4.0)

[Step 2: Places API]
  → 回傳 2 間評分符合的日本料理店

[Step 3: Gemini]
  → 「有兩間推薦：
      和食処○○｜...｜⭐ 4.4｜評論：平日午間定食只要280元，新鮮度很高。
      ...」
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;demo-script-快速測試&quot;&gt;Demo Script 快速測試&lt;/h3&gt;

&lt;p&gt;不需要 LINE Bot，直接在本機：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# 只測試 Tool Combo（主功能）&lt;/span&gt;
python demo.py combo

&lt;span class=&quot;c&quot;&gt;# 三個功能全跑&lt;/span&gt;
python demo.py all
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;舊架構-vs-新架構&quot;&gt;舊架構 vs 新架構&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;舊架構（Maps Grounding only）&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;新架構（Tool Combo）&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;工具&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google_maps&lt;/code&gt;（built-in）&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google_maps&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;search_nearby_restaurants&lt;/code&gt;（custom）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;評分資料&lt;/td&gt;
      &lt;td&gt;Gemini 自行描述（可能不準）&lt;/td&gt;
      &lt;td&gt;Places API 真實數字&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;評論&lt;/td&gt;
      &lt;td&gt;AI 生成&lt;/td&gt;
      &lt;td&gt;真實用戶評論（最多3則）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;API 呼叫次數&lt;/td&gt;
      &lt;td&gt;1次&lt;/td&gt;
      &lt;td&gt;1次（Step1）+ 1次（Step3）= 2次，但對用戶透明&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;準確度&lt;/td&gt;
      &lt;td&gt;中&lt;/td&gt;
      &lt;td&gt;高&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;自訂過濾&lt;/td&gt;
      &lt;td&gt;靠 prompt&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min_rating&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;radius_m&lt;/code&gt; 精確控制&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;分析與展望&quot;&gt;分析與展望&lt;/h2&gt;

&lt;p&gt;這次實作讓我對 Gemini Tool Combinations 的潛力有了更清楚的認識。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool Combinations 真正解決的問題&lt;/strong&gt;，是讓 Grounding 和 Function Calling 不再是二選一。以前要做「有地圖情境 + 有真實外部資料」，只能自己在應用層手動串兩次 API，或是用 Gemini 的文字生成去「模擬」外部資料（不可靠）。現在模型自己知道何時該用地圖情境、何時該呼叫 Places API，開發者只需要把工具掛上去。&lt;/p&gt;

&lt;p&gt;不過這次實作也有幾個值得注意的地方：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lat/lng&lt;/code&gt; 注入模式很重要&lt;/strong&gt;：不能讓模型自己猜座標，一定要從 session 注入，否則定位精度會很差。這個模式也適用於所有「有 session 狀態」的 function calling 場景。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;兩次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generate_content&lt;/code&gt; 的成本&lt;/strong&gt;：Tool Combo 的 agentic loop 需要兩次模型呼叫，token 消耗大約是單次的 1.5–2 倍。對低延遲要求高的場景要特別考量。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;SDK 版本差異&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google-genai&lt;/code&gt; 各版本對 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenerateContentConfig&lt;/code&gt; 的欄位支援不同，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;include_server_side_tool_invocations&lt;/code&gt; 這類新欄位加版本號確認再用，否則 Pydantic 驗證錯誤很難追。&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;未來可以延伸的方向：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;把 Postback 快速回覆（點「找餐廳」按鈕）也接上 Tool Combo，讓每個入口都能拿到真實評分&lt;/li&gt;
  &lt;li&gt;加入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;searchText&lt;/code&gt; endpoint 支援更複雜的關鍵字搜尋（e.g. 米其林推薦）&lt;/li&gt;
  &lt;li&gt;Tool Combo 搭配其他 built-in 工具（如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google_search&lt;/code&gt;）實現更複雜的多工具串接&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;總結&quot;&gt;總結&lt;/h2&gt;

&lt;p&gt;這次改動的核心概念只有一句話：&lt;strong&gt;把 Google Maps grounding 和 Places API function tool 掛在同一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;types.Tool&lt;/code&gt; 裡，Gemini 就會在單次對話裡自己協調兩者。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;關鍵程式只有這幾行：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# 這就是 Tool Combo 的全部魔法
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Tool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;google_maps&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GoogleMaps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;                       &lt;span class=&quot;c1&quot;&gt;# ← Maps 情境
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;function_declarations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SEARCH_NEARBY_RESTAURANTS_FN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# ← Places API
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;但要讓它真的 work，還需要注意：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FunctionResponse&lt;/code&gt; 的建構方式、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;candidates&lt;/code&gt; 的 guard、Places API endpoint 的正確欄位、以及 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lat/lng&lt;/code&gt; 從 session 注入而不是讓模型猜。&lt;/p&gt;

&lt;p&gt;完整程式碼在 &lt;a href=&quot;https://github.com/kkdai/linebot-spot-finder&quot;&gt;GitHub&lt;/a&gt;，歡迎 clone 來玩。&lt;/p&gt;

&lt;p&gt;我們下次見！&lt;/p&gt;
</description>
				<pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini3-flash-combo/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini3-flash-combo/</guid>
			</item>
		
			<item>
				<title>AI Agent 的安全性宣告：深入探索 A2AS (Agent-to-Agent Security) 憑證機制</title>
				<description>&lt;p&gt;&lt;img src=&quot;https://img.shields.io/badge/A2AS-CERTIFIED-f3af80&quot; alt=&quot;A2AS-CERTIFIED&quot; /&gt;&lt;/p&gt;

&lt;p&gt;參考連結：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://a2as.org&quot;&gt;A2AS.org 官方網站&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.a2as.org/certified/agents/kkdai/linebot-adk&quot;&gt;linebot-adk 專案認證頁面&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇文章記錄了我在維護 &lt;strong&gt;linebot-adk (LINE Bot Agent Development Kit)&lt;/strong&gt; 時，收到的一個有趣 Pull Request：為專案加上 &lt;strong&gt;A2AS 安全憑證&lt;/strong&gt;。這不只是一個 YAML 檔案，更是 2026 年 AI Agent 邁向「工業級安全」的重要里程碑。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;../images/Google Chrome 2026-03-26 22.45.44.png&quot; alt=&quot;Google Chrome 2026-03-26 22.45.44&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&gt;

&lt;p&gt;當我們在開發像 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linebot-adk&lt;/code&gt; 這樣具備 Tool Use (Function Calling) 能力的 Agent 時，使用者最擔心的問題往往是：「這個 Agent 會不會背著我亂下指令？」或是「它到底能存取哪些資料？」。&lt;/p&gt;

&lt;p&gt;傳統上我們只能在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;README.md&lt;/code&gt; 寫寫說明，但那是給人看的，不是給系統驗證的。這就是為什麼 &lt;strong&gt;A2AS (Agent-to-Agent Security)&lt;/strong&gt; 出現了——它被譽為「AI 界的 HTTPS」。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;️-第一步理解-a2as-的-basic-模型&quot;&gt;🛠️ 第一步：理解 A2AS 的 BASIC 模型&lt;/h2&gt;

&lt;p&gt;A2AS 不只是個名稱，它背後有一套完整的 &lt;strong&gt;BASIC 安全模型&lt;/strong&gt;，旨在解決 AI Agent 之間的信任問題：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;(B)ehavior Certificates&lt;/strong&gt;: 宣告式憑證，明確定義 Agent 的行為邊界。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;(A)uthenticated Prompts&lt;/strong&gt;: 確保提示詞的來源可信且具備追蹤性。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;(S)ecurity Boundaries&lt;/strong&gt;: 利用結構化標籤（如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;a2as:user&amp;gt;&lt;/code&gt;）隔離不受信任的輸入。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;(I)n-Context Defenses&lt;/strong&gt;: 在 Prompt 中嵌入防禦邏輯，拒絕惡意注入。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;(C)odified Policies&lt;/strong&gt;: 將業務規則寫成程式碼，在推論時強制執行。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;-第二步解構-a2asyamlagent-的身份證&quot;&gt;🎨 第二步：解構 a2as.yaml——Agent 的身份證&lt;/h2&gt;

&lt;p&gt;在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linebot-adk&lt;/code&gt; 收到 PR #1 中，最核心的變動就是新增了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a2as.yaml&lt;/code&gt;。這個檔案就像是 Agent 的「數位簽名」，將程式碼邏輯顯性化：&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;manifest&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;subject&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;kkdai/linebot-adk&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;scope&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;main.py&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;multi_tool_agent/agent.py&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;issued&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;by&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;A2AS.org&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;https://a2as.org/certified/agents/kkdai/linebot-adk&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;agents&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;root_agent&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;instance&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;tools&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;get_weather&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;get_current_time&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;為什麼這很重要&quot;&gt;為什麼這很重要？&lt;/h3&gt;
&lt;p&gt;這份憑證直接與我們的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.py&lt;/code&gt; 內容掛鉤。當憑證宣告了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools: [get_weather, get_current_time]&lt;/code&gt;，就代表這是一個&lt;strong&gt;有限授權&lt;/strong&gt;的 Agent。如果它試圖執行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delete_database&lt;/code&gt;，安全性監控系統就能立刻發現這超出了憑證範圍。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;-第三步結合程式碼邏輯&quot;&gt;🌐 第三步：結合程式碼邏輯&lt;/h2&gt;

&lt;p&gt;在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linebot-adk&lt;/code&gt; 中，我們使用了 Google 的 &lt;strong&gt;ADK (Agent Development Kit)&lt;/strong&gt; 來建構 Agent。A2AS 憑證能精準地映射我們的程式架構：&lt;/p&gt;

&lt;h3 id=&quot;1-工具宣告與實現&quot;&gt;1. 工具宣告與實現&lt;/h3&gt;
&lt;p&gt;在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;multi_tool_agent/agent.py&lt;/code&gt; 中，我們定義了兩個工具：&lt;/p&gt;
&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;get_weather&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;city&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# 實現獲取天氣的邏輯
&lt;/span&gt;    &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;get_current_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;city&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# 實現獲取時間的邏輯
&lt;/span&gt;    &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;A2AS 憑證會將這些 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;function&lt;/code&gt; 註冊在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools&lt;/code&gt; 區塊，確保 Agent 的能力邊界是透明且可審計的。&lt;/p&gt;

&lt;h3 id=&quot;2-runner-與執行循環&quot;&gt;2. Runner 與執行循環&lt;/h3&gt;
&lt;p&gt;在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.py&lt;/code&gt; 中，我們透過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Runner&lt;/code&gt; 來啟動 Agent：&lt;/p&gt;
&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;runner&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Runner&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;agent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;root_agent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;app_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;APP_NAME&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;session_service&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;session_service&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;憑證中的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;manifest.subject.scope&lt;/code&gt; 標註了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.py&lt;/code&gt;，這意味著整個啟動流程（包含 FastAPI 的 Webhook 處理）都在 A2AS 的合規範圍內。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;-第四步為什麼這是ai-界的-https&quot;&gt;🚀 第四步：為什麼這是「AI 界的 HTTPS」？&lt;/h2&gt;

&lt;p&gt;想像一下，如果你要讓一個「旅遊代理 Agent」去跟「飯店預約 Agent」對話。&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;沒有 A2AS&lt;/strong&gt;：旅遊 Agent 只能「盲目相信」飯店 Agent。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;有了 A2AS&lt;/strong&gt;：旅遊 Agent 可以先檢查對方的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a2as.yaml&lt;/code&gt; 憑證。如果對方宣稱有「修改訂單」的權限但憑證裡沒寫，旅遊 Agent 就可以拒絕交易。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這種 &lt;strong&gt;「先驗證，後執行」&lt;/strong&gt; 的模式，正是 A2AS 想要建立的信任網路。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;️-常見坑洞與故障排除&quot;&gt;🛠️ 常見坑洞與故障排除&lt;/h2&gt;

&lt;h3 id=&quot;-憑證過期或-commit-hash-不符怎麼辦&quot;&gt;❓ 憑證過期或 Commit Hash 不符怎麼辦？&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;原因：&lt;/strong&gt; A2AS 憑證是綁定特定 Git Commit 的。當你修改了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent.py&lt;/code&gt; 的邏輯但沒更新憑證，驗證就會失效。
&lt;strong&gt;修正：&lt;/strong&gt; 每次修改 Agent 的核心功能（如新增 Tool 或更換 Model）後，都必須重新產出並簽署 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a2as.yaml&lt;/code&gt;。&lt;/p&gt;

&lt;h3 id=&quot;-使用-a2as-會增加延遲嗎&quot;&gt;❓ 使用 A2AS 會增加延遲嗎？&lt;/h3&gt;
&lt;p&gt;不會。A2AS 主要是「宣告式」與「結構化」的規範。在推論階段，它是透過結構化標籤（BASIC 模型中的 S）來幫助 LLM 區分指令與資料，反而能減少模型因混淆而產生的幻覺，提升執行效率。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;-總結&quot;&gt;🏁 總結&lt;/h2&gt;

&lt;p&gt;透過這次 A2AS 憑證的導入，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linebot-adk&lt;/code&gt; 不再只是一個簡單的 LINE Bot 範例，它成為了一個符合 2026 年安全性標準的透明 Agent。在 AI 代理人逐漸滲透我們生活的時代，「透明」就是最好的防禦。&lt;/p&gt;

&lt;p&gt;如果你也在開發 AI Agent，不妨去 &lt;a href=&quot;https://a2as.org&quot;&gt;A2AS.org&lt;/a&gt; 看看，為你的專案加上那枚象徵信任的勳章。Happy Coding! 🦞&lt;/p&gt;
</description>
				<pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/what-is-a2as/</link>
				<guid isPermaLink="true">https://www.evanlin.com/what-is-a2as/</guid>
			</item>
		
	</channel>
</rss>
