<?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>[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>
		
			<item>
				<title>打造 Agent Skill Hub：從技能開發到 GitHub Pages 多國語言文件自動化部署</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260322225856161.png&quot; alt=&quot;image-20260322225856161&quot; /&gt;&lt;/p&gt;

&lt;p&gt;參考連結：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/kkdai/agent-skill-hub&quot;&gt;Agent Skill Hub 儲存庫&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/pages&quot;&gt;GitHub Pages 官方說明文件&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇文章記錄了我在開發 &lt;strong&gt;Agent Skill Hub (2026 技能庫)&lt;/strong&gt; 時，如何從零開始建構技能描述規範，並參考極簡美學打造出支援中英雙語的 GitHub Pages 文件站。&lt;/p&gt;

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

&lt;p&gt;隨著 AI Agent（如 OpenClaw 或 Gemini CLI）的普及，我們發現「如何讓 Agent 快速理解並執行特定任務」成為了關鍵。與其每次都寫長長的 Prompt，不如將常用的操作封裝成標準化的 &lt;strong&gt;Skills&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;為了方便社群交流與 Agent 讀取，我建立了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent-skill-hub&lt;/code&gt;。但只有程式碼是不夠的，我們還需要一個像樣的「門面」—— 一個既美觀又具備技術細節的文件網站。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;️-第一步標準化技能描述-skillmd&quot;&gt;🛠️ 第一步：標準化技能描述 (SKILL.md)&lt;/h2&gt;

&lt;p&gt;在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent-skill-hub&lt;/code&gt; 中，每個技能（如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcp-helper&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;n8n-executor&lt;/code&gt;）都擁有一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SKILL.md&lt;/code&gt;。這個檔案的結構至關重要，因為它不只是給人看的，更是給 LLM 讀取的：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Name &amp;amp; Description&lt;/strong&gt;: 讓 Agent 知道這是什麼。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;When to Use&lt;/strong&gt;: 定義觸發場景。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Core Pattern&lt;/strong&gt;: 提供標準指令範例。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Common Mistakes&lt;/strong&gt;: 減少 Agent 幻覺導致的錯誤。&lt;/li&gt;
&lt;/ul&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;docs&lt;/code&gt; 目錄下的網頁時，我參考了 &lt;strong&gt;whisperASR&lt;/strong&gt; 的風格。那種深色背景搭配亮眼點綴色（Teal）的設計，非常符合現代開發者的審美：&lt;/p&gt;

&lt;h3 id=&quot;視覺元素重點&quot;&gt;視覺元素重點：&lt;/h3&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;漸層標題&lt;/strong&gt;：利用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;linear-gradient&lt;/code&gt; 營造出高端的質感。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Teal 點綴色&lt;/strong&gt;：使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#14b8a6&lt;/code&gt; 作為關鍵按鈕與標題的強調色。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;卡片式佈局&lt;/strong&gt;：清楚呈現每個技能的圖示與簡介，具備良好的回應式設計（Responsive Design）。&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;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;docs/
├── index.html (語言偵測與導向)
├── en/ (英文版本)
│   └── skills/
└── zh/ (繁體中文版本)
    └── skills/
&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;index.html&lt;/code&gt; 加入了一段簡單的 JavaScript，會根據使用者的瀏覽器設定自動引導至正確的語系：&lt;/p&gt;

&lt;div class=&quot;language-javascript 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;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;lang&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;navigator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;language&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;navigator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;userLanguage&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;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;lang&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;zh&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&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;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;href&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;./zh/index.html&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&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;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;href&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;./en/index.html&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;-第四步github-pages-部署流程&quot;&gt;🚀 第四步：GitHub Pages 部署流程&lt;/h2&gt;

&lt;p&gt;在 2026 年，最推薦的部署方式是將內容放在主分支的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/&lt;/code&gt; 目錄下，這樣可以保持 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; 分支的整潔，同時讓開發與文件同步更新。&lt;/p&gt;

&lt;h3 id=&quot;1-準備目錄結構&quot;&gt;1. 準備目錄結構&lt;/h3&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;&lt;span class=&quot;nb&quot;&gt;mkdir&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; docs/en/skills docs/zh/skills docs/assets/css
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;2-git-提交與-push&quot;&gt;2. Git 提交與 Push&lt;/h3&gt;
&lt;p&gt;完成 HTML/CSS 開發後，執行標準的 Git 流程：&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 add docs/
git commit &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;docs: add GitHub Pages documentation in English and Chinese&quot;&lt;/span&gt;
git push origin main
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;3-開啟-github-pages-設定&quot;&gt;3. 開啟 GitHub Pages 設定&lt;/h3&gt;
&lt;ol&gt;
  &lt;li&gt;進入 GitHub 儲存庫的 &lt;strong&gt;Settings &amp;gt; Pages&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;在 &lt;strong&gt;Build and deployment&lt;/strong&gt; 下的 &lt;strong&gt;Branch&lt;/strong&gt;，選擇 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; 分支與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/docs&lt;/code&gt; 資料夾。&lt;/li&gt;
  &lt;li&gt;點擊 &lt;strong&gt;Save&lt;/strong&gt;，幾分鐘後網站就會上線。&lt;/li&gt;
&lt;/ol&gt;

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

&lt;hr /&gt;

&lt;h2 id=&quot;成果&quot;&gt;成果&lt;/h2&gt;

&lt;h5 id=&quot;web-app-httpswwwevanlincomagent-skill-hubzhindexhtml&quot;&gt;Web App: &lt;a href=&quot;https://www.evanlin.com/agent-skill-hub/zh/index.html&quot;&gt;https://www.evanlin.com/agent-skill-hub/zh/index.html&lt;/a&gt;&lt;/h5&gt;

&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href=&quot;https://github.com/kkdai/agent-skill-hub&quot;&gt;https://github.com/kkdai/agent-skill-hub&lt;/a&gt;&lt;/p&gt;

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

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

&lt;h3 id=&quot;-為什麼網頁樣式css載不出來&quot;&gt;❓ 為什麼網頁樣式（CSS）載不出來？&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;原因：&lt;/strong&gt; 在子目錄（如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;en/skills/&lt;/code&gt;）下的 HTML 檔案，引用的路徑必須正確使用相對路徑。
&lt;strong&gt;修正：&lt;/strong&gt;&lt;/p&gt;
&lt;div class=&quot;language-html 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;&amp;lt;!-- 在首頁 index.html --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;link&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;rel=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;stylesheet&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;href=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;../assets/css/style.css&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;&amp;lt;!-- 在技能詳情頁 --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;link&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;rel=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;stylesheet&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;href=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;../../assets/css/style.css&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;-如何確保-agent-能正確讀取文件&quot;&gt;❓ 如何確保 Agent 能正確讀取文件？&lt;/h3&gt;
&lt;p&gt;我們在 HTML 中保留了大量的語意化標籤（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;article&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;h2&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;code&lt;/code&gt;），這樣 Agent 在進行 RAG（檢索增強生成）或直接讀取網頁時，能更精準地抓取核心邏輯。&lt;/p&gt;

&lt;hr /&gt;

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

&lt;p&gt;透過這次開發，我體會到「文件即產品」的重要性。一個好的 AI 技能庫，除了強大的程式邏輯，更需要一個清晰、直覺且多國語言友好的導覽系統。&lt;/p&gt;

&lt;p&gt;如果你也想為你的 AI 專案打造專業的門面，不妨參考這次的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/&lt;/code&gt; 結構佈局。Happy Coding! 🦞&lt;/p&gt;

&lt;hr /&gt;
</description>
				<pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/agent-skill-hub-docs/</link>
				<guid isPermaLink="true">https://www.evanlin.com/agent-skill-hub-docs/</guid>
			</item>
		
			<item>
				<title>在 Google Cloud VM 部署 OpenClaw：避開 sudo-rs 權限與 NVM 路徑的技術坑</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/2026-02-28-openclaw-gcp.png&quot; alt=&quot;OpenClaw on GCP&quot; /&gt;
&lt;em&gt;(Image generated by &lt;a href=&quot;https://github.com/kkdai/nanobanana&quot;&gt;Nano Banana&lt;/a&gt; - Gemini Image Generation)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;參考文章：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://openclaw.ai/&quot;&gt;OpenClaw 官方網站&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://yu-wenhao.com/zh-TW/blog/openclaw-tools-skills-tutorial/&quot;&gt;OpenClaw 實戰教學：中文整理 FAQ 與建議 Skills&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://yu-wenhao.com/zh-TW/blog/2026-02-04-is-openclaw-safe-security-guide/&quot;&gt;OpenClaw 安全指南：資安加強建議&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://youtu.be/FC3Wo3ew130&quot;&gt;YouTube 教學影片：在 GCP 部署 OpenClaw&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇文章記錄了在 Google Cloud Platform (GCP) 的 Debian/Ubuntu 環境下，安裝 &lt;strong&gt;OpenClaw (2026 最新版)&lt;/strong&gt; 時遇到的權限、環境變數及進程持久化問題的完整解決流程。&lt;/p&gt;

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

&lt;p&gt;最近 AI Agent 領域非常熱門，&lt;strong&gt;OpenClaw&lt;/strong&gt; 作為一個能 24 小時運作的開源 AI 代理，其強大的系統存取與瀏覽能力讓人印象深刻。為了安全起見，將它部署在雲端 VM (如 GCP GCE) 是最理想的作法，既能保證 24/7 在線，又能隔離本機的敏感資料。&lt;/p&gt;

&lt;p&gt;但在 GCP 的預設 Debian/Ubuntu 環境中，由於權限機制與一般的 Desktop Linux 略有不同，照著官方腳本安裝往往會踩到不少坑。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;️-openclaw-在-gcp-的基本安裝流程&quot;&gt;🛠️ OpenClaw 在 GCP 的基本安裝流程&lt;/h2&gt;

&lt;p&gt;在進入故障排除之前，我們先快速跑一遍標準的安裝邏輯：&lt;/p&gt;

&lt;h3 id=&quot;1-建立-vm-執行個體&quot;&gt;1. 建立 VM 執行個體&lt;/h3&gt;
&lt;p&gt;在 GCP Console 建立一個新的 VM：&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;e2-small&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e2-medium&lt;/code&gt;（視您的 Agent 負載而定）。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;作業系統&lt;/strong&gt;：建議選用 &lt;strong&gt;Ubuntu 24.04 LTS&lt;/strong&gt; 或 &lt;strong&gt;Debian 12&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;硬碟&lt;/strong&gt;：建議 20GB 以上。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;2-連線與基礎更新&quot;&gt;2. 連線與基礎更新&lt;/h3&gt;
&lt;p&gt;透過 SSH 進入 VM 後，先執行系統更新：&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;sudo &lt;/span&gt;apt update &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt upgrade &lt;span class=&quot;nt&quot;&gt;-y&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-y&lt;/span&gt; git curl build-essential
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;3-正式安裝-openclaw&quot;&gt;3. 正式安裝 OpenClaw&lt;/h3&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;curl &lt;span class=&quot;nt&quot;&gt;-fsSL&lt;/span&gt; https://openclaw.ai/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;但是！&lt;/strong&gt; 如果您直接執行上述腳本，在 GCP 上通常會遇到以下兩個嚴重的權限與路徑問題。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;️-問題一sudo-rs-的hal-9000式拒絕&quot;&gt;🛠️ 問題一：sudo-rs 的「HAL 9000」式拒絕&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;現象：&lt;/strong&gt;
當執行官方安裝腳本時，遇到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo-rs&lt;/code&gt; 報錯：&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo-rs: I&apos;m sorry evanslin. I&apos;m afraid I can&apos;t do that&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;原因：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;互動限制&lt;/strong&gt;：透過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl ... | bash&lt;/code&gt; 執行的腳本無法在需要 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&lt;/code&gt; 時從終端機獲取密碼輸入。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;無密碼帳號&lt;/strong&gt;：GCP 預設使用 SSH Key 登入，使用者帳號通常沒有設定實體密碼，導致 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&lt;/code&gt; 認證失敗。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;解決方案：&lt;/strong&gt;
改用 &lt;strong&gt;NVM (Node Version Manager)&lt;/strong&gt; 安裝 Node.js，將環境建立在使用者目錄下，完全避開 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&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;c&quot;&gt;# 1. 安裝 NVM&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-o-&lt;/span&gt; https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

&lt;span class=&quot;c&quot;&gt;# 重新載入 shell 配置&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;source&lt;/span&gt; ~/.bashrc

&lt;span class=&quot;c&quot;&gt;# 2. 安裝 Node.js&lt;/span&gt;
nvm &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;node &lt;span class=&quot;c&quot;&gt;# 建議版本 v25.7.0+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;️-問題二nvm-路徑與環境變數&quot;&gt;🛠️ 問題二：NVM 路徑與環境變數&lt;/h2&gt;

&lt;p&gt;在使用 NVM 後，雖然避開了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&lt;/code&gt;，但新的問題隨之而來：當您重新登入或使用非互動式 Shell 執行命令時，系統可能找不到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;node&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openclaw&lt;/code&gt; 指令。&lt;/p&gt;

&lt;p&gt;這是因為 NVM 的路徑是動態載入的，建議在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.bashrc&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;export &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;NVM_DIR&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$HOME&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/.nvm&quot;&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;[&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$NVM_DIR&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/nvm.sh&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\.&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$NVM_DIR&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/nvm.sh&quot;&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;[&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$NVM_DIR&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/bash_completion&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\.&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$NVM_DIR&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/bash_completion&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;️-問題三如何讓-openclaw-247-穩定執行&quot;&gt;🛠️ 問題三：如何讓 OpenClaw 24/7 穩定執行？&lt;/h2&gt;

&lt;p&gt;安裝完成後，為了讓 Agent 在關閉 SSH 視窗後繼續運行，我從原本的 GCP Web SSH 轉向使用本機的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; CLI，但也因此發現了新的小坑。&lt;/p&gt;

&lt;h3 id=&quot;1-為什麼-gcloud-ssh-找不到-openclaw&quot;&gt;1. 為什麼 gcloud ssh 找不到 openclaw？&lt;/h3&gt;
&lt;p&gt;這通常是因為 GCP 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud compute ssh&lt;/code&gt; 可能會根據您的&lt;strong&gt;本機帳號名稱&lt;/strong&gt;建立一個新的使用者名稱，而不是延用您在 VM 上安裝時使用的帳號（例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;evanslin&lt;/code&gt;）。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;驗證方法：&lt;/strong&gt;
請在「網頁版 SSH」與「本機 gcloud SSH」兩個視窗分別輸入：&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;whoami&lt;/span&gt;
&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;evanslin&lt;/code&gt;，但 gcloud 版顯示的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;evan_lin_yourdomain_com&lt;/code&gt; 之類的名稱，那麼兩者的家目錄（Home Directory）路徑完全不同，您的 NVM 與 OpenClaw 設定當然會「消失」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;解決方案：&lt;/strong&gt;
在執行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&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 compute ssh evanslin@openclaw-evanlin
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;這樣就能確保您回到正確的環境！&lt;/p&gt;

&lt;h3 id=&quot;2-使用-tmux-與啟動腳本實現完美執行&quot;&gt;2. 使用 tmux 與啟動腳本實現完美執行&lt;/h3&gt;
&lt;p&gt;為了確保環境變數在任何 SSH 會話（網頁版或 gcloud 版）都能正確載入，並讓 OpenClaw 在背景穩定運行，建議採用以下「腳本化」的啟動方式。&lt;/p&gt;

&lt;h4 id=&quot;步驟-1建立啟動腳本&quot;&gt;步驟 1：建立啟動腳本&lt;/h4&gt;
&lt;p&gt;在您能正常執行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openclaw&lt;/code&gt; 的視窗（通常是網頁版 SSH）中，建立一個啟動腳本：&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;cat&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;EOF&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos; &amp;gt; ~/start_openclaw.sh
#!/bin/bash
# 1. 強制載入 NVM 路徑
export NVM_DIR=&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$HOME&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;/.nvm&quot;
[ -s &quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$NVM_DIR&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;/nvm.sh&quot; ] &amp;amp;&amp;amp; &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\.&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt; &quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$NVM_DIR&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;/nvm.sh&quot;

# 2. 自動修正 PATH (請根據您的 Node 版本調整路徑)
export PATH=&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$HOME&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;/.nvm/versions/node/v25.7.0/bin:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$PATH&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;

# 3. 執行指令
openclaw &quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$@&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;
&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;EOF

&lt;/span&gt;&lt;span class=&quot;c&quot;&gt;# 賦予執行權限&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;chmod&lt;/span&gt; +x ~/start_openclaw.sh
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;步驟-2驗證腳本&quot;&gt;步驟 2：驗證腳本&lt;/h4&gt;
&lt;p&gt;以後不論從哪裡登入，請統一使用此腳本。在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud ssh&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;~/start_openclaw.sh gateway
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;如果能成功跑起來，代表路徑已手動接通！&lt;/p&gt;

&lt;h4 id=&quot;步驟-3結合-tmux-解決斷線問題&quot;&gt;步驟 3：結合 tmux 解決斷線問題&lt;/h4&gt;
&lt;p&gt;現在我們把腳本跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tmux&lt;/code&gt; 結合，實現真正的 24/7 背景運行：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;開啟新會話&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tmux new -s openclaw&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;~/start_openclaw.sh gateway&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;完美分離 (Detach)&lt;/strong&gt;：按下 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl + B&lt;/code&gt; 後放開，再按下 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D&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;tmux a -t openclaw&lt;/code&gt; 即可。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;️-問題四更新到一半硬碟爆了怎麼辦&quot;&gt;🛠️ 問題四：更新到一半硬碟爆了怎麼辦？&lt;/h2&gt;

&lt;h3 id=&quot;-為什麼-openclaw-指令會突然消失&quot;&gt;❓ 為什麼 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openclaw&lt;/code&gt; 指令會突然消失？&lt;/h3&gt;

&lt;p&gt;如果你在執行過程中遇到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-bash: openclaw: command not found&lt;/code&gt;，通常有兩個原因：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;NVM 未載入&lt;/strong&gt;：NVM 安裝在使用者目錄下，非互動式 Shell 可能不會自動執行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nvm.sh&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;磁碟滿載導致設定損壞&lt;/strong&gt;：當磁碟 100% 時，系統可能無法讀取或寫入環境變數設定。&lt;/li&gt;
&lt;/ol&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;nb&quot;&gt;export &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;NVM_DIR&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$HOME&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/.nvm&quot;&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;[&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$NVM_DIR&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/nvm.sh&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\.&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$NVM_DIR&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/nvm.sh&quot;&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;export &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;PATH&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$HOME&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/.nvm/versions/node/v25.7.0/bin:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$PATH&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&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;ol&gt;
  &lt;li&gt;&lt;strong&gt;空間確認&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;df -h&lt;/code&gt; 看到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/dev/sda1&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;which openclaw&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;openclaw gateway&lt;/code&gt; 跑起來後，有沒有看到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Gateway is listening on port 18789&lt;/code&gt;？&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;如果以上都 OK，你的 AI Agent 就正式復活了！需要我幫你把這整段「復活流程」也補進你的 MD 檔案中嗎？&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;-為什麼改了-gcp-磁碟大小df--h-卻沒變&quot;&gt;💡 為什麼改了 GCP 磁碟大小，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;df -h&lt;/code&gt; 卻沒變？&lt;/h3&gt;

&lt;p&gt;在雲端控制台調整磁碟大小後，必須進入系統手動執行「磁碟擴張」兩部曲，空間才會真正釋放：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;擴展分割區&lt;/strong&gt;：使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;growpart&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;resize2fs&lt;/code&gt; 指令讓檔案系統填充剩餘空間。&lt;/li&gt;
&lt;/ol&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;nb&quot;&gt;sudo &lt;/span&gt;growpart /dev/sda 1
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;resize2fs /dev/sda1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;The AI service is temporarily overloaded&lt;/code&gt; 時，恭喜你，你的基礎設施（GCP, OpenClaw, Path）已經完全健康了！這剩下的 1% 屬於 AI 供應商的範疇。&lt;/p&gt;

&lt;h3 id=&quot;如何優化-ai-回應穩定性&quot;&gt;如何優化 AI 回應穩定性？&lt;/h3&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;備用模型&lt;/strong&gt;：在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;models configure&lt;/code&gt; 中設定多個模型，當 Pro 版過載時切換到 Flash 版。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;重試機制&lt;/strong&gt;：OpenClaw 內建了基本的重試邏輯，但遇到 Overload 時，最好的方式是「冷靜 30 秒」。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;區域選擇&lt;/strong&gt;：如果你使用 Vertex AI，可以嘗試切換不同的區域（Region），例如從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;us-central1&lt;/code&gt; 切換到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;asia-east1&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;結語&lt;/strong&gt;：從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo-rs&lt;/code&gt; 的拒絕、NVM 的迷蹤、GCP 磁碟的炸裂，到最後的 AI 過載，我們成功在雲端建立了一個強大的 AI Agent 基地。Happy Coding! 🦞&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;在 GCP 部署 OpenClaw 的關鍵在於 &lt;strong&gt;「使用者目錄優先」&lt;/strong&gt;。透過 NVM 避開系統層級的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo-rs&lt;/code&gt; 限制，不僅安裝過程更流暢，也能更方便地切換 Node.js 版本以符合 OpenClaw 的最新需求。&lt;/p&gt;

&lt;p&gt;部署成功後，別忘了使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openclaw onboard&lt;/code&gt; 開始配置您的 API Keys 與通訊頻道（如 Telegram 或 Discord）。&lt;/p&gt;

&lt;p&gt;希望這篇筆記能幫到同樣在 GCP 上奮鬥的開發者們。我們下次見！&lt;/p&gt;
</description>
				<pubDate>Sat, 28 Feb 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/install-openclaw-on-gcp/</link>
				<guid isPermaLink="true">https://www.evanlin.com/install-openclaw-on-gcp/</guid>
			</item>
		
			<item>
				<title>[Gemini] Multimodal Function Response 實戰：打造能「看圖說故事」的 LINE 電商客服機器人</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260225234804185.png&quot; alt=&quot;image-20260225234804185&quot; /&gt;&lt;/p&gt;

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

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

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/function-calling?hl=zh-tw#multimodal&quot;&gt;Gemini API - Function Calling with Multimodal&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/kkdai/linebot-gemini-multimodel-funcal&quot;&gt;GitHub: linebot-gemini-multimodel-funcal&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/function-calling#mm-fr&quot;&gt;Vertex AI - Multimodal Function Response&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;完整程式碼  &lt;a href=&quot;https://github.com/kkdai/linebot-gemini-multimodel-funcal&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;相信很多人都用過 LINE Bot + Function Calling 的組合。當用戶問「我上個月買了什麼衣服？」時，Bot 呼叫資料庫查詢函式，取回訂單資料，然後 Gemini 根據那份 JSON 回答：&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;幫我看看我之前買過的那件外套&quot;
Bot: [呼叫 get_order_history()]
函式回傳: {&quot;product_name&quot;: &quot;棕色飛行員外套&quot;, &quot;order_date&quot;: &quot;2026-01-15&quot;, ...}
Gemini: &quot;您在 1 月 15 日購買了棕色飛行員外套，金額 NT$1,890。&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;回答完全正確，但總覺得少了什麼——用戶說的是「那件外套」，Gemini 只是轉述 JSON 裡的文字，完全沒有辦法「確認」那件衣服長什麼樣子。如果資料庫裡剛好有三件外套，AI 根本無法判斷哪件才是用戶記憶中的那件。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI 能讀懂文字，但看不到圖片&lt;/strong&gt;——這個限制在傳統 Function Calling 架構下一直是死角。&lt;/p&gt;

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

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

&lt;p&gt;直到 Gemini 推出了 &lt;strong&gt;Multimodal Function Response&lt;/strong&gt;，這個問題才被真正解決。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;什麼是-multimodal-function-response&quot;&gt;什麼是 Multimodal Function Response？&lt;/h2&gt;

&lt;p&gt;傳統的 Function Calling 流程是這樣的：&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;[用戶訊息] → Gemini → [function_call] → [執行函式] → [回傳 JSON] → Gemini → [文字回答]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Multimodal Function Response&lt;/strong&gt; 改變了中間那一步。函式不只能回傳 JSON，還能在同一個回應中夾帶圖片（JPEG/PNG/WebP）或文件（PDF）：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;../images/Google Chrome 2026-02-25 23.04.28.png&quot; alt=&quot;Google Chrome 2026-02-25 23.04.28&quot; /&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;[用戶訊息] → Gemini → [function_call] → [執行函式] → [回傳 JSON + 圖片 bytes] → Gemini → [看過圖片的文字回答]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Gemini 在下一輪生成回答時，能同時「看到」函式回傳的結構化資料和圖片，從而生成更豐富、更精準的回應。&lt;/p&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;圖片&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image/jpeg&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image/png&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image/webp&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;application/pdf&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text/plain&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;這個功能的應用場景非常廣泛：電商客服（辨識商品圖片）、醫療諮詢（分析檢驗報告 PDF）、設計評審（看截圖給建議）……幾乎所有需要「函式回傳視覺資料給 AI 分析」的場景都適用。&lt;/p&gt;

&lt;hr /&gt;

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

&lt;p&gt;這次我用 Multimodal Function Response 打造了一個 &lt;strong&gt;LINE 電商客服機器人&lt;/strong&gt;，示範以下場景：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;用戶：「幫我看看我之前買過的那件外套」
Bot（傳統）：「您購買了棕色飛行員外套。」
Bot（Multimodal）：「從照片中可以看到這是一件棕色飛行員外套，輕量尼龍材質，側邊有金屬拉鏈裝飾口袋。這是您 1 月 15 日的訂單 ORD-2026-0115，共 NT$1,890，已送達。」+ &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;架構設計&quot;&gt;架構設計&lt;/h2&gt;

&lt;h3 id=&quot;為什麼不用-google-adk&quot;&gt;為什麼不用 Google ADK？&lt;/h3&gt;

&lt;p&gt;原本這個 repo 是用 Google ADK（Agent Development Kit）來管理 Agent，ADK 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Runner&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Agent&lt;/code&gt; 封裝了 Function Calling 的整個流程，非常方便。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;但 Multimodal Function Response 需要在函式回應的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;parts&lt;/code&gt; 裡手動夾帶圖片 bytes，ADK 在這一層完全封裝掉了，沒辦法插手。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;所以這次直接用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google.genai.Client&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;# 舊架構（ADK）
&lt;/span&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;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;event&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&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;run_async&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;# ADK 幫你處理所有 function call，但你無法控制回應內容
&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 新架構（直接用 google.genai）
&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;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;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;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;n&quot;&gt;ECOMMERCE_TOOLS&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;# 自己處理 function call，自己夾帶圖片
&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;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
    │
    ▼ POST /
FastAPI Webhook Handler
    │
    ▼
EcommerceAgent.process_message(text, line_user_id)
    │
    ├─ ① 呼叫 Gemini（帶入對話歷史）
    │
    ├─ ② Gemini 決定呼叫工具 → function_call
    │
    ├─ ③ _execute_tool()
    │       ├─ 執行查詢函式（search_products / get_order_history / get_product_details）
    │       └─ 讀取 img/ 目錄中的真實商品照片（Unsplash JPEG）
    │
    ├─ ④ 建構 Multimodal Function Response
    │       └─ FunctionResponsePart(inline_data=FunctionResponseBlob(data=image_bytes))
    │
    ├─ ⑤ 再次呼叫 Gemini（Gemini 看到圖片 + 資料）
    │
    └─ ⑥ 回傳 (ai_text, image_bytes)
    │
    ▼
LINE Reply:
  TextSendMessage(text=ai_text)
  ImageSendMessage(url=BOT_HOST_URL/images/{uuid})   ← FastAPI /images 端點提供
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;商品圖片怎麼來&quot;&gt;商品圖片怎麼來？&lt;/h3&gt;

&lt;p&gt;這個 demo 使用真實的 &lt;strong&gt;Unsplash 服飾攝影照片&lt;/strong&gt;，五件商品各對應一張實際拍攝的衣物照片，儲存在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;img/&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;generate_product_image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;product&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;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;s&quot;&gt;&quot;&quot;&quot;讀取商品圖片並返回 JPEG bytes。&quot;&quot;&quot;&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;product&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;image_path&quot;&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PRODUCTS_DB&lt;/code&gt; 裡每件商品都有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image_path&lt;/code&gt; 欄位指向對應的圖片檔案：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;商品 ID&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;P001&lt;/td&gt;
      &lt;td&gt;棕色飛行員外套&lt;/td&gt;
      &lt;td&gt;tobias-tullius-…-unsplash.jpg&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;P002&lt;/td&gt;
      &lt;td&gt;白色棉質大學T&lt;/td&gt;
      &lt;td&gt;mediamodifier-…-unsplash.jpg&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;P003&lt;/td&gt;
      &lt;td&gt;深藍色牛仔外套&lt;/td&gt;
      &lt;td&gt;caio-coelho-…-unsplash.jpg&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;P004&lt;/td&gt;
      &lt;td&gt;米白色針織披肩&lt;/td&gt;
      &lt;td&gt;milada-vigerova-…-unsplash.jpg&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;P005&lt;/td&gt;
      &lt;td&gt;淺藍色簡約T恤&lt;/td&gt;
      &lt;td&gt;cristofer-maximilian-…-unsplash.jpg&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;讀到的圖片 bytes 有兩個用途：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;作為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FunctionResponseBlob&lt;/code&gt; 夾帶給 Gemini 分析——真實照片讓 Gemini 能描述實際的布料質感、剪裁細節&lt;/li&gt;
  &lt;li&gt;暫存在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image_cache&lt;/code&gt; dict，透過 FastAPI &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/images/{uuid}&lt;/code&gt; 端點提供給 LINE Bot 顯示&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

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

&lt;h3 id=&quot;step-1定義工具functiondeclaration&quot;&gt;Step 1：定義工具（FunctionDeclaration）&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;ECOMMERCE_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;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;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;get_order_history&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;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;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;time_range&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;時間範圍：all / last_month / last_3_months&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                        &lt;span class=&quot;n&quot;&gt;enum&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;all&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;last_month&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;last_3_months&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;n&quot;&gt;required&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;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# ... search_products, get_product_details
&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;h3 id=&quot;step-2函式呼叫循環最多-5-次迭代&quot;&gt;Step 2：函式呼叫循環（最多 5 次迭代）&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;process_message&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;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;n&quot;&gt;line_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;contents&lt;/span&gt; &lt;span class=&quot;o&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;_get_history&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;line_user_id&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;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;p&quot;&gt;]&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_iteration&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;range&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;c1&quot;&gt;# 最多 5 次，防止無限循環
&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;bp&quot;&gt;self&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;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;bp&quot;&gt;self&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;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;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;system_instruction&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_SYSTEM_INSTRUCTION&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;n&quot;&gt;ECOMMERCE_TOOLS&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;model_content&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;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;n&quot;&gt;contents&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;model_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# 找出所有 function_call parts
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;fc_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;p&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model_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;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&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;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&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;name&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;fc_parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;# 沒有 function call → 最終文字回應
&lt;/span&gt;            &lt;span class=&quot;n&quot;&gt;final_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;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&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;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model_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;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&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;k&quot;&gt;break&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# 有 function call → 執行工具、夾帶圖片
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;tool_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;fc_part&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fc_parts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;result_dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image_bytes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_execute_tool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;fc_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;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;fc_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;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;line_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;tool_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;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_build_multimodal_response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fc_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;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result_dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image_bytes&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;contents&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;tool&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;tool_parts&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建構-multimodal-function-response最關鍵的步驟&quot;&gt;Step 3：建構 Multimodal Function Response（最關鍵的步驟）&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;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_build_multimodal_response&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;func_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result_dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;multimodal_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;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# ⚠️ 注意：這裡要用 FunctionResponseBlob，不是 types.Blob！
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;multimodal_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;FunctionResponsePart&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;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;FunctionResponseBlob&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;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# raw bytes，SDK 內部自動處理 base64
&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;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;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;func_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_dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# 結構化 JSON 資料
&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;multimodal_parts&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&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;# ← 圖片在這裡！Gemini 收到後能「看見」
&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;Gemini 在下一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generate_content&lt;/code&gt; 呼叫中，會同時接收到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;result_dict&lt;/code&gt;（訂單 JSON）和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image_bytes&lt;/code&gt;（商品圖片），生成的回答因此能描述圖片視覺內容。&lt;/p&gt;

&lt;h3 id=&quot;step-4line-bot-同時回傳文字--圖片&quot;&gt;Step 4：LINE Bot 同時回傳文字 + 圖片&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;c1&quot;&gt;# main.py
&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;ai_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image_bytes&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;ecommerce_agent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;process_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;msg_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line_user_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;reply_messages&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;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;ai_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;image_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;image_id&lt;/span&gt; &lt;span class=&quot;o&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;uuid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;image_cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_id&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;image_bytes&lt;/span&gt;              &lt;span class=&quot;c1&quot;&gt;# 暫存
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;image_url&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;BOT_HOST_URL&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/images/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_id&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;c1&quot;&gt;# FastAPI 提供服務
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;reply_messages&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;ImageSendMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;original_content_url&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;preview_image_url&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_url&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;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;get_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;n&quot;&gt;reply_messages&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;LINE Bot 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;reply_message&lt;/code&gt; 支援一次回傳多則訊息（最多 5 則），所以文字和圖片可以同時送出。&lt;/p&gt;

&lt;hr /&gt;

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

&lt;h3 id=&quot;-坑-1functionresponseblob-不是-blob&quot;&gt;❌ 坑 1：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FunctionResponseBlob&lt;/code&gt; 不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Blob&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;最容易踩的坑：建構多模態圖片部件時，&lt;strong&gt;不能用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;types.Blob&lt;/code&gt;&lt;/strong&gt;，要用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;types.FunctionResponseBlob&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;# ❌ 錯誤（會 TypeError）
&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;FunctionResponsePart&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;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;Blob&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;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_bytes&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;# ✅ 正確
&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;FunctionResponsePart&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;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;FunctionResponseBlob&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;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_bytes&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;mime_type&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data&lt;/code&gt; 欄位，但 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FunctionResponsePart&lt;/code&gt; 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;inline_data&lt;/code&gt; 欄位型別是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FunctionResponseBlob&lt;/code&gt;，Pydantic 驗證會直接拒絕 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Blob&lt;/code&gt;。用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;python -c &quot;from google.genai import types; print(types.FunctionResponsePart.model_fields)&quot;&lt;/code&gt; 就能確認。&lt;/p&gt;

&lt;h3 id=&quot;-坑-2aiohttpclientsession-不能在-module-level-建立&quot;&gt;❌ 坑 2：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aiohttp.ClientSession&lt;/code&gt; 不能在 module level 建立&lt;/h3&gt;

&lt;p&gt;原本的程式碼在模組層級直接建立 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aiohttp.ClientSession()&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;# ❌ 舊寫法：module level
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;session&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;aiohttp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ClientSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 如果沒有 running event loop 就會警告或出錯
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;async_http_client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AiohttpAsyncHttpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;在 pytest 測試中 import &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.py&lt;/code&gt; 時，因為還沒有 running event loop 就會出現 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RuntimeError: no running event loop&lt;/code&gt;。解法是改成 lazy initialization，第一次真正需要時才建立：&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;# ✅ 新寫法：lazy init
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_line_bot_api&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;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;get_line_bot_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;global&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_line_bot_api&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_line_bot_api&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&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;session&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;aiohttp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ClientSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 在 async route handler 內呼叫，保證有 event loop
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;_line_bot_api&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AsyncLineBotApi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel_access_token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AiohttpAsyncHttpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&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;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_line_bot_api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;-坑-3line-bot-發圖片需要-https-url&quot;&gt;❌ 坑 3：LINE Bot 發圖片需要 HTTPS URL&lt;/h3&gt;

&lt;p&gt;Gemini 收到的是 raw bytes，但 LINE Bot 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ImageSendMessage&lt;/code&gt; 需要 &lt;strong&gt;公開可訪問的 HTTPS URL&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;解法是在 FastAPI 加一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/images/{image_id}&lt;/code&gt; 端點，把讀取到的圖片 bytes 暫存在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image_cache&lt;/code&gt; dict，LINE 透過這個端點取圖：&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;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;app&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;/images/{image_id}&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;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;serve_image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_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;image_bytes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image_cache&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;image_id&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;image_bytes&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&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;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;404&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Image not found&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;Response&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;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;media_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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;本機開發用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ngrok&lt;/code&gt; 把 8000 port 暴露出去，Cloud Run 部署後直接用服務 URL。&lt;/p&gt;

&lt;hr /&gt;

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

&lt;h3 id=&quot;mock-資料庫demo-用預設資料&quot;&gt;Mock 資料庫（Demo 用預設資料）&lt;/h3&gt;

&lt;p&gt;系統內建 5 件商品（均附有真實 Unsplash 照片），每位 LINE 用戶第一次查詢訂單時，自動綁定兩筆 demo 訂單：&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;ORD-2026-0115&lt;/td&gt;
      &lt;td&gt;2026-01-15&lt;/td&gt;
      &lt;td&gt;P001 棕色飛行員外套&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ORD-2026-0108&lt;/td&gt;
      &lt;td&gt;2026-01-08&lt;/td&gt;
      &lt;td&gt;P003 深藍色牛仔外套&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&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;用戶傳送: &quot;幫我看看我之前買過的外套&quot;

[Gemini → function_call]
  get_order_history(time_range=&quot;all&quot;)

[_execute_tool 執行]
  - get_order_history() 回傳兩筆訂單（P001、P003）
  - 讀取 img/tobias-tullius-...-unsplash.jpg → 棕色飛行員外套真實照片 bytes

[Multimodal Function Response]
  Part.from_function_response(
    name=&quot;get_order_history&quot;,
    response={&quot;orders&quot;: [...], &quot;order_count&quot;: 2},
    parts=[FunctionResponsePart(inline_data=FunctionResponseBlob(data=&amp;lt;照片&amp;gt;))]
  )

[Gemini 看到真實照片後回應]
  &quot;從照片中可以看到這是一件棕色飛行員外套，輕量尼龍材質帶有
   光澤感，左袖有金屬拉鏈裝飾口袋。這是您 2026 年 1 月 15 日
   的訂單 ORD-2026-0115，共 NT$1,890，狀態：已送達。&quot;

LINE 顯示: [文字] + [棕色飛行員外套真實照片]
&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 → function_call]
  search_products(description=&quot;深藍色外套&quot;, color=&quot;深藍色&quot;)

[Gemini 看到 P003 深藍色牛仔外套真實照片後]
  &quot;有！照片中這件深藍色牛仔外套（P003）採復古縫線設計，
   翻領搭配金屬鈕扣，整體成衣感十足，售價 NT$1,490，庫存 8 件。&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;場景-3p004-的針織披肩有什麼特色&quot;&gt;場景 3：「P004 的針織披肩有什麼特色？」&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 → function_call]
  get_product_details(product_id=&quot;P004&quot;)

[Gemini 看到米白色針織披肩真實照片後]
  &quot;照片中是一件米白色手工鉤針編織披肩，V 領設計搭配底部流蘇，
   可以看到輕盈的蕾絲感網格編織紋路，質感優雅，售價 NT$1,290。&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;傳統-function-response-vs-multimodal-function-response&quot;&gt;傳統 Function Response vs Multimodal Function Response&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;Multimodal&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;純 JSON&lt;/td&gt;
      &lt;td&gt;JSON + 圖片/PDF bytes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Gemini 感知&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;API 差異&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Part.from_function_response(name, response)&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Part.from_function_response(name, response, parts=[...])&lt;/code&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;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

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

&lt;p&gt;這次實作讓我對 Gemini 的 Function Calling 能力有了新的認識。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multimodal Function Response 真正解決的問題&lt;/strong&gt;，是讓 AI 代理能夠在「呼叫外部系統」這個動作本身就帶入視覺資訊，而不是先查文字、再另外上傳圖片。這在電商、醫療、設計等視覺高度相關的領域，會是一個重要的基礎能力。&lt;/p&gt;

&lt;p&gt;不過目前還有幾個值得注意的限制：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;圖片 URL 不能直接用&lt;/strong&gt;：Gemini 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FunctionResponseBlob&lt;/code&gt; 需要 raw bytes，不能直接填 URL（這點跟直接在 prompt 裡帶圖片不同）。如果圖片本來就是 URL，需要先用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requests.get()&lt;/code&gt; 下載成 bytes 再傳入。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;沒有 display_name 也能用&lt;/strong&gt;：官方文件的範例有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;display_name&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$ref&lt;/code&gt; JSON reference，但實測在 google-genai 1.49.0 中，不填 display_name 也可以正常運作，Gemini 仍然能看到並分析圖片。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;模型限制&lt;/strong&gt;：官方標示支援 Gemini 3 系列，但 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-2.0-flash&lt;/code&gt; 在實測中也能正常處理，API 結構相同。&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;未來可以延伸的方向很多：讓用戶傳送自己的商品照片讓 Bot 比對、在函式回應中夾帶 PDF 型錄讓 Gemini 直接閱讀、或者在醫療場景中讓 Bot 分析函式從 DICOM 轉換出的報告圖片……只要能從外部系統取得視覺資料，Multimodal Function Response 都能讓 AI 的回答更有深度。&lt;/p&gt;

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

&lt;p&gt;這次的 LINE Bot 實作重點只有一句話：&lt;strong&gt;讓函式回應帶著圖片，Gemini 的回答就會從「轉述資料」升級成「看圖說故事」&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;核心 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;c1&quot;&gt;# Gemini 看到函式回傳圖片的完整寫法
&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;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;get_order_history&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;orders&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;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;FunctionResponsePart&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;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;FunctionResponseBlob&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# ← 不是 types.Blob！
&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;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;image_bytes&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;完整程式碼在 &lt;a href=&quot;https://github.com/kkdai/linebot-gemini-multimodel-funcal&quot;&gt;GitHub&lt;/a&gt;，歡迎 clone 來玩。&lt;/p&gt;

&lt;p&gt;我們下次見！&lt;/p&gt;
</description>
				<pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-multimodel-response/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-multimodel-response/</guid>
			</item>
		
			<item>
				<title>[Gemini CLI] Google Developer Knowledge API 與 MCP Server：為你的 AI 助手裝上官方知識庫</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/iTerm2 2026-02-08 01.24.09.png&quot; alt=&quot;iTerm2 2026-02-08 01.24.09&quot; /&gt;&lt;/p&gt;

&lt;p&gt;參考文章：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.googleblog.com/introducing-the-developer-knowledge-api-and-mcp-server/&quot;&gt;Introducing the Developer Knowledge API and MCP server&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.google.com/knowledge/mcp#claude-code&quot;&gt;Google Knowledge MCP Server&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.google.com/knowledge/reference/corpus-reference&quot;&gt;Developer Knowledge API Corpus Reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;還記得上週我用 Gemini CLI 寫 Gemini API 整合時，它信心滿滿地告訴我：「這個 API 參數是這樣用的」。結果執行後噴了一堆錯誤，原來 Google 三個月前就改了 API 格式。這不是 AI 的錯，它的訓練資料截止日期就在那裡，面對日新月異的技術文件，再強的模型也會「過時」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;過去我們遇到的典型場景：&lt;/strong&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;Gemini，幫我寫一個 Gemini Function Calling 的範例&quot;
AI: &quot;好的，你可以這樣寫...&quot; [產生基於 2024 年 6 月文件的程式碼]
開發者: [複製貼上，執行]
終端機: ❌ Error: Parameter &apos;tools&apos; format has changed in v2
開發者: 😤 &quot;又要去翻官網文件了...&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;../images/iTerm2 2026-02-08 12.52.42.png&quot; alt=&quot;iTerm2 2026-02-08 12.52.42&quot; /&gt;&lt;/p&gt;

&lt;p&gt;（以上就是一個範例， LLM 不清楚的部分只能去找網路。但是卻不能保證答案一定是正確且最新的用法）&lt;/p&gt;

&lt;p&gt;這樣的循環你是不是很熟悉？即便是 Gemini 1.5 Pro，有時也會因為自己的 API 更新太快而給出舊版建議。&lt;strong&gt;AI 的知識是靜態的，但技術文件是動態的&lt;/strong&gt;，這個矛盾一直困擾著我們。&lt;/p&gt;

&lt;p&gt;為了徹底解決這個問題，Google 在 2025 年初釋出了兩大殺手級工具：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Developer Knowledge API&lt;/strong&gt; - 機器可讀的官方文件 API&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Knowledge MCP Server&lt;/strong&gt; - 基於 Model Context Protocol 的即時文件查詢服務&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這意味著你的 AI 助手現在不再只是「憑記憶」寫程式，而是可以在需要時主動「翻閱最新官方文件」，成為一個真正擁有官方掛保證、永不過時的開發專家。&lt;/p&gt;

&lt;h2 id=&quot;什麼是-developer-knowledge-api&quot;&gt;什麼是 Developer Knowledge API？&lt;/h2&gt;

&lt;h3 id=&quot;過去-ai-學習文件的方式網頁爬蟲的困境&quot;&gt;過去 AI 學習文件的方式：網頁爬蟲的困境&lt;/h3&gt;

&lt;p&gt;傳統上，AI 模型是透過爬蟲抓取網頁來學習文件的。但這種方式有幾個致命問題：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ 雜訊干擾&lt;/strong&gt;&lt;/p&gt;
&lt;div class=&quot;language-html 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;&amp;lt;!-- AI 看到的實際內容 --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;nav&amp;gt;&lt;/span&gt;...&lt;span class=&quot;nt&quot;&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;  &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- 導覽列 --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;ad&amp;gt;&lt;/span&gt;...&lt;span class=&quot;nt&quot;&gt;&amp;lt;/ad&amp;gt;&lt;/span&gt;    &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- 廣告 --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;cookie-banner&amp;gt;&lt;/span&gt;...&lt;span class=&quot;nt&quot;&gt;&amp;lt;/cookie-banner&amp;gt;&lt;/span&gt;  &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- Cookie 提示 --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;content&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- 真正的文件內容只佔 30% --&amp;gt;&lt;/span&gt;
  這是 Gemini API 的使用方式...
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;footer&amp;gt;&lt;/span&gt;...&lt;span class=&quot;nt&quot;&gt;&amp;lt;/footer&amp;gt;&lt;/span&gt;  &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- 頁尾 --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;AI 必須從這堆 HTML 中「猜測」哪些才是真正的文件內容。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ 格式不一致&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;有些用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;code&amp;gt;&lt;/code&gt; 標籤，有些用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;pre&amp;gt;&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;有些用 Markdown 渲染，有些用自訂語法&lt;/li&gt;
  &lt;li&gt;圖片說明可能在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;alt&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;title&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;figcaption&lt;/code&gt; 裡&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;❌ 更新延遲&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;爬蟲可能幾個月才抓一次&lt;/li&gt;
  &lt;li&gt;新增的 API 參數要等下次訓練才知道&lt;/li&gt;
  &lt;li&gt;訓練資料截止日期成為永遠的痛&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;developer-knowledge-api機器優先的文件系統&quot;&gt;Developer Knowledge API：機器優先的文件系統&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Developer Knowledge API&lt;/strong&gt; 徹底改變了這個遊戲規則，它提供了：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;✅ 機器可讀的真理來源&lt;/strong&gt;：
    &lt;ul&gt;
      &lt;li&gt;直接提供純淨的 Markdown 格式&lt;/li&gt;
      &lt;li&gt;無雜訊、無廣告、無導覽列&lt;/li&gt;
      &lt;li&gt;結構化的 metadata（作者、更新時間、版本）&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;✅ 即時性&lt;/strong&gt;：
    &lt;ul&gt;
      &lt;li&gt;與 Google 官方文件&lt;strong&gt;同步更新&lt;/strong&gt;（延遲 &amp;lt; 1 小時）&lt;/li&gt;
      &lt;li&gt;API 改了，AI 就能立刻讀到新文件&lt;/li&gt;
      &lt;li&gt;永遠不會有「訓練資料過時」的問題&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;✅ 全面性&lt;/strong&gt;：
它能直接檢索並獲取以下 Google 官方網域的文檔，如果你的開發領域與這些相關，強烈建議開啟這個 MCP：
    &lt;ul&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ai.google.dev&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;developer.android.com&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;developer.chrome.com&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;developers.home.google.com&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;developers.google.com&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs.cloud.google.com&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs.apigee.com&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firebase.google.com&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fuchsia.dev&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;web.dev&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;www.tensorflow.org&lt;/code&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;mcp-server讓-ai-更有常識&quot;&gt;MCP Server：讓 AI 更有「常識」&lt;/h2&gt;

&lt;p&gt;Model Context Protocol (MCP) 是一個開放標準，它就像是 AI 工具的「外掛插槽」。Google 這次推出的 &lt;strong&gt;Knowledge MCP Server&lt;/strong&gt;，讓各種支援 MCP 的工具（如 Claude Code, Cursor, 甚至是我們最愛的 Gemini CLI）都能輕鬆整合。&lt;/p&gt;

&lt;p&gt;透過這個 MCP Server，AI 不再只是憑記憶寫 Code，而是可以針對特定問題去「翻書」：&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;：直接根據最新的 Error Code 文件進行診斷。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;版本比較&lt;/strong&gt;：了解不同版本 API 之間的差異。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果你對於特定領域的 MCP 應用感興趣，我也在之前的文章分享過 &lt;a href=&quot;https://www.evanlin.com/map-mcp-grounding/&quot;&gt;Google Maps Platform Assist MCP：讓 AI 幫你寫出更精準的地圖應用&lt;/a&gt;，這也是一個非常強大的工具，能讓 AI 助手在開發地圖功能時更具優勢。&lt;/p&gt;

&lt;h2 id=&quot;實戰讓-ai-助手導入官方知識庫&quot;&gt;實戰：讓 AI 助手導入官方知識庫&lt;/h2&gt;

&lt;p&gt;要讓 AI 助手能讀取官方文件，我們需要先在 Google Cloud 完成簡單的準備工作。&lt;/p&gt;

&lt;h3 id=&quot;第一步啟用-developer-knowledge-api&quot;&gt;第一步：啟用 Developer Knowledge API&lt;/h3&gt;
&lt;ol&gt;
  &lt;li&gt;前往 Google API 程式庫中的 &lt;a href=&quot;https://console.cloud.google.com/apis/library/knowledge.googleapis.com&quot;&gt;Developer Knowledge API 頁面&lt;/a&gt;。&lt;/li&gt;
  &lt;li&gt;確認你選擇了正確的專案。&lt;/li&gt;
  &lt;li&gt;點擊「&lt;strong&gt;啟用 (Enable)&lt;/strong&gt;」。這個 API 不需要特殊的 IAM 權限即可使用。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;第二步建立並保護你的-api-金鑰&quot;&gt;第二步：建立並保護你的 API 金鑰&lt;/h3&gt;
&lt;p&gt;為了確保安全，建議對金鑰進行限制：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;在 Google Cloud 控制台導覽至「&lt;strong&gt;憑證 (Credentials)&lt;/strong&gt;」頁面。&lt;/li&gt;
  &lt;li&gt;點擊「&lt;strong&gt;建立憑證&lt;/strong&gt;」，然後選擇「&lt;strong&gt;API 金鑰&lt;/strong&gt;」。&lt;/li&gt;
  &lt;li&gt;點擊「&lt;strong&gt;編輯 API 金鑰&lt;/strong&gt;」。&lt;/li&gt;
  &lt;li&gt;在名稱欄位輸入好辨識的名字（例如：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Dev-Knowledge-Key&lt;/code&gt;）。&lt;/li&gt;
  &lt;li&gt;在「API 限制」下，選擇「&lt;strong&gt;限制金鑰&lt;/strong&gt;」。&lt;/li&gt;
  &lt;li&gt;從 API 清單中選擇「&lt;strong&gt;Developer Knowledge API&lt;/strong&gt;」，然後點擊確定。&lt;/li&gt;
  &lt;li&gt;點擊「&lt;strong&gt;儲存&lt;/strong&gt;」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;建立完成後，點擊「顯示金鑰」並將其記下來，這就是我們接下來要使用的憑證。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;../images/Google Chrome 2026-02-07 20.52.15.png&quot; alt=&quot;Google Chrome 2026-02-07 20.52.15&quot; /&gt;&lt;/p&gt;

&lt;p&gt;如果你正在使用 &lt;strong&gt;Claude Code&lt;/strong&gt; 或 &lt;strong&gt;Gemini CLI&lt;/strong&gt;，現在只需要簡單的配置就能讓它變強。&lt;/p&gt;

&lt;h3 id=&quot;配置範例-以-gemini-cli-為例&quot;&gt;配置範例 (以 Gemini CLI 為例)&lt;/h3&gt;

&lt;p&gt;你只需要在設定中加入 Google 的 MCP Server 位址，並附上你的 API Key：&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;# 加入 Google Developer Knowledge MCP Server&lt;/span&gt;
gemini mcp add &lt;span class=&quot;nt&quot;&gt;-t&lt;/span&gt; http &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; google-developer-knowledge https://developerknowledge.googleapis.com/mcp &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;一旦設定完成，你在詢問「如何使用最新的 Gemini API 進行 Function Calling」時，AI 就會主動調用 MCP Server，去抓取目前官網上最準確、最即時的文件內容來回答你。&lt;/p&gt;

&lt;h2 id=&quot;分析與展望為什麼這很重要&quot;&gt;分析與展望：為什麼這很重要？&lt;/h2&gt;

&lt;p&gt;這項技術的推出，標誌著開發流程中的兩個重大轉變：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;從「依賴記憶」轉向「即時查詢」&lt;/strong&gt;
過去我們追求讓模型變得更大，記住更多東西。現在，我們透過 MCP 讓模型學會「查資料」。這不僅大幅減少了幻覺，也減輕了模型需要頻繁重新訓練的壓力。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;更強大的開發代理 (AI Agents)&lt;/strong&gt;
當 AI 助手能讀取文件、執行指令、並進行版本控制時，它就真正進化成了一個能獨立處理任務的「數位同僚」。Developer Knowledge API 提供的結構化資訊，正是 AI Agent 進行複雜推理所需的燃料。&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Google 這次不僅提供了強大的模型，更提供了優質的「數據接口」。對於追求效率的開發者來說，配置好 Developer Knowledge MCP Server 絕對是值得投資的 5 分鐘。&lt;/p&gt;

&lt;p&gt;以後寫 Code 時，AI 助手不再只是個會寫程式的機器，而是一個隨時翻閱著最新官網文件、給你最精準建議的技術顧問。還不趕快去申請一個 API Key 來試試看？&lt;/p&gt;

&lt;p&gt;我們下次見！&lt;/p&gt;
</description>
				<pubDate>Sat, 07 Feb 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-cli-developer-mcp/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-cli-developer-mcp/</guid>
			</item>
		
			<item>
				<title>[Recap] Google Developer Year-end 2025：Gemini 2025 新功能回顧與 LINE Bot 的完美結合</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260207202439911.png&quot; alt=&quot;image-20260207202439911&quot; /&gt;s&lt;/p&gt;

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

&lt;p&gt;昨天參加了 Google 舉辦的開發者尾牙聚會（Google Developer Year-end 2025），也順便參觀了 Google 板橋辦公室。很開心能以 LINE Taiwan Developer Relations 的身份，跟大家分享我在 2025 年一整年觀察到的 Gemini 技術演進心得。&lt;/p&gt;

&lt;p&gt;在熱門動畫《葬送的芙莉蓮》中，我很喜歡一級魔法使測驗篇的角色「尤蓓爾」。她有一個獨特的能力概念：「只要能想像切得開，就一定能切得開」。&lt;/p&gt;

&lt;p&gt;這句話完美呼應了現在的 AI 時代——&lt;strong&gt;想像力與理解力變得比以往更加重要&lt;/strong&gt;。如何「精準地想像出解決問題的方式」，成為了讓 AI 能精準協助你的關鍵。這篇文章將整理當天分享的 Gemini 2025 重點功能，以及我對「軟體工程師」在 AI 浪潮下核心能力的看法。&lt;/p&gt;

&lt;p&gt;### 2025 Gemini 功能演進回顧&lt;/p&gt;

&lt;p&gt;回顧 2025 年，Gemini 與 LINE Bot 的結合在多個時間點都有突破性的更新。以下是重新審視這一年來的技術節點：&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;strong&gt;2025.04&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Google ADK&lt;/td&gt;
      &lt;td&gt;Agent 與 Messaging API 的初步結合，展示了天氣查詢等基礎 Agent 應用。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2025.06&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Gemini CLI&lt;/td&gt;
      &lt;td&gt;開發者體驗大升級，直接在終端機與 AI 協作，進行檔案操作與程式撰寫。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2025.08&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Video Understanding&lt;/td&gt;
      &lt;td&gt;支援 YouTube 影片理解。透過 Gemini 2.5 直接抓取字幕與影像內容進行摘要與互動。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2025.11&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;File Search&lt;/td&gt;
      &lt;td&gt;強化檔案搜尋能力，支援 JSON、JS、PDF、Python 等多種格式的 RAG 應用。&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2025.12&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Map Grounding&lt;/td&gt;
      &lt;td&gt;結合 Google Maps Platform，讓 Bot 能回答「最近的地震資訊」或「附近的餐廳」等地理資訊問題。&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;#### 技術亮點詳解&lt;/p&gt;

&lt;p&gt;##### 1. Gemini CLI 與 Vibe Coding&lt;/p&gt;

&lt;iframe class=&quot;speakerdeck-iframe&quot; frameborder=&quot;0&quot; src=&quot;https://speakerdeck.com/player/d2de8583542d473baeda52a5b212eec6?slide=4&quot; title=&quot;Gemini 2025 新功能回顧 LINE Bot 完美結合&quot; allowfullscreen=&quot;true&quot; style=&quot;border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;&quot; data-ratio=&quot;1.7777777777777777&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;在六月推出的 Gemini CLI 改變了許多開發者的習慣。不僅僅是印出 “Hello World”，它整合了 Git、Gcloud 等工具。這帶出了一個新的開發概念：&lt;strong&gt;Vibe Coding&lt;/strong&gt;。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;定義&lt;/strong&gt;：這不僅是寫程式，而是透過 Gemini CLI、Vertex AI Studio 和 Antigravity 等工具，讓開發流程進入一種「心流」狀態。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;關鍵&lt;/strong&gt;：重點在於開發者如何指揮這些工具串接，而非手刻每一行代碼。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;##### 2. 視覺與地理資訊的整合 (Video &amp;amp; Map)&lt;/p&gt;

&lt;iframe class=&quot;speakerdeck-iframe&quot; frameborder=&quot;0&quot; src=&quot;https://speakerdeck.com/player/d2de8583542d473baeda52a5b212eec6?slide=7&quot; title=&quot;Gemini 2025 新功能回顧 LINE Bot 完美結合&quot; allowfullscreen=&quot;true&quot; style=&quot;border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;&quot; data-ratio=&quot;1.7777777777777777&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;八月的 Video Understanding 讓我們能直接丟入 YouTube 連結，Gemini 就能生成摘要甚至回答影片細節。到了年底的 Map Grounding，則是補足了 LLM 最缺乏的「實時地理資訊」。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;應用場景&lt;/strong&gt;：用戶問「找餐廳」，Bot 透過 Map Grounding 找出附近的 “CHILLAX” 或 “博感情” 等餐廳，並附上地址與類型。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;資料來源&lt;/strong&gt;：結合了 World Knowledge (Google Search) 與 Private Knowledge (Your Data/RAG)，讓回答更具落地性。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;### 重新審視：卓越人才的三大支柱&lt;/p&gt;

&lt;iframe class=&quot;speakerdeck-iframe&quot; frameborder=&quot;0&quot; src=&quot;https://speakerdeck.com/player/d2de8583542d473baeda52a5b212eec6?slide=11&quot; title=&quot;Gemini 2025 新功能回顧 LINE Bot 完美結合&quot; allowfullscreen=&quot;true&quot; style=&quot;border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;&quot; data-ratio=&quot;1.7777777777777777&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;在技術工具日新月異的同時，我也在思考，什麼樣的能力是 AI 無法取代的？就像前面提到的「尤蓓爾」的想像力，我認為卓越人才需要具備三大支柱：&lt;/p&gt;

&lt;p&gt;#### 1. AI 協作力 (AI Collaboration)&lt;/p&gt;

&lt;p&gt;這不僅僅是會用工具，而是具備 &lt;strong&gt;Prompt Engineering&lt;/strong&gt; 的能力。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;差異&lt;/strong&gt;：懂得如何與 AI 對話、引導 AI 產出結果的人，生產速度可以提升 10 倍。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;重點&lt;/strong&gt;：AI 是你的 Copilot，但你是機長。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;#### 2. 專業深度 (Domain Depth)&lt;/p&gt;

&lt;p&gt;在 AI 氾濫的時代，&lt;strong&gt;Domain Knowledge（領域知識）&lt;/strong&gt; 是你最強的護城河。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;價值&lt;/strong&gt;：AI 可以寫出語法正確的程式碼，但「解決複雜問題的經驗」與「對業務邏輯的深刻理解」，是 AI 難以模仿的。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;#### 3. 同理與創意 (Empathy &amp;amp; Creativity)&lt;/p&gt;

&lt;p&gt;將「熱情」轉化為人類特有的 &lt;strong&gt;批判性思考&lt;/strong&gt; 與 &lt;strong&gt;同理心&lt;/strong&gt;。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;核心&lt;/strong&gt;：這是建立人脈與管理決策的核心。AI 可以處理數據，但無法理解人的情感與需求背後的真正動機。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;### 結語&lt;/p&gt;

&lt;p&gt;這次的 Google Developer Year-end 讓我再次確認，系統的理解與對問題的認知，是軟體工程師最重要的能力。&lt;/p&gt;

&lt;p&gt;AI 時代下的開發，不再只是單純的 Coding，而是像設計 AP2 協議一樣，需要考慮整體的架構與安全性。若我們只是快速地 Vibe Coding 而忽略了底層原理（例如忘記考慮 Token 的時效性或資料的正確性），很容易就會產出有漏洞的系統。&lt;/p&gt;

&lt;p&gt;所以，保持對技術的好奇心，同時深化自己的領域知識，這才是我們證明 AI 無法取代我們的重要原因。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;投影片下載&lt;/strong&gt;： 有興趣的朋友可以參考當天的投影片： &lt;a href=&quot;https://speakerdeck.com/line_developers_tw/2025-features-recap-perfect-integration-linebot&quot;&gt;https://speakerdeck.com/line_developers_tw/2025-features-recap-perfect-integration-linebot&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(如果覺得有幫助，歡迎分享這篇文章！)&lt;/em&gt;&lt;/p&gt;
</description>
				<pubDate>Fri, 06 Feb 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/Google-developer-yearend-2025/</link>
				<guid isPermaLink="true">https://www.evanlin.com/Google-developer-yearend-2025/</guid>
			</item>
		
	</channel>
</rss>
