<?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>[AI 實戰] 用 Gemini 3.5 Transcribe 做跟讀評分：讓 Song Lingo 聽你念歌詞</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260928234100152.png&quot; alt=&quot;image-20260928234100152&quot; /&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;a href=&quot;https://www.evanlin.com/gemini-song-lingo/&quot;&gt;第一篇&lt;/a&gt;我用 Gemini 3.8 Flash TTS 做了 Song Lingo：貼 YouTube MV 網址，Gemini 轉錄歌詞、加上拼音翻譯文法，再由一位用 voice design 設計的老師逐句示範發音。&lt;a href=&quot;https://www.evanlin.com/gemini-song-lingo-gcp-deploy/&quot;&gt;第二篇&lt;/a&gt;把它部署到 Cloud Run，用 IAP 鎖到只有我自己能用。&lt;/p&gt;

&lt;p&gt;部署完我在 GitHub 開了一份 roadmap，排了三件事：手機版面、跟讀評分、單字卡。手機版面已經做完了，這篇講的是第二件。&lt;/p&gt;

&lt;p&gt;老師會示範，但我一直不知道自己念得對不對。&lt;strong&gt;聽十遍老師念，不如自己念一遍、被指出哪裡錯。&lt;/strong&gt;這是 Song Lingo 從「聽歌看歌詞」變成「練發音」的關鍵一步。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;gemini-35-transcribe-是什麼&quot;&gt;Gemini 3.5 Transcribe 是什麼&lt;/h1&gt;

&lt;p&gt;&lt;img src=&quot;../images/Google Chrome 2026-09-28 23.40.44.tiff&quot; alt=&quot;Google Chrome 2026-09-28 23.40.44&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Google 在 8/26 發布了 &lt;a href=&quot;https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-5-transcribe/&quot;&gt;Gemini 3.5 Transcribe&lt;/a&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-transcribe&lt;/code&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;gemini-3.5-transcribe-live&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;透過 Live API 的 WebSocket 即時串流，一邊講一邊出字&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;幾個重點功能：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;85 種以上語言&lt;/strong&gt;，會自動偵測，也能處理講到一半換語言的情況。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;逐字時間戳&lt;/strong&gt;：每個詞都有開始和結束時間（整段轉錄的版本才有）。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;講者分辨&lt;/strong&gt;：標出每句話是誰講的。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;自訂詞彙&lt;/strong&gt;：最多 1,000 個專有名詞，提高特殊用語的辨識率。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;兩種模式&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;smart&lt;/code&gt; 會處理說話時的自我修正、拿掉語助詞、自動排版；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verbatim&lt;/code&gt; 則是逐字照實轉錄。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;官方部落格引用 Artificial Analysis 的測量：整段轉錄的平均字錯誤率（WER）2.6%，串流版 4.0%；和前一代 Chirp 3 比，拿到最終轉錄結果的時間快了 70%。價格沒有公布。&lt;/p&gt;

&lt;p&gt;實際呼叫的方式和 TTS 一樣走 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;interactions&lt;/code&gt; API，但音檔要先用 Files API 上傳：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;uploaded&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;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;audio_path&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;mime_type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;audio/m4a&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;interaction&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;interactions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-3.5-transcribe&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;input&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;type&quot;&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;s&quot;&gt;&quot;uri&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uploaded&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uri&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;mime_type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uploaded&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;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;transcription_config&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;language_codes&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;ja-JP&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;mode&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;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;verbatim&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;timestamp_granularities&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;word&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;支援的格式很多，其中 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;audio/webm&lt;/code&gt; 剛好是 Chrome 錄音的格式，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;audio/m4a&lt;/code&gt; 則對應 iPhone Safari 錄出來的 AAC。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;選哪個模型&quot;&gt;選哪個模型&lt;/h1&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;th&gt;用在跟讀&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-transcribe&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;錄完整段再轉錄&lt;/td&gt;
      &lt;td&gt;✅ 有逐字時間戳，實測約 5 秒回來&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-transcribe-live&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;即時串流&lt;/td&gt;
      &lt;td&gt;一句歌詞只有幾秒，等念完再轉錄就夠了；要在 Cloud Run 和 IAP 後面維持 WebSocket 也麻煩得多&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.8-flash&lt;/code&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;p&gt;最後選了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-transcribe&lt;/code&gt;。專門的語音轉文字模型，結果穩定，有逐字資訊，而且每次只用大約 40 個 token，&lt;strong&gt;不占 TTS 那每天 100 次的額度&lt;/strong&gt;。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;先看真實回應再寫程式&quot;&gt;先看真實回應，再寫程式&lt;/h1&gt;

&lt;p&gt;第一篇最貴的一課是：TTS 的網頁版照著 Python SDK 的寫法讀 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_audio&lt;/code&gt;，結果 REST 回應裡根本沒有這個欄位，一個小時燒光一整天的額度。&lt;/p&gt;

&lt;p&gt;所以這次在 roadmap 的 issue 裡直接寫了一條規則：&lt;strong&gt;先用真實的 API 回應驗證解析邏輯，再上線。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;測試音檔用 macOS 內建的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;say&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;say &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; Kyoko &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; ok.aiff &lt;span class=&quot;s2&quot;&gt;&quot;今日は晴れです&quot;&lt;/span&gt;
afconvert &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; m4af &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; aac ok.aiff ok.m4a   &lt;span class=&quot;c&quot;&gt;# 和 iPhone 錄音一樣是 AAC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;然後攔截 SDK 送出與收到的原始 HTTP 內容，把回應的結構印出來。結果發現了兩件文件沒講清楚、照著寫就會出錯的事。&lt;/p&gt;

&lt;h2 id=&quot;output_text-又是-sdk-自己組的&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_text&lt;/code&gt; 又是 SDK 自己組的&lt;/h2&gt;

&lt;p&gt;文件說轉錄結果在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;interaction.output_text&lt;/code&gt;。實際的 REST 回應長這樣：&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;steps[] → { type: &quot;model_output&quot;,
            content[] → { type: &quot;text&quot;, text: &quot;今日は晴れです。&quot;,
                          annotations[] → { type: &quot;word_info&quot;, text: &quot;今日&quot;,
                                            start_index: 0, end_index: 6,
                                            start_offset: &quot;0.100s&quot;, end_offset: &quot;0.400s&quot; } } }
&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;output_text&lt;/code&gt; 和上次的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_audio&lt;/code&gt; 一樣，是 Python SDK 從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steps&lt;/code&gt; 裡組出來的便利欄位。這次在寫程式之前就知道了，所以直接從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steps[].content[]&lt;/code&gt; 讀。&lt;/p&gt;

&lt;h2 id=&quot;詞的位置是用-utf-8-位元組算的&quot;&gt;詞的位置是用 UTF-8 位元組算的&lt;/h2&gt;

&lt;p&gt;「今日」這個詞的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;start_index&lt;/code&gt; 是 0、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;end_index&lt;/code&gt; 是 6，不是 0 到 2。&lt;strong&gt;位置是用 UTF-8 的位元組計算的&lt;/strong&gt;，一個中日文字元佔 3 個位元組。如果照 JavaScript 字串的索引去切，日文會全部錯位。&lt;/p&gt;

&lt;p&gt;這兩件事都只有看過真實回應才會知道。這次花了兩次 API 呼叫，換來的是上線後不用再猜。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;怎麼判斷念對了&quot;&gt;怎麼判斷「念對了」&lt;/h1&gt;

&lt;h2 id=&quot;用-verbatim不用-smart&quot;&gt;用 verbatim，不用 smart&lt;/h2&gt;

&lt;p&gt;官方主打的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;smart&lt;/code&gt; 模式會處理說話時的自我修正、拿掉語助詞。對會議記錄來說這是優點，對跟讀卻是缺點：&lt;strong&gt;你念錯了又改口，smart 可能只留下改好的版本；你漏念了一個助詞，它可能幫你補得很通順。&lt;/strong&gt;跟讀要的是「你實際念了什麼」，所以用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verbatim&lt;/code&gt;。&lt;/p&gt;

&lt;h2 id=&quot;不用自訂詞彙&quot;&gt;不用自訂詞彙&lt;/h2&gt;

&lt;p&gt;把這句的原文設成自訂詞彙，看起來能提高辨識率，但它會讓模型&lt;strong&gt;偏向聽成正確答案&lt;/strong&gt;，反而抓不到念錯的地方。而且文件寫明，自訂詞彙和逐字時間戳不能同時使用。&lt;/p&gt;

&lt;h2 id=&quot;讀音和寫法都比&quot;&gt;讀音和寫法都比&lt;/h2&gt;

&lt;p&gt;轉錄結果可能寫成漢字「今日」，也可能寫成假名「きょう」，兩種都是念對。只比對文字的話，會把念對的判成錯。&lt;/p&gt;

&lt;p&gt;所以日文同時用兩種方式比對，&lt;strong&gt;任一方式對得上就算念對&lt;/strong&gt;：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;讀音&lt;/strong&gt;：把轉錄結果用 pykakasi 轉成平假名，和這句已經標註好的讀音比對。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;寫法&lt;/strong&gt;：直接比對文字，防止 pykakasi 把某個漢字念錯。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;比對本身用 Python 內建的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;difflib.SequenceMatcher&lt;/code&gt; 做字元對齊，再把結果對回每個詞。英文和韓文則以詞為單位比對。&lt;/p&gt;

&lt;h2 id=&quot;分清楚念錯和沒念到&quot;&gt;分清楚「念錯」和「沒念到」&lt;/h2&gt;

&lt;p&gt;第一版只看「這個詞的字有沒有對上」，結果「今日は雨です」的「晴れ」被標成&lt;strong&gt;沒念到&lt;/strong&gt;，但其實是&lt;strong&gt;念成了別的詞&lt;/strong&gt;。&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;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;全部念對，100 分&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;換掉一個詞&lt;/td&gt;
      &lt;td&gt;今日は雨です。&lt;/td&gt;
      &lt;td&gt;「晴れ」念錯，75 分&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;跳過中間的詞&lt;/td&gt;
      &lt;td&gt;今日はです&lt;/td&gt;
      &lt;td&gt;「晴れ」沒念到，75 分&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;念到一半&lt;/td&gt;
      &lt;td&gt;今日は&lt;/td&gt;
      &lt;td&gt;「晴れ」「です」沒念到，50 分&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;キョウハ　ハレデス！&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;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;這些都是不呼叫 API 的單元測試，每次修改比對邏輯都能馬上跑一遍。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;先講清楚限制&quot;&gt;先講清楚限制&lt;/h1&gt;

&lt;p&gt;語音轉文字檢查的是「&lt;strong&gt;別人聽不聽得出你念的是哪個詞&lt;/strong&gt;」，不是精細的發音評分：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;稍微不標準、但還聽得出是正確的詞，&lt;strong&gt;會被判成念對&lt;/strong&gt;。模型會往最可能的詞去猜，這是語音轉文字的本質。&lt;/li&gt;
  &lt;li&gt;長音、促音「っ」、音調高低這些細節，只有錯到變成另一個詞時才會被抓到。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;所以卡片上的說明直接寫著：「只檢查聽不聽得出是哪個詞，音調與長短音不在評分範圍」。之後如果想要更細的講評，可以另外把錄音和老師的示範音一起交給 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.8-flash&lt;/code&gt;，請它用文字說明，但那是另一個功能。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑一按鈕在麥克風還沒準備好時就說錄音中&quot;&gt;踩坑一：按鈕在麥克風還沒準備好時，就說「錄音中」&lt;/h1&gt;

&lt;p&gt;第一版的流程是：點「跟讀」→ 按鈕立刻變成「■ 停止並評分」→ 開始要求麥克風。&lt;/p&gt;

&lt;p&gt;問題在於&lt;strong&gt;第一次使用時，瀏覽器會跳出麥克風權限的詢問&lt;/strong&gt;。這時按鈕已經顯示「停止並評分」，但麥克風根本還沒開始錄。如果這時再按一次，程式會以為還沒開始，於是&lt;strong&gt;又要求一次麥克風&lt;/strong&gt;，而不是停止。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：多一個「準備麥克風中」的狀態。按下之後按鈕先顯示「準備麥克風…」並暫時不能按，&lt;strong&gt;等 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MediaRecorder&lt;/code&gt; 真的開始錄了，才切換成「停止並評分」&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;順帶一提，issue 裡原本寫的是「按住錄音、放開送出」，實作時改成「點一下開始、再點一下停止」，原因也是這個權限詢問：按住的話，手指一定會在按權限按鈕時放開，第一次永遠錄不到。再加上 iPhone 長按按鈕容易觸發文字選取，念一整句歌詞也要好幾秒，點兩下比按住好用。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑二mac-上的無頭-chrome-拿不到麥克風&quot;&gt;踩坑二：Mac 上的無頭 Chrome 拿不到麥克風&lt;/h1&gt;

&lt;p&gt;端對端測試我一直用 puppeteer 開無頭 Chrome。Chrome 有一組測試參數可以「拿一個音檔假裝成麥克風」：&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;--use-fake-ui-for-media-stream
--use-fake-device-for-media-stream
--use-file-for-fake-audio-capture=ok.wav
&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;getUserMedia&lt;/code&gt; 一直卡住不回應。換成 48kHz 的 wav、不指定音檔、預先授予麥克風權限，全部一樣。連最基本的假裝置都拿不到，看起來是 macOS 的麥克風權限機制擋住了無頭 Chrome。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：與其卡在測試環境，不如換一個切入點。在頁面載入前替換掉 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getUserMedia&lt;/code&gt;，把測試音檔解碼後用 Web Audio 播放，產生一條真的音訊串流：&lt;/p&gt;

&lt;div class=&quot;language-js 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;navigator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;mediaDevices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getUserMedia&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&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;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ctx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;AudioContext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;sampleRate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;48000&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;buffer&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;nx&quot;&gt;ctx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;decodeAudioData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;testWavBytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;buffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;src&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ctx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;createBufferSource&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;src&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;buffer&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;buffer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;dest&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ctx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;createMediaStreamDestination&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;src&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;src&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;start&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;nx&quot;&gt;dest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stream&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;MediaRecorder&lt;/code&gt; 錄成 webm/opus）、上傳、轉錄、比對、顯示結果整條流程都是真的。測試結果是 100 分、四個詞都是綠色，只送出一次評分請求，伺服器的暫存資料夾也沒有殘留檔案。&lt;/p&gt;

&lt;p&gt;踩坑一的 bug，也是在這個測試的第一個版本裡抓到的：按鈕顯示「停止並評分」，但 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MediaRecorder&lt;/code&gt; 從來沒有被建立過。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;隱私與額度&quot;&gt;隱私與額度&lt;/h1&gt;

&lt;p&gt;跟讀會碰到兩樣敏感的東西：&lt;strong&gt;我的錄音&lt;/strong&gt;，以及 &lt;strong&gt;API 額度&lt;/strong&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;finally&lt;/code&gt; 裡），不進 bucket，也不寫 log&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;不信任瀏覽器傳來的歌詞&lt;/td&gt;
      &lt;td&gt;伺服器依歌曲 ID 和句子編號，自己去讀這句的原文和讀音&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;不重複計費&lt;/td&gt;
      &lt;td&gt;同一時間只處理一次評分；SDK 的自動重試關掉&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;額度用完就停&lt;/td&gt;
      &lt;td&gt;收到 429 之後，在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Retry-After&lt;/code&gt; 指定的時間內直接拒絕，不再呼叫 Gemini&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;沒登入的人碰不到&lt;/td&gt;
      &lt;td&gt;跟讀的 API 一樣在 IAP 後面，沒登入的請求在 IAP 那一層就被擋下，連程式都進不來，也就不會花到額度&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;錄音送給 Gemini 轉錄是這個功能本身必要的，但在自己的服務這一側，錄音存在的時間只有那一次呼叫的幾秒鐘。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;順帶讓-pwa-在-iap-後面裝得起來&quot;&gt;順帶：讓 PWA 在 IAP 後面裝得起來&lt;/h1&gt;

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

&lt;p&gt;roadmap 的第一項是手機版面：MV 固定在上方、卡片可以左右滑動換句、拇指按得到的底部控制列，外加可以加入主畫面的 PWA。版面本身沒什麼特別，但 PWA 在 IAP 後面有一個坑值得記一下。&lt;/p&gt;

&lt;p&gt;瀏覽器抓 PWA 的設定檔（manifest）時，&lt;strong&gt;預設不帶 cookie&lt;/strong&gt;。在 IAP 後面，沒有 cookie 的請求會被導向 Google 登入頁，manifest 就讀不到，PWA 也就裝不起來。&lt;/p&gt;

&lt;p&gt;HTML 的解法是在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;link rel=&quot;manifest&quot;&amp;gt;&lt;/code&gt; 加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;crossorigin=&quot;use-credentials&quot;&lt;/code&gt;。但翻 Next.js 16 的原始碼才發現，它內建的 manifest 連結&lt;strong&gt;只有在 Vercel 的預覽環境才會加這個屬性&lt;/strong&gt;：&lt;/p&gt;

&lt;div class=&quot;language-js 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;nx&quot;&gt;crossOrigin&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;nx&quot;&gt;manifestOrigin&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;VERCEL_ENV&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;preview&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;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;use-credentials&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;kc&quot;&gt;undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：不用 Next 內建的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app/manifest.ts&lt;/code&gt;，改用一般的 route 提供 manifest，自己在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 放帶 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;use-credentials&lt;/code&gt; 的連結。manifest 裡的圖示也直接內嵌成 data URL，免得瀏覽器再發一次不帶 cookie 的請求。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;成果與效益&quot;&gt;成果與效益&lt;/h1&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;這次的 commit&lt;/td&gt;
      &lt;td&gt;2（手機版面與 PWA、跟讀評分）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;repo 總 commit&lt;/td&gt;
      &lt;td&gt;18&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;關閉的 issue&lt;/td&gt;
      &lt;td&gt;2 / 3（roadmap 剩單字卡）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;一次跟讀評分&lt;/td&gt;
      &lt;td&gt;約 9–10 秒（上傳約 2 秒、轉錄約 4 秒，其餘是啟動 Python）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;一次評分的用量&lt;/td&gt;
      &lt;td&gt;約 40 個 token，不占 TTS 額度&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;驗證 API 花掉的呼叫&lt;/td&gt;
      &lt;td&gt;5–6 次 transcribe&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;現在在手機上學一首歌的流程是：看卡片 → 聽老師念 → 按跟讀自己念 → 看哪個詞沒念對 → 聽自己的錄音和老師對照 → 滑到下一句。&lt;/p&gt;

&lt;h2 id=&quot;幾個我會帶走的東西&quot;&gt;幾個我會帶走的東西&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;「先看真實回應」要寫成規則，不能靠記得。&lt;/strong&gt;這次在 issue 裡明確寫了這條，開工前就先打了兩次 API。結果 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_text&lt;/code&gt; 和 UTF-8 位元組這兩件事，都在寫第一行程式之前就知道了。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;官方主打的模式，不一定適合你的用途。&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;smart&lt;/code&gt; 模式對大部分場景都是加分，但跟讀要的恰好是它會修掉的東西。選模式之前，先想清楚你要的是「整理好的結果」還是「實際發生的事」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;能幫你提高準確度的功能，可能正好在幫倒忙。&lt;/strong&gt;自訂詞彙會讓模型偏向正確答案，而評分需要的是能抓到錯誤。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UI 狀態要反映真實狀態，而不是你以為的狀態。&lt;/strong&gt;按鈕說「錄音中」的時候，麥克風其實還沒開，這種落差在第一次授權的那一刻最明顯，而那剛好是使用者最容易亂按的時候。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;測試環境卡住時，換一個切入點。&lt;/strong&gt;與其跟 macOS 的麥克風權限搏鬥，不如把「系統麥克風」這一小段替換掉，讓其他部分全部照真的跑。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;先講清楚能做到什麼。&lt;/strong&gt;語音轉文字檢查的是「聽不聽得懂」，不是發音評分。把這個限制直接寫在畫面上，比讓使用者以為拿了 100 分就代表發音完美誠實得多。&lt;/p&gt;

&lt;p&gt;程式碼在 &lt;a href=&quot;https://github.com/kkdai/song-lingo&quot;&gt;kkdai/song-lingo&lt;/a&gt;，跟讀評分的實作在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shadow.py&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;web/app/api/songs/[id]/lines/[index]/shadow/&lt;/code&gt;。官方資料：&lt;a href=&quot;https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-5-transcribe/&quot;&gt;Gemini 3.5 Transcribe 發布文章&lt;/a&gt;、&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/transcribe&quot;&gt;Transcribe 文件&lt;/a&gt;。&lt;/p&gt;
</description>
				<pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-song-lingo-stt/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-song-lingo-stt/</guid>
			</item>
		
			<item>
				<title>[AI 實戰] 把 Song Lingo 搬上 Cloud Run：讓一個放著完整歌詞的網站，真的只有我自己看得到</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260927012108201.png&quot; alt=&quot;image-20260927012108201&quot; /&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;a href=&quot;https://www.evanlin.com/gemini-song-lingo/&quot;&gt;上一篇&lt;/a&gt;我用 Gemini 3.8 Flash TTS 做了 Song Lingo：貼 YouTube MV 網址，Gemini 轉錄歌詞、加上拼音翻譯文法，再由一位用 voice design 設計出來的老師一句一句念給你聽。&lt;/p&gt;

&lt;p&gt;它一直只跑在我自己的電腦上，但我希望拿起手機就能用。所以這篇要做的事很單純：&lt;strong&gt;把它搬上 Cloud Run，讓手機也能用。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;但這個網站有一個特殊的地方：頁面上是完整的歌詞和翻譯。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;需求只有我看得到要多準確&quot;&gt;需求：「只有我看得到」要多準確&lt;/h1&gt;

&lt;p&gt;一般的 side project 部署上去，被別人看到頂多有點尷尬。Song Lingo 不一樣，公開有兩個實際的後果：&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;：加一首歌會呼叫 Gemini Flash，第一次播放示範音會呼叫 TTS。任何人都能用，就等於任何人都能花我的額度。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;所以目標不是「有登入功能」，而是&lt;strong&gt;從頭到尾每一層都確認過，只有我的帳號能碰到歌詞和 API&lt;/strong&gt;。&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;A. IAP + 程式內驗證&lt;/th&gt;
      &lt;th&gt;B. 不開放外部，用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run services proxy&lt;/code&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&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;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;p&gt;B 是最安全的，但手機不能用，而手機正是這次部署的理由。所以選 A。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;第一版設計的漏洞簽署網址&quot;&gt;第一版設計的漏洞：簽署網址&lt;/h1&gt;

&lt;p&gt;我最初的規劃是這樣：音檔放 GCS，播放時由 API 產生一個短時效的&lt;strong&gt;簽署網址（signed URL）&lt;/strong&gt;，把瀏覽器重新導向過去。好處是音檔不經過 Cloud Run，省流量，GCS 本身也支援 Range 請求。&lt;/p&gt;

&lt;p&gt;寫到一半重新想「怎樣才能準確地只有我看得到」時，才發現這是一個漏洞：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;簽署網址在有效期限內，任何拿到網址的人都能直接下載，完全不經過 IAP。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;它本質上是一張不記名的通行證。網址出現在瀏覽器歷史、被貼到哪裡、被某個擴充功能記下來，別人就能繞過前面所有的登入檢查。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：「只有我能存取」是一整條鏈，強度取決於最弱的那一環。最後的做法是&lt;strong&gt;不使用簽署網址&lt;/strong&gt;，音檔一律由 Cloud Run 讀出來再送給瀏覽器，每一次播放都要先通過 IAP 和程式內的驗證。一段音檔 250KB 左右，多繞一段路的流量成本可以忽略。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;架構&quot;&gt;架構&lt;/h1&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;一個映像同時裝 Node 22 和 uv/Python，Next.js 直接呼叫原本的 Python 腳本&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;歌曲資料與音檔&lt;/td&gt;
      &lt;td&gt;私人的 Cloud Storage bucket，&lt;strong&gt;掛載成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data&lt;/code&gt; 資料夾&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;API key&lt;/td&gt;
      &lt;td&gt;Secret Manager，以環境變數提供&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;存取控制&lt;/td&gt;
      &lt;td&gt;IAP + 程式內驗證 IAP 的簽章&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;執行個體&lt;/td&gt;
      &lt;td&gt;最多 1 個，最少 0 個；CPU 持續分配&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;幾個決定的理由：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;把 bucket 掛載成資料夾&lt;/strong&gt;，而不是改寫成呼叫 GCS API。程式原本就是讀寫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output/&lt;/code&gt; 資料夾，掛載之後只要把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SONG_DATA_DIR&lt;/code&gt; 指到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data&lt;/code&gt;，幾乎不用改程式。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;只跑 1 個執行個體&lt;/strong&gt;。避免重複產生音檔、額度用完時暫停呼叫、加入新歌的進度，這些狀態都存在記憶體裡；掛載的 bucket 也沒有跨執行個體的鎖定。個人使用，1 個就夠，而且這樣上一篇那些防止重複計費的機制才會有效。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CPU 持續分配&lt;/strong&gt;（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--no-cpu-throttling&lt;/code&gt;）。「加入新歌」是在回應送出後繼續在背景跑轉錄和分析的，Cloud Run 預設會在回應送出後限制 CPU，背景工作會卡住。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;四層防護&quot;&gt;四層防護&lt;/h1&gt;

&lt;p&gt;最後的存取控制是四層，任何一層單獨出錯，其他層還是擋得住：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Cloud Run 權限&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--no-allow-unauthenticated&lt;/code&gt;，只有 IAP 的服務帳號能呼叫這個服務。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;IAP&lt;/strong&gt;：只有被授予 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;roles/iap.httpsResourceAccessor&lt;/code&gt; 的帳號能通過，也就是只有我。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;程式內驗證&lt;/strong&gt;：每個請求都驗證 IAP 附上的簽章標頭，再比對 email。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;私人 bucket&lt;/strong&gt;：強制禁止公開存取，只有這個服務專用的服務帳號能讀寫。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;第三層看起來多餘，IAP 已經擋在前面了。但它防的是「IAP 那一層被設定錯」：有人不小心加了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--allow-unauthenticated&lt;/code&gt;、IAP 被關掉、或是 ingress 設定改了。這種事一旦發生，第三層就是最後一道門。&lt;/p&gt;

&lt;h2 id=&quot;在-nextjs-16-裡驗證-iap-簽章&quot;&gt;在 Next.js 16 裡驗證 IAP 簽章&lt;/h2&gt;

&lt;p&gt;Next.js 16 把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;middleware&lt;/code&gt; 改名成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;proxy.ts&lt;/code&gt;，預設跑在 Node.js 執行環境，剛好適合做這件事：&lt;/p&gt;

&lt;div class=&quot;language-ts 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;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;proxy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextRequest&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;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;K_SERVICE&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;nx&quot;&gt;NextResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&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;nx&quot;&gt;checkIapAssertion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;x-goog-iap-jwt-assertion&lt;/span&gt;&lt;span class=&quot;dl&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;na&quot;&gt;audience&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;IAP_AUDIENCE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;allowedEmails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ALLOWED_EMAILS&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;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ok&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;nx&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;warn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`[auth] rejected &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;nextUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;pathname&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;reason&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&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;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;reason&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;na&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;status&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;nx&quot;&gt;NextResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;next&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;ul&gt;
  &lt;li&gt;&lt;strong&gt;用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;K_SERVICE&lt;/code&gt; 判斷是不是在 Cloud Run 上&lt;/strong&gt;。這個環境變數是 Cloud Run 自動設定的，本機開發時不存在，所以本機完全不受影響，也不需要額外設一個「關閉驗證」的開關（那種開關遲早會被帶上線）。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;設定漏掉時一律拒絕&lt;/strong&gt;。如果 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IAP_AUDIENCE&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALLOWED_EMAILS&lt;/code&gt; 沒設定，所有請求回傳 500。設定出錯的結果是「網站打不開」，而不是「網站對外開放」。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;驗證本身照 &lt;a href=&quot;https://cloud.google.com/iap/docs/signed-headers-howto&quot;&gt;IAP 的文件&lt;/a&gt;：ES256 簽章、issuer 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://cloud.google.com/iap&lt;/code&gt;、audience 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/projects/專案編號/locations/區域/services/服務名稱&lt;/code&gt;，公鑰從 Google 的 JWK 端點抓。&lt;/p&gt;

&lt;h2 id=&quot;上線前怎麼測&quot;&gt;上線前怎麼測&lt;/h2&gt;

&lt;p&gt;沒有真的 IAP 可以打，所以把金鑰來源做成可以替換的參數，在本機用自己產生的 ES256 金鑰測了 12 種情況：&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;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;其他帳號、沒有 email&lt;/td&gt;
      &lt;td&gt;403&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;錯誤的 audience、錯誤的 issuer、偽造的簽章、過期、亂填、沒帶標頭&lt;/td&gt;
      &lt;td&gt;401&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;沒設定 audience、允許清單是空的&lt;/td&gt;
      &lt;td&gt;500&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;K_SERVICE&lt;/code&gt;）&lt;/td&gt;
      &lt;td&gt;全部 200&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Run 上，但忘了設定&lt;/td&gt;
      &lt;td&gt;全部 500&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Run 上，設定完整，但沒帶標頭或帶偽造的標頭&lt;/td&gt;
      &lt;td&gt;全部 401&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑一另一個-claude-視窗已經先做了一半&quot;&gt;踩坑一：另一個 Claude 視窗已經先做了一半&lt;/h1&gt;

&lt;p&gt;寫程式寫到一半，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git status&lt;/code&gt; 出現兩個我沒建立的檔案：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Dockerfile&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.dockerignore&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;原來是我不小心開了兩個 Claude Code 視窗，兩邊都在討論 song-lingo，另一邊也聊到了部署，已經寫好了 Dockerfile，用的還是跟我不同的做法：&lt;strong&gt;把 GCS 掛載成資料夾&lt;/strong&gt;，而我這邊正在寫一整層儲存抽象，把所有讀寫改成呼叫 GCS API。&lt;/p&gt;

&lt;p&gt;比較之後，掛載的做法明顯比較好：幾乎不用改程式，而我那一層抽象唯一的優勢（寫入時的版本檢查）在只有一個執行個體、只有一個使用者的情況下用不太到。所以我刪掉了自己寫的那層，改用掛載。&lt;/p&gt;

&lt;p&gt;事情還沒完。部署完之後我發現服務已經是&lt;strong&gt;第 2 個 revision&lt;/strong&gt;，第 1 個是當天稍早建立的，而且&lt;strong&gt;已經開了 IAP&lt;/strong&gt;。那個視窗不只寫了 Dockerfile，還真的部署過一次。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：兩個 agent 在同一個 repo、同一個 GCP 專案上各做各的，彼此不知道對方的存在。這次沒出事，是因為動手前有先看 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git status&lt;/code&gt;，部署後有去查 revision 清單和服務的設定，而不是假設「我是第一個」。之後我的習慣是：&lt;strong&gt;同一件事只在一個視窗裡做&lt;/strong&gt;，開始部署前先查一次雲端上已經有什麼。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑二個人-gmail-的專案iap-會對每個請求回-502&quot;&gt;踩坑二：個人 Gmail 的專案，IAP 會對每個請求回 502&lt;/h1&gt;

&lt;p&gt;部署完、開了 IAP，打開網址，得到的是 502。&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;HTTP/2 502
x-goog-iap-generated-response: true

Empty Google Account OAuth client ID(s)/secret(s).
&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;x-goog-iap-generated-response: true&lt;/code&gt; 代表&lt;strong&gt;這個錯誤是 IAP 自己回的&lt;/strong&gt;，請求根本沒到我的程式。訊息是 IAP 沒有 OAuth client 可以用。&lt;/p&gt;

&lt;p&gt;原因是我的專案&lt;strong&gt;不屬於任何組織&lt;/strong&gt;，是用個人 Gmail 建的。IAP 預設使用 Google 管理的 OAuth client，而那個 client 只支援組織內的帳號。這種專案一定要自己建一個 OAuth client：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Google Auth Platform&lt;/strong&gt;（OAuth 同意畫面）：目標對象是 External。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;憑證 → 建立 OAuth 用戶端 ID → 網頁應用程式&lt;/strong&gt;，重新導向 URI 填 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://iap.googleapis.com/v1/oauth/clientIds/用戶端ID:handleRedirect&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud iap settings set&lt;/code&gt; 把 client ID 和密鑰套用到這個服務。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;第 3 步我是在自己的終端機執行，而不是在 Claude Code 的對話裡跑，這樣用戶端密鑰不會出現在任何對話紀錄裡。&lt;/p&gt;

&lt;h2 id=&quot;同意畫面已經公開了怎麼辦&quot;&gt;同意畫面已經公開了怎麼辦&lt;/h2&gt;

&lt;p&gt;我的專案裡放了很多其他服務，OAuth 同意畫面早就因為別的應用程式設成公開了。我第一個反應是「公開的話，是不是任何人都能登入？要不要切回測試模式？」&lt;/p&gt;

&lt;p&gt;答案是&lt;strong&gt;不用改，也不應該改&lt;/strong&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;th&gt;公開後的影響&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;OAuth 同意畫面&lt;/td&gt;
      &lt;td&gt;確認「你是哪一個 Google 帳號」&lt;/td&gt;
      &lt;td&gt;任何帳號都能完成登入這一步&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;IAP 存取權限&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;再確認一次簽章與 email&lt;/td&gt;
      &lt;td&gt;不受影響&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;同意畫面只負責「認出你是誰」，真正決定「你能不能進來」的是後面兩層。切回測試模式反而會影響共用同一個同意畫面的其他服務：只有測試使用者能登入，授權大約 7 天就失效。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：看到 502 先看回應標頭，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x-goog-iap-generated-response&lt;/code&gt; 能直接告訴你問題在 IAP 還是在你的程式。個人 Gmail 的專案要自己建 OAuth client；同意畫面是否公開，不影響誰能使用服務。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑三確保-env-和歌詞不會被上傳&quot;&gt;踩坑三：確保 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt; 和歌詞不會被上傳&lt;/h1&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run deploy --source .&lt;/code&gt; 會把整個資料夾上傳到 Cloud Build。本機的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt; 有 API key，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output/&lt;/code&gt; 有所有歌詞，這兩個絕對不能上傳。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 在沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gcloudignore&lt;/code&gt; 時會改用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitignore&lt;/code&gt;，而這兩個本來就在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitignore&lt;/code&gt; 裡。但「應該會被排除」不夠，所以我明確寫了一份 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gcloudignore&lt;/code&gt;，再用 gcloud 本身的指令列出實際會上傳的檔案：&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 meta list-files-for-upload &lt;span class=&quot;nb&quot;&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;結果是 48 個檔案，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output/&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;node_modules&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.next&lt;/code&gt; 都是 0。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：會把檔案送出去的指令，先用工具本身列出「實際會送出什麼」，而不是靠自己推論 ignore 規則。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;幾個會讓你誤判的小事&quot;&gt;幾個會讓你誤判的小事&lt;/h1&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;zsh 的萬用字元&lt;/strong&gt;：用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud storage ls -r gs://bucket/**&lt;/code&gt; 數物件，結果是 0。zsh 會先把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;**&lt;/code&gt; 當成本機的萬用字元展開，找不到就報錯，查詢根本沒送出。加上引號才正確：116 個物件，跟本機完全一致。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--format&lt;/code&gt; 取不到的欄位不會報錯&lt;/strong&gt;：用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--format=&apos;value(iamConfiguration.publicAccessPrevention)&apos;&lt;/code&gt; 查 bucket 設定，輸出是空的。欄位名稱在新版 gcloud 變了，但不會報錯，只會安靜地給你空白。改用 JSON 輸出才看到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;public_access_prevention: enforced&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這兩個的共通點是：&lt;strong&gt;「0」和「空白」不等於「沒有」或「沒問題」&lt;/strong&gt;。驗證安全設定時，拿到空結果要先懷疑查詢本身。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;驗證每一層都要實際測過&quot;&gt;驗證：每一層都要實際測過&lt;/h1&gt;

&lt;p&gt;&lt;img src=&quot;../images/image-20260927012153245.png&quot; alt=&quot;image-20260927012153245&quot; /&gt;&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;沒登入就打開首頁、API、音檔&lt;/td&gt;
      &lt;td&gt;IAP 回 302，導向 Google 登入&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;附上偽造的 IAP 簽章標頭&lt;/td&gt;
      &lt;td&gt;一樣 302，IAP 不接受外部帶進來的簽章&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;沒登入就用 POST 加入新歌&lt;/td&gt;
      &lt;td&gt;IAP 回 401&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;匿名存取 bucket 的檔案和列表&lt;/td&gt;
      &lt;td&gt;403&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Run 的呼叫權限&lt;/td&gt;
      &lt;td&gt;只有 IAP 的服務帳號&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;bucket 的公開權限&lt;/td&gt;
      &lt;td&gt;沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;allUsers&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;allAuthenticatedUsers&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;我的帳號登入、播放&lt;/td&gt;
      &lt;td&gt;正常，程式內驗證 0 次拒絕&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;其他 Google 帳號登入&lt;/td&gt;
      &lt;td&gt;「You don’t have access」&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;其中最後兩項只能在瀏覽器測，而「我的帳號能用」其實是最需要確認的：audience 的格式是照文件填的，填錯的話會變成通過了 IAP、卻被自己的程式擋下來。確認的方式是去 Cloud Run 的 log 搜尋 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[auth] rejected&lt;/code&gt;，結果是 0。&lt;/p&gt;

&lt;p&gt;log 裡還看到一件讓我很開心的事：我&lt;strong&gt;直接在雲端加了一首新歌&lt;/strong&gt;，轉錄、分析、產生示範音、從 bucket 讀出來播放，整條流程在容器裡都跑通了。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;成本與還沒做的事&quot;&gt;成本與還沒做的事&lt;/h1&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;Cloud Run&lt;/td&gt;
      &lt;td&gt;沒人使用時縮到 0，不收費；CPU 持續分配只在執行個體啟動期間計費&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Storage&lt;/td&gt;
      &lt;td&gt;約 27MB，一個月不到 US$0.01&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Build&lt;/td&gt;
      &lt;td&gt;每次部署幾分鐘，在免費額度內&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Gemini API&lt;/td&gt;
      &lt;td&gt;跟本機時一樣，依使用量計費&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;還有兩件事是我自己要在 Console 做的：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;限制 API key 的用途&lt;/strong&gt;，只能呼叫 Generative Language API。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;設定預算警示&lt;/strong&gt;。前面所有防護都失效時，這是讓損失有上限、而且我會知道的最後一道保險。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;另外要注意：bucket 和本機的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output/&lt;/code&gt; 現在是兩份各自獨立的資料，雲端加的歌不會自動同步回來，需要時用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud storage rsync&lt;/code&gt; 處理。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;幾個我會帶走的東西&quot;&gt;幾個我會帶走的東西&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;「只有我能存取」是一條鏈，不是一道門。&lt;/strong&gt; IAP 設得再好，旁邊一個簽署網址就能繞過去。檢查存取控制時，要把「資料可以從哪些路徑出去」全部列出來，而不是只看入口。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;設定漏掉時，要讓系統關起來，而不是打開。&lt;/strong&gt; 程式內驗證在缺少設定時一律拒絕。未來某次部署漏了一個環境變數，結果會是我自己打不開網站、馬上發現，而不是網站默默對外開放好幾個禮拜。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;看錯誤是誰回的。&lt;/strong&gt; 同樣是 502，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x-goog-iap-generated-response&lt;/code&gt; 這個標頭直接把問題範圍從「整個服務」縮小到「IAP 的 OAuth 設定」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;空結果要先懷疑查詢本身。&lt;/strong&gt; 物件數量 0、設定欄位空白，都是查詢寫錯，而不是資源真的不存在。驗證安全設定時，這種誤判的代價特別高。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;同一件事只在一個 agent 視窗裡做。&lt;/strong&gt; 兩個 Claude Code 視窗各自對同一個專案動手，這次靠著「動手前先看現況」才沒有互相覆蓋。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;密鑰不要經過對話。&lt;/strong&gt; OAuth client 的密鑰、API key 都在我自己的終端機處理，AI 只負責告訴我指令，這樣對話紀錄裡永遠不會有密鑰。&lt;/p&gt;

&lt;p&gt;程式碼在 &lt;a href=&quot;https://github.com/kkdai/song-lingo&quot;&gt;kkdai/song-lingo&lt;/a&gt;，README 的部署段落有完整的指令和驗證清單（值都換成了佔位符）。相關文件：&lt;a href=&quot;https://cloud.google.com/run/docs/securing/identity-aware-proxy-cloud-run&quot;&gt;IAP for Cloud Run&lt;/a&gt;、&lt;a href=&quot;https://cloud.google.com/iap/docs/signed-headers-howto&quot;&gt;驗證 IAP 簽章標頭&lt;/a&gt;、&lt;a href=&quot;https://cloud.google.com/iap/docs/custom-oauth-configuration&quot;&gt;IAP 自訂 OAuth 設定&lt;/a&gt;。&lt;/p&gt;
</description>
				<pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-song-lingo-gcp-deploy/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-song-lingo-gcp-deploy/</guid>
			</item>
		
			<item>
				<title>[AI 實戰] Gemini 3.8 Flash TTS 正式推出：我用它做了一個「跟著 MV 學日文」的 Web App，然後把一天的額度燒光了</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260927012006728.png&quot; alt=&quot;image-20260927012006728&quot; /&gt;&lt;/p&gt;

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

&lt;p&gt;每次看到 Gemini 出新功能，我第一個念頭都是「能不能接進我的 LINE Bot」。&lt;/p&gt;

&lt;p&gt;9/22 的 &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/changelog&quot;&gt;Gemini API release notes&lt;/a&gt; 寫著 &lt;strong&gt;Gemini 3.8 Flash TTS 與 Gemini 3.8 Flash-Lite TTS 正式推出（GA）&lt;/strong&gt;，官方部落格同步發了 &lt;a href=&quot;https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-8-text-to-speech/&quot;&gt;Gemini 3.8 Flash TTS and Gemini 3.8 Flash-Lite TTS&lt;/a&gt;。我照慣例先列了一排 LINE Bot 點子：爸媽聲音的睡前故事、把群組聊天變成廣播劇、早晨雙主持人 podcast⋯⋯&lt;/p&gt;

&lt;p&gt;列到一半我發現，這次最打動我的其實不是 bot，而是「&lt;strong&gt;逐句導演語氣&lt;/strong&gt;」這件事特別適合拿來教發音。於是題目改成一個 Web App：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;選一首歌，拿到歌詞，同步翻成中文。&lt;/li&gt;
  &lt;li&gt;如果是日文、韓文，就附上拼音，然後變身語言老師，一句一句教你怎麼念。&lt;/li&gt;
  &lt;li&gt;變成一個「用歌曲學語言」的工具。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;結果做下來，TTS 本身的品質沒什麼好挑的，真正花時間的是它周邊那些文件沒寫的事。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;gemini-38-flash-tts-是什麼&quot;&gt;Gemini 3.8 Flash TTS 是什麼&lt;/h1&gt;

&lt;p&gt;這次 GA 的是兩個模型：&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.8-flash-tts&lt;/code&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;gemini-3.8-flash-lite-tts&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;便宜、快，適合大量產生&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;跟前一代比，多了三個我覺得真正有用的東西：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Voice design&lt;/strong&gt;：用一段文字描述生出一個聲音，例如「一位 60 多歲、帶英國腔、語氣溫暖的天文學家」。生出來的聲音會拿到一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;voice_id&lt;/code&gt;，之後重複使用。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Voice replication&lt;/strong&gt;：用 10–30 秒的錄音複製一個人的聲音。前提是聲音主人要親口錄一段同意聲明，產出的語音也會帶 SynthID 浮水印與 C2PA 標記。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;逐句的演出控制&lt;/strong&gt;：每一段文字都能附一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;style&lt;/code&gt;（例如「慢慢念、每個音節都念清楚」），也支援 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;laughs&amp;gt;&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;sigh&amp;gt;&lt;/code&gt; 這類標籤，以及最多兩人的對話。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;實際呼叫的長相，跟過去的 &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;interactions&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;voices&lt;/code&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;# 用文字描述設計一個聲音（一次性，存起來重複用）
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;voice&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;voices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;store&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;voice&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;model&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gemini-3.8-flash-tts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;prompted&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;display_name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Song Lingo Japanese Teacher&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;gender&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;female&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;language_code&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;ja-JP&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;prompted&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;input&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;A warm, patient Japanese language teacher in her early 30s from Tokyo...&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;c1&quot;&gt;# 用這個聲音念一句話，style 控制語氣與速度
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;interaction&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;interactions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-3.8-flash-tts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;input&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;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;user_input&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;content&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;type&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;text&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;annotations&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;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;speech_metadata&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;style&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;speaking slowly and clearly&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;response_format&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;type&quot;&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;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;speech_config&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;voice&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;voice&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;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;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;WAV，16-bit PCM、單聲道、24 kHz；也支援串流&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;語言&lt;/td&gt;
      &lt;td&gt;100 種以上，語言表裡有簡體中文、繁體中文、粵語；&lt;strong&gt;沒看到台語&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;自訂聲音上限&lt;/td&gt;
      &lt;td&gt;每個專案 200 個，保存 1 年；也可以選自己保管的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;voicekey_&lt;/code&gt;，7 天有效&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Voice replication 地區限制&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;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Tier 1 額度&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;每天 100 次請求&lt;/strong&gt;（這個後面會變成主角）&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;做之前先處理的事歌詞的版權&quot;&gt;做之前先處理的事：歌詞的版權&lt;/h1&gt;

&lt;p&gt;「輸入歌名，自動上網抓歌詞」是最直覺的做法，但它卡在兩個地方：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;版權&lt;/strong&gt;：歌詞網站是付費取得授權才能顯示的。自己的 App 抓來顯示全文、再附上翻譯（翻譯在法律上算改作），公開上線就有侵權風險。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;技術&lt;/strong&gt;：就算用 Gemini 搭配 Google Search 去抓，Gemini 有防止重現受版權保護文字的機制，常會直接回 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finishReason: RECITATION&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;後來我想到另一條路：&lt;strong&gt;把 YouTube MV 網址直接丟給 Gemini，請它把歌詞轉錄出來。&lt;/strong&gt; 技術上完全可行，Gemini API 本來就吃公開的 YouTube 網址。&lt;/p&gt;

&lt;p&gt;但這裡要講清楚：&lt;strong&gt;換一種方式取得歌詞，並不會讓它變合法。&lt;/strong&gt; 不管是從網站抓、自己打字、還是讓 AI 從 MV 聽寫，拿到的都是同一份受保護的文字。決定風險的是你怎麼用它。&lt;/p&gt;

&lt;p&gt;所以我的決定是：這是個人學習工具，歌詞只存在本機的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output/&lt;/code&gt; 資料夾，而且這個資料夾從第一個 commit 就放進 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitignore&lt;/code&gt;，永遠不會出現在 GitHub 上。開發過程中我也要求 Claude Code 驗證資料時&lt;strong&gt;只印統計數字&lt;/strong&gt;，從頭到尾沒有把歌詞印到終端機或寫進任何 log。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;架構三支-python-腳本--一個-nextjs&quot;&gt;架構：三支 Python 腳本 + 一個 Next.js&lt;/h1&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;YouTube 網址
  ─transcribe.py─▶ 歌詞與時間軸
  ─annotate.py──▶ 拼音、翻譯、單字拆解、文法重點
  ─speak.py────▶ 老師示範音（正常速 / 慢速）
  ─Next.js─────▶ 跟著 MV 逐句學
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;轉錄讓-gemini-優先讀-mv-畫面上的字幕&quot;&gt;轉錄：讓 Gemini 優先讀 MV 畫面上的字幕&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;transcribe.py&lt;/code&gt; 把 YouTube 網址當成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;file_data&lt;/code&gt; 丟給 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.8-flash&lt;/code&gt;，用 structured output 要求每一句都有開始結束時間、原文、日文的平假名讀音，以及一個「沒聽清楚」的旗標。&lt;/p&gt;

&lt;p&gt;prompt 裡最關鍵的一句是：&lt;strong&gt;如果 MV 畫面上有歌詞字幕，優先用字幕。&lt;/strong&gt; 唱歌的轉錄比說話難很多，有伴奏、拉長音、和聲；日文還有同音字的問題，聽到「kimi」不知道歌詞寫的是「君」還是「きみ」。有字幕的話，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;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;td&gt;40（23）&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;td&gt;45（35）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Take That〈Back for Good〉&lt;/td&gt;
      &lt;td&gt;英文&lt;/td&gt;
      &lt;td&gt;聽音訊&lt;/td&gt;
      &lt;td&gt;37（26）&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;結構都完整：沒有空白句、時間沒有倒退、沒有連續重複同一句（模型出錯時常見的症狀）。唯一讓我不放心的是&lt;strong&gt;每一句都標「確定」&lt;/strong&gt;，連靠聽的那兩首都是。模型對自己太有自信了，這件事後面會用另一個方法補。&lt;/p&gt;

&lt;h2 id=&quot;標註拼音不要全交給-llm&quot;&gt;標註：拼音不要全交給 LLM&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;annotate.py&lt;/code&gt; 替每一句加上繁體中文翻譯、逐字拆解（讀音、詞性、意思）、一個文法重點和一個發音提示。副歌會重複出現，所以只替&lt;strong&gt;不重複的句子&lt;/strong&gt;呼叫 Gemini，大約省下三到四成。&lt;/p&gt;

&lt;p&gt;拼音是這一段最有意思的地方。我原本的想法是用現成的函式庫，比 LLM 可靠，結果兩個都出事：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;pykakasi&lt;/strong&gt;（日文）把助詞「は」拼成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ha&lt;/code&gt;，正確念法是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wa&lt;/code&gt;。光看平假名，分不出這個「は」是不是助詞。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;korean-romanizer&lt;/strong&gt;（韓文）把「감사합니다」拼成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gamsahapnida&lt;/code&gt;。韓國官方的 Revised Romanization 要照實際發音拼，應該是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gamsahamnida&lt;/code&gt;，這個套件沒處理鼻音化。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;最後的分工是：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;日文&lt;/strong&gt;：Gemini 負責斷詞並給出每個詞的讀音和詞性，程式再用 pykakasi 轉成羅馬拼音，並依詞性把助詞 は、へ、を 修正成 wa、e、o。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;韓文&lt;/strong&gt;：韓文字本身就是表音文字，直接請 Gemini 照 Revised Romanization 的發音規則輸出。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：函式庫擅長的是「確定性的轉換」，不擅長「需要理解上下文的判斷」；LLM 剛好相反。把需要理解的部分（斷詞、詞性）交給 LLM，確定性的部分（假名轉拼音）交給程式，兩邊都用在它擅長的地方。&lt;/p&gt;

&lt;h2 id=&quot;用兩次獨立的結果交叉檢查&quot;&gt;用兩次獨立的結果交叉檢查&lt;/h2&gt;

&lt;p&gt;前面說轉錄結果每一句都標「確定」，不太可信。我補的方法是：轉錄時 Gemini 已經給過一次整句讀音，標註時又給了每個詞的讀音。&lt;strong&gt;這兩次是分開產生的，對不上的句子，最可能是漢字念錯了。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;實際跑下來，兩首日文歌各有 1 句對不上（1/23、1/35），程式會替它加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;needs_review&lt;/code&gt;，網頁就能提醒使用者校對。&lt;/p&gt;

&lt;p&gt;這招不用多花任何 API 呼叫，只是把已經有的兩份結果拿來比對。&lt;/p&gt;

&lt;h2 id=&quot;老師用-voice-design設計出來&quot;&gt;老師：用 voice design「設計」出來&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speak.py&lt;/code&gt; 替每種語言設計一位老師，例如日文是「東京出身、30 出頭、溫柔有耐心的日文老師」，只建立一次，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;voice_id&lt;/code&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;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;2.5–6.1 秒，平均 4.3 秒&lt;/td&gt;
      &lt;td&gt;4.4–9.7 秒，平均 7.1 秒&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;慢速／正常&lt;/td&gt;
      &lt;td&gt; &lt;/td&gt;
      &lt;td&gt;每句至少 1.24 倍，最多 2.34 倍&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;慢速只靠一句 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;style&lt;/code&gt; 描述，沒有調任何播放速度，而且是「念得慢而清楚」，不是把正常速度的音檔拉長。這是這次 TTS 讓我最有感的地方。&lt;/p&gt;

&lt;h2 id=&quot;web-appnextjs-16&quot;&gt;Web App：Next.js 16&lt;/h2&gt;

&lt;p&gt;網頁用 Next.js，由伺服器端直接讀 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;../output/&lt;/code&gt; 的資料。畫面左邊是嵌入的 MV，下面是這一句的教學卡片（漢字上方標假名、拼音、翻譯、單字拆解、文法、發音提示，以及「老師念」「慢速」「原曲這句」三顆按鈕）；右邊是整首歌的逐句列表，會跟著 MV 播放自動切換。&lt;/p&gt;

&lt;p&gt;一開 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;create-next-app&lt;/code&gt; 就遇到一個有意思的東西，它產生的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AGENTS.md&lt;/code&gt; 第一行寫著：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;This is NOT the Next.js you know&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;意思是這個版本有破壞性變更，要 agent 先讀 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;node_modules/next/dist/docs/&lt;/code&gt; 再寫程式。Claude Code 照做了，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;params&lt;/code&gt; 變成 Promise、route handler 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RouteContext&lt;/code&gt; 型別都是從那裡查到的。框架主動寫給 AI 看的說明文件，我覺得是個會越來越常見的做法。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;額度tier-1-每天只有-100-次&quot;&gt;額度：Tier 1 每天只有 100 次&lt;/h1&gt;

&lt;p&gt;這是整個專案最重要的限制，所以先講。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.8-flash-tts&lt;/code&gt; 在 Tier 1 &lt;strong&gt;每天 100 次請求&lt;/strong&gt;。一首歌 30 幾句、每句正常速加慢速兩段，大約就要 50–70 次。&lt;strong&gt;照這個額度，一天只能處理一到兩首歌。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;所以後來我把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speak.py&lt;/code&gt; 批次產生的做法，改成&lt;strong&gt;使用者按下播放時才產生&lt;/strong&gt;，產生後存起來：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;動作&lt;/th&gt;
      &lt;th&gt;會消耗 Gemini 嗎？&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;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;播放 MV、按「原曲這句」&lt;/td&gt;
      &lt;td&gt;不會，那是 YouTube 播放器&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;播放已經產生過的示範音&lt;/td&gt;
      &lt;td&gt;不會&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;第一次&lt;/strong&gt;播放某一句的示範音&lt;/td&gt;
      &lt;td&gt;1 次 TTS（正常速、慢速分開算）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;加入新歌&lt;/td&gt;
      &lt;td&gt;約 2 次 Gemini Flash，不佔 TTS&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;音檔用句子內容的雜湊值命名，重複出現的副歌自然共用同一段音檔。只有真的練到的句子才會花額度。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑一程式卡住了其實是在睡七小時&quot;&gt;踩坑一：程式「卡住」了，其實是在睡七小時&lt;/h1&gt;

&lt;p&gt;替第二首歌批次產生音檔時，進度停在 56/70 不動了。&lt;/p&gt;

&lt;p&gt;查下去的狀態很奇怪：Python 程式還在、CPU 0%，4 個 worker 各自連著 Google 的伺服器乾等。第一個判斷是請求沒有逾時設定，於是加了 60 秒 timeout 跟重試。重跑之後，十分鐘只多了 1 段，而且這次&lt;strong&gt;連網路連線都沒有了&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;關掉 SDK 的自動重試、直接看回應，答案才出來：&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;429 Rate limit exceeded for model gemini-3.8-flash-tts
(limit: 100 requests per day on Tier 1). Please retry in 7h12m16s
retry-after: 25936
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;每日額度用完了，而 SDK 收到 429 之後，&lt;strong&gt;照著 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Retry-After&lt;/code&gt; 乖乖等 25,936 秒再重試&lt;/strong&gt;。從外面看，就是一個不吃 CPU、不連網路、永遠不會結束的程式。&lt;/p&gt;

&lt;p&gt;更麻煩的是，我一開始用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HttpRetryOptions(attempts=1)&lt;/code&gt; 想關掉重試，完全沒效。原因是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;interactions&lt;/code&gt; 這組 API 走的是 SDK 裡另一套 HTTP client（模組名稱是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_gaos&lt;/code&gt;），不吃 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HttpRetryOptions&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;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 這組 API 不吃 HttpRetryOptions；不關掉的話，遇到每日額度會睡好幾個小時
&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;interactions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sdk_configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;retry_config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_retries&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&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;Retry-After&lt;/code&gt;」對每分鐘的限速是好設計，對每日額度就是災難。現在程式自己處理重試：暫時性錯誤最多試 3 次；遇到訊息含「per day」的 429，所有 worker 立刻停下來，印出還要等多久，已經產生的音檔都保留。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑二python-sdk-有的欄位rest-沒有&quot;&gt;踩坑二：Python SDK 有的欄位，REST 沒有&lt;/h1&gt;

&lt;p&gt;這是這次最貴的一課。&lt;/p&gt;

&lt;p&gt;改成「按下播放才產生」之後，產生音檔的工作從 Python 搬到了 Next.js 的伺服器端，用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetch&lt;/code&gt; 直接打 REST API。Python 版本讀音訊是這樣寫的：&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;audio&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;base64&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;b64decode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;interaction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output_audio&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;TS 版本就照著寫了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json.output_audio?.data&lt;/code&gt;。問題是，&lt;strong&gt;REST 回應裡根本沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_audio&lt;/code&gt; 這個欄位。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;翻 SDK 原始碼才知道，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_audio&lt;/code&gt; 是 Python SDK 在 pydantic validator 裡&lt;strong&gt;自己算出來的便利欄位&lt;/strong&gt;：它從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steps&lt;/code&gt; 陣列裡找 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type: &quot;model_output&quot;&lt;/code&gt; 的步驟，再從步驟的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;content&lt;/code&gt; 裡取出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type: &quot;audio&quot;&lt;/code&gt; 的項目。真正的 REST 回應長這樣：&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;steps[] → { type: &quot;model_output&quot;, content[] → { type: &quot;audio&quot;, data: &quot;&amp;lt;base64&amp;gt;&quot; } }
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;於是發生了這件事：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Gemini 每一次都&lt;strong&gt;成功產生了音訊，也算進了額度&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;我的程式讀不到音訊，回傳 502，沒有存檔。&lt;/li&gt;
  &lt;li&gt;使用者每按一次播放，加上瀏覽器自己送出的多個 Range 請求，都&lt;strong&gt;再呼叫一次 TTS&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;大約一個小時內，100 次額度全部用完，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output/audio/&lt;/code&gt; 裡一個新檔案都沒有。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;更難看的是，這段程式碼上線的時候，因為當天額度已經用完，成功回應的格式根本沒辦法驗證。Claude Code 在交付時有把「成功路徑未驗證」寫在報告裡，但我們還是讓它上線了。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：分兩層修。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;照 SDK 的邏輯解析&lt;/strong&gt;：依序找 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steps&lt;/code&gt;、舊格式的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;outputs&lt;/code&gt;，最後才是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_audio&lt;/code&gt;。沒有額度可以打 API 驗證，所以拿同一份模擬回應分別餵給 Python SDK 的解析器和新的 TS 函式，確認兩邊取出的資料一致。後來額度空出一個名額，第一段英文音檔真的產生並存下來了（5.04 秒、24 kHz），這才算真正驗證。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;防止失敗時重複計費&lt;/strong&gt;：同一段音檔產生失敗後的 60 秒內，直接回傳上次的錯誤；收到 429 之後，所有音檔一律擋到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Retry-After&lt;/code&gt; 指定的時間為止。&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-ts 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;// A failed generation may still have been billed, so don&apos;t let repeated clicks or the&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// browser&apos;s parallel range requests retry it: replay the error for a while instead.&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;recentFailures&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Map&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&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;na&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;TtsError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;until&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;number&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;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這個 bug 教我的是：&lt;strong&gt;失敗不等於沒有花錢。&lt;/strong&gt; 只要請求送到了模型、模型也跑完了，就算你的程式在最後一步解析失敗，帳單照樣算。會消耗額度的程式，一定要在上線前看過一次真正的成功回應，而且失敗路徑要預設「這次可能已經被計費了」。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑三env-裡的引號&quot;&gt;踩坑三：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt; 裡的引號&lt;/h1&gt;

&lt;p&gt;Next.js 端第一次打 API，回的是：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;400 API key not valid. Please pass a valid API key.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;同一把 key，Python 用得好好的。檢查 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt; 的格式（只看長度和頭尾字元，不印出 key）才發現，我的 key 寫成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GEMINI_API_KEY=&quot;...&quot;&lt;/code&gt;，&lt;strong&gt;有雙引號&lt;/strong&gt;。Python 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;python-dotenv&lt;/code&gt; 會自動去掉引號，Node 端自己寫的讀取程式沒有，就把引號一起當成 key 送出去了。&lt;/p&gt;

&lt;p&gt;順帶還抓到另一個問題：Gemini 的錯誤回應有時是陣列 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[{ &quot;error&quot;: ... }]&lt;/code&gt; 而不是物件，所以原本的程式連錯誤訊息都讀不到，畫面上只顯示空白的「400：」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：讀取 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt; 的邏輯改成跟 python-dotenv 一致（允許 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;export&lt;/code&gt; 前綴、去掉引號），錯誤解析兩種格式都處理。小事，但兩個語言共用一份設定檔時，這種「一邊會自動幫你處理」的差異特別容易漏。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑四音檔用行號命名一校對就全部錯位&quot;&gt;踩坑四：音檔用行號命名，一校對就全部錯位&lt;/h1&gt;

&lt;p&gt;轉錄一定會有錯，所以我加了校對介面：卡片上可以直接改原文、讀音、翻譯，或按「✓ 沒問題」一鍵確認；改了原文之後，按「重新分析整首」會重跑一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;annotate.py&lt;/code&gt;（1 次 Flash 請求），而且保留手動修改的翻譯和已校對的標記。&lt;/p&gt;

&lt;p&gt;動手之前先發現一個問題：音檔原本是依「第幾個不重複的句子」命名的，例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;028_normal.wav&lt;/code&gt;。&lt;strong&gt;只要改了某一句歌詞，後面句子的編號可能跟著移動，已經產生的音檔就會對到錯的句子。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：改成用句子內容的雜湊值命名：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;clip_name&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;speed&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;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;Clips are keyed by line content so editing a lyric only invalidates that line&apos;s audio.&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&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;hashlib&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sha1&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;encode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hexdigest&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;mi&quot;&gt;16&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;_&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;speed&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.wav&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;改了一句歌詞，只有那一句的音檔失效，其他全部不受影響。已經產生的 103 段音檔全部搬成新檔名，已經花掉的額度沒有浪費。Python 和 TS 兩邊各算一次同一段日文的雜湊值，確認完全一致後才上線。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑五有些-mv-不給嵌入&quot;&gt;踩坑五：有些 MV 不給嵌入&lt;/h1&gt;

&lt;p&gt;試用時發現有些影片在頁面裡播不出來。YouTube 播放器遇到「影片擁有者不允許在其他網站播放」時，會回報錯誤碼 101 或 150。&lt;/p&gt;

&lt;p&gt;現在偵測到這些錯誤，影片區塊會換成縮圖、說明原因，加一顆「在 YouTube 開啟（從這句開始）」的按鈕，「原曲這句」也變成開新分頁並跳到那一句的時間點。老師示範音和逐句教學照常可用。&lt;/p&gt;

&lt;p&gt;縮圖這邊又有一個小坑：高解析的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;maxresdefault.jpg&lt;/code&gt; 不是每支影片都有，但 YouTube 找不到時&lt;strong&gt;不會回 404，而是回一張 120×90 的灰色預設圖&lt;/strong&gt;，所以 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;img onError&amp;gt;&lt;/code&gt; 不會觸發，畫面上就是一片放大的灰色。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：圖片載入後檢查寬度，不到 121 像素就換成一定存在的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hqdefault.jpg&lt;/code&gt;。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;開發流程讓-claude-code-驗證但不讓它看到歌詞&quot;&gt;開發流程：讓 Claude Code 驗證，但不讓它看到歌詞&lt;/h1&gt;

&lt;p&gt;這次整個專案都是跟 Claude Code 一起做的，從查 changelog、討論產品方向到寫程式、部署規劃。幾個我覺得做對的地方：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;邊做邊推上 GitHub&lt;/strong&gt;：每完成一個階段就 commit、push，commit message 寫清楚「為什麼」，而不只是「改了什麼」。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;驗證只看統計數字&lt;/strong&gt;：檢查轉錄品質時，看的是句數、時間是否倒退、有沒有連續重複、讀音裡有沒有混進漢字；檢查音檔看的是長度、慢速倍率、有沒有異常長的檔案。從頭到尾不需要把歌詞印出來。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;測試用假歌曲&lt;/strong&gt;：測校對、重新分析、影片無法嵌入時，用一首自己編的假歌（內容是「駅まで歩く」這種測試句），放進 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output/&lt;/code&gt; 測完就刪，不動到真實資料。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;用無頭 Chrome 實際點按鈕&lt;/strong&gt;：伺服器端的 API 測過還不夠，播放、校對、加入新歌的流程都用 puppeteer 實際點過一輪。「沒聲音」那次，也是靠它確認了 Chrome 端的播放本身是正常的，才把範圍縮小到伺服器端。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;也有做錯的地方，前面兩個踩坑都是：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;在成功回應格式沒驗證的情況下，讓會消耗額度的程式上線（踩坑二）。&lt;/li&gt;
  &lt;li&gt;背景處理「加入新歌」的工作完成後，狀態一直留在記憶體裡；如果之後檔案被刪掉，重新加入時會說「已完成」而不重做。這是寫「從中斷處接著做」的測試時自己抓到的，改成以磁碟上的檔案為準。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;成果與效益&quot;&gt;成果與效益&lt;/h1&gt;

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

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;數字&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;commit&lt;/td&gt;
      &lt;td&gt;11&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;已加入的歌曲&lt;/td&gt;
      &lt;td&gt;3 首（日文 2、英文 1）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;已產生的老師示範音&lt;/td&gt;
      &lt;td&gt;104 段&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;單段示範音產生時間&lt;/td&gt;
      &lt;td&gt;約 5 秒&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;加入一首新歌&lt;/td&gt;
      &lt;td&gt;約 48 秒（轉錄 14 秒、分析 34 秒）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;一首歌的 API 用量&lt;/td&gt;
      &lt;td&gt;約 2 次 Flash，示範音按需產生&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;現在的功能：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;貼 YouTube 網址加入新歌，背景自動轉錄與分析。&lt;/li&gt;
  &lt;li&gt;跟著 MV 逐句學：假名、拼音、翻譯、單字拆解、文法、發音提示。&lt;/li&gt;
  &lt;li&gt;老師示範音，正常速與慢速，第一次播放時才產生。&lt;/li&gt;
  &lt;li&gt;校對介面：修改歌詞、一鍵確認、重新分析整首。&lt;/li&gt;
  &lt;li&gt;影片不能嵌入時的替代畫面。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;下一步搬上-cloud-run&quot;&gt;下一步：搬上 Cloud Run&lt;/h2&gt;

&lt;p&gt;目前還只能在自己的電腦上跑。接下來打算部署到 GCP：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Cloud Run&lt;/strong&gt; 跑 Next.js，容器裡一併裝 Python 和 uv，三支腳本不用改。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;GCS&lt;/strong&gt; 存音檔和歌曲資料。播放時由 API 產生短時效的簽署網址讓瀏覽器直接抓，GCS 本身支援 Range，Safari 也能播。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Secret Manager&lt;/strong&gt; 放 API key。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;IAP&lt;/strong&gt; 把整個服務鎖起來，只有我的 Google 帳號能用。這一步不是可有可無：網站一旦公開，別人加歌、播放都會消耗我的額度；更重要的是頁面上有完整歌詞和翻譯，公開就不再是「個人學習」了。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;幾個我會帶走的東西&quot;&gt;幾個我會帶走的東西&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SDK 的便利欄位不是 API 的一部分。&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_audio&lt;/code&gt; 在 Python 裡用起來理所當然，但它是 SDK 自己組出來的。換語言、換成直接打 REST 的時候，要回去看真正的回應長什麼樣子，而不是照抄另一個 SDK 的寫法。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;「尊重 Retry-After」要看是哪一種限制。&lt;/strong&gt; 每分鐘限速時等幾秒是好事，每日額度時等七小時就是一個看起來當掉的程式。遇到額度類的錯誤，快速失敗、把原因講清楚，比默默重試有用得多。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;失敗的請求可能已經被計費。&lt;/strong&gt; 會花錢的操作，失敗路徑要假設「這次已經花了」，並且防止重試風暴。這次如果一開始就有 60 秒的失敗快取，損失會是幾次請求，而不是一整天的額度。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;把 LLM 和確定性程式用在各自擅長的地方。&lt;/strong&gt; 斷詞與詞性交給 Gemini，假名轉拼音交給 pykakasi；兩次獨立產生的讀音拿來互相比對，不用多花一次呼叫就能找出可能念錯的地方。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;版權的問題要在寫第一行程式之前想清楚。&lt;/strong&gt; 換一種方式取得歌詞不會讓它變合法。這次的做法是從第一個 commit 就把資料夾排除在 git 之外、驗證時只看統計數字、部署時用 IAP 鎖住，讓它從頭到尾都是一個個人學習工具。&lt;/p&gt;

&lt;p&gt;程式碼在 &lt;a href=&quot;https://github.com/kkdai/song-lingo&quot;&gt;kkdai/song-lingo&lt;/a&gt;（歌詞資料不在 repo 裡，要自己加歌）。TTS 的官方文件是 &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/speech-generation&quot;&gt;Speech generation&lt;/a&gt; 與 &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/voice-design&quot;&gt;Voice design&lt;/a&gt;；如果你也要從 Node 或其他語言直接打 REST，記得音訊在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;steps[].content[]&lt;/code&gt; 裡，不在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_audio&lt;/code&gt;。&lt;/p&gt;
</description>
				<pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-song-lingo/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-song-lingo/</guid>
			</item>
		
			<item>
				<title>[AI 實戰] Gemini 3.8 Live 升級記：一次 GA 讓我還掉一筆技術債，以及我的實測腳本自己騙了我三次</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260916214342753.png&quot; alt=&quot;image-20260916214342753&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&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;。丟網址回摘要跟社群文案，丟 YouTube 連結可以針對影片連續追問，還有書籤、地點查詢那些。其中一個功能是 LIFF 語音助理：開一個網頁，用 WebSocket 接 Gemini Live API，可以按住說話或免持對話，講完的內容會整理成一則訊息推回 LINE 聊天室。&lt;/p&gt;

&lt;p&gt;九月中 Google 發了 &lt;a href=&quot;https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-8-live-gemini-3-8-live-extended-thinking/&quot;&gt;Gemini 3.8 Live&lt;/a&gt;，看完之後我想做的事情，跟公告想賣的東西不太一樣。&lt;/p&gt;

&lt;p&gt;公告在講能力：Speech to Speech Index 排第一（82.6 分）、Big Bench Audio 97.7%、97 種語言自動偵測且可以在對話中途切換、工具呼叫可以在背景執行而不中斷對話流、即時視覺輸入。還有一顆 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Gemini 3.8 Live Extended Thinking&lt;/code&gt;，能一邊推理一邊出聲。&lt;/p&gt;

&lt;p&gt;我第一個念頭是：&lt;strong&gt;這下我可以把那個 preview 例外拔掉了。&lt;/strong&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;真正的動機一個-preview-模型埋了兩次雷&quot;&gt;真正的動機：一個 preview 模型埋了兩次雷&lt;/h1&gt;

&lt;p&gt;上一篇寫 agentic video 的時候提過一件事：我的智慧對話功能壞了整段時間，沒有人發現。原因是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;loader/chat_session.py&lt;/code&gt; 寫死了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-pro-preview&lt;/code&gt;，那個模型從 Vertex AI 下架之後，真實路徑直接回 404，而那個檔案的例外處理是直接 raise。&lt;/p&gt;

&lt;p&gt;修完之後我加了一組守衛，用測試強制「模型 ID 只能出現在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config/agent_config.py&lt;/code&gt;」「不准使用 preview 或實驗模型」。但那組守衛有兩個例外：&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;# Live API 僅支援 gemini-3.1-flash-live-preview；TTS 是獨立模型家族。
# 這兩處必須維持 preview 模型，不在守衛範圍內。
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;EXEMPT_FILES&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;services/voice_live.py&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;tools/tts_tool.py&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;語音那個例外的理由是「Live API 只有這一顆模型能用」。當時是真的。但那句話一旦寫進註解，就沒有人會再去查它還成不成立——同一類 bug 的最後一個缺口，就這樣光明正大掛在那裡好幾個月。&lt;/p&gt;

&lt;p&gt;3.8 Live 的意義因此不只是能力升級。它的模型名稱&lt;strong&gt;不含 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-preview&lt;/code&gt;&lt;/strong&gt;，這代表那個例外可以縮掉。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;先問-api不要問文件&quot;&gt;先問 API，不要問文件&lt;/h1&gt;

&lt;p&gt;這次我沒有先讀文件，而是直接問 API 上實際存在哪些東西：&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;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;https://generativelanguage.googleapis.com/v1beta/models?key=&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$KEY&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;amp;pageSize=200&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  | jq &lt;span class=&quot;nt&quot;&gt;-r&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;.models[]?.name&apos;&lt;/span&gt; | rg &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;live&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;models/gemini-3.5-transcribe-live
models/gemini-3.1-flash-live-preview
models/gemini-3.8-live
models/gemini-3.8-live-extended-thinking
models/gemini-3.5-live-translate-preview
&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.8-live&lt;/code&gt; 就在上面，名字乾淨。&lt;/p&gt;

&lt;p&gt;順帶還撿到一個我本來不知道的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-live-translate-preview&lt;/code&gt;。我原本在想「要不要做即時口譯模式」，看來那是一顆專門的模型，不是拿通用模型硬幹。這個之後再說。&lt;/p&gt;

&lt;p&gt;再往下問 metadata：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3.1-flash-live-preview&lt;/code&gt;&lt;/th&gt;
      &lt;th&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3.8-live&lt;/code&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;inputTokenLimit&lt;/td&gt;
      &lt;td&gt;131072&lt;/td&gt;
      &lt;td&gt;131072&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;outputTokenLimit&lt;/td&gt;
      &lt;td&gt;65536&lt;/td&gt;
      &lt;td&gt;65536&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;supportedGenerationMethods&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bidiGenerateContent&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bidiGenerateContent&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;數字一模一樣。到這裡我對「可以無痛換」有了初步信心，但這還只是 metadata，不是行為。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑一我警告了一個不存在的風險&quot;&gt;踩坑一：我警告了一個不存在的風險&lt;/h1&gt;

&lt;p&gt;我第一次評估這件事的時候，很嚴肅地提醒了一句：「公告沒提到 Vertex AI，而這個專案的決策是全面改用 Vertex，所以 3.8 Live 不確定拿不拿得到。」&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;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;live_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;vertexai&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;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;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;vertexai=False&lt;/code&gt;。語音這條路徑從一開始就是走 Gemini API 加 AI Studio 的金鑰，根本不走 Vertex——而 3.8 Live 正是在 Gemini API 上發表的。&lt;/p&gt;

&lt;p&gt;我對著自己的 repo，警告了一個在自己 repo 裡不存在的風險。整個專案確實「全面改用 Vertex AI」，但語音是例外，而那個例外是我自己當初寫的。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：專案層級的決策紀錄會變成一種記憶捷徑。「我們都用 Vertex」是對的總結，但總結會吃掉例外。查一次原始碼只要三十秒，比憑印象推論便宜太多。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑二對照組救了我一次&quot;&gt;踩坑二：對照組救了我一次&lt;/h1&gt;

&lt;p&gt;確認模型存在之後，我寫了一支拋棄式腳本去實測。關鍵設計是：&lt;strong&gt;不另外寫一份 config，直接 import 專案裡現有的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_live_config()&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_voice_tools()&lt;/code&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;=== gemini-3.8-live ===
  [handsfree] {&apos;connected&apos;: True, &apos;audio&apos;: True, ..., &apos;error&apos;: None}
  [PTT]       {&apos;connected&apos;: True, &apos;audio&apos;: False, ...,
               &apos;error&apos;: &apos;APIError: 1007 None. Precondition check failed.&apos;}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;按住說話那個模式直接被擋。如果我只測了 3.8，這裡就會得到一個結論：「3.8 Live 不支援 PTT，不能升級。」&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;=== gemini-3.1-flash-live-preview ===
  [PTT]       {..., &apos;error&apos;: &apos;APIError: 1007 None. Precondition check failed.&apos;}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;一模一樣的錯誤。兩個模型用同一種方式失敗，那就不是模型的問題，是我的腳本有問題。&lt;/p&gt;

&lt;p&gt;實際上的錯是：PTT 模式下正式環境送的是 PCM 音訊，我圖方便送了文字。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;activity_start&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;activity_end&lt;/code&gt; 之間夾一段文字輸入，本來就是不合法的組合。&lt;/p&gt;

&lt;p&gt;改成送真實的 16kHz PCM 之後：&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-3.8-live ===
  [PTT] {&apos;connected&apos;: True, &apos;audio&apos;: True, &apos;out_tx&apos;: True, &apos;in_tx&apos;: True,
         &apos;resume&apos;: True, &apos;events&apos;: [&apos;audio&apos;,&apos;in_tx&apos;,&apos;out_tx&apos;,&apos;resume&apos;,&apos;turn_complete&apos;]}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;全部到齊，而且跟舊模型逐項一致。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：這次唯一做對的事情是&lt;strong&gt;保留了對照組&lt;/strong&gt;。測一個新東西的時候順手把舊東西用同樣方式測一遍，成本幾乎是零，但它能分辨「新東西壞了」跟「我的測試寫錯了」。這兩件事看起來長得一模一樣。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑三正弦波不是人聲&quot;&gt;踩坑三：正弦波不是人聲&lt;/h1&gt;

&lt;p&gt;第二版腳本我用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;math.sin&lt;/code&gt; 產了一段 180Hz 的 PCM 當作音訊輸入。PTT 模式測得很順，但免持模式兩個模型都 timeout。&lt;/p&gt;

&lt;p&gt;我差點把這個寫成「免持模式行為待確認」。但想一下就知道為什麼：免持模式靠的是 Gemini 自己的語音活動偵測，而一段純正弦波不是語音，VAD 正確地判斷「沒有人在講話」，所以永遠不會觸發。&lt;/p&gt;

&lt;p&gt;所以那不是「測出問題」，是&lt;strong&gt;完全沒測到&lt;/strong&gt;。兩次 timeout 看起來像資料，實際上是兩個空格。&lt;/p&gt;

&lt;p&gt;這件事後來延伸出一份清單。我把腳本測到的跟沒測到的分開列：&lt;/p&gt;

&lt;p&gt;測到了：連線、config 被接受、PTT 音訊往返、雙向逐字稿、resumption handle、turn_complete、工具呼叫觸發且參數正確。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;沒測到&lt;/strong&gt;：真人中文語音辨識、免持自動 VAD、說話中插話打斷、十分鐘連線回收後的重連、resumption handle 帶回去能不能真的接上上下文、Google Search grounding 實際生效、慢任務轉交推回 LINE 的後半段、語音品質與延遲。&lt;/p&gt;

&lt;p&gt;八項裡沒測到的有八項。腳本跑得很漂亮，但它碰到的是協定層，不是使用者會遇到的東西。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;實測對照表&quot;&gt;實測對照表&lt;/h1&gt;

&lt;p&gt;繞完一圈之後，最後的對照長這樣。左右兩欄都是用&lt;strong&gt;專案現有的 config 產生器&lt;/strong&gt;打真實 API 的結果：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;驗證項目&lt;/th&gt;
      &lt;th&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3.1-flash-live-preview&lt;/code&gt;&lt;/th&gt;
      &lt;th&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3.8-live&lt;/code&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;PTT（activity 信號 + PCM 分塊）&lt;/td&gt;
      &lt;td&gt;音訊／雙向逐字稿／resume／turn_complete&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;session_resumption&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;context_window_compression&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;AudioTranscriptionConfig&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;Aoede&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;google_search&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;function_declarations&lt;/code&gt; 分屬兩個 Tool&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api_version=&quot;v1beta&quot;&lt;/code&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;p&gt;換句話說，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.8-live&lt;/code&gt; 對既有設定是可以直接換上去的。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;extended-thinking-有一個必填欄位&quot;&gt;Extended Thinking 有一個必填欄位&lt;/h1&gt;

&lt;p&gt;順手也測了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.8-live-extended-thinking&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;APIError: 1007 None. Thinking level must be specified for this model.
&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;thinking_config&lt;/code&gt; 之後 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LOW&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HIGH&lt;/code&gt; 都可以連。所以它不是壞的，是多一個必填參數。&lt;/p&gt;

&lt;p&gt;我決定不採用，理由是現在的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_live_config()&lt;/code&gt; 不送 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_config&lt;/code&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;# Live API（bidiGenerateContent）支援名單。2026-09-16 實測連線確認。
# 刻意不含 gemini-3.8-live-extended-thinking：該模型強制要求 thinking_level，
# 未帶 thinking_config 連線會被擋下（1007）。
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LIVE_CAPABLE&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;gemini-3.8-live&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;上一次我在註解裡寫「Live API 只支援某個模型」，那句話過期了好幾個月沒人發現。這次我把它寫成一個會失敗的斷言。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;改了什麼&quot;&gt;改了什麼&lt;/h1&gt;

&lt;p&gt;實際的程式改動很小，七個檔案、八十行上下：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config/agent_config.py&lt;/code&gt;&lt;/strong&gt;：新增 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VOICE_MODEL&lt;/code&gt; 模組常數與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AgentConfig.voice_model&lt;/code&gt;。這裡有個細節——我刻意沒有只放進 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;get_agent_config()&lt;/code&gt;，因為那個函式要求 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GOOGLE_CLOUD_PROJECT&lt;/code&gt;，而 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;services/voice_live.py&lt;/code&gt; 在 import 的時候就需要模型 ID。走那條路的話，沒設專案環境變數就會連 import 都失敗。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;services/voice_live.py&lt;/code&gt;&lt;/strong&gt;：模型字面值整個拿掉，改從 config 拿。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tests/test_model_config.py&lt;/code&gt;&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EXEMPT_FILES&lt;/code&gt; 從兩個檔縮成一個；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;voice_model&lt;/code&gt; 納入既有的「不得 preview」與「必須在實測可用清單內」兩個守衛。&lt;/li&gt;
&lt;/ul&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;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;test_voice_live_model_unchanged&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;Live API 只支援 gemini-3.1-flash-live-preview，換掉會讓語音助理整個壞掉。&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;assert&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;VOICE_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;/p&gt;

&lt;p&gt;改成守名單之後，守的是「這個值必須是實測可用的 Live 模型」這一類問題，不是某一個特定值。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools/tts_tool.py&lt;/code&gt; 那個例外留著。我查了一下，API 上三顆 TTS 模型（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2.5-flash-preview-tts&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2.5-pro-preview-tts&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3.1-flash-tts-preview&lt;/code&gt;）全都還是 preview，沒有 GA 版可以換。例外從兩個縮成一個，沒有歸零。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;部署順序設定先就位程式碼後到&quot;&gt;部署順序：設定先就位，程式碼後到&lt;/h1&gt;

&lt;p&gt;環境變數是在合併之前先設好的：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud run services update linebot-helper-python &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-central1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--update-env-vars&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;VOICE_MODEL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;gemini-3.8-live
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這一步當下&lt;strong&gt;完全沒有作用&lt;/strong&gt;。線上跑的還是舊 image，那份程式碼把模型寫死在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;voice_live.py&lt;/code&gt;，根本不會去讀這個變數。&lt;/p&gt;

&lt;p&gt;但順序是對的。合併會觸發 Cloud Build 自動部署，新 image 一上去就直接讀到已經就位的設定，中間不會有一個「程式碼上了但設定還沒」的空窗。&lt;/p&gt;

&lt;p&gt;這個做法還有一個副作用，我覺得比升級本身更有價值：&lt;strong&gt;回滾不需要重新部署&lt;/strong&gt;。&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud run services update linebot-helper-python &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-central1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--update-env-vars&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;VOICE_MODEL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;gemini-3.1-flash-live-preview
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;如果真人測起來覺得不對，一行指令就切回去，不用等 build。模型 ID 從寫死改成環境變數，換來的就是這個。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;然後我真人測了結果推翻了我的觀察&quot;&gt;然後我真人測了，結果推翻了我的觀察&lt;/h1&gt;

&lt;p&gt;腳本裡有一件事我一直覺得可疑。工具呼叫那一輪，舊模型會先出聲講一句填充話再等工具結果，3.8 沒有。&lt;/p&gt;

&lt;p&gt;我當時的判斷很小心：這&lt;strong&gt;可能&lt;/strong&gt;就是公告講的「工具在背景執行不中斷對話流」，也可能只是我的迴圈提早收掉。自動化測不出來，得真人講話才知道是改善還是冷場。&lt;/p&gt;

&lt;p&gt;合併部署之後實際用 LIFF 講了幾句，答案是：&lt;strong&gt;跟原來沒有差別，很順暢。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;所以那個差異在真人耳裡不存在。它是我 drain 迴圈在拿到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turn_complete&lt;/code&gt; 就 return 的產物，不是行為改變。我觀察到的是自己腳本的形狀。&lt;/p&gt;

&lt;p&gt;這篇文章裡我的腳本因此騙了我三次：PTT 那次讓新模型看起來壞了，正弦波那次讓沒測到看起來像測過了，這次讓一個不存在的差異看起來像新能力。三次都不是模型的問題。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;296-個測試全綠證明不了語音能用&quot;&gt;296 個測試全綠，證明不了語音能用&lt;/h1&gt;

&lt;p&gt;這是我覺得整件事最值得寫下來的部分。&lt;/p&gt;

&lt;p&gt;升級完成後測試從 293 變成 296，全綠。但這個數字跟「語音在 3.8 上能不能用」幾乎沒有關係。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tests/test_voice_live.py&lt;/code&gt; 有 32 個測試，涵蓋 PTT 停用自動 VAD、activity 信號、逐字稿轉發、工具呼叫執行、resumption handle、連線回收、插話打斷。看起來很完整。但它們全部使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FakeLiveSession&lt;/code&gt;，不連網。模型從 3.1 換成 3.8，這 32 個測試一個字都不會變色。&lt;/p&gt;

&lt;p&gt;它們測的是「我送出去的東西長得對不對」，不是「對面回什麼」。&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;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;repo 測試套件（296 個）&lt;/td&gt;
      &lt;td&gt;送出去的 config 與訊息格式&lt;/td&gt;
      &lt;td&gt;CI 每次 push&lt;/td&gt;
      &lt;td&gt;可以&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;拋棄式 probe（3 支）&lt;/td&gt;
      &lt;td&gt;真實 API 的協定層相容性&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;td&gt;不行&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;中間那層才是唯一碰到 3.8 的自動化測試，而它不在 repo 裡、CI 不會跑、下次模型下架的時候不會有人知道。&lt;/p&gt;

&lt;p&gt;新加的守衛也只擋得住「設成一個已知會壞的值」，擋不住「Google 把 3.8-live 也下架了」——也就是上次真正發生的那件事。&lt;/p&gt;

&lt;p&gt;這個缺口還開著。把 probe 收成一個需要金鑰才跑的 smoke test 是解法，我還沒做。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;程式碼在 &lt;a href=&quot;https://github.com/kkdai/linebot-helper-python&quot;&gt;kkdai/linebot-helper-python&lt;/a&gt;，這次的改動在 PR #24。官方公告是 &lt;a href=&quot;https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-8-live-gemini-3-8-live-extended-thinking/&quot;&gt;Gemini 3.8 Live&lt;/a&gt;，裡面提到所有 AI 生成的音訊都帶 SynthID 浮水印，這點我沒有驗證。&lt;/p&gt;

&lt;p&gt;公告花最多篇幅講的即時視覺輸入，我這次完全沒碰——目前 LIFF 那支 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getUserMedia&lt;/code&gt; 是寫死 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;video: false&lt;/code&gt; 的。那大概是下一篇。&lt;/p&gt;
</description>
				<pubDate>Tue, 15 Sep 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-3-8-live/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-3-8-live/</guid>
			</item>
		
			<item>
				<title>實測 Google 官方 google-cloud-developer plugin：讓 AI Agent 不再亂掰 gcloud 指令</title>
				<description>&lt;p&gt;最近翻 Google Cloud 的&lt;a href=&quot;https://docs.cloud.google.com/docs/get-started/developer-environment?hl=en#antigravity-cli&quot;&gt;開發環境設定文件&lt;/a&gt;，
在一堆 gcloud CLI、Cloud Shell、Cloud Workstations 中間，多了一段以前沒有的東西：&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;agy plugin &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;https://github.com/google/skills/plugins/cloud/google-cloud-developer
&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;agy&lt;/code&gt; 是 Google Antigravity CLI。但真正讓我停下來的不是這個新 CLI，而是後面那串網址：
Google 開始把自家的產品知識，包成 agent plugin 放在 GitHub 上發佈。&lt;/p&gt;

&lt;p&gt;結果我打開自己的 Claude Code 一看，這個 plugin 早就裝在機器上了。所以這篇不用先裝 Antigravity CLI，
直接拿現成的來實測，看看它到底做了什麼。&lt;/p&gt;

&lt;h3 id=&quot;這個-plugin-裡面裝了什麼&quot;&gt;這個 plugin 裡面裝了什麼&lt;/h3&gt;

&lt;p&gt;先把它拆開看。整包其實不大，5 個 skill、1 份路由規則、1 個 MCP server，全部加起來 1549 行 markdown，沒有任何程式碼：&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;skills/gcloud/                        # gcloud 指令的安全護欄與語法驗證（272 行 + 340 行參考文件）
skills/retrieving-developer-knowledge/ # 查官方文件（103 行 + 182 行參考文件）
skills/finding-google-skills/          # 按需從遠端目錄撈技能（134 行）
skills/google-cloud-recipe-onboarding/ # 新手開第一個專案（228 行）
skills/google-cloud-recipe-auth/       # 憑證與 ADC 選擇（260 行）
rules/google-cloud-discovery.md        # 技能路由表（30 行）
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;有趣的是同一個目錄底下塞了四套 manifest：&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;.claude-plugin/plugin.json   # Claude Code
.codex-plugin/plugin.json    # Codex
gemini-extension.json        # Gemini CLI / Antigravity
plugin.json                  # 通用格式
&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;plugin.json&lt;/code&gt; 開頭是這樣：&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;$schema&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;https://agent-plugins.org/schemas/1.0.0/plugin.schema.json&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;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-developer&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;version&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;1.1.2&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;author&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;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 LLC&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;span class=&quot;nl&quot;&gt;&quot;license&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;Apache-2.0&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;agent-plugins.org&lt;/code&gt; 是個跨工具的 plugin schema。Google 沒有為自己的 Antigravity 做一份專屬格式，
而是同一包東西讓四種 agent 都吃得下去。換句話說，你不裝 Antigravity CLI 也用得到這包東西。&lt;/p&gt;

&lt;h3 id=&quot;安裝三條路挑一條&quot;&gt;安裝：三條路挑一條&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Antigravity CLI（文件上寫的）&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;agy plugin &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;https://github.com/google/skills/plugins/cloud/google-cloud-developer
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&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;claude plugin marketplace add google/skills
claude plugin &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;google-cloud-developer@google-plugins
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;npx skills add google/skills
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;裝完後要開 API：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gcloud services &lt;span class=&quot;nb&quot;&gt;enable &lt;/span&gt;developerknowledge.googleapis.com &lt;span class=&quot;nt&quot;&gt;--project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;YOUR_PROJECT_ID
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;nv&quot;&gt;$ &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;ls&lt;/span&gt; ~/.claude/plugins/cache/google-plugins/google-cloud-developer/
1.1.2
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;實測一護欄擋不擋得住亂掰的-flag&quot;&gt;實測一：護欄擋不擋得住亂掰的 flag&lt;/h3&gt;

&lt;p&gt;這個 plugin 最核心的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; 這個 skill。它開頭第一段話講得很直白：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;All pre-existing knowledge of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud&lt;/code&gt; commands, flags, flag values, and positional
argument syntax is &lt;strong&gt;stale and prone to hallucination&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;翻成白話：你（模型）記得的 gcloud 語法都是過期的，不准憑記憶寫。
所以它訂了一條硬規則：動手之前一定要先跑 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud help &amp;lt;leaf command&amp;gt;&lt;/code&gt;，而且父層的 help 不算數，
要驗到最底層的子指令。它甚至明文禁止用網路搜尋來查語法，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud help&lt;/code&gt; 是唯一權威。&lt;/p&gt;

&lt;p&gt;這條規則有沒有必要？拿 Cloud Run 的 scaling flag 來測：&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;nv&quot;&gt;$ &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;f &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--min-scale&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--max-scale&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--min-instances&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--max-instances&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do
    &lt;/span&gt;gcloud &lt;span class=&quot;nb&quot;&gt;help &lt;/span&gt;run deploy | rg &lt;span class=&quot;nt&quot;&gt;-q&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$f&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$f&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;  存在&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$f&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;  不存在&quot;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;done&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;--min-scale&lt;/span&gt;      不存在
&lt;span class=&quot;nt&quot;&gt;--max-scale&lt;/span&gt;      不存在
&lt;span class=&quot;nt&quot;&gt;--min-instances&lt;/span&gt;  存在
&lt;span class=&quot;nt&quot;&gt;--max-instances&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;--min-scale&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--max-scale&lt;/code&gt; 是 Knative 和早期 Cloud Run 的寫法，現在已經不是了。
但這兩個字串在網路上的舊文章、舊 Stack Overflow 回答裡到處都是，
所以模型非常容易吐出一個看起來很合理、跑起來直接報錯的指令。
先跑一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud help&lt;/code&gt;，這個問題就消失了。&lt;/p&gt;

&lt;p&gt;順帶一提，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud run deploy&lt;/code&gt; 其實沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--dry-run&lt;/code&gt;，只有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--async&lt;/code&gt;。
skill 的規則是「help 輸出裡有列 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--dry-run&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--validate-only&lt;/code&gt; 就一定要先跑一次」：
有列才跑，沒列就算了。&lt;/p&gt;

&lt;h3 id=&quot;實測二盲目-list-到底會吃掉多少-context&quot;&gt;實測二：盲目 list 到底會吃掉多少 context&lt;/h3&gt;

&lt;p&gt;skill 裡有一條規則我一開始覺得有點囉嗦：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;DO NOT execute any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list&lt;/code&gt; command without including at least one data reduction flag
(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--limit&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--filter&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--format&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;實際量一下就知道為什麼了。我手上有個 project 跑著 52 個 Cloud Run 服務：&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;# A. 盲目 list&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;gcloud run services list &lt;span class=&quot;nt&quot;&gt;--project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class=&quot;nt&quot;&gt;--format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;json | &lt;span class=&quot;nb&quot;&gt;wc&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt;
281918

&lt;span class=&quot;c&quot;&gt;# B. 只投影需要的兩個欄位（同樣 52 個服務，一個都沒少）&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;gcloud run services list &lt;span class=&quot;nt&quot;&gt;--project&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;json(metadata.name,status.url)&quot;&lt;/span&gt; | &lt;span class=&quot;nb&quot;&gt;wc&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt;
7963
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;281,918 bytes 對 7,963 bytes，差 35 倍。&lt;/strong&gt;換算成 token 大約是 70k 對 2k。
也就是說，一個沒加 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--format&lt;/code&gt; 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list&lt;/code&gt;，就要吃掉 70k tokens，
而被砍掉的那 97% 是 annotations、conditions、revision template 和一堆時間戳。&lt;/p&gt;

&lt;p&gt;skill 還教了一招我覺得很實用的「先探 schema 再查全量」：&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 &amp;lt;GROUP&amp;gt; &amp;lt;RESOURCE&amp;gt; list &lt;span class=&quot;nt&quot;&gt;--limit&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;1 &lt;span class=&quot;nt&quot;&gt;--format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;先抓一筆看 JSON 長什麼樣，確認欄位路徑之後，再去組 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--filter&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--format&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;其他幾條執行規則也都是同一個思路：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;一次只跑一個指令，不准 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;amp;&amp;amp;&lt;/code&gt; 串接&lt;/li&gt;
  &lt;li&gt;禁止 pipe、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$( )&lt;/code&gt;、重導向（理由是讓人類 review 指令時看得懂）&lt;/li&gt;
  &lt;li&gt;一律加 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--project=&amp;lt;PROJECT_ID&amp;gt;&lt;/code&gt;，不准依賴 active config 的預設值&lt;/li&gt;
  &lt;li&gt;一律加 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--quiet&lt;/code&gt;，避免在沒有 TTY 的環境卡在互動式確認&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;實測三哪些指令它不准自己跑&quot;&gt;實測三：哪些指令它不准自己跑&lt;/h3&gt;

&lt;p&gt;這是我覺得最值得抄的一段。skill 裡有一份明確的 denylist，
列出&lt;strong&gt;沒有人類明確授權就絕對不能自動執行&lt;/strong&gt;的操作：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;任何 IAM policy / role / binding 的異動（權限提升、把自己鎖在門外的風險）&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud * delete&lt;/code&gt;（不可逆的資源銷毀）&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud billing *&lt;/code&gt;（帳單爆炸的風險）&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud organizations *&lt;/code&gt;（組織層級的設定會影響所有人）&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud kms *&lt;/code&gt;（有機會把資料永久鎖死）&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcloud infra-manager deployments apply&lt;/code&gt;（自動跑 IaC 可能砍掉一整批資源）&lt;/li&gt;
  &lt;li&gt;主動啟用 API 也在名單上，理由是開 API 可能連帶開始計費&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;最後那條我覺得特別有意思。開個 API 聽起來無害，但 skill 直接寫「assume necessary APIs are enabled」，
要開就回頭問人。這種程度的保守，是有認真想過 agent 接上正式環境會發生什麼事的。&lt;/p&gt;

&lt;h3 id=&quot;實測四developer-knowledge查文件而不是憑記憶&quot;&gt;實測四：Developer Knowledge，查文件而不是憑記憶&lt;/h3&gt;

&lt;p&gt;plugin 附了一個 MCP server：&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;mcpServers&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;developer-knowledge&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;streamable-http&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;url&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;https://developerknowledge.googleapis.com/mcp&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;span class=&quot;p&quot;&gt;}&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;這裡遇到第一個坑。MCP server 在設定檔裡宣告了，不代表它真的接得起來。
我這邊列出來的工具只有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;authenticate&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;complete_authentication&lt;/code&gt;，
文件講的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;answer_query&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;search_documents&lt;/code&gt; 根本沒出現，代表還卡在認證流程。&lt;/p&gt;

&lt;p&gt;有趣的是 skill 自己早就預期到了，白紙黑字寫著：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;A declared server is not always a connected server.&lt;/strong&gt; Some clients cannot complete
the MCP handshake with this server and expose no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;answer_query&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;search_documents&lt;/code&gt;
or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;get_documents&lt;/code&gt; tool at all, even though the plugin declares one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;然後它直接給了 REST fallback。用本來就有的 gcloud 憑證就能打，不用另外申請 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;curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-X&lt;/span&gt; POST &lt;span class=&quot;s2&quot;&gt;&quot;https://developerknowledge.googleapis.com/v1:answerQuery&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;X-Goog-User-Project: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;gcloud config get-value project&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Content-Type: application/json&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;{&quot;query&quot;: &quot;How do I deploy a container to Cloud Run with gcloud?&quot;}&apos;&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;HTTP 200&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;nv&quot;&gt;$ &lt;/span&gt;jq &lt;span class=&quot;s1&quot;&gt;&apos;.answer | keys&apos;&lt;/span&gt; response.json
&lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;answerText&quot;&lt;/span&gt;, &lt;span class=&quot;s2&quot;&gt;&quot;citations&quot;&lt;/span&gt;, &lt;span class=&quot;s2&quot;&gt;&quot;references&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;jq &lt;span class=&quot;s1&quot;&gt;&apos;.answer.references | length&apos;&lt;/span&gt; response.json
10

&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;jq &lt;span class=&quot;nt&quot;&gt;-r&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;.answer.references[].documentReference.documentChunk.document.uri&apos;&lt;/span&gt; response.json | &lt;span class=&quot;nb&quot;&gt;sort&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-u&lt;/span&gt;
https://docs.cloud.google.com/run/docs/deploying
https://docs.cloud.google.com/run/docs/quickstarts/deploy-container
https://docs.cloud.google.com/sdk/gcloud/reference/run/deploy
https://docs.cloud.google.com/run/docs/configuring/services/containers
https://docs.cloud.google.com/run/docs/create-jobs
...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;不只給答案，還附 10 篇官方文件當出處，而且 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;citations&lt;/code&gt; 是用字元區間對應到 reference index：&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;nl&quot;&gt;&quot;startIndex&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;mi&quot;&gt;447&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;endIndex&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;mi&quot;&gt;505&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;sources&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;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;referenceIndex&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;mi&quot;&gt;3&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;referenceIndex&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;mi&quot;&gt;7&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;答案裡第 447 到 505 個字是從第 3 和第 7 篇文件來的。這種粒度的引用，要驗證答案真假就容易多了。&lt;/p&gt;

&lt;p&gt;查精確語法則用另一個端點，我拿 Cloud Run 的 IAM 權限字串測：&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;-s&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-G&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;https://developerknowledge.googleapis.com/v1/documents:searchDocumentChunks&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--data-urlencode&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;query=cloud run invoker IAM permission run.routes.invoke&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--data-urlencode&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;pageSize=3&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;X-Goog-User-Project: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;gcloud config get-value project&lt;span class=&quot;si&quot;&gt;)&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;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;run.routes.invoke
run.services.create
run.services.delete
run.services.get
run.services.list
run.services.update
&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;service.resource.verb&lt;/code&gt; 格式的字串，是模型最容易少一個字或多一個 s 的地方，查一下比猜穩多了。&lt;/p&gt;

&lt;p&gt;小坑記一下：skill 自己的 fallback 文件寫回應要從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;documentChunks[].content&lt;/code&gt; 讀，
但實際回來的欄位是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;results[].content&lt;/code&gt;。這個小出入本身就很說明問題。
連官方文件都會跟實際 API 對不上，所以「先驗證再動手」這條規則確實有用。&lt;/p&gt;

&lt;h3 id=&quot;實測五137-個技能怎麼做到不吃-context&quot;&gt;實測五：137 個技能怎麼做到不吃 context&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finding-google-skills&lt;/code&gt; 解決的是另一個問題：Google 的技能目錄很大，但不可能全部預載。&lt;/p&gt;

&lt;p&gt;它的做法是只留一頁路由表，真正要用的時候才去撈遠端目錄：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;curl &lt;span class=&quot;nt&quot;&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/google/skills/main/index.json &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; index.json
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;jq &lt;span class=&quot;s1&quot;&gt;&apos;.skills | length&apos;&lt;/span&gt; index.json
137
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;wc&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt; index.json
84476
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;137 個技能，84KB。全塞進 context 太浪費，所以 skill 教你先用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jq&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;nv&quot;&gt;$ &lt;/span&gt;curl &lt;span class=&quot;nt&quot;&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/google/skills/main/index.json &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  | jq &lt;span class=&quot;nt&quot;&gt;-r&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;.skills[] | select((.name+&quot; &quot;+.description)|test(&quot;cloud run&quot;;&quot;i&quot;)) | .name&apos;&lt;/span&gt;
cloud-run-basics
cloud-run-alert-configuration
cloud-monitoring-metric-selection
google-cloud-global-frontend-configuration
...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;選出最多三個最相關的，才去抓它們的 entrypoint。這個「目錄放遠端、按需載入」的模式，
我覺得是目前處理大量 skill 最合理的解法。&lt;/p&gt;

&lt;p&gt;還有一個細節：這個 skill 明文禁止在憑證驗證失敗時用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl -k&lt;/code&gt; 繞過，
連 PowerShell 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ServicePointManager&lt;/code&gt; callback 都點名禁止。理由寫得很好：
「你正準備照著抓回來的東西執行指令，一份沒驗證過的目錄比沒有目錄更糟。」&lt;/p&gt;

&lt;h3 id=&quot;五個實際用得上的場景&quot;&gt;五個實際用得上的場景&lt;/h3&gt;

&lt;h4 id=&quot;場景一把-agent-接上正式環境但只准它看不准它動&quot;&gt;場景一：把 agent 接上正式環境，但只准它看不准它動&lt;/h4&gt;

&lt;p&gt;這是我最想要的一個。以前不太敢把 Claude Code 指到 production project，
因為它可能興致一來就幫你「整理」掉一批資源。有了 denylist 之後，界線變得很清楚。&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;幫我看一下 production 專案裡有哪些 Cloud Run 服務超過三個月沒有新的 revision
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;它會去 list、會 filter、會整理成表給你，但碰到 delete 就停下來問。
它省掉的是翻文件和組指令的時間，決定權還在你手上。這個分界抓得很準。&lt;/p&gt;

&lt;h4 id=&quot;場景二從零開一個-gcp-專案把-line-bot-丟上-cloud-run&quot;&gt;場景二：從零開一個 GCP 專案，把 LINE Bot 丟上 Cloud Run&lt;/h4&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;我要開一個新的 GCP 專案部署 LINE Bot，幫我從建專案、綁帳單、開 API 到部署 Cloud Run 走一遍
&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-recipe-onboarding&lt;/code&gt; 會把 billing → project → 啟用 API → 部署整條線帶完。
每個指令動手前先驗語法，開 API 前一定停下來問你（因為在 denylist 上）。
適合久久開一次新專案、每次都要重新 google 一輪的人。&lt;/p&gt;

&lt;h4 id=&quot;場景三憑證地獄本機跑得動上-cloud-run-就-403&quot;&gt;場景三：憑證地獄——本機跑得動，上 Cloud Run 就 403&lt;/h4&gt;

&lt;p&gt;這大概是 GCP 最常見的鬼打牆。&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;我的服務本機跑正常，部署到 Cloud Run 之後呼叫 Vertex AI 一直 403，幫我釐清是哪個憑證的問題
&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-recipe-auth&lt;/code&gt; 負責釐清 user credential、ADC、service account 三者的關係和適用場合，
Developer Knowledge 負責查到精確的 IAM 權限字串。這兩件事合起來，
比在 Stack Overflow 上翻五年前的答案快多了。&lt;/p&gt;

&lt;h4 id=&quot;場景四雲端資源盤點與帳單健檢&quot;&gt;場景四：雲端資源盤點與帳單健檢&lt;/h4&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;列出這個專案所有 Cloud Run 服務的名稱、URL 和最後部署時間，找出可能已經沒在用的
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;重點在前面量過的那 35 倍。沒有護欄的話，52 個服務的原始 JSON 就足以讓 agent 在真正開始分析之前，
context 已經被垃圾資料塞滿。有了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--format&lt;/code&gt; 投影和「先 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--limit=1&lt;/code&gt; 探 schema」的習慣，
52 個服務只吃 2k tokens，資源再多幾倍也還撐得住。&lt;/p&gt;

&lt;h4 id=&quot;場景五問新功能時不要再吃到模型的舊記憶&quot;&gt;場景五：問新功能時不要再吃到模型的舊記憶&lt;/h4&gt;

&lt;p&gt;Gemini API、Cloud Run、Firebase 這種迭代很快的產品，模型的訓練資料一定落後。&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;Cloud Run 現在要怎麼設定 min instances 和 startup CPU boost？
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;有了 Developer Knowledge，答案會附上官方文件連結和字元級的引用區間。
更重要的是查不到的時候它會直說：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Presenting recalled documentation as a retrieved result is the worst available outcome,
because nothing in the reply distinguishes it from a real lookup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;「我沒查到，這是憑記憶回答的」比一個聽起來很順但是錯的 flag 有價值太多了。&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;小結&quot;&gt;小結&lt;/h3&gt;

&lt;p&gt;實測下來，讓我印象最深的其實是兩件跟功能無關的事。&lt;/p&gt;

&lt;p&gt;第一，&lt;strong&gt;它整包就是 1549 行 markdown，沒有半行程式碼。&lt;/strong&gt;
所有的安全性都靠文字規則實現：先驗語法、限制輸出、明確的 denylist、失敗時誠實說失敗。
這代表你也可以用同樣的方式，把自己團隊的 runbook 和安全規則寫成 skill：
門檻比想像中低很多。&lt;/p&gt;

&lt;p&gt;第二，四套 manifest 擠在同一個目錄裡。
Google 沒有為 Antigravity 做專屬格式，而是讓同一包東西在 Claude Code、Codex、Gemini CLI 上都能用。
Skill 正在變成一種不綁定工具的共通格式。&lt;/p&gt;

&lt;p&gt;如果你手上有 GCP 專案又在用 coding agent，這個 plugin 裝起來大概五分鐘。
光是不會再亂掰 flag、不會自己去動 IAM 這兩點，就夠划算了。&lt;/p&gt;

&lt;h3 id=&quot;參考連結&quot;&gt;參考連結&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.cloud.google.com/docs/get-started/developer-environment?hl=en#antigravity-cli&quot;&gt;Set up your Google Cloud developer environment&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/google/skills&quot;&gt;google/skills - GitHub&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/google/skills/tree/main/plugins/cloud/google-cloud-developer&quot;&gt;google-cloud-developer plugin 原始碼&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://antigravity.google/docs/cli/&quot;&gt;Google Antigravity CLI 文件&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Mon, 14 Sep 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/google-cloud-developer-plugin/</link>
				<guid isPermaLink="true">https://www.evanlin.com/google-cloud-developer-plugin/</guid>
			</item>
		
			<item>
				<title>【Steam Deck 指南】解決 Windows .exe 補丁找不到路徑的痛點：超穩定的「中繼搬移法」</title>
				<description>&lt;p&gt;在 Steam Deck 上遊玩各種電腦遊戲時，經常會遇到需要安裝「社群中文化補丁」、「民間修復更新」或「MOD 擴充包」的情況。然而，許多開發者或漢化小組釋出的補丁是包裝成 Windows 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.exe&lt;/code&gt; 安裝程式，並在執行時要求玩家手動選擇遊戲目錄。&lt;/p&gt;

&lt;p&gt;這在 Steam Deck 的 SteamOS（Linux 架構）底下往往會遇到一個巨大的痛點：&lt;strong&gt;安裝程式的資料夾選取視窗根本找不到遊戲裝在哪裡。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;本文將解析這個問題的核心成因，並分享一套目前最直覺、成功率最高且不需輸入複雜指令的「中繼搬移法」。&lt;/p&gt;

&lt;h3 id=&quot;為什麼補丁程式會迷路&quot;&gt;為什麼補丁程式會「迷路」？&lt;/h3&gt;

&lt;p&gt;SteamOS 是透過相容層（Proton 或 Wine）來模擬 Windows 環境執行程式，底層有兩個機制容易導致安裝程式卡關：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;隱藏資料夾限制&lt;/strong&gt;：Steam 預設將所有遊戲安裝在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/home/deck/.local/share/Steam/steamapps/common/&lt;/code&gt;。在 Linux 系統中，名稱開頭為小數點（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.&lt;/code&gt;）的資料夾是隱藏資料夾。多數 Windows 補丁自帶的「瀏覽資料夾」瀏覽器無法辨識或顯示這些隱藏路徑。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;沙盒權限隔離&lt;/strong&gt;：如果你習慣使用 Bottles（樽）或部分 Flatpak 應用程式來啟動 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.exe&lt;/code&gt;，這些工具預設受到沙盒權限控管，無法直接讀取使用者的系統深層檔案。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;核心解法中繼搬移三步驟&quot;&gt;核心解法：中繼搬移三步驟&lt;/h3&gt;

&lt;p&gt;與其跟隱藏資料夾設定與 Linux 指令搏鬥，最乾淨的做法是先將遊戲資料夾搬移到&lt;strong&gt;非隱藏的公開目錄（如 Downloads 或 Desktop）&lt;/strong&gt;，完成補丁注入後再覆蓋回原始路徑。&lt;/p&gt;

&lt;h4 id=&quot;第一步複製遊戲目錄至中繼路徑&quot;&gt;第一步：複製遊戲目錄至中繼路徑&lt;/h4&gt;

&lt;ol&gt;
  &lt;li&gt;長按電源鍵，切換至 &lt;strong&gt;Switch to Desktop（桌面模式）&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;開啟 Steam 收藏庫，在目標遊戲上點擊右鍵 ➔ &lt;strong&gt;管理&lt;/strong&gt; ➔ &lt;strong&gt;瀏覽本機檔案&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;檔案總管（Dolphin）會打開遊戲所在的目錄。點擊網址列往上一層回到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;common&lt;/code&gt; 資料夾。&lt;/li&gt;
  &lt;li&gt;對著「該遊戲的資料夾」按右鍵選擇 &lt;strong&gt;Copy（複製）&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;從左側快捷欄進入 &lt;strong&gt;Downloads（下載）&lt;/strong&gt; 或 &lt;strong&gt;Desktop（桌面）&lt;/strong&gt;，在空白處按右鍵選擇 &lt;strong&gt;Paste One Folder（貼上）&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;h4 id=&quot;第二步執行補丁並透過-z-槽選取路徑&quot;&gt;第二步：執行補丁並透過 Z 槽選取路徑&lt;/h4&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;透過 Bottles、Protontricks 或將補丁加入非 Steam 遊戲來啟動補丁 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.exe&lt;/code&gt;。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;當安裝程式跳出「請選擇遊戲安裝目錄」視窗時，點開 &lt;strong&gt;My Computer（我的電腦）&lt;/strong&gt; ➔ &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Z:&lt;/code&gt; 槽&lt;/strong&gt;（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Z:\&lt;/code&gt; 即對應 Steam Deck 的根目錄）。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;依序展開以下公開路徑：&lt;/p&gt;

    &lt;ul&gt;
      &lt;li&gt;
        &lt;p&gt;&lt;strong&gt;若剛才貼在 Downloads：&lt;/strong&gt;&lt;/p&gt;

        &lt;p&gt;Plaintext&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;Z:\home\deck\Downloads\[你的遊戲資料夾]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;        &lt;/div&gt;
      &lt;/li&gt;
      &lt;li&gt;
        &lt;p&gt;&lt;strong&gt;若剛才貼在 Desktop：&lt;/strong&gt;&lt;/p&gt;

        &lt;p&gt;Plaintext&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;Z:\home\deck\Desktop\[你的遊戲資料夾]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;        &lt;/div&gt;
      &lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;選定資料夾後按下確認，開始執行補丁寫入。由於路徑完全透明，安裝程式能順利將替換檔案解壓進去。&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h4 id=&quot;第三步覆蓋回原路徑與啟動檔確認&quot;&gt;第三步：覆蓋回原路徑與啟動檔確認&lt;/h4&gt;

&lt;ol&gt;
  &lt;li&gt;補丁安裝完成並關閉視窗後，回到剛才的 Downloads 或 Desktop 目錄。&lt;/li&gt;
  &lt;li&gt;進入補丁更新完畢的遊戲資料夾，按 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl + A&lt;/code&gt; 全選所有檔案，點右鍵選擇 &lt;strong&gt;Copy（複製）&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;回到 Steam 原始的遊戲資料夾（Steam 收藏庫 ➔ 遊戲點右鍵 ➔ 管理 ➔ 瀏覽本機檔案）。&lt;/li&gt;
  &lt;li&gt;在空白處按右鍵 &lt;strong&gt;Paste（貼上）&lt;/strong&gt;，當系統跳出覆蓋提示時，選擇 &lt;strong&gt;Overwrite / Write Over（全數覆蓋）&lt;/strong&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;關鍵檢查&lt;/strong&gt;：部分補丁在安裝後會產生獨立的執行檔（例如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Game_Patch.exe&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Game_ZH.exe&lt;/code&gt;）。若有此情況，請將原本的遊戲啟動主程式（如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Game.exe&lt;/code&gt;）備份改名，並將補丁產生的新執行檔改回 Steam 預設的啟動檔名。&lt;/li&gt;
  &lt;li&gt;清理暫存檔案：確認無誤後，即可將 Downloads 或 Desktop 中的中繼資料夾刪除。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;注意事項與除錯技巧&quot;&gt;注意事項與除錯技巧&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Linux 檔案大小寫敏感&lt;/strong&gt;：Linux 系統會將 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Data&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data&lt;/code&gt; 視為完全不同的目錄。如果補丁解壓縮出來的資料夾大小寫與原遊戲不同，請手動將內容合併，避免生成雙重資料夾導致遊戲讀取不到補丁資源。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;動畫與編碼相容性&lt;/strong&gt;：若打完補丁後進入遊戲遇到文字方塊、日文缺字或開場動畫黑屏，請在 Steam 該遊戲的 &lt;strong&gt;內容 ➔ 相容性&lt;/strong&gt; 中改用 &lt;strong&gt;GE-Proton&lt;/strong&gt;（可透過 Discover 商店的 ProtonUp-Qt 安裝），通常能解決絕大多數自製影片解碼器的問題。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;透過這套「中繼搬移」技巧，未來無論遇到介面多古老、路徑多刁鑽的 Windows 一次性補丁程式，都能在 Steam Deck 上精準完成安裝，順利享受最佳的遊戲體驗。&lt;/p&gt;
</description>
				<pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/steam-deck-windows-game-patch/</link>
				<guid isPermaLink="true">https://www.evanlin.com/steam-deck-windows-game-patch/</guid>
			</item>
		
			<item>
				<title>[AI 實戰] Gemini Agentic Video 實測：官方沒說的四個前提，以及我把它接進 LINE Bot 的過程</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260903123726193.png&quot; alt=&quot;image-20260903123726193&quot; /&gt;&lt;/p&gt;

&lt;h1 id=&quot;前情提要&quot;&gt;前情提要&lt;/h1&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;。丟網址給它會回摘要跟四個平台的社群文案，丟 YouTube 連結會回影片摘要，還有書籤、地點查詢、語音助理那些。跑在 Cloud Run 上，用 Vertex AI。&lt;/p&gt;

&lt;p&gt;八月底 Google 發了一篇 &lt;a href=&quot;https://blog.google/innovation-and-ai/models-and-research/gemini-models/introducing-agentic-video-in-gemini/&quot;&gt;Introducing agentic video in Gemini&lt;/a&gt;，看完我第一個念頭是：這東西能讓我的 bot 多做一件現在做不到的事——&lt;strong&gt;針對影片提問&lt;/strong&gt;，而不是只給一份摘要。&lt;/p&gt;

&lt;p&gt;結果做下來，公告裡沒寫的部分比寫了的更值得記。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;agentic-video-是什麼&quot;&gt;Agentic video 是什麼&lt;/h1&gt;

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

&lt;p&gt;原本 Gemini 讀影片是「靜態」的：不管你問什麼，它都照固定的取樣率把整支影片的每一格畫面、每一段音訊全部塞進 context。一支兩小時的影片，光影片本身就佔掉幾十萬 token，而你可能只是想問「他有講到定價嗎」。&lt;/p&gt;

&lt;p&gt;Agentic 模式把這件事反過來：讓模型自己決定要載入哪一段。先掃逐字稿，判斷答案可能在 1:24 附近，就只把那一段的畫面拉進來。&lt;/p&gt;

&lt;p&gt;官方公告給的數字是長片場景 token 少 88%、成本降 66%、正確率高 7%。開啟方式就是在 Part 上多一個參數：&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;video_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;file_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;FileData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file_uri&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;mime_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;video/mp4&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;media_processing&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;AGENTIC&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# 或 &quot;STATIC&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;gemini-3.7-flash&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.6-flash&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-flash-lite&lt;/code&gt;。輸入來源可以是 YouTube 網址、Cloud Storage URI，或內嵌的 base64。&lt;/p&gt;

&lt;h2 id=&quot;它實際能做到什麼&quot;&gt;它實際能做到什麼&lt;/h2&gt;

&lt;p&gt;我拿兩小時的 Google I/O ‘25 keynote 測，問「他有講到定價嗎？如果有，請給我幾分幾秒」，回來的是：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;影片中有提到價格與訂閱方案的定價相關資訊。主要出現在影片的 &lt;strong&gt;1:24:40 到 1:26:10&lt;/strong&gt; 左右⋯⋯&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;然後正確描述了 Google AI Pro 與 Google AI Ultra 兩個方案的差異。再問 Android XR 眼鏡那段在哪，回 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1:36:31 至 1:50:30&lt;/code&gt;，內容也對得上。&lt;/p&gt;

&lt;p&gt;這種在兩小時影片裡精準定位的能力，是我覺得這個功能真正值錢的地方。摘要人人都會做，「這支長片裡他哪時候講到 X」目前沒什麼工具做得好。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;前置條件四個參數少一個都不會報錯&quot;&gt;前置條件：四個參數，少一個都不會報錯&lt;/h1&gt;

&lt;p&gt;這是這次最貴的一課，所以放前面講。&lt;/p&gt;

&lt;p&gt;在 Vertex AI 上要讓 agentic 真的生效，&lt;strong&gt;四件事必須同時成立&lt;/strong&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;th&gt;少了會怎樣&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api_version=&quot;v1beta1&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;agentic 只在 v1beta1 提供，用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v1&lt;/code&gt; 會靜默退回 STATIC&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;media_processing=&quot;AGENTIC&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;預設就是 STATIC，不設等於沒開&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3&lt;/td&gt;
      &lt;td&gt;模型在支援名單內&lt;/td&gt;
      &lt;td&gt;不支援的模型會靜默降級為 STATIC&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level&lt;/code&gt; 有設定&lt;/td&gt;
      &lt;td&gt;見下一節，這個最麻煩&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;關鍵字是&lt;strong&gt;靜默&lt;/strong&gt;。少任何一個，API 都會回 200、答案照樣產出、看起來完全正常，只有帳單不一樣。沒有任何錯誤訊息會告訴你 agentic 沒生效。&lt;/p&gt;

&lt;p&gt;還有兩個環境面的前提：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;SDK 版本&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;media_processing&lt;/code&gt; 這個欄位是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google-genai&lt;/code&gt; &lt;strong&gt;2.20.0&lt;/strong&gt; 才加進去的。我逐版下載驗證過，2.19.0 沒有、2.20.0 有。版本太舊的話這個參數會被當成未知欄位丟掉，一樣不報錯。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Vertex 專屬&lt;/strong&gt;：官方文件的多輪對話範例是寫給 Gemini Developer API 的，Vertex 這條路上行為不同，後面會講。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我的作法是把這四項各寫一支測試，斷言送進 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;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;test_thinking_level_is_low&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;captured&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;youtube_tool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;summarize_youtube_video&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VIDEO_URL&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;captured&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;call_kwargs&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;config&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;assert&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;n&quot;&gt;thinking_config&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;assert&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;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;thinking_config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;thinking_level&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upper&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;endswith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;LOW&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;這四支測試存在的唯一理由，就是「這四件事錯了，程式照跑、答案照出，只有成本默默變動」。這種 bug 靠人工 review 抓不到。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑一vertex-ai-上留不住影片-context&quot;&gt;踩坑一：Vertex AI 上留不住影片 context&lt;/h1&gt;

&lt;p&gt;我原本的設計是這樣：使用者貼連結拿到摘要，按「問這部影片」進入問答模式，之後每次追問都沿用上一輪的 context，不用重跑影片。&lt;/p&gt;

&lt;p&gt;官方文件確實這樣寫：回應會帶 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tool_call&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tool_response&lt;/code&gt; parts，把它們原樣放回 history，下一輪就不用重新處理影片。&lt;/p&gt;

&lt;p&gt;實測結果是拿不到。&lt;/p&gt;

&lt;p&gt;Vertex 回的 parts 裡只有光禿禿的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thought_signature&lt;/code&gt;，沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tool_call&lt;/code&gt; 也沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tool_response&lt;/code&gt;。把 history 傳回去：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;400 INVALID_ARGUMENT: Invalid thought signature.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;我試了三種序列化方式，全部失敗。然後試了&lt;strong&gt;完全不序列化、直接把 response 物件傳回去&lt;/strong&gt;，一樣失敗。所以問題不在我的序列化，在平台。&lt;/p&gt;

&lt;p&gt;把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level&lt;/code&gt; 設成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LOW&lt;/code&gt; 之後不報錯了，但第二輪的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tool_use_prompt_token_count&lt;/code&gt; 是 &lt;strong&gt;2,163&lt;/strong&gt;，跟第一輪的 &lt;strong&gt;2,163&lt;/strong&gt; 一模一樣。影片被完整重跑了一遍，context 一點都沒留下。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：那套多輪機制是寫給 Gemini Developer API 的。既然 Vertex 上保不住，就別假裝保得住：改成 stateless 重新查詢，每次提問都是獨立的一次呼叫。&lt;/p&gt;

&lt;p&gt;這個決定讓實作反而變簡單了：不用序列化 history、不用處理 thought signature、session 只要存「這個使用者現在在問哪支影片」三個欄位。原本規劃要做的複雜版本直接被砍掉。&lt;/p&gt;

&lt;p&gt;順帶一提，我原本擔心的「history 序列化會不會超過 Firestore 單文件 1 MiB 上限」，實測只有 1.5–7 KiB，&lt;strong&gt;從頭到尾都不是問題&lt;/strong&gt;。花時間擔心的地方跟真正出事的地方經常不是同一個。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;踩坑二thinking-token-才是成本主角而且擋不住&quot;&gt;踩坑二：thinking token 才是成本主角，而且擋不住&lt;/h1&gt;

&lt;p&gt;這一坑我修正了三次因果推論才搞對，過程比結論有意思。&lt;/p&gt;

&lt;h2 id=&quot;第一版結論錯的&quot;&gt;第一版結論（錯的）&lt;/h2&gt;

&lt;p&gt;最初的 spike，同一支 10 分鐘影片：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;模式&lt;/th&gt;
      &lt;th&gt;in&lt;/th&gt;
      &lt;th&gt;out（含 thinking）&lt;/th&gt;
      &lt;th&gt;成本&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;STATIC&lt;/td&gt;
      &lt;td&gt;54,546&lt;/td&gt;
      &lt;td&gt;291&lt;/td&gt;
      &lt;td&gt;$0.0171&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AGENTIC，未設 thinking_level&lt;/td&gt;
      &lt;td&gt;2,216&lt;/td&gt;
      &lt;td&gt;37,574&lt;/td&gt;
      &lt;td&gt;$0.0946&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AGENTIC + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level=&quot;LOW&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;2,216&lt;/td&gt;
      &lt;td&gt;638&lt;/td&gt;
      &lt;td&gt;$0.0023&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;看起來很清楚：agentic 把影片從 input 拿掉了（54,546 → 2,216），但模型改用「思考」去導航，而 &lt;strong&gt;thinking token 按 output 計價&lt;/strong&gt;（$2.50/M，是 input 的 8.3 倍）。所以不設 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level&lt;/code&gt; 反而比 static 貴 5.5 倍，設了就便宜 7.4 倍。&lt;/p&gt;

&lt;p&gt;然後我拿兩小時的影片跑，同樣設 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LOW&lt;/code&gt;，thinking token 是 &lt;strong&gt;359,961&lt;/strong&gt;，單次 $0.91。&lt;/p&gt;

&lt;p&gt;我的結論是：「長片上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level&lt;/code&gt; 會被忽略」。&lt;/p&gt;

&lt;h2 id=&quot;第二版結論也是錯的&quot;&gt;第二版結論（也是錯的）&lt;/h2&gt;

&lt;p&gt;實作階段，負責那個 task 的 agent 照我要求跑了驗證，撞到門檻停下來回報：同一支影片、同一組設定，連續五次的 thinking token 是&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;0, 0, 34911, 35122, 37410
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;它還去撈了 DEBUG log，確認每一次送出的請求都確實帶著 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v1beta1&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AGENTIC&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level=LOW&lt;/code&gt;，不是客戶端漏設。&lt;/p&gt;

&lt;p&gt;所以不是影片長度——&lt;strong&gt;我每個設定只跑一次，把抽樣雜訊當成了因果關係&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;它提了一個假說：可能跟 prompt 複雜度有關，因為前面那批用的是一句話的短 prompt，而出事那批用的是正式版的長 prompt。合理，於是我讓它用正式 prompt 重測。&lt;/p&gt;

&lt;h2 id=&quot;第三版實際的樣子&quot;&gt;第三版（實際的樣子）&lt;/h2&gt;

&lt;p&gt;27 次呼叫，三種設定各 6 次加問答路徑各 3 次：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;設定&lt;/th&gt;
      &lt;th&gt;thinking 尖峰&lt;/th&gt;
      &lt;th&gt;tool_use&amp;gt;0&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level=&quot;LOW&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;0/6&lt;/td&gt;
      &lt;td&gt;✓&lt;/td&gt;
      &lt;td&gt;$0.00146&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_budget=0&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;0/6&lt;/td&gt;
      &lt;td&gt;✓&lt;/td&gt;
      &lt;td&gt;$0.00137&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;完全不設&lt;/td&gt;
      &lt;td&gt;0/6&lt;/td&gt;
      &lt;td&gt;✓&lt;/td&gt;
      &lt;td&gt;$0.00137&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;STATIC&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;0/6&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;✗（=0）&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;$0.01659&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;全部 0 尖峰。但先前用&lt;strong&gt;同一組正式 prompt&lt;/strong&gt; 的那批，是 4 次中 3 次尖峰。&lt;/p&gt;

&lt;p&gt;所以 prompt 複雜度也不是。三種客戶端設定的行為完全一樣，唯一的差別是&lt;strong&gt;何時呼叫&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：這是伺服器端的非決定性，客戶端沒有任何槓桿能控制。順帶確認兩件事：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_budget&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level&lt;/code&gt; 不能並用（server 直接回 400）；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;STATIC&lt;/code&gt; 是唯一決定性的選項，但它的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tool_use_tokens&lt;/code&gt; 是 0。那不是「同樣功能換一種穩定模式」，是把 agentic 整個關掉。&lt;/p&gt;

&lt;p&gt;我的決定是維持 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level=&quot;LOW&quot;&lt;/code&gt;，並加一道警告 log：&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;# 實測 thinking token 只有兩群：~0 或 ~35,000-37,000，中間沒有值。
# 成因是伺服器端非決定性，沒有任何客戶端設定能防。
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;THINKING_TOKENS_WARN_THRESHOLD&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;維持 LOW 不是因為它比較穩（並沒有），而是三種設定量測不出差異，換一個等於拿已驗證的行為去換沒驗證的。真正有價值的補強是把那個 60 倍的成本事件從隱形變成 Cloud Run log 裡能 grep 到的東西。擋不住，至少要看得見。&lt;/p&gt;

&lt;p&gt;所以誠實的成本圖像是：&lt;strong&gt;正常約 $0.0014 一次，尖峰約 60 倍，無法預測也無法預防&lt;/strong&gt;。公告說的「成本降 66%」在不尖峰時成立，尖峰時反過來。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level&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;_generate_video&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;youtube_url&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;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;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;thinking_level&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;給它預設值就是給人漏掉的機會，而漏掉不會有任何徵兆。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;接進-line-bot功能長什麼樣&quot;&gt;接進 LINE Bot：功能長什麼樣&lt;/h1&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;使用者貼 YouTube 連結
  → 收到摘要 + 社群文案（原有功能）
  → 多一顆「🎬 問這部影片」
  → 按下後直接打字問：「他有講到定價嗎？」
  → 回答帶時間戳
  → 貼新網址 → 自動離開影片模式
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;三個新模組，各自獨立可測：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools/youtube_tool.py&lt;/code&gt;（改）— 唯一跟 Gemini 影片 API 溝通的地方，四個必要參數集中在一個函式裡&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;services/video_qa.py&lt;/code&gt;（新）— 記住「使用者 → 影片」，TTL 30 分鐘&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;services/usage_meter.py&lt;/code&gt;（新）— 記錄每次呼叫的 token 與成本&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.py&lt;/code&gt; 只增加兩個接點：訊息攔截，跟一個 postback 分支。&lt;/p&gt;

&lt;h2 id=&quot;判斷順序是行為契約&quot;&gt;判斷順序是行為契約&lt;/h2&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;脫離條件檢查 → 配額檢查 → 呼叫 Gemini
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;脫離判斷必須在配額判斷之前。&lt;/strong&gt; 使用者貼新網址想換主題，不該因為影片問答的配額用完而被擋。那是兩件不相干的事，順序寫反的話，人會困在一個每則訊息都被拒絕、又打不出去的模式裡。&lt;/p&gt;

&lt;p&gt;這條有專屬測試，因為它是契約不是實作細節。&lt;/p&gt;

&lt;h2 id=&quot;脫離模式用啟發式不用-llm&quot;&gt;脫離模式用啟發式，不用 LLM&lt;/h2&gt;

&lt;p&gt;判斷使用者是否想離開影片模式，我用的是最笨的方法：訊息含網址、或以 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/&lt;/code&gt; 開頭，就離開。&lt;/p&gt;

&lt;p&gt;刻意不用 LLM 判斷意圖，因為那要在每則訊息上多打一次 Gemini，而誤判的代價很低（使用者再問一次就好）。不值得那個成本和延遲。&lt;/p&gt;

&lt;p&gt;實作上有個細節值得記：這個判斷直接呼叫主路徑在用的同一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;find_url()&lt;/code&gt;。一開始我是自己寫一份 regex，審查時有人建議把它加寬去支援無 scheme 的網址（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;www.youtube.com/...&lt;/code&gt;）。我去查主路徑實際用什麼判斷，發現它的 regex 也抓不到：&lt;strong&gt;單方面加寬只會製造分歧&lt;/strong&gt;：影片模式退出了，主路徑卻不當它是網址，使用者拿到一個沒用的聊天回覆取代一個沒用的影片回覆。&lt;/p&gt;

&lt;p&gt;改成共用同一個函式之後，兩者永遠一致；日後 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;find_url&lt;/code&gt; 若被加寬，影片模式會自動跟上。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;開發流程用-subagent-分工讓審查者去抓我的錯&quot;&gt;開發流程：用 subagent 分工，讓審查者去抓我的錯&lt;/h1&gt;

&lt;p&gt;這次我用 Claude Code 的 subagent 分工跑完整個實作：一個計畫拆成 8 個 task，每個 task 派一個全新的 agent 實作，做完再派另一個 agent 審查，我自己只做協調和裁決。&lt;/p&gt;

&lt;p&gt;這個安排的好處在於&lt;strong&gt;審查者沒有實作者的執念&lt;/strong&gt;。實作的 agent 剛寫完程式，很容易覺得自己寫的是對的；審查的 agent 拿到的只有 diff、需求文件和報告，沒有「我剛剛想了很久」的包袱。&lt;/p&gt;

&lt;p&gt;實際抓到的東西，比我預期的有價值得多。&lt;/p&gt;

&lt;h2 id=&quot;它們抓到我三次錯誤的因果推論&quot;&gt;它們抓到我三次錯誤的因果推論&lt;/h2&gt;

&lt;p&gt;前面 thinking token 那一節的三個版本，第一次修正是實作的 agent 撞到我設的門檻停下來回報的（我在指示裡寫了「看到三萬以上就停下來，不要安靜 commit」，它照做了）。第二次是它自己提出 prompt 複雜度的假說並設計實驗排除掉。&lt;/p&gt;

&lt;p&gt;第三次最有意思。我在改文件時，把被推翻的說法換成「&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-flash-lite&lt;/code&gt; 是唯一成本穩定的模型」。審查的 agent 指出：那 27 次非決定性實驗&lt;strong&gt;就是在 3.5-flash-lite 上跑的&lt;/strong&gt;，尖峰正是在它身上量到的，這句話跟它自己引用的段落直接矛盾。&lt;/p&gt;

&lt;p&gt;我把一個沒根據的說法換成了另一個沒根據的說法，而且犯了三次（spec、設定檔註解、測試 docstring 各一次）。&lt;/p&gt;

&lt;h2 id=&quot;它抓到一個八輪審查都漏掉的-critical&quot;&gt;它抓到一個八輪審查都漏掉的 Critical&lt;/h2&gt;

&lt;p&gt;最後的整支分支審查抓到一件事：&lt;strong&gt;整個功能的入口按鈕根本不會顯示&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;按鈕掛在 carousel 訊息上，但後面又 append 了四則文字訊息。LINE 只渲染&lt;strong&gt;最後一則&lt;/strong&gt;訊息的 quickReply，所以按鈕被埋掉了。使用者貼 YouTube 連結，會收到摘要跟四則文案，然後看不到任何按鈕。&lt;/p&gt;

&lt;p&gt;八輪 task 審查全部放行，因為每一輪看到的都是「按鈕正確地掛在 carousel 上」，沒有人看到後面還會再加四則訊息。測試也漏掉了——那些測試直接呼叫 handler，跳過了訊息組裝那一段。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;這是單一 task 的審查在結構上看不到的東西&lt;/strong&gt;，只有把整條路徑攤開才看得見。&lt;/p&gt;

&lt;p&gt;修完之後我又用突變測試驗證一次：把掛載搬回原本的位置，只有新加的多網址測試會紅，原本那支單一網址的測試照樣過，那正是它抓不到這個 bug 的原因。&lt;/p&gt;

&lt;h2 id=&quot;我自己犯的錯&quot;&gt;我自己犯的錯&lt;/h2&gt;

&lt;p&gt;流程再好也擋不住我在指示裡寫錯東西：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;我在設計文件和給 agent 的指示裡寫了「reply 失敗會改用 push」，還在對話中複述過兩次。實際上那個機制&lt;strong&gt;不存在&lt;/strong&gt;：我把 docstring 裡描述「訊息溢位處理」的句子誤讀成「失敗回退」，從來沒去驗證。最後的整支審查抓到的。&lt;/li&gt;
  &lt;li&gt;我給的程式碼片段有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UnboundLocalError&lt;/code&gt;，變數只在某個分支賦值。實作的 agent 更正了。&lt;/li&gt;
  &lt;li&gt;我叫它把入口按鈕加在一個&lt;strong&gt;不存在的地方&lt;/strong&gt;：那段 postback handler 是死碼，repo 裡沒有任何地方會產生對應的按鈕。它自己找到真正可達的路徑掛上去。&lt;/li&gt;
  &lt;li&gt;我要求「還原數值捨入」，沒先驗算它跟既有測試容差的關係，害那個 task 多繞一圈。實作的 agent 把數字算給我看，沒有自己選一邊做下去。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;寫下來有點難看，但這些正是這套流程真正的產出：&lt;strong&gt;每一個錯誤都在合併前被攔下來了&lt;/strong&gt;。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;成果與效益&quot;&gt;成果與效益&lt;/h1&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;commit&lt;/td&gt;
      &lt;td&gt;18&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;測試&lt;/td&gt;
      &lt;td&gt;192 → 275 passed&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;收斂的模型字面值&lt;/td&gt;
      &lt;td&gt;28 處&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;修好的線上故障&lt;/td&gt;
      &lt;td&gt;1（智慧對話 404）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;實測花費&lt;/td&gt;
      &lt;td&gt;約 6–8 美元（大部分在三輪成本量測上）&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;順帶完成了 roadmap 上擱置很久的一項：token 與成本記錄。這件事原本是「有空再做」，做完 thinking token 那一坑之後變成必要配套：既然尖峰無法預防，至少要能事後看到它發生過。&lt;/p&gt;

&lt;h2 id=&quot;幾個我會帶走的東西&quot;&gt;幾個我會帶走的東西&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;先查限制，再設計。&lt;/strong&gt; 這次幾乎每個設計決定都是被限制逼出來的：Vertex 留不住 context 逼出 stateless 查詢、thinking token 不可控逼出警告 log、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_budget&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thinking_level&lt;/code&gt; 不能並用直接砍掉一個選項。先把限制查清楚再開始設計，比先設計再撞牆省事得多。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;單次觀測不是結論。&lt;/strong&gt; 我最貴的錯誤是拿一次觀測就下因果判斷，然後把那個判斷寫進設計文件，再由文件擴散到程式碼註解、環境變數說明、決策紀錄。等到發現它是錯的，光是把殘留清乾淨就花了三輪——而且中間還兩度產生新的錯誤說法。推翻一個結論比建立它費工得多。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;測試守的是 bug class，不是字串。&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3-pro-preview&lt;/code&gt; 下架讓招牌功能壞掉那件事，如果我只是換一個模型名稱，下次還會再發生。改成「不准用 preview 模型」之後，同一類問題才真的被擋住。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;靜默失敗值得專門的測試。&lt;/strong&gt; 那四個必要參數，任一個錯了程式都照跑、答案都照出。這種東西不會出現在錯誤日誌裡，也不會被 code review 抓到，只有帳單會告訴你——而等到帳單告訴你，通常已經過了一個月。&lt;/p&gt;

&lt;p&gt;程式碼在 &lt;a href=&quot;https://github.com/kkdai/linebot-helper-python&quot;&gt;kkdai/linebot-helper-python&lt;/a&gt;，這次的實作在 PR #20。官方文件是 &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/video-understanding&quot;&gt;Video understanding&lt;/a&gt;，但那份是寫給 Gemini Developer API 的；如果你跟我一樣走 Vertex AI，多輪對話那一節請當作參考而不是保證。&lt;/p&gt;
</description>
				<pubDate>Wed, 02 Sep 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-agent-video/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-agent-video/</guid>
			</item>
		
			<item>
				<title>[LINE 新功能實戰] 當訊息可以反悔：用 Edit 與 Unsend Webhook 打造會「變形」的 LINE 團購 Bot</title>
				<description>&lt;iframe width=&quot;485&quot; height=&quot;862&quot; src=&quot;https://www.youtube.com/embed/ldjbkUSKZCs&quot; title=&quot;編輯訊息 LINE Bot 測試影片 - 百變店長・單單&quot; frameborder=&quot;0&quot; allow=&quot;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share&quot; referrerpolicy=&quot;strict-origin-when-cross-origin&quot; allowfullscreen=&quot;&quot;&gt;&lt;/iframe&gt;

&lt;h1 id=&quot;當訊息可以反悔用-edit-與-unsend-webhook-打造會變形的-line-團購-bot&quot;&gt;當訊息可以反悔：用 Edit 與 Unsend Webhook 打造會「變形」的 LINE 團購 Bot&lt;/h1&gt;

&lt;p&gt;作者：Evan Lin，LINE Taiwan Developer Relations Team Lead&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://vos.line-scdn.net/landpress-content-v2-3ub8nanc40829phmlme9ov4o/f4888b425e8244f7bc09c770f2b96659.png&quot; alt=&quot;img&quot; /&gt;&lt;/p&gt;

&lt;p&gt;2026 年 8 月 20 日，LINE 宣布在 LINE Labs 開放「編輯訊息」功能免費試用。&lt;/p&gt;

&lt;p&gt;對一般使用者來說，這是一個很直覺的功能：訊息送出後才發現打錯字、日期寫錯，或語氣想再調整，不必再經過「收回、重打、重新送出」的流程，直接編輯原本的文字訊息就可以了。&lt;/p&gt;

&lt;p&gt;但我看到這個功能時，第一個想到的是另一件事：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;如果使用者改掉一則已經被 LINE Bot 處理過的訊息，Bot 知道嗎？&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;答案是：知道。LINE Messaging API 提供了 Edit event；而當使用者收回訊息時，也有 Unsend event 可以接收。&lt;/p&gt;

&lt;p&gt;這篇文章想跟大家分享，我如何把這兩種 webhook event 做成一個有故事的 LINE Bot Demo，以及實作時幾個容易忽略、卻非常重要的細節。&lt;/p&gt;

&lt;p&gt;完整範例程式碼放在 GitHub：&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/kkdai/linebot-edit-unsend&quot;&gt;https://github.com/kkdai/linebot-edit-unsend&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;先從-line-labs-的編輯訊息開始&quot;&gt;先從 LINE Labs 的「編輯訊息」開始&lt;/h2&gt;

&lt;p&gt;目前在 LINE Labs 試用編輯訊息，需要先符合以下條件：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;手機版 LINE 更新至 26.12.0（含）以上版本。&lt;/li&gt;
  &lt;li&gt;進入「主頁 → 設定 → LINE Labs」。&lt;/li&gt;
  &lt;li&gt;開啟「訊息編輯」。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;在一般一對一與群組聊天室中，文字訊息送出後 15 分鐘內可以編輯；Keep 筆記則是 6 天內。照片、影片、語音、檔案與貼圖目前不能編輯。修改後，聊天室會顯示「已編輯」，但不提供舊版本查看或還原。&lt;/p&gt;

&lt;p&gt;這裡還有一個跟 Bot 開發直接相關的限制：目前與 LINE 官方帳號的一對一聊天室不支援編輯訊息。因此，要測試 Messaging API 的 Edit event，必須把 Bot 加入群組聊天室。&lt;/p&gt;

&lt;p&gt;功能與啟用方式可以參考 &lt;a href=&quot;https://www.linecorp.com/tw/pr/news/2026/0820/&quot;&gt;LINE 台灣新聞室公告&lt;/a&gt;。&lt;/p&gt;

&lt;h2 id=&quot;從-ui-功能想到-bot-的資料一致性&quot;&gt;從 UI 功能想到 Bot 的資料一致性&lt;/h2&gt;

&lt;p&gt;過去 Bot 收到文字訊息後，常見的處理流程是：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;接收 message webhook。&lt;/li&gt;
  &lt;li&gt;解析文字。&lt;/li&gt;
  &lt;li&gt;寫入資料庫或觸發後續流程。&lt;/li&gt;
  &lt;li&gt;回覆處理結果。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;如果訊息送出後不能修改，這個模型很單純。但當訊息可以被編輯，原本的文字便不一定是使用者最後的意圖。&lt;/p&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;珍珠奶茶 / 半糖 / 少冰 / 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Bot 已經把它記錄成一杯 60 元的飲料。幾秒後，使用者直接編輯原訊息：&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;珍珠奶茶 / 微糖 / 去冰 / 2
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;如果 Bot 沒有處理 Edit event，聊天室看到的是兩杯，但後端仍然記成一杯。畫面上的世界和 Bot 裡的世界就分開了。&lt;/p&gt;

&lt;p&gt;這就是 Edit event 最實際的價值：它不只是通知「文字改過了」，而是讓服務有機會同步使用者的最新意圖。&lt;/p&gt;

&lt;h2 id=&quot;團購變形記讓-api-demo-有一個故事&quot;&gt;團購變形記：讓 API Demo 有一個故事&lt;/h2&gt;

&lt;p&gt;為了同時展示 edit 與 unsend，我把 Bot 設定成「百變店長・單單」，負責拯救下午三點陷入低電量的辦公室。&lt;/p&gt;

&lt;p&gt;Demo 流程如下：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;團主輸入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;開團 五十嵐 15:20 截單&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;Alice 輸入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;珍珠奶茶 / 半糖 / 少冰 / 1&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;Alice 編輯同一則訊息，改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;珍珠奶茶 / 微糖 / 去冰 / 2&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;Bot 收到 Edit event，更新杯數、甜度、冰量及總金額。&lt;/li&gt;
  &lt;li&gt;Bob 下單後收回訊息。&lt;/li&gt;
  &lt;li&gt;Bot 收到 Unsend event，刪除 Bob 的訂單並重新計算。&lt;/li&gt;
  &lt;li&gt;輸入 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;目前訂單&lt;/code&gt;，以 Flex Message 查看最新總結。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;這個場景很適合 Demo，是因為大家都知道團購最常發生的兩件事：下單後改口，以及下單後突然不喝了。&lt;/p&gt;

&lt;p&gt;更重要的是，觀眾可以直接從杯數和金額看出 webhook 是否真的影響了後端狀態，而不只是 Bot 回覆一句「收到編輯」。&lt;/p&gt;

&lt;h2 id=&quot;edit-event-的結構&quot;&gt;Edit event 的結構&lt;/h2&gt;

&lt;p&gt;Edit event 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type&lt;/code&gt; 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;messageEdited&lt;/code&gt;，其中會帶著編輯後的文字、timestamp、reply token 與 message ID：&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;type&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;messageEdited&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;replyToken&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;950e63e8f46542ab89f645b4c2a1180a&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;message&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;text&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;id&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;610830548529053697&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;text&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;珍珠奶茶 / 微糖 / 去冰 / 2&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;span class=&quot;nl&quot;&gt;&quot;timestamp&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;mi&quot;&gt;1776914799524&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;source&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;group&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;groupId&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;Ca56f94637c...&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;userId&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;U4af4980629...&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;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;message.id&lt;/code&gt;，與原本 message event 的 message ID 相同。&lt;/p&gt;

&lt;p&gt;因此，我們可以直接把 message ID 當成訂單 ID：&lt;/p&gt;

&lt;div class=&quot;language-go 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;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageEvent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;h&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;handleMessage&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;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageEditedEvent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;h&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;handleEdit&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;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UnsendEvent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;h&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;handleUnsend&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;收到原始 message event 時新增訂單；收到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MessageEditedEvent&lt;/code&gt; 時，用相同 message ID 找出訂單並覆寫內容。&lt;/p&gt;

&lt;p&gt;另外，Edit event 有自己的 reply token，而且與原始 message event 的 reply token 不同，因此 Bot 可以直接針對這次編輯回覆「訂單變形成功」。&lt;/p&gt;

&lt;p&gt;如果你的 Bot 同時使用 Mark as Read API，也要注意 Edit event 不會帶 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;markAsReadToken&lt;/code&gt;，不能把一般 message event 的處理流程原封不動套過來。這類欄位差異很適合在升級 SDK 後，搭配 webhook fixture 做一次完整測試。&lt;/p&gt;

&lt;p&gt;完整欄位請參考 &lt;a href=&quot;https://developers.line.biz/en/reference/messaging-api/#edit-event&quot;&gt;Messaging API：Edit event&lt;/a&gt;。&lt;/p&gt;

&lt;h2 id=&quot;第一個陷阱edit-event-可能不按順序抵達&quot;&gt;第一個陷阱：Edit event 可能不按順序抵達&lt;/h2&gt;

&lt;p&gt;這是這次實作中最容易被忽略的地方。&lt;/p&gt;

&lt;p&gt;使用者可能連續編輯同一則訊息，而多個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;messageEdited&lt;/code&gt; webhook 不保證按照編輯順序抵達。官方文件建議以最大的 timestamp 代表最新狀態。&lt;/p&gt;

&lt;p&gt;因此不能單純採用「最後收到的 webhook」，而應該採用「timestamp 最新的 webhook」：&lt;/p&gt;

&lt;div class=&quot;language-go 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;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timestamp&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UpdatedAt&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;summarize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errStaleEdit&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UpdatedAt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timestamp&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;buy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Orders&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;messageID&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;current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;假設第二次編輯先抵達、第一次編輯稍後才抵達，這個判斷可以避免舊內容蓋掉新內容。&lt;/p&gt;

&lt;p&gt;同時，LINE Platform 也可能重新傳送 webhook，所以範例中另外使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;webhookEventId&lt;/code&gt; 去重。這兩個機制處理的是不同問題：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;webhookEventId&lt;/code&gt;：避免同一個 event 被處理兩次。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timestamp&lt;/code&gt;：避免不同 edit events 以錯誤順序更新資料。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;兩者都需要處理。&lt;/p&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;我突然不想喝了
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這時候新的訊息已經無法通過訂單格式驗證。如果後端繼續保留舊訂單，仍然會造成狀態不一致。&lt;/p&gt;

&lt;p&gt;這個 Demo 的做法是：清除舊訂單內容，將這筆記錄標成無效並暫時移出總計，再請使用者繼續編輯修正。&lt;/p&gt;

&lt;div class=&quot;language-go 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;parsed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parseOrder&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;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;nil&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Item&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;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Sugar&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;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Ice&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;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Quantity&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UnitPrice&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Valid&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;false&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UpdatedAt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timestamp&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;buy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Orders&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;messageID&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;current&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;summarize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&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;h2 id=&quot;unsend-event技術之外還有使用者意圖&quot;&gt;Unsend event：技術之外，還有使用者意圖&lt;/h2&gt;

&lt;p&gt;Unsend event 的內容比 Edit event 更簡單：&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;type&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;unsend&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;source&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;group&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;groupId&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;Ca56f94637c...&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;userId&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;U4af4980629...&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;span class=&quot;nl&quot;&gt;&quot;unsend&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;messageId&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;610830548529053697&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;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;它只告訴我們哪一個 message ID 被收回，不會重新附上訊息內容，也沒有 reply token。&lt;/p&gt;

&lt;p&gt;因為我們已經用 message ID 當訂單 ID，所以刪除很直接：&lt;/p&gt;

&lt;div class=&quot;language-go 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;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Orders&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;messageID&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;delete()&lt;/code&gt;，而是如何尊重使用者「我希望收回這段內容」的意圖。&lt;/p&gt;

&lt;p&gt;官方文件特別提醒，服務提供者收到 Unsend event 後，應謹慎處理，讓目標訊息未來無法再被看見或使用。因此這個 Demo 不會在 Bot 回覆中引用被收回的品項、甜度或其他原文，而只會說：&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;💨 一筆訂單已安全撤回，保存的內容也已刪除。
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;由於 Unsend event 沒有 reply token，如果想主動通知群組，只能使用 push message。這表示它會計入訊息用量，設計正式服務時也要把配額與成本考慮進去。&lt;/p&gt;

&lt;p&gt;完整說明請參考 &lt;a href=&quot;https://developers.line.biz/en/reference/messaging-api/#unsend-event&quot;&gt;Messaging API：Unsend event&lt;/a&gt;。&lt;/p&gt;

&lt;h2 id=&quot;用-flex-message-讓狀態變化看得見&quot;&gt;用 Flex Message 讓狀態變化看得見&lt;/h2&gt;

&lt;p&gt;純文字雖然能顯示結果，但如果要讓 Demo 一眼就懂，我更推薦用 Flex Message 顯示目前訂單。&lt;/p&gt;

&lt;p&gt;這次的卡片包含：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;店家與截單時間&lt;/li&gt;
  &lt;li&gt;團購進行中或已結單&lt;/li&gt;
  &lt;li&gt;每位成員的品項、甜度、冰量、數量與小計&lt;/li&gt;
  &lt;li&gt;總杯數與總金額&lt;/li&gt;
  &lt;li&gt;「重新整理訂單」按鈕&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;每次新增、編輯或收回訂單後，都重新從目前狀態產生 Flex Message。這樣 edit 前後的杯數與金額會立即變化，unsend 之後該筆明細也會消失。&lt;/p&gt;

&lt;p&gt;為了避免 Flex Message 過大，範例最多顯示八筆訂單，其餘筆數用摘要呈現。正式產品可以依需求改用 carousel、LIFF 頁面，或加入分頁查詢。&lt;/p&gt;

&lt;h2 id=&quot;sdk-與執行環境版本&quot;&gt;SDK 與執行環境版本&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;github.com/line/line-bot-sdk-go/v8 v8.22.0
Go 1.25
&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;UnsendEvent&lt;/code&gt;，但不一定包含新的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MessageEditedEvent&lt;/code&gt; 型別。升級 SDK 後，也要確認它所要求的 Go 版本；這次從舊範例升級時，Go toolchain 與 CI workflow 都需要一起調整。&lt;/p&gt;

&lt;p&gt;這是一個常見但容易漏掉的升級問題：本機可以編譯，不代表 Cloud Build 或 GitHub Actions 還在使用相同版本。&lt;/p&gt;

&lt;h2 id=&quot;部署到-cloud-run-時的狀態問題&quot;&gt;部署到 Cloud Run 時的狀態問題&lt;/h2&gt;

&lt;p&gt;這個 Demo 部署在 Google Cloud Run，channel secret 與 channel access token 透過 Secret Manager 注入，沒有寫進 repository。&lt;/p&gt;

&lt;p&gt;為了讓範例保持簡單，目前訂單存在程式記憶體。這會帶來兩個限制：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;instance 重啟或縮容到零，訂單就會消失。&lt;/li&gt;
  &lt;li&gt;同時存在多個 instances 時，每個 instance 會擁有不同的訂單狀態。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;因此 Demo 環境先把最大 instance 設為 1，避免同一場團購的 webhook 被分流。但這只適合展示，不是正式環境的完整解法。&lt;/p&gt;

&lt;p&gt;正式服務應該使用 Redis、Firestore 或其他共享儲存，並進一步處理：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;原子更新與併發控制&lt;/li&gt;
  &lt;li&gt;webhook idempotency&lt;/li&gt;
  &lt;li&gt;資料保存期限&lt;/li&gt;
  &lt;li&gt;unsend 後的跨系統刪除&lt;/li&gt;
  &lt;li&gt;Push Message 失敗重試&lt;/li&gt;
  &lt;li&gt;稽核紀錄與隱私要求之間的界線&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;尤其是 unsend，如果訊息內容已經被送往搜尋索引、分析平台或其他下游服務，只刪除主資料庫並不完整。資料流設計一開始就應該知道內容去了哪裡，才有能力真正完成刪除。&lt;/p&gt;

&lt;h2 id=&quot;如何執行這個-demo&quot;&gt;如何執行這個 Demo&lt;/h2&gt;

&lt;p&gt;首先，請在 LINE Developers Console 啟用 webhook，並允許 Bot 加入群組聊天室。把 webhook URL 指向部署服務的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/callback&lt;/code&gt;。&lt;/p&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;開團 五十嵐 15:20 截單
珍珠奶茶 / 半糖 / 少冰 / 1
目前訂單
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;珍珠奶茶 / 微糖 / 去冰 / 2
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;你會看到 Bot 回覆「訂單變形成功」，Flex Message 的杯數與總金額也同步更新。&lt;/p&gt;

&lt;p&gt;最後再收回這則訂單訊息，Bot 會刪除內容並顯示新的訂單總結。&lt;/p&gt;

&lt;p&gt;範例中的菜單與價格是固定的 Demo 資料，方便讓金額變化清楚可見。完整啟動方式、環境變數及測試指令請參考 repository 的 README：&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/kkdai/linebot-edit-unsend&quot;&gt;https://github.com/kkdai/linebot-edit-unsend&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;未來還能怎麼應用&quot;&gt;未來還能怎麼應用？&lt;/h2&gt;

&lt;p&gt;團購只是其中一個容易理解的故事。相同的事件模型也可以延伸到：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;行程 Bot：編輯集合時間時更新行程，收回訊息時取消活動。&lt;/li&gt;
  &lt;li&gt;任務 Bot：編輯負責人或期限，收回訊息時移除任務。&lt;/li&gt;
  &lt;li&gt;預約 Bot：修改時段或人數，收回訊息時取消預約。&lt;/li&gt;
  &lt;li&gt;公告 Bot：更新公告內容，收回後同步撤下其他通路的副本。&lt;/li&gt;
  &lt;li&gt;互動故事 Bot：編輯選擇時改寫劇情，收回訊息時退回上一個節點。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;只要 Bot 曾經把「一則訊息」轉換成某種系統狀態，就值得重新檢查：當訊息被編輯或收回時，那個狀態是否也應該一起改變？&lt;/p&gt;

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

&lt;p&gt;訊息編輯看起來是一個聊天介面的改善，但從 Bot 開發者的角度來看，它其實改變了事件的生命週期。&lt;/p&gt;

&lt;p&gt;一則訊息不再只有「被送出」這個瞬間。它可能被更新，也可能被撤回。後端服務需要理解這些事件、維持正確順序，並尊重使用者的最新意圖。&lt;/p&gt;

&lt;p&gt;這次我用一個下午茶團購故事，把 Edit event、Unsend event、Flex Message 與 Cloud Run 串在一起。希望這個小範例能讓大家更快掌握新功能，也開始思考自己的 LINE Bot 在訊息被改變之後，應該如何回應。&lt;/p&gt;

&lt;p&gt;歡迎參考程式碼，也期待看到大家做出更多有趣的應用：&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/kkdai/linebot-edit-unsend&quot;&gt;https://github.com/kkdai/linebot-edit-unsend&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;參考資料&quot;&gt;參考資料&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.linecorp.com/tw/pr/news/2026/0820/&quot;&gt;LINE Premium「編輯訊息」功能於 LINE Labs 開放免費試用&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.line.biz/en/reference/messaging-api/#edit-event&quot;&gt;LINE Messaging API reference：Edit event&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.line.biz/en/reference/messaging-api/#unsend-event&quot;&gt;LINE Messaging API reference：Unsend event&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://techblog.lycorp.co.jp/zh-hant/linebot-mark-as-read&quot;&gt;LINE Messaging API 新功能介紹：Mark as Read API 讓你的聊天機器人標記訊息已讀&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Tue, 01 Sep 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/linebot-edit-msg/</link>
				<guid isPermaLink="true">https://www.evanlin.com/linebot-edit-msg/</guid>
			</item>
		
			<item>
				<title>[AI 實戰] Gemini 3.5 Transcribe 的兩顆模型：即時逐字稿與語者分離，實作進 macOS 會議翻譯 App</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260828120048222.png&quot; alt=&quot;Gemini 3.5 Transcribe 發表圖&quot; /&gt;&lt;/p&gt;

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

&lt;p&gt;我有一個自己在用的 macOS App，&lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos&quot;&gt;gemini-live-translate-macos&lt;/a&gt;。它用 ScreenCaptureKit 直接抓指定 App 的音訊，不需要 BlackHole 那類虛擬音效卡，然後丟給 Gemini Live API 做即時翻譯，一邊出繁中字幕、一邊播中文語音。開發過程寫過兩篇，&lt;a href=&quot;/agy-macos-app/&quot;&gt;第一篇&lt;/a&gt;是用 AGY CLI 從零做出來，&lt;a href=&quot;/agy-macos-app-enhance/&quot;&gt;第二篇&lt;/a&gt;是 Claude Code 接力把它從能動變成好用。&lt;/p&gt;

&lt;p&gt;這次要加的東西起點很單純：我看到 Live API 多了一份「即時逐字稿」的文件，想說既然已經在接 Live API 了，加個純逐字稿模式應該就是改幾個參數的事。&lt;/p&gt;

&lt;p&gt;結果查完文件才發現，Google 這次一口氣放了&lt;strong&gt;兩顆名字很像但能力差很多的模型&lt;/strong&gt;，而我真正想要的那個功能（語者分離）在我原本以為的那顆上面根本沒有。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;兩個名字只差兩個字的模型&quot;&gt;兩個名字只差兩個字的模型&lt;/h1&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-transcribe-live&lt;/code&gt;&lt;/th&gt;
      &lt;th&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-transcribe&lt;/code&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;走哪個 API&lt;/td&gt;
      &lt;td&gt;Live API（WebSocket 串流）&lt;/td&gt;
      &lt;td&gt;Interactions API（一般 HTTP 請求）&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;不支援&lt;/td&gt;
      &lt;td&gt;最多 8 位&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;單次 session 10 分鐘&lt;/td&gt;
      &lt;td&gt;1 小時，開語者分離時 30 分鐘&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;SMART&lt;/code&gt; 可用&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;smart&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;interimInputTranscription&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;不適用&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;官方文件在 Live 那頁的限制章節寫得很直白：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Speaker diarization is not supported in live streaming sessions. For speaker diarization, use the non-streaming Audio transcription endpoint.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;所以「即時看到誰在講什麼」這件事，目前做不到。要語者分離就得錄下來、會後整份送出去。這個限制決定了我後面整個架構。&lt;/p&gt;

&lt;h2 id=&quot;即時那顆interim-是它的重點&quot;&gt;即時那顆：interim 是它的重點&lt;/h2&gt;

&lt;p&gt;setup 的形狀跟原本的翻譯模型不一樣，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;responseModalities&lt;/code&gt; 要是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TEXT&lt;/code&gt;，轉錄參數放在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;setup.inputAudioTranscription&lt;/code&gt; 底下：&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;setup&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;model&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;models/gemini-3.5-transcribe-live&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;generationConfig&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;responseModalities&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;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;TEXT&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;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;inputAudioTranscription&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;languageCodes&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;p&quot;&gt;[],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;mode&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;SMART&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;span class=&quot;nl&quot;&gt;&quot;realtimeInputConfig&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;automaticActivityDetection&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;disabled&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;kc&quot;&gt;false&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;span class=&quot;p&quot;&gt;}&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;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;languageCodes&lt;/code&gt; 留空是自動偵測語言，填 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[&quot;en-US&quot;]&lt;/code&gt; 這種 BCP-47 代碼則是給它語言偏好。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mode&lt;/code&gt; 有兩個值：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VERBATIM&lt;/code&gt; 逐字保留所有內容，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SMART&lt;/code&gt; 會拿掉 uh、um 這類填充詞並自動補上格式。會議記錄我選 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SMART&lt;/code&gt;，讀起來乾淨很多。&lt;/p&gt;

&lt;p&gt;還有一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;customVocabulary&lt;/code&gt; 可以塞專有名詞，上限 1000 條，但文件建議 100 條以內效果最好。這次我沒做，等真的遇到人名一直被聽錯再說。&lt;/p&gt;

&lt;p&gt;回應端則多了一個原本翻譯模型沒有的欄位：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;interimInputTranscription&lt;/code&gt;：說話中的暫定結果，會被後面的內容覆蓋&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;inputTranscription&lt;/code&gt;：說話者停頓或這一輪結束時的定稿&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這個分野會影響 UI 怎麼寫，後面會講。&lt;/p&gt;

&lt;h2 id=&quot;批次那顆走的是完全不同的-api&quot;&gt;批次那顆：走的是完全不同的 API&lt;/h2&gt;

&lt;p&gt;這是最容易踩空的地方。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-transcribe&lt;/code&gt; 不是打 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generateContent&lt;/code&gt;，而是 Interactions API：&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;err&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;https://generativelanguage.googleapis.com/v&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;beta/interactions&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;span class=&quot;nl&quot;&gt;&quot;model&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;gemini-3.5-transcribe&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;input&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;p&quot;&gt;[&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;span class=&quot;nl&quot;&gt;&quot;type&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;audio&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;uri&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;YOUR_FILE_URI&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;mime_type&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;audio/wav&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;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;generation_config&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;transcription_config&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;language_codes&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;p&quot;&gt;[],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;mode&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;verbatim&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;diarization_mode&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;speaker&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;timestamp_granularities&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;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;word&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;p&quot;&gt;}&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;span class=&quot;p&quot;&gt;}&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;diarization_mode: &quot;speaker&quot;&lt;/code&gt; 就是語者分離的開關，但它只能配 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verbatim&lt;/code&gt;。也就是說：&lt;strong&gt;想要語者分離，就得放棄 SMART 模式去贅詞的好處&lt;/strong&gt;，兩個不能同時要。&lt;/p&gt;

&lt;p&gt;那個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timestamp_granularities&lt;/code&gt; 我一開始沒寫，因為文件的簡短範例裡沒有它。少了它整個功能會安靜地失效，過程寫在最後一節。&lt;/p&gt;

&lt;p&gt;音訊要先上傳到 Files API 換一個 file URI 再帶進請求裡，官方文件沒有給 base64 內嵌的範例。回應則是這樣：&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;steps&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;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;model_output&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;content&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;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;text&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;text&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;Hello world&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;annotations&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;p&quot;&gt;[&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;span class=&quot;nl&quot;&gt;&quot;type&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;word_info&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;text&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;Hello&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;speaker&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;spk_1&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;start_offset&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;0.100s&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;end_offset&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;0.450s&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;span class=&quot;p&quot;&gt;]&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;span class=&quot;p&quot;&gt;}]&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;strong&gt;字詞層級&lt;/strong&gt;的 annotations 裡，是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_1&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_2&lt;/code&gt; 這種代號，模型不知道誰是誰。文件另外註明最多 8 位講者，而且「3 人以上的歸屬屬於實驗性質」。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;第一階段把純逐字稿模式加進-app&quot;&gt;第一階段：把純逐字稿模式加進 App&lt;/h1&gt;

&lt;p&gt;&lt;img src=&quot;../images/MeetingTranslator%202026-08-28%2012.30.37.png&quot; alt=&quot;MeetingTranslator 用 gemini-3.5-transcribe-live 轉錄中文 podcast，停止後自動跳出 AI 整理的會議記錄&quot; /&gt;&lt;/p&gt;

&lt;p&gt;這部分比我預期的簡單，因為 App 原本就在解析 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;inputTranscription&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;outputTranscription&lt;/code&gt;（翻譯模型本來就會回這兩個，用來做中英雙語字幕）。真正要動的是模式的分岔。&lt;/p&gt;

&lt;p&gt;原本的連線層是這樣判斷的：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isTranslateModel&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;modelName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;live-translate&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;一個布林值分兩條路。現在要三條，就換成 enum：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;LiveMode&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;translate&lt;/span&gt;    &lt;span class=&quot;c1&quot;&gt;// gemini-*-live-translate-*：輸出翻譯語音 + 雙語字幕&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;transcribe&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;// gemini-*-transcribe-live：只出文字&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;general&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;// 其他 Live 模型：靠 systemInstruction 做口譯&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;modelName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&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;kt&quot;&gt;LiveMode&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;modelName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;transcribe&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;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transcribe&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;modelName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;live-translate&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;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;translate&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;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;general&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;順手把 setup config 的產生跟伺服器回應的解析從 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GeminiLiveConnection&lt;/code&gt; 裡抽出來變成純函式。這支檔案本來就有點肥，抽完之後淨減了六十幾行，而且抽出來的東西可以直接測。&lt;/p&gt;

&lt;h2 id=&quot;一個預防性的取捨重覆的字&quot;&gt;一個預防性的取捨：重覆的字&lt;/h2&gt;

&lt;p&gt;解析回應時我做了一個決定，值得記一下。轉錄模型除了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;inputTranscription&lt;/code&gt; 之外，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modelTurn&lt;/code&gt; 的 parts 也可能帶同一段文字。如果兩邊都收，同一句話會在字幕跟匯出檔裡出現兩次。&lt;/p&gt;

&lt;p&gt;老實說我&lt;strong&gt;沒有實際看到這個狀況發生&lt;/strong&gt;，是讀文件跟舊程式碼時發現兩條路都會走到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;didReceiveOutputTranscription&lt;/code&gt;，覺得有風險才擋掉的：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;// 純逐字稿模式的內容已經由 inputTranscription 提供，&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// 再收 modelTurn 的文字會讓同一句話重覆出現。&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transcribe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;text&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;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;as?&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&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;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isEmpty&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;events&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;append&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;nf&quot;&gt;outputTranscription&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;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這種預防性的防禦有個壞處：如果實際上不會發生，這行就是永遠不會生效的死碼，而且沒人知道。所以我把它寫成一條測試（「transcribe 模式忽略 modelTurn 文字」），至少行為是被鎖住的、意圖是被寫下來的。&lt;/p&gt;

&lt;h2 id=&quot;沒有測試框架的專案怎麼跑測試&quot;&gt;沒有測試框架的專案，怎麼跑測試&lt;/h2&gt;

&lt;p&gt;這個專案沒有用 Xcode 專案檔，是一支 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_app.sh&lt;/code&gt; 直接呼叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swiftc&lt;/code&gt; 把所有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.swift&lt;/code&gt; 編成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.app&lt;/code&gt;。沒有 SwiftPM，也就沒有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swift test&lt;/code&gt;，沒有 XCTest。&lt;/p&gt;

&lt;p&gt;我的解法是同一招再用一次：既然抽出來的都是純函式，就寫一支 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.swift&lt;/code&gt; 當斷言 runner，跟那些純函式一起編成執行檔，用離開碼判斷成敗。&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;swiftc &lt;span class=&quot;nt&quot;&gt;-sdk&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$SDK_PATH&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-target&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;ARCH&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;-apple-macos13.0&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;BUILD_DIR&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/run_tests&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  LiveSetupConfig.swift TranscriptFormatter.swift &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  WAVRecorder.swift AudioChunker.swift &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  GeminiTranscribeService.swift GeminiSummaryService.swift &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  Tests/main.swift
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;斷言函式本身十行不到：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;func&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;checkEqual&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;T&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;actual&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;T&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;expected&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;T&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&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;actual&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;expected&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;passedCount&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;failures&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;（實際：&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;describing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;actual&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;，預期：&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;expected&lt;/span&gt;&lt;span class=&quot;se&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這當然比不上真正的測試框架，沒有 setup/teardown、沒有平行執行、失敗了不會告訴你在第幾行。但它能跑、能擋回歸，而且不需要為了測試把整個專案改成 SwiftPM。這一階段結束時是 37 條斷言，做完語者分離變成 98 條。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;第二階段語者分離要動的東西比想像多&quot;&gt;第二階段：語者分離要動的東西比想像多&lt;/h1&gt;

&lt;p&gt;「加一個語者分離」聽起來像是多打一支 API，實際上要多做三件事：把音訊落地成檔案、上傳、然後接一條完全不同的 API。而且因為批次轉錄可能要跑好幾分鐘，它不能卡在原本的停止流程裡。&lt;/p&gt;

&lt;p&gt;最後的資料流長這樣：&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TD
    A[ScreenCaptureKit 擷取 PCM] --&amp;gt; B[Gemini Live API&amp;lt;br/&amp;gt;即時字幕]
    A --&amp;gt; C[WAVRecorder&amp;lt;br/&amp;gt;同步寫檔]

    D[按下停止] --&amp;gt; E[階段一：立即產出]
    E --&amp;gt; E1[逐字稿 md]
    E --&amp;gt; E2[AI 會議摘要]
    E --&amp;gt; E3[會議記錄網頁]

    D --&amp;gt; F[階段二：背景執行]
    F --&amp;gt; F1[收檔 → 超過 30 分鐘就切段]
    F1 --&amp;gt; F2[上傳 Files API]
    F2 --&amp;gt; F3[Interactions API 批次轉錄]
    F3 --&amp;gt; F4[刪除雲端副本]
    F4 --&amp;gt; F5[寫 diarized.md]
    F5 --&amp;gt; F6[拿語者版重跑摘要 + 推斷姓名]
    F6 --&amp;gt; F7[重新產出網頁]
    F7 --&amp;gt; F8[刪除本機錄音]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;分成兩階段是刻意的。階段二的每一步都可能斷（上傳逾時、配額用完、回應格式跟文件不一樣），如果把它接在原本的流程中間，一失敗連原本好好的逐字稿跟摘要都拿不到。現在的作法是階段一完全不動，階段二只在後面追加，失敗就只是少一份產出。&lt;/p&gt;

&lt;p&gt;錄音格式也是撿現成的：送給 Live API 的音訊本來就已經被重採樣成 16kHz 單聲道 16-bit PCM，直接把同一份資料多寫一份到磁碟就是合法的 WAV 內容，補個 44 bytes 的檔頭就好，完全不需要重新編碼。&lt;/p&gt;

&lt;p&gt;同樣的防禦心態也用在解析上：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;parseDiarized&lt;/code&gt; 對回應結構的假設是照文件寫的，如果實際格式有出入，我留了一條退路——抓不到字詞標註時退回整段 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_text&lt;/code&gt;，至少逐字稿不會整個空掉。這條退路後來確實派上用場了，只是方式跟我想的不太一樣，最後一節會講。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;重大踩坑與解決方案&quot;&gt;重大踩坑與解決方案&lt;/h1&gt;

&lt;h2 id=&quot;踩坑一切段不能切滿-30-分鐘再留零頭&quot;&gt;踩坑一：切段不能「切滿 30 分鐘再留零頭」&lt;/h2&gt;

&lt;p&gt;開語者分離的音訊上限是 30 分鐘，但會議常常一小時起跳，所以要切段。&lt;/p&gt;

&lt;p&gt;我第一版的想法是最直覺的那種：切滿 30 分鐘算一段，剩下的當最後一段。寫測試的時候才發現這個做法有兩個洞。&lt;/p&gt;

&lt;p&gt;一場 60 分 5 秒的會議，會切成 30 分、30 分、5 秒三段。那 5 秒的尾巴送去轉錄毫無意義，還要多付一次上傳跟一次 API 呼叫。那把尾巴併進前一段？併完那段就變成 30 分 5 秒，&lt;strong&gt;超過 API 上限&lt;/strong&gt;，整段被打回來。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：改成平均切分。先算出需要幾段（無條件進位），再把總長度平均分配：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;count&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;usable&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxChunkBytes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxChunkBytes&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&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;return&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;o&quot;&gt;..&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;usable&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;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;ranges&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;kt&quot;&gt;Range&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&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;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;index&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;usable&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;index&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;count&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;blockAlign&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;// 對齊 16-bit 取樣邊界&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;index&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;usable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;ranges&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;..&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;end&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;60 分 5 秒變成兩段各 30 分 2.5 秒？不對，會超過。實際上是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ceil(3605 / 1800) = 3&lt;/code&gt;，切成三段各 20 分鐘。永遠不會有零頭段，也永遠不會超過上限。&lt;/p&gt;

&lt;p&gt;那個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;end -= end % blockAlign&lt;/code&gt; 也是必要的。16-bit 單聲道每個取樣佔 2 bytes，如果切在奇數位元組上，後面整段音訊的位元組會全部錯開一格，播出來是噪音。&lt;/p&gt;

&lt;h2 id=&quot;踩坑二第-2-段的-spk_1-不是第-1-段的-spk_1&quot;&gt;踩坑二：第 2 段的 spk_1 不是第 1 段的 spk_1&lt;/h2&gt;

&lt;p&gt;切段之後才想到的問題。每一段都是獨立的一次 API 呼叫，模型不知道前一段發生過什麼事，所以第 2 段標出來的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_1&lt;/code&gt; 跟第 1 段的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_1&lt;/code&gt; 完全沒有關係，可能是不同人。&lt;/p&gt;

&lt;p&gt;如果直接把三段接起來輸出，讀的人會很自然地以為 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_1&lt;/code&gt; 從頭到尾都是同一個人。這比沒有語者標籤還糟：&lt;strong&gt;它給的是錯誤的確定感&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：只要切過段，就把段號併進代號，變成命名空間：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;static&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;qualifiedLabel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;chunkIndex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;chunkCount&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;speaker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&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;kt&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunkCount&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&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;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;speaker&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;s&quot;&gt;&quot;第&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;chunkIndex&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;段-&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;speaker&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;匯出時在段落交界處插一行說明：&lt;/p&gt;

&lt;div class=&quot;language-markdown 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;gt&quot;&gt;
&amp;gt; 第 2 段（語者代號與前一段不互通）&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這套代號同時也是後面丟給 AI 做姓名推斷的那份文字用的代號。兩邊共用同一套詞彙，模型才有機會透過名字把跨段的同一個人接回來——如果第 1 段跟第 3 段都有人被叫「Evan」，它可以各自對應，而不是被迫假設代號相同。&lt;/p&gt;

&lt;h2 id=&quot;踩坑三字詞接起來變成你-好-世-界&quot;&gt;踩坑三：字詞接起來變成「你 好 世 界」&lt;/h2&gt;

&lt;p&gt;批次 API 回的是&lt;strong&gt;字詞層級&lt;/strong&gt;的標註，要自己接成句子。英文很直覺，用空格接。&lt;/p&gt;

&lt;p&gt;問題是中文。Gemini 回的中文 token 接上空格之後會變成「你好 世界 我們 今天」，看起來像斷詞練習。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：接字的時候看兩邊的字元屬性，任一邊是中日韓文字就不加空格；標點符號前面也不加：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;needsSpace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;after&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;previous&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Character&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;before&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Character&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;kt&quot;&gt;Bool&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;nf&quot;&gt;isCJK&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;previous&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;nf&quot;&gt;isCJK&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;next&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;kc&quot;&gt;false&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;next&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isPunctuation&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;kc&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;kc&quot;&gt;true&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;isCJK&lt;/code&gt; 是查 Unicode 區段，涵蓋 CJK 統一表意文字、假名、韓文、全形字元那幾塊。&lt;/p&gt;

&lt;p&gt;標點那條是後來補的。原本只擋 CJK，測試 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[&quot;Hello&quot;, &quot;,&quot;, &quot;world&quot;]&lt;/code&gt; 的時候才發現會變成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Hello , world&lt;/code&gt;。這種東西不寫測試根本不會發現，因為它不會壞、只是有點醜。&lt;/p&gt;

&lt;h2 id=&quot;踩坑四兩個非同步流程搶著寫同一份摘要&quot;&gt;踩坑四：兩個非同步流程搶著寫同一份摘要&lt;/h2&gt;

&lt;p&gt;階段一停止後會產生一次 AI 會議摘要，階段二拿到語者版逐字稿之後會再產生一次（這次帶語者資訊，待辦事項的「負責人」欄位才有東西可以填）。&lt;/p&gt;

&lt;p&gt;正常情況下階段二一定比較慢——它要上傳一百多 MB 的音訊再等轉錄。但「正常情況下比較慢」不是保證。如果階段一的摘要 API 剛好卡住重試，而階段二的音訊只有一分鐘、很快就跑完，順序就會反過來，階段一晚回來的舊摘要會把語者版的結果蓋掉。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：階段二在寫入之前先等階段一結束：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;minutesTask&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;value&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isCancelled&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;return&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;一行的事，但要先想到「這兩件事其實沒有順序保證」。這種 race 幾乎不會在測試中出現，只會在某個網路特別差的日子裡讓使用者拿到一份莫名其妙變回舊版的會議記錄。&lt;/p&gt;

&lt;h2 id=&quot;踩坑五tdd-的-red-階段測試自己崩潰了&quot;&gt;踩坑五：TDD 的 RED 階段，測試自己崩潰了&lt;/h2&gt;

&lt;p&gt;寫 WAV 檔頭的測試時我照 TDD 的規矩先寫測試、建一個回傳空 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Data()&lt;/code&gt; 的 stub，然後跑起來看它失敗。結果不是失敗，是整支測試程式當場掛掉：&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;Swift/arm64e-apple-macos.swiftinterface:41299: Fatal error:
UnsafeRawBufferPointer.load out of bounds
Trace/BPT trap: 5
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;我的測試會去讀第 24 個位元組檢查取樣率是不是 16000，但 stub 回的是空的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Data&lt;/code&gt;，讀出界了。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：讀之前先補零：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;produced&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;WAVRecorder&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;header&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;dataByteCount&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;64000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;nf&quot;&gt;checkEqual&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;produced&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;44&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;WAV 檔頭為 44 bytes&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;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;header&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;produced&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;repeating&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;nv&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;max&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;mi&quot;&gt;44&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;produced&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;count&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;strong&gt;測試程式本身也要能承受被測物完全壞掉的情況。&lt;/strong&gt; 如果一個測試在 RED 階段是用崩潰收場而不是回報失敗，那你只知道「有東西壞了」，不知道是哪十二個欄位分別錯在哪。補完零之後，同一次執行就把十二個欄位的預期值全部列出來，實作的時候是照著清單填，不是猜。&lt;/p&gt;

&lt;h2 id=&quot;踩坑六捲動錨點在新模式下失效&quot;&gt;踩坑六：捲動錨點在新模式下失效&lt;/h2&gt;

&lt;p&gt;即時逐字稿模式跟翻譯模式有個結構差異：翻譯模式會把字一個個累積到「當前這句」，湊到標點才推進歷史；而轉錄模式的每一則 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;inputTranscription&lt;/code&gt; 本身就是一句定稿，直接進歷史就好。&lt;/p&gt;

&lt;p&gt;原本的自動捲動是這樣寫的：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;proxy&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;scrollTo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;currentLine&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;anchor&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;bottom&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;currentLine&lt;/code&gt; 這個 id 掛在「當前這句」的顯示區塊上。在逐字稿模式下，句子一定稿就進歷史，那個區塊隨即消失——捲動的目標不存在了，畫面就停在原地不動。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：改用一個永遠存在的底部錨點：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clear&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;bottomAnchor&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;這個 bug 沒有錯誤訊息、沒有崩潰，就只是「新加的功能捲不下去」，而且要有足夠多的內容超出畫面才看得出來。我是在讀 view 的條件分支時發現的，不是跑出來的。&lt;/p&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;spk_1&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_2&lt;/code&gt; 之後，最想做的當然是把代號換成真名。而這件事其實可行——會議裡常常有「Evan 你那邊進度如何」「我是 Sarah，負責前端」這種線索，把帶標籤的逐字稿丟給模型，它看得到就能對上。&lt;/p&gt;

&lt;p&gt;但這正是最容易生出幻覺的地方。模型很樂意從語氣、職務內容、發言比重「推理」出一個名字給你，而那個名字會以完全相同的自信度出現在會議記錄裡。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：三個手段一起上。&lt;/p&gt;

&lt;p&gt;第一，schema 明確允許 null，並要求附上判斷依據：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;properties&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;speakers&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;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;ARRAY&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;items&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;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;OBJECT&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;properties&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;label&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;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;STRING&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;// 找不到線索時必須回 null，而不是硬湊一個名字&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;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;STRING&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;nullable&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;evidence&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;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;STRING&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;nullable&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&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;s&quot;&gt;&quot;required&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;label&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;第二，prompt 把規則講死：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;只有在逐字稿或會議筆記中出現明確的稱呼、點名或自我介紹時才填 name，並在 evidence 說明是哪一句讓你這樣判斷。不要從語氣、職務內容或發言比重臆測姓名。找不到明確線索時，name 與 evidence 一律回 null。&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;confidence&lt;/code&gt; 欄位，後來拿掉了。理由是模型自評信心值本來就不可靠，而 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;evidence&lt;/code&gt; 已經完整承擔了這個角色：有證據就是推斷成功，沒證據就是沒推斷出來。多一個信心分數只會讓人以為它比實際上更可信——「這裡寫 0.7 耶，那應該有七成準吧」，但那個 0.7 不是從任何真實的機率分佈來的。&lt;/p&gt;

&lt;p&gt;輸出長這樣，推斷成功的附上依據，推斷不出來的老實承認：&lt;/p&gt;

&lt;div class=&quot;language-markdown 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;gu&quot;&gt;## 與會者&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;gs&quot;&gt;**Evan**&lt;/span&gt;（spk_1）— 依據：第 3 段被稱呼「Evan 你那邊進度如何」
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; spk_2 — 逐字稿與筆記中沒有足夠線索指出姓名
&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;**Evan（spk_1）**&lt;/code&gt; 你會知道這是推斷來的、對不上時可以自己核對；如果直接寫成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;**Evan**&lt;/code&gt;，它看起來就跟事實一樣了。&lt;/p&gt;

&lt;p&gt;另外一個加分的線索來源：我把使用者當場寫的會議筆記也一起送進去。筆記裡常常已經有與會者名單，對應成功率會高不少。但這裡要加一道防線——prompt 必須說清楚「筆記只能用來對應語者姓名，不可以把筆記內容當成有人說過的話寫進摘要或待辦事項」，不然你自己寫的備忘錄會變成某人的發言。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;隱私與清理不要留下你不想留的東西&quot;&gt;隱私與清理：不要留下你不想留的東西&lt;/h1&gt;

&lt;p&gt;批次轉錄天生比即時串流多了兩份資料：本機的錄音檔，跟上傳到 Google 的那份副本。這兩份都要有明確的下場。&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; 一小時的會議大約 115MB，累積起來很可觀。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;失敗時反而保留。&lt;/strong&gt; 這是刻意的例外：轉錄失敗時錄音檔留著，狀態列會告訴你路徑，讓你能重試或自己處理。此時刪掉才是真的丟資料。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;雲端副本用完立刻刪除。&lt;/strong&gt; Files API 的檔案會存 48 小時自動過期，但「反正它會自己消失」跟「我確定它現在已經不在了」是兩件事。&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-swift 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;// 用完立刻把雲端副本刪掉，不留著等 48 小時自動過期&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;GeminiFilesUploader&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;apiKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;apiKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uploaded&lt;/span&gt;&lt;span class=&quot;o&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;成果與效益&quot;&gt;成果與效益&lt;/h1&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;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;2&lt;/td&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;累計測試斷言&lt;/td&gt;
      &lt;td&gt;37&lt;/td&gt;
      &lt;td&gt;98&lt;/td&gt;
      &lt;td&gt;108&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;commit&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos/commit/b0e12c5&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;b0e12c5&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos/commit/486ba8f&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;486ba8f&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos/commit/686899a&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;686899a&lt;/code&gt;&lt;/a&gt;、&lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos/commit/1cc41c3&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1cc41c3&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GeminiLiveConnection.swift&lt;/code&gt; 這支原本什麼都做的檔案，setup 產生跟回應解析都被抽走之後淨減了六十幾行，而且抽出來的兩個純函式模組現在有 20 幾條測試守著。這是這次的意外收穫：&lt;strong&gt;為了讓東西可測而做的拆分，本身就是我拖了很久沒做的重構。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;要先確認你想要的功能在哪顆模型上。&lt;/strong&gt; 我一開始理所當然地以為「即時逐字稿」那顆會有語者分離——都是轉錄模型，差別只在即時與否嘛。查了文件才發現不是，而且這個差異不是參數開關，是架構層級的：要語者分離就得錄檔、上傳、走另一條 API、接受 30 分鐘上限、放棄 SMART 模式。如果我沒先查就開始寫，會在「改幾個參數」的心理預期下撞上一整套新的子系統。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;限制往往決定架構。&lt;/strong&gt; 這次每一個設計決定幾乎都是被限制逼出來的：30 分鐘上限逼出切段、切段逼出代號命名空間、批次很慢逼出兩階段匯出、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;smart&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;diarization&lt;/code&gt; 互斥逼我在乾淨的逐字稿跟語者標籤之間二選一。先把限制查清楚再開始設計，比先設計再撞牆省事得多。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;即時逐字稿那半邊已經實跑過了。&lt;/strong&gt; 上面那張截圖就是拿日文影片餵進去的結果，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemini-3.5-transcribe-live&lt;/code&gt; 自動偵測到日文、直接輸出日文逐字稿，中間沒有經過翻譯，停止後也照常吐出會議記錄網頁。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;languageCodes&lt;/code&gt; 留空的自動偵測是真的能用。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;語者分離那半邊實跑之後出事了&lt;/strong&gt;，而且是我沒預料到的方式：檔案有產生、逐字稿內容正確、程式一個錯都沒報，就是沒有分人。這一段值得單獨講，寫在下一節。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;後記文件的範例會給你一份不報錯的空結果&quot;&gt;後記：文件的範例會給你一份不報錯的空結果&lt;/h1&gt;

&lt;p&gt;發文之後我拿一場真的對話跑了語者分離。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;meeting-2026-08-28-11-54-diarized.md&lt;/code&gt; 有產生、內容完整、一個字都沒少，但從頭到尾沒有任何語者標籤，就是一整段連在一起的文字。&lt;/p&gt;

&lt;p&gt;沒有錯誤訊息，狀態列顯示成功，檔案該有的都有。這種失敗最難查，因為它看起來就像成功。&lt;/p&gt;

&lt;h2 id=&quot;先做一個能自己重現的最小案例&quot;&gt;先做一個能自己重現的最小案例&lt;/h2&gt;

&lt;p&gt;第一個問題是我手上沒有證據——App 沒有把原始回應存下來，那場會議的錄音也因為「轉錄成功」被自動刪掉了。要重跑就得再開一場會議，而且不保證重現。&lt;/p&gt;

&lt;p&gt;所以我沒有去猜哪裡壞了，而是先想辦法拿到一份原始回應。macOS 內建的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;say&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;say &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; Alex     &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; a1.aiff &lt;span class=&quot;s2&quot;&gt;&quot;Hi Samantha, did you finish the quarterly report yesterday?&quot;&lt;/span&gt;
say &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; Samantha &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; a2.aiff &lt;span class=&quot;s2&quot;&gt;&quot;Yes Alex, I sent it to the whole team this morning.&quot;&lt;/span&gt;
say &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; Alex     &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; a3.aiff &lt;span class=&quot;s2&quot;&gt;&quot;Sure, I will look at the budget section this afternoon.&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;f &lt;span class=&quot;k&quot;&gt;in &lt;/span&gt;a1 a2 a3&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do &lt;/span&gt;afconvert &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; WAVE &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; LEI16@16000 &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt; 1 &lt;span class=&quot;nv&quot;&gt;$f&lt;/span&gt;.aiff &lt;span class=&quot;nv&quot;&gt;$f&lt;/span&gt;.wav&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;三段接起來是 17 秒、兩位語者、格式跟 App 錄出來的一模一樣（16kHz 單聲道 16-bit）。接著用 curl 走完整條上傳與轉錄，把 JSON 整份倒出來。&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-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;steps&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;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;content&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;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;text&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;Hi Samantha, did you finish...&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;type&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;text&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;span class=&quot;nl&quot;&gt;&quot;type&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;model_output&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;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;811 bytes，完整的逐字稿，&lt;strong&gt;一個 annotation 都沒有&lt;/strong&gt;。所以問題不在我的解析，在請求。&lt;/p&gt;

&lt;h2 id=&quot;根因一字詞層級標註要自己開不開就什麼都沒有&quot;&gt;根因一：字詞層級標註要自己開，不開就什麼都沒有&lt;/h2&gt;

&lt;p&gt;同一個音檔、同一個上傳的檔案，只改請求裡的一個欄位：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;請求內容&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;回應中帶 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speaker&lt;/code&gt; 的物件&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;diarization_mode: &quot;speaker&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;拿掉 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;language_codes: []&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;加上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timestamp_granularities: [&quot;word&quot;]&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;41&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;語者代號是掛在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;word_info&lt;/code&gt; 標註上的，而字詞層級標註要在請求裡明確要求才會回傳。沒要求，就沒有標註；沒有標註，就沒有語者。&lt;/p&gt;

&lt;p&gt;我原本沒寫這個欄位，是因為照著文件裡那段最短的 Python 範例抄的——那個範例只有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type&lt;/code&gt; 跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;diarization_mode&lt;/code&gt;。文件另一處的完整 REST 範例其實是有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timestamp_granularities&lt;/code&gt; 的，但我當時已經「知道」該怎麼寫了，就沒再回頭看。&lt;/p&gt;

&lt;p&gt;最難受的是&lt;strong&gt;它不報錯&lt;/strong&gt;。API 回 200、給你完整的逐字稿、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;completed&lt;/code&gt;。如果它回一個「你要求語者分離但沒開字詞標註」的錯誤，我五分鐘就修好了。&lt;/p&gt;

&lt;h2 id=&quot;根因二實際的代號格式跟文件不一樣&quot;&gt;根因二：實際的代號格式跟文件不一樣&lt;/h2&gt;

&lt;p&gt;加上欄位之後 annotations 出現了，但長得跟我以為的不一樣：&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;nl&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Hi,&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;start_offset&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;0.100s&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;end_offset&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;0.500s&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;speaker&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;spk:0&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;word_info&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk:0&lt;/code&gt;。&lt;strong&gt;冒號，而且從 0 開始。&lt;/strong&gt; 文件從頭到尾寫的都是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_1&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_2&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;而我的顯示邏輯是這樣寫的：&lt;/p&gt;

&lt;div class=&quot;language-swift 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;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;range&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;speaker&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;range&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;spk_&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;speaker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;range&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upperBound&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;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;語者 &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;se&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;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;speaker&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;spk:0&lt;/code&gt; 比不到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_&lt;/code&gt;，於是直接掉進 fallback，畫面上會出現 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;**spk:0**：&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;這個 bug 我的測試完全抓不到，原因很簡單：&lt;strong&gt;測試資料是照文件寫的。&lt;/strong&gt; 文件錯了，測試就跟著錯，然後綠燈告訴我一切正常。這是我這次最想記下來的一點——對外部 API 的測試，你測的其實是「我對這個 API 的理解」，不是 API 本身。理解錯了，測試只會忠實地保護那個錯誤。&lt;/p&gt;

&lt;p&gt;修法我沒有把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_&lt;/code&gt; 改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk:&lt;/code&gt;，那只是把賭注換一個方向下。改成不解析代號裡的數字，用語者在該段落中第一次出現的順序來編號：&lt;/p&gt;

&lt;div class=&quot;language-swift 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 決定（實測回傳 spk:0、spk:1，官方文件寫的則是 spk_1），&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;/// 所以不去解析代號裡的數字，改用它在這一段裡第一次出現的順序來編號。&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;speakerOrder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;chunk&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;kt&quot;&gt;DiarizedSegment&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;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&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;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;order&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;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&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;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;segment&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;where&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;segment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;speaker&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isEmpty&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;order&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;segment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;speaker&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;kc&quot;&gt;nil&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;order&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;segment&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;speaker&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;order&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;order&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;h2 id=&quot;我自己寫的退路讓失敗看起來像成功&quot;&gt;我自己寫的退路，讓失敗看起來像成功&lt;/h2&gt;

&lt;p&gt;前面我寫過這段，當時還挺滿意的：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;parseDiarized&lt;/code&gt; 對回應結構的假設是照文件寫的，如果實際格式有出入，我留了一條退路——抓不到字詞標註時退回整段 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output_text&lt;/code&gt;，至少逐字稿不會整個空掉。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;這條退路確實生效了，效果也如預期：使用者拿到一份完整的逐字稿，沒有東西遺失。&lt;/p&gt;

&lt;p&gt;但它同時也&lt;strong&gt;把失敗藏起來了&lt;/strong&gt;。如果沒有那條退路，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-diarized.md&lt;/code&gt; 會是空的或根本不會產生，我立刻就知道有事。有了它，我拿到的是一份看起來很正常、只是少了我要的那個功能的檔案——而那個功能正是我開這個功能的唯一理由。&lt;/p&gt;

&lt;p&gt;這裡的分寸我還沒完全想清楚。降級處理本身沒有錯，錯的是&lt;strong&gt;降級之後沒有講出來&lt;/strong&gt;。現在的作法是退路保留，但在退路被觸發時把狀態列訊息換掉，明講「這次沒有取得語者資訊」，而不是照常顯示「語者逐字稿已儲存」。&lt;/p&gt;

&lt;h2 id=&quot;修完之後&quot;&gt;修完之後&lt;/h2&gt;

&lt;p&gt;拿真實回應跑完整條解析，Alex 講的兩段都正確回到語者 1：&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;**語者 1**：Hi, Samantha. Did you finish the quarterly report yesterday?
**語者 2**：Yes, Alex. I sent it to the whole team this morning. Could you review the budget section?
**語者 1**：Sure. I will look at the budget section this afternoon and get back to you.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;語者分離本身的品質是好的。測試從 98 條加到 106 條，多的四條就是這兩個根因的回歸測試——這次的測試資料不是照文件抄的，是從真實回應剪下來的。&lt;/p&gt;

&lt;p&gt;順帶三個實測才知道的細節：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;REST 端點只吃 snake_case，送 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generationConfig&lt;/code&gt; 會被明確退回 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Unknown parameter &apos;generationConfig&apos;. Did you mean &apos;generation_config&apos;?&lt;/code&gt;。這個錯誤報得很好，比上面那個安靜的失敗有用一百倍。&lt;/li&gt;
  &lt;li&gt;annotations 帶了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;start_index&lt;/code&gt; 與 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;end_index&lt;/code&gt;，直接對應到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;content.text&lt;/code&gt; 裡的位置。用它來切字串會比我那套中英文接字的啟發式準得多，等於整個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;joinWords&lt;/code&gt; 都可以拿掉。這個我還沒動。&lt;/li&gt;
  &lt;li&gt;上傳的檔案 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt; 直接就是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ACTIVE&lt;/code&gt;，17 秒的音訊沒有經過 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PROCESSING&lt;/code&gt; 階段。我原本寫了輪詢等待的邏輯，看來對短音訊是多餘的，但長音訊會不會需要還不知道，先留著。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;這篇文章因此有了兩段結尾，我決定兩段都留著。&lt;/strong&gt; 前面那段寫「語者分離還沒驗到，等我跑過再回來補」，然後我真的跑了，然後它壞了。如果我把前面那段改掉、只留修好之後的版本，這篇就會變成一篇很順的「我做了 X，它成功了」——而實際發生的事情是「我照文件做了 X，它安靜地失敗了，我花了半小時才知道為什麼」。後面這個對讀者有用得多。&lt;/p&gt;

&lt;p&gt;程式碼在 &lt;a href=&quot;https://github.com/kkdai/gemini-live-translate-macos&quot;&gt;kkdai/gemini-live-translate-macos&lt;/a&gt;。兩份官方文件分別是 &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/live-api/live-transcribe&quot;&gt;Live transcription&lt;/a&gt; 跟 &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/transcribe&quot;&gt;Audio transcription&lt;/a&gt;，語者分離那頁的限制章節建議連著讀完再動手。&lt;/p&gt;
</description>
				<pubDate>Thu, 27 Aug 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/gemini-3.5-transcribe-live/</link>
				<guid isPermaLink="true">https://www.evanlin.com/gemini-3.5-transcribe-live/</guid>
			</item>
		
			<item>
				<title>[Go 實戰] 讓 AI 寫出當代的 Go：JetBrains go-modern-guidelines 實測，順手把一支 1039 行的 main.go 拆掉</title>
				<description>&lt;p&gt;&lt;img src=&quot;../images/image-20260826233144561.png&quot; alt=&quot;JetBrains go-modern-guidelines：Help AI coding agents write modern Go&quot; /&gt;&lt;/p&gt;

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

&lt;p&gt;AI 時代下，連我自己都會將大部分得程式碼優化或是寫作的部分都會麻煩 AI 代勞。但是因為模型訓練的資料因素，有太多的寫作方式都太老舊了。這樣造成寫出來的程式無法應用到最新版本 Go 的一些功能，這樣相當的可惜。&lt;/p&gt;

&lt;p&gt;還好， JetBrains 出了 go-modern-guidelines 這個很好用的 plugin 。可以讓你的 AI Agent 變得更加的聰明，並且知道該如何使用最新的程式語法來優化你的 Golang 程式碼。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;什麼是-go-modern-guidelines&quot;&gt;什麼是 go-modern-guidelines？&lt;/h1&gt;

&lt;h2 id=&quot;它想解決的問題模型的知識有截止日go-沒有&quot;&gt;它想解決的問題：模型的知識有截止日，Go 沒有&lt;/h2&gt;

&lt;p&gt;這個專案的定位寫得很直白：&lt;strong&gt;為 AI agent 提供當代的 Go 撰寫規範，讓它們不會因為知識截止日而寫出過時的 Go。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;問題有兩層。第一層很好理解：模型訓練資料有截止時間，截止之後才進標準庫的東西，它沒看過就不會用。專案自己舉的例子是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;errors.AsType[T]&lt;/code&gt;（Go 1.26），模型沒見過，自然不會寫。&lt;/p&gt;

&lt;p&gt;第二層比較微妙，專案稱之為 &lt;strong&gt;frequency bias&lt;/strong&gt;：就算模型「知道」新寫法，訓練資料裡舊寫法出現的次數還是壓倒性地多。網路上十年份的 Go 程式碼裡，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;interface{}&lt;/code&gt; 的出現次數遠遠多於 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;any&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sort.Slice&lt;/code&gt; 遠多於 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;slices.SortFunc&lt;/code&gt;。模型是在做機率預測，多數決贏的往往是舊的那個。&lt;/p&gt;

&lt;p&gt;這第二點我在這次重構裡真的看到了。原本專案裡有這麼一段：&lt;/p&gt;

&lt;div class=&quot;language-go 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;// The oauth2 library can return an error containing &quot;invalid_grant&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;// when the refresh token is expired, revoked, or otherwise invalid.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;nil&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;errorStr&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt;&lt;span class=&quot;o&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;c&quot;&gt;// Basic substring check to avoid importing &quot;strings&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;=&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;errorStr&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;m&quot;&gt;13&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&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;errorStr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;13&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;s&quot;&gt;&quot;invalid_grant&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;no&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;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;一段手刻的字串搜尋，註解還特地解釋「為了避免 import strings」。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;strings&lt;/code&gt; 是標準庫，import 它的成本是零。這段程式碼要的其實就是一行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;strings.Contains(err.Error(), &quot;invalid_grant&quot;)&lt;/code&gt;。&lt;/p&gt;

&lt;h2 id=&quot;它怎麼運作兩個指令一份隨-go-版本增長的清單&quot;&gt;它怎麼運作：兩個指令，一份隨 Go 版本增長的清單&lt;/h2&gt;

&lt;p&gt;工具本體是一支 CLI，只有兩個子指令：&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;list [--go-version &amp;lt;version&amp;gt; | --file-path &amp;lt;path&amp;gt;]
    回傳這個 Go 版本支援的規範清單，由新到舊排序。

explain &amp;lt;id&amp;gt;...
    回傳特定規範的詳細說明與 before/after 範例。
&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;list&lt;/code&gt; 的設計重點在於&lt;strong&gt;它會依 Go 版本回答不同的答案&lt;/strong&gt;。你可以直接丟一個檔案路徑給它，它會自己往上找 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.mod&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.work&lt;/code&gt;，或退而求其次看本機的 Go toolchain：&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;nv&quot;&gt;$ &lt;/span&gt;go-modern-guidelines list &lt;span class=&quot;nt&quot;&gt;--file-path&lt;/span&gt; ~/Documents/linebot-file/main.go
&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;go.mod&lt;/code&gt; 寫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go 1.24.0&lt;/code&gt;，所以它回了 45 條。換個版本號，數字就跟著變：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Go 版本&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;規範數&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1.21&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;32&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.22&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;37&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.23&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;41&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.24&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;45&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.25&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;46&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.26&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;48&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.27&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;54&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;這個設計是刻意的：&lt;strong&gt;它只會建議你的專案版本真的能用的寫法。&lt;/strong&gt; 對 AI agent 來說這很關鍵，不然它可能會很開心地建議你用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;errors.AsType[T]&lt;/code&gt;，然後你的 CI 因為跑在 Go 1.24 上直接掛掉。&lt;/p&gt;

&lt;p&gt;看一下版本之間差在哪，其實就是一份 Go 近年新特性的濃縮清單：&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;nv&quot;&gt;$ &lt;/span&gt;diff &amp;lt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;list &lt;span class=&quot;nt&quot;&gt;--go-version&lt;/span&gt; 1.21&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &amp;lt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;list &lt;span class=&quot;nt&quot;&gt;--go-version&lt;/span&gt; 1.22&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; range_over_int: Use &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;i :&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; range n when iterating from 0 to n-1.
&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; loopvar_capture: Do not add redundant loop-variable copies before closures or
  taking addresses&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; Go 1.22 gives each iteration its own variables.
&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; cmp_or: Use cmp.Or to pick the first non-zero value from a fallback chain.
&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; reflect_type_for: Use reflect.TypeFor[T]&lt;span class=&quot;o&quot;&gt;()&lt;/span&gt; instead of reflect.TypeOf&lt;span class=&quot;o&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;*&lt;/span&gt;T&lt;span class=&quot;o&quot;&gt;)(&lt;/span&gt;nil&lt;span class=&quot;o&quot;&gt;))&lt;/span&gt;.Elem&lt;span class=&quot;o&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;.&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; http_servemux_patterns: Use method-aware ServeMux patterns and r.PathValue &lt;span class=&quot;k&quot;&gt;for
  &lt;/span&gt;path parameters.
&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;list&lt;/code&gt; 給的是一行摘要，真的要動手時再用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;explain&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;$ go-modern-guidelines explain cmp_or

cmp_or:
  Since: Go 1.22

  Summary:
    Use cmp.Or to pick the first non-zero value from a fallback chain.

  Details:
    cmp.Or returns the first non-zero value from its arguments. It is concise
    for simple fallback chains, but remember that all arguments are evaluated
    before the call.

  Examples:

  Before:
    name := os.Getenv(&quot;NAME&quot;)
    if name == &quot;&quot; {
      name = &quot;default&quot;
    }

  After:
    name := cmp.Or(os.Getenv(&quot;NAME&quot;), &quot;default&quot;)
&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;Details&lt;/code&gt; 那段最後補的那句：「所有參數在呼叫前都會被求值」。這是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cmp.Or&lt;/code&gt; 真正的陷阱——如果你的 fallback 來源是一個昂貴的函式呼叫，寫成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cmp.Or(a(), b())&lt;/code&gt; 會兩個都執行。這種「可以用，但要知道代價」的提醒，比單純叫你換寫法有用得多。&lt;/p&gt;

&lt;h2 id=&quot;兩層資訊的設計其實是為了省-context&quot;&gt;兩層資訊的設計，其實是為了省 context&lt;/h2&gt;

&lt;p&gt;這個 list／explain 分層乍看只是介面設計，實際上是為了 AI agent 的 context window。45 條規範，每條一行摘要大概 1000 個 token 就講完了；但如果每條都附上完整說明跟 before/after 範例，光是塞這份清單就要燒掉幾萬個 token。&lt;/p&gt;

&lt;p&gt;所以流程是：先 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list&lt;/code&gt; 掃過全部，判斷哪幾條跟眼前這段程式碼有關，再只對那幾條呼叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;explain&lt;/code&gt;。這次我實際只 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;explain&lt;/code&gt; 了六條。&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TD
    A[準備修改 Go 程式碼] --&amp;gt; B[list --file-path main.go]
    B --&amp;gt; C[從 go.mod 解析出 Go 版本]
    C --&amp;gt; D[回傳該版本可用的 45 條規範&amp;lt;br/&amp;gt;每條一行摘要]
    D --&amp;gt; E{哪幾條跟這段程式碼有關?}
    E --&amp;gt;|挑出候選| F[explain cmp_or min_max ...]
    F --&amp;gt; G[取得詳細說明與 before/after]
    G --&amp;gt; H[實際套用到程式碼]
    E --&amp;gt;|沒有相關的| I[照原本方式寫]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;skill 的說明文件裡還有一條規定寫得特別重：&lt;strong&gt;不要把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list&lt;/code&gt; 的輸出接到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;head&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tail&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grep&lt;/code&gt; 後面&lt;/strong&gt;，因為可能會漏掉重要的規範。這條我第一次就違規了，後面「踩坑」那段會講。&lt;/p&gt;

&lt;h2 id=&quot;安裝&quot;&gt;安裝&lt;/h2&gt;

&lt;p&gt;Claude Code 的話兩行：&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;/plugin marketplace add JetBrains/go-modern-guidelines
/plugin install modern-go-guidelines@goland-claude-marketplace
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;裝完之後遇到 Go 相關任務會自動觸發，也可以手動叫：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/modern-go-guidelines:use-modern-go&lt;/code&gt;。Cursor、Junie、Codex 有各自的安裝方式，其他 agent 可以走 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npx skills add JetBrains/go-modern-guidelines&lt;/code&gt;。專案採 Apache 2.0 授權。&lt;/p&gt;

&lt;p&gt;第一次執行時，wrapper script 會自己去把 CLI 裝到本機快取目錄：&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;go-modern-guidelines: installing github.com/JetBrains/go-modern-guidelines@v0.1.1
  into /Users/xxx/.cache/go-modern-guidelines/v0.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;這次的專案一支長到-1039-行的-maingo&quot;&gt;這次的專案：一支長到 1039 行的 main.go&lt;/h1&gt;

&lt;p&gt;先交代一下背景。linebot-file 的架構不複雜：&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph LR
    A[LINE App] --&amp;gt;|傳送檔案| B[LINE Platform]
    B --&amp;gt;|webhook| C[Cloud Run]
    C --&amp;gt;|讀取 token| D[(Firestore)]
    C --&amp;gt;|上傳/查詢| E[Google Drive API]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;使用者用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/connect_drive&lt;/code&gt; 授權，token 存 Firestore，之後傳到聊天室的檔案就會自動上傳到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LINE Bot Uploads/YYYY-MM/&lt;/code&gt; 這樣的資料夾結構下。功能是陸續加上去的，所有東西都堆在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.go&lt;/code&gt;，其中 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main()&lt;/code&gt; 這個函式本身就佔了 564 行。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;健檢找到什麼&quot;&gt;健檢找到什麼&lt;/h1&gt;

&lt;p&gt;這段跟 go-modern-guidelines 沒有直接關係——它管的是「寫法夠不夠當代」，不是「邏輯對不對」。但這些才是真正會咬到使用者的東西，還是記一下。&lt;/p&gt;

&lt;h2 id=&quot;一編譯得過卻永遠不會執行的一段程式&quot;&gt;一、編譯得過，卻永遠不會執行的一段程式&lt;/h2&gt;

&lt;p&gt;這是最有意思的一個。原本的事件處理長這樣：&lt;/p&gt;

&lt;div class=&quot;language-go 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;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&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;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;type&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;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageEvent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;switch&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;e&lt;/span&gt;&lt;span class=&quot;o&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;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;type&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;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextMessageContent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c&quot;&gt;// ...&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FileMessageContent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c&quot;&gt;// ...&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FollowEvent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;          &lt;span class=&quot;c&quot;&gt;// ← 注意這裡的縮排層級&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Source&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;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UserSource&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;bot&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LinkRichMenuIdToUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UserId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;richMenuConnect&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;webhook.FollowEvent&lt;/code&gt; 被寫在&lt;strong&gt;內層&lt;/strong&gt; switch 裡了。內層 switch 判斷的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e.Message&lt;/code&gt;，型別是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MessageContentInterface&lt;/code&gt;——一個 follow 事件永遠不可能是一則訊息的內容。&lt;/p&gt;

&lt;p&gt;那為什麼編譯得過？Go 對型別 switch 是有檢查的，case 的型別如果不可能實作那個 interface，編譯器會報 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;impossible type switch case&lt;/code&gt;。問題出在這個 SDK 的 interface 定義：&lt;/p&gt;

&lt;div class=&quot;language-go 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;type&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MessageContentInterface&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;GetType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&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;GetType() string&lt;/code&gt;。而 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FollowEvent&lt;/code&gt; 剛好也有這個方法（所有事件型別都有），所以它在型別系統上「可以」是一個 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MessageContentInterface&lt;/code&gt;，編譯器放行，執行期永遠不匹配。&lt;/p&gt;

&lt;p&gt;實際後果：&lt;strong&gt;新使用者加好友時，引導授權的 Rich Menu 從來沒有被綁定過。&lt;/strong&gt; 這功能大概壞了很久，因為它不會報錯，只是安靜地什麼都不做。&lt;/p&gt;

&lt;h2 id=&quot;二群組訊息會-panic&quot;&gt;二、群組訊息會 panic&lt;/h2&gt;

&lt;div class=&quot;language-go 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;userID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Source&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;webhook&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UserSource&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;UserId&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;未檢查的型別斷言，出現了六次。只要機器人被拉進群組、有人在裡面傳一張圖，這行就 panic。&lt;/p&gt;

&lt;p&gt;順帶一提，同一支檔案裡另外幾個地方寫的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e.Source.(*webhook.GroupSource)&lt;/code&gt;（指標）。查了 SDK 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UnmarshalSource&lt;/code&gt;，它回傳的是&lt;strong&gt;值&lt;/strong&gt;不是指標，所以那幾個帶 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;, ok&lt;/code&gt; 的斷言永遠是 false，同樣是死碼。同一個檔案裡兩種寫法都錯，方向還相反。&lt;/p&gt;

&lt;h2 id=&quot;三recent_files-回傳的是資料夾&quot;&gt;三、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/recent_files&lt;/code&gt; 回傳的是資料夾&lt;/h2&gt;

&lt;div class=&quot;language-go 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;// 上傳時：檔案放進 LINE Bot Uploads/YYYY-MM/&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;monthFolderID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;findOrCreateFolder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;srv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;2026-08&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mainFolderID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;srv&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Files&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;drive&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;File&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Parents&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;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monthFolderID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}})&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;// 查詢時：只找 LINE Bot Uploads 底下&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fmt&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Sprintf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&apos;%s&apos; in parents and trashed=false&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mainFolderID&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;檔案存在月份子資料夾，查詢卻只看根資料夾。在 Google Drive 的資料模型裡資料夾也是一種 file，所以這個查詢確實會回傳東西——回傳的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2026-08&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2026-07&lt;/code&gt; 這些資料夾本身。&lt;/p&gt;

&lt;h2 id=&quot;四使用者輸入直接串進-drive-query&quot;&gt;四、使用者輸入直接串進 Drive query&lt;/h2&gt;

&lt;div class=&quot;language-go 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;query&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fmt&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Sprintf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;... and name contains &apos;%s&apos;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;searchQuery&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;it&apos;s&lt;/code&gt;，那個單引號就把查詢語法打斷了；再往下想，也可以塞進額外的查詢條件。修法是老實寫一個跳脫函式，注意順序不能反：&lt;/p&gt;

&lt;div class=&quot;language-go highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;// 反斜線必須先跳脫，否則為了跳脫引號而加進去的那個反斜線，&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;// 會被第二輪處理再跳脫一次&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;escapeDriveQuery&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;strings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ReplaceAll&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;`\`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&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;strings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ReplaceAll&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;`&apos;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&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;h2 id=&quot;五quit-被當成搜尋指令&quot;&gt;五、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/quit&lt;/code&gt; 被當成搜尋指令&lt;/h2&gt;

&lt;div class=&quot;language-go 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;k&quot;&gt;else&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;nb&quot;&gt;len&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;Text&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;m&quot;&gt;13&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&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;Text&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;m&quot;&gt;13&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;s&quot;&gt;&quot;/search_files&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;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;message&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;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&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;Text&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;m&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;s&quot;&gt;&quot;/q&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;commandPrefixLen&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&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;commandPrefixLen&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;14&lt;/span&gt;  &lt;span class=&quot;c&quot;&gt;// &quot;/search_files &quot; 的長度&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;k&quot;&gt;if&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;commandPrefixLen&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;3&lt;/span&gt;   &lt;span class=&quot;c&quot;&gt;// &quot;/q &quot; 的長度&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;searchQuery&lt;/span&gt; &lt;span class=&quot;o&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;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;commandPrefixLen&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;p&gt;手工切字串，而且寫死了「後面一定接一個空白」。使用者打 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/quit&lt;/code&gt;，前兩個字是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/q&lt;/code&gt;，於是變成搜尋 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;it&lt;/code&gt;。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;go-modern-guidelines-實際改了哪些地方&quot;&gt;go-modern-guidelines 實際改了哪些地方&lt;/h1&gt;

&lt;p&gt;回到主題。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list&lt;/code&gt; 那 45 條裡，真正套用到這次改動的有這些：&lt;/p&gt;

&lt;h2 id=&quot;http_servemux_patterns連帶砍掉一段手寫的路徑檢查&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http_servemux_patterns&lt;/code&gt;：連帶砍掉一段手寫的路徑檢查&lt;/h2&gt;

&lt;p&gt;原本的寫法是所有請求都進同一個 handler，再自己判斷路徑：&lt;/p&gt;

&lt;div class=&quot;language-go 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;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HandleFunc&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;func&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;w&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ResponseWriter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Request&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;c&quot;&gt;// LINE Platform 一定是 POST 到 webhook URL&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;URL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Path&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;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NotFound&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;w&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;req&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;c&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;Go 1.22 之後 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ServeMux&lt;/code&gt; 的 pattern 支援方法與精確路徑：&lt;/p&gt;

&lt;div class=&quot;language-go 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;mux&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NewServeMux&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;// &quot;/{$}&quot; 只匹配根路徑，而不是它底下的所有路徑&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;mux&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HandleFunc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;POST /{$}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webhookHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;mux&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HandleFunc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GET /oauth/callback&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;oauthCallbackHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;// 不能叫 /healthz，原因見後面的踩坑五&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;mux&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HandleFunc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GET /health&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;healthHandler&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;{$}&lt;/code&gt; 這個語法是關鍵：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;/&quot;&lt;/code&gt; 在 ServeMux 裡是 subtree pattern，會吃掉底下所有路徑，這也是為什麼原本的程式碼需要那段手寫檢查。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;/{$}&quot;&lt;/code&gt; 只匹配根路徑本身，檢查就不用寫了。順手還把 method 限制、健康檢查端點一起補上。&lt;/p&gt;

&lt;p&gt;健康檢查端點這件事後來出了問題，不過那是部署之後才發現的，留到踩坑五再講。&lt;/p&gt;

&lt;h2 id=&quot;cmp_or三段-fallback-變成三行&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cmp_or&lt;/code&gt;：三段 fallback 變成三行&lt;/h2&gt;

&lt;div class=&quot;language-go 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;// Before&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;port&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;o&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;PORT&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;port&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;port&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;5000&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;// After（順手把預設值改成 8080，對齊 Dockerfile 的 EXPOSE 與 Cloud Run 慣例）&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;port&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Or&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;o&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;PORT&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;8080&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;richMenuConnect&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Or&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;o&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;RICH_MENU_CONNECT&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;defaultRichMenuConnect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;richMenuMain&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Or&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;o&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;RICH_MENU_MAIN&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;defaultRichMenuMain&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;explain&lt;/code&gt; 提醒的使用條件：三個參數都是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;os.Getenv&lt;/code&gt; 跟常數，全部求值也沒有副作用。&lt;/p&gt;

&lt;h2 id=&quot;strings_cut_prefix_suffix把手工切字串換掉&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;strings_cut_prefix_suffix&lt;/code&gt;：把手工切字串換掉&lt;/h2&gt;

&lt;p&gt;前面第五點那段 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;message.Text[:13]&lt;/code&gt; 的指令解析，換成正規的解析函式：&lt;/p&gt;

&lt;div class=&quot;language-go 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;func&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parseCommand&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;kt&quot;&gt;string&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;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;arg&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;bool&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;text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;strings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TrimSpace&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;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HasPrefix&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;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;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;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;false&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;arg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;strings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Cut&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;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;switch&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;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmdConnect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmdReconnect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmdDisconnect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmdRecent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmdSearch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cmdSearchShort&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&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;strings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TrimSpace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;arg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&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;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;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;false&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;strings.Cut&lt;/code&gt; 先切出完整的指令名稱、再用 switch 比對，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/quit&lt;/code&gt; 那個 bug 就從結構上消失了——它切出來的 name 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/quit&lt;/code&gt;，不在允許清單裡，直接回 false。&lt;/p&gt;

&lt;h2 id=&quot;slices_sort_func--min修掉那個假的排序&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;slices_sort_func&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min&lt;/code&gt;：修掉那個假的排序&lt;/h2&gt;

&lt;p&gt;原本的搜尋結果去重之後長這樣：&lt;/p&gt;

&lt;div class=&quot;language-go 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;// Remove duplicates and sort by creation time (newest first)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;uniqueFiles&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;make&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&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;drive&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;File&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;_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;file&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;range&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;files&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;_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exists&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uniqueFiles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;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;exists&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;uniqueFiles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;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;file&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;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;make&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;drive&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;File&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&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;uniqueFiles&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;_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;file&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;range&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uniqueFiles&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;nb&quot;&gt;append&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;file&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;nb&quot;&gt;len&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;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;10&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;n&quot;&gt;result&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;m&quot;&gt;10&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;註解寫「sort by creation time (newest first)」，實際上從頭到尾沒有任何排序動作——&lt;strong&gt;map 的迭代順序是隨機的&lt;/strong&gt;，然後直接砍掉前 10 筆。所以使用者拿到的是隨機的 10 筆，不是最新的 10 筆。&lt;/p&gt;

&lt;div class=&quot;language-go 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;// Drive 回傳的 createdTime 是 RFC 3339 UTC 字串，直接做字串比較就是正確的時間順序&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sortAndTrimFiles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;files&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;drive&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;File&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;int&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;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;drive&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;File&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;slices&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SortStableFunc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;drive&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;File&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;int&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;cmp&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Compare&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreatedTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreatedTime&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;files&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;min&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;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;limit&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;min&lt;/code&gt; 這個內建函式（Go 1.21）在這裡省掉一個 if。&lt;/p&gt;

&lt;h2 id=&quot;anyerrors_is小地方&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;any&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;errors_is&lt;/code&gt;：小地方&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;map[string]interface{}&lt;/code&gt; 換成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;map[string]any&lt;/code&gt;，這種一行的東西不用多說。&lt;/p&gt;

&lt;h2 id=&quot;cryptorandtext一個需要先升版本的建議&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;crypto/rand.Text()&lt;/code&gt;：一個需要先升版本的建議&lt;/h2&gt;

&lt;p&gt;原本產生 OAuth state 的寫法：&lt;/p&gt;

&lt;div class=&quot;language-go 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;func&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;generateState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;make&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([]&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;byte&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;16&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;rand&lt;/span&gt;&lt;span class=&quot;o&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;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c&quot;&gt;// 錯誤被忽略&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;base64&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;URLEncoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;EncodeToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;b&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;Go 1.24 新增的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;crypto/rand.Text()&lt;/code&gt; 直接回傳一個隨機字串，不會失敗，而且輸出是 base32（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;A-Z&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2-7&lt;/code&gt;），天然就是 URL-safe，正好適合當 state 跟 Firestore 的 document ID：&lt;/p&gt;

&lt;div class=&quot;language-go 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;func&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;generateState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&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;rand&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;但這條有前提，下面會講。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;重大踩坑與解決方案&quot;&gt;重大踩坑與解決方案&lt;/h1&gt;

&lt;h2 id=&quot;踩坑一skill-明確叫你不要-grep我第一次就照做了相反的事&quot;&gt;踩坑一：skill 明確叫你不要 grep，我第一次就照做了相反的事&lt;/h2&gt;

&lt;p&gt;skill 文件寫得很清楚：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Do not pipe the output through head, tail, grep, sed, or any other truncating/filtering command. Important guidelines may otherwise be missed.&lt;/p&gt;
&lt;/blockquote&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;nv&quot;&gt;$ &lt;/span&gt;go-modern-guidelines list &lt;span class=&quot;nt&quot;&gt;--file-path&lt;/span&gt; main.go 2&amp;gt;&amp;amp;1 | &lt;span class=&quot;nb&quot;&gt;tail&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-60&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;list&lt;/code&gt; 明講是&lt;strong&gt;由新到舊排序&lt;/strong&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tail&lt;/code&gt; 拿到的正好是最舊那批；二是這次剛好沒出事，只是因為當時 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.mod&lt;/code&gt; 寫的是 1.23，總共 41 行，小於 60，所以 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tail -60&lt;/code&gt; 把整份都印出來了。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：純運氣。如果專案當時是 Go 1.27（54 條），&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tail -60&lt;/code&gt; 一樣不會截斷；但如果我打的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;head -20&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grep slices&lt;/code&gt;，就會漏掉整批東西，而且完全不會有任何提示告訴我漏了。這種「輸出被截斷但看起來很正常」的失敗最難發現。老實把完整輸出讀完就好，45 行而已。&lt;/p&gt;

&lt;h2 id=&quot;踩坑二工具建議的寫法你的-gomod-不一定准你用&quot;&gt;踩坑二：工具建議的寫法，你的 go.mod 不一定准你用&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rand.Text()&lt;/code&gt; 出現在建議清單裡，但專案的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.mod&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;module github.com/kkdai/linebot-file

// +heroku goVersion go1.21
go 1.23.0

toolchain go1.24.3
&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;go 1.23.0&lt;/code&gt; 這行決定的是&lt;strong&gt;語言版本&lt;/strong&gt;，跟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;toolchain&lt;/code&gt; 是兩回事。工具是照著能解析到的版本回答的，但要真的用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rand.Text()&lt;/code&gt;，得動 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.mod&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;這不是無腦改一行就好，得先確認整條鏈上的東西都對得起來：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;toolchain&lt;/code&gt; 已經是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go1.24.3&lt;/code&gt;，Dockerfile 用的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;golang:1.24-alpine&lt;/code&gt;，兩邊都沒問題。倒是 CI 有問題——&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.github/workflows/go.yml&lt;/code&gt; 裡寫死 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go-version: &apos;1.22&apos;&lt;/code&gt;，比 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.mod&lt;/code&gt; 要求的還舊，目前是靠 Go 的 toolchain 自動下載機制才沒炸。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.mod&lt;/code&gt; 提到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go 1.24.0&lt;/code&gt;，順手把那行過時的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;// +heroku goVersion go1.21&lt;/code&gt; 也清掉（這專案早就跑在 Cloud Run 上了），CI 則改成以 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.mod&lt;/code&gt; 為單一事實來源：&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;actions/setup-go@v5&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# 以 go.mod 為單一事實來源，避免 CI 與專案版本不一致&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;go-version-file&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;go.mod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;踩坑三改了-gomod-之後工具的答案就變了&quot;&gt;踩坑三：改了 go.mod 之後，工具的答案就變了&lt;/h2&gt;

&lt;p&gt;這是這次最有意思的一個發現。升完 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go.mod&lt;/code&gt; 之後，我在寫測試前又跑了一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list&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;testing_t_context: Use t.Context() when a test function needs a context tied to
                   the test lifetime.
json_omitzero:     Use omitzero on JSON-tagged bool, numeric, struct, and time
                   fields whose zero value should be omitted...
testing_b_loop:    Use b.Loop() for the main loop in benchmark functions.
strings_split_seq: Use strings or bytes SplitSeq and FieldsSeq helpers...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;這四條正是 Go 1.24 新增的。而其中 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;testing_t_context&lt;/code&gt; 直接改到我當下正在寫的測試：&lt;/p&gt;

&lt;div class=&quot;language-go 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;// Before&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;srv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;drive&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NewService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;option&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WithEndpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;server&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;URL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;option&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WithoutAuthentication&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;// After — context 綁定測試生命週期，測試結束自動取消&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;srv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;drive&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NewService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;option&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WithEndpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;server&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;URL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;option&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WithoutAuthentication&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;strong&gt;原因與解法&lt;/strong&gt;：這個工具的輸出是&lt;strong&gt;跟著專案狀態變動的&lt;/strong&gt;，不是一份靜態文件。升版本、切專案，答案就不一樣。所以正確的用法不是開工前查一次就收工，而是在&lt;strong&gt;修改內容性質改變的時候重跑&lt;/strong&gt;——我這次是「改完主程式、要開始寫測試」的節點重跑，剛好接到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;testing_t_context&lt;/code&gt;。如果我只在最開始查那一次，這條就漏掉了。&lt;/p&gt;

&lt;h2 id=&quot;踩坑四工具管寫法不管架構而架構的坑更深&quot;&gt;踩坑四：工具管寫法，不管架構——而架構的坑更深&lt;/h2&gt;

&lt;p&gt;這條是反過來講：go-modern-guidelines 沒有、也不該有意見的地方。&lt;/p&gt;

&lt;p&gt;原本的檔案上傳是在 webhook handler 裡同步做完的：從 LINE 下載一支影片，再上傳到 Drive，可能要好幾十秒。LINE 對 webhook 有回應時間期待，超時它會重送，重送就會&lt;strong&gt;重複上傳同一個檔案&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;這種情況的標準建議幾乎是反射性的：先回 200，剩下的丟 goroutine 做。我一開始也是這樣想的，寫到一半才想起來一件事——&lt;strong&gt;這服務跑在 Cloud Run 上，預設只在請求處理期間配置 CPU。&lt;/strong&gt; 回應送出去之後，那個 goroutine 會被 CPU throttling 掐住，變成一個看起來有做、實際上不知道什麼時候才會跑完的黑洞。這比同步處理還糟，至少同步處理會誠實地失敗。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：改成用 webhook 的 event ID 做去重，讓重送不會造成重複上傳，同時保留同步處理：&lt;/p&gt;

&lt;div class=&quot;language-go 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;// handledEvents 記住最近處理過的 webhook event ID。LINE 會重送它認為失敗的&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;// 請求，沒有這層防護的話，重送就會把同一個檔案再上傳一次。&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;handledEvents&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;mu&lt;/span&gt;   &lt;span class=&quot;n&quot;&gt;sync&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Mutex&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;seen&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Time&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;h&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;handledEvents&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;markHandled&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;bool&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;id&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;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&lt;/span&gt;  &lt;span class=&quot;c&quot;&gt;// 沒有 ID 就無從去重，一律當成新事件&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;c&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;再加上「reply token 過期就改用 push message」的 fallback，讓大檔案上傳完還是通知得到使用者。&lt;/p&gt;

&lt;p&gt;這是個折衷方案，真正的解法是走 Cloud Tasks 或 Pub/Sub。我把它寫進專案的 roadmap 裡了，包含「不能只開 goroutine」這個原因——不然下一個接手的人（可能是三個月後的我）大概會再踩一次。&lt;/p&gt;

&lt;h2 id=&quot;踩坑五servemux-寫對了cloud-run-不讓你用&quot;&gt;踩坑五：ServeMux 寫對了，Cloud Run 不讓你用&lt;/h2&gt;

&lt;p&gt;PR 合併之後我用 gcloud 查了一下 Cloud Build，狀態是 SUCCESS，新的 revision 也 Ready 了，流量全部切過去。看起來就是收工的樣子。&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;GET  /            405   ← method-aware ServeMux 生效
POST /   無簽章    400   ← 簽章驗證生效
GET  /nope        404   ← {$} 精確匹配生效
GET  /healthz     404   ← ？
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;前三個都對，健康檢查那個回 404。&lt;/p&gt;

&lt;p&gt;一開始以為是自己路由寫錯，把回應內容印出來才發現不對——那是一個 Google 品牌的 HTML 錯誤頁（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Error 404 (Not Found)!!1&lt;/code&gt;，還帶著 Google 那隻機器人的圖），不是 Go 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;404 page not found&lt;/code&gt; 純文字。這代表請求根本沒進到我的程式裡。&lt;/p&gt;

&lt;p&gt;去翻 Cloud Run 的 request log，證實了這件事：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;15:44:54  GET  400  /oauth/callback
15:44:36  GET  404  /nope
15:44:36  POST 400  /
15:44:36  GET  405  /
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;我發了五個請求，log 裡只有四個。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/healthz&lt;/code&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;/healthz         404  GFE(Google)   ← 被攔截
/healthz/        404  app(Go)       ← 只差一個斜線
/health          404  app(Go)
/readyz          404  app(Go)
/livez           404  app(Go)
/_ah/health      404  app(Go)
/status          404  app(Go)
/ping            404  app(Go)
/healthcheck     404  app(Go)
&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;/healthz&lt;/code&gt; 這個精確路徑被 Google Frontend 攔下來，連加一個斜線都能正常到達。查了一下這是 Cloud Run 的已知行為，&lt;a href=&quot;https://github.com/streamlit/streamlit/issues/3028&quot;&gt;Streamlit&lt;/a&gt; 跟 &lt;a href=&quot;https://github.com/n8n-io/n8n/issues/26163&quot;&gt;n8n&lt;/a&gt; 也都踩過。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：端點改叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/health&lt;/code&gt;，一行的事。麻煩的是這個坑完全不會叫——&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go vet&lt;/code&gt; 不會說話、測試不會說話、CI 全綠、build 成功、Cloud Run 顯示 Ready，連 request log 都不會留下痕跡。唯一會發現的方法是真的去戳那個端點，而且要注意到回來的 404 長得跟自己程式回的不一樣。&lt;/p&gt;

&lt;p&gt;所以修的時候我在程式碼裡留了註解，README 也寫了一段：&lt;/p&gt;

&lt;div class=&quot;language-go 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;// Not &quot;/healthz&quot;: Cloud Run&apos;s frontend reserves that exact path and&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;// answers it with its own 404, so the request never reaches us.&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;mux&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HandleFunc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GET /health&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;func&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;w&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ResponseWriter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Request&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;/health&lt;/code&gt; 覺得「這不是慣例、應該叫 healthz」的人（很可能就是我自己）會改回去。&lt;/p&gt;

&lt;h2 id=&quot;踩坑六我以為外部呼叫都加了-context結果漏了一整條路徑&quot;&gt;踩坑六：我以為外部呼叫都加了 context，結果漏了一整條路徑&lt;/h2&gt;

&lt;p&gt;修 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/healthz&lt;/code&gt; 的時候順手把程式碼又掃了一遍，找到一個更難堪的東西。&lt;/p&gt;

&lt;p&gt;前面我很滿意的一項改動是「全流程改用 context，所有外部呼叫都有逾時」。Firestore 有了，Drive 有了。然後我 grep 了一下所有外部呼叫：&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;webhook.go:312   blob.GetMessageContent(messageID)
line.go:73       bot.ReplyMessage(...)
line.go:94       bot.PushMessage(...)
line.go:118      bot.LinkRichMenuIdToUser(...)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;四個 LINE 的呼叫，沒有一個吃 context。翻進 SDK 才知道為什麼：&lt;/p&gt;

&lt;div class=&quot;language-go 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;c&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessagingApiAPI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;channelToken&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;channelToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;httpClient&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt;   &lt;span class=&quot;n&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DefaultClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;   &lt;span class=&quot;c&quot;&gt;// ← Timeout 是零值，等於不逾時&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;context&lt;/code&gt;，所以我在 handler 外層包的那個逾時，對這四個呼叫完全沒有作用。LINE 那端只要卡住，goroutine 就一直掛著。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：問題不在我不知道要加逾時，而在於「我加過 context 了」這個記憶蓋掉了「這個 SDK 到底收不收 context」這個事實。改 Drive 和 Firestore 的時候是一路 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.Context(ctx)&lt;/code&gt; 加下去的，加得很順，順到我沒停下來想還有哪些外部呼叫不長那個樣子。&lt;/p&gt;

&lt;p&gt;SDK 有提供注入點：&lt;/p&gt;

&lt;div class=&quot;language-go 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;bot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;messaging_api&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NewMessagingApiAPI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;accessToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;messaging_api&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WithHTTPClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;http&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;Timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lineAPITimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}))&lt;/span&gt;      &lt;span class=&quot;c&quot;&gt;// 10 秒&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;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;messaging_api&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NewMessagingApiBlobAPI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;accessToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;messaging_api&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WithBlobHTTPClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;http&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;Timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lineBlobTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}))&lt;/span&gt; &lt;span class=&quot;c&quot;&gt;// 5 分鐘&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;blob 那邊給到 5 分鐘，因為它要下載使用者傳的影片。&lt;/p&gt;

&lt;p&gt;順帶一個更隱蔽的陷阱。SDK 另外提供了一個看起來正是我要的東西：&lt;/p&gt;

&lt;div class=&quot;language-go 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;func&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;call&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessagingApiAPI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WithContext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ctx&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Context&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;MessagingApiAPI&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;call&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ctx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ctx&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;call&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;bot&lt;/code&gt; 是套件層級的共用變數，多個請求同時進來各自呼叫 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WithContext&lt;/code&gt;，就是一個標準的 data race。名字取得像 functional option，行為卻是 mutation。這行我在程式碼裡也留了註解，免得之後有人覺得它比 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WithHTTPClient&lt;/code&gt; 精準而改過去。&lt;/p&gt;

&lt;h2 id=&quot;踩坑七不會叫的-bug要靠測試主動去撞&quot;&gt;踩坑七：不會叫的 bug，要靠測試主動去撞&lt;/h2&gt;

&lt;p&gt;同一輪掃出來的還有一個。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uploadParents&lt;/code&gt; 這個函式負責列舉所有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YYYY-MM&lt;/code&gt; 月份資料夾，原本寫成這樣：&lt;/p&gt;

&lt;div class=&quot;language-go 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;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;srv&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Files&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&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;n&quot;&gt;Q&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;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Fields&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;files(id)&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;Context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ctx&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;Do&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;PageSize&lt;/code&gt;。Drive API 預設一頁 100 筆，超過的要拿 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nextPageToken&lt;/code&gt; 再問一次。每個月產生一個資料夾，所以滿 100 個月——大約 8 年 4 個月——之後，最舊的那些資料夾會從搜尋和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/recent_files&lt;/code&gt; 的涵蓋範圍裡消失。不會有錯誤，不會有警告，就只是結果變少。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;原因與解法&lt;/strong&gt;：改用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pages()&lt;/code&gt; 走完所有頁。真正想講的是後面那步。我不太信任自己剛寫完的分頁邏輯，所以寫了一個會回傳 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nextPageToken&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;--- FAIL: TestUploadParentsPagesThroughAllSubfolders
    uploadParents() = [root_id month_1 month_2], want [root_id month_1 month_2 month_3]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;確認會失敗，才把實作還原。這步花不到兩分鐘，但沒做的話，我手上只有一個「有跑過、是綠的」測試，不知道它到底有沒有在測東西。靜默截斷這類 bug 本來就不會自己現身，測試如果也是綠的假象，等於什麼都沒有。&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;成果與效益&quot;&gt;成果與效益&lt;/h1&gt;

&lt;p&gt;先講最直接的數字。原本 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.go&lt;/code&gt; 1039 行、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main()&lt;/code&gt; 佔 564 行，拆成六個檔案之後：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;檔案&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.go&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;94&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;config.go&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;74&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;webhook.go&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;344&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;line.go&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;164&lt;/td&gt;
      &lt;td&gt;LINE 訊息組裝&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;drive.go&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;183&lt;/td&gt;
      &lt;td&gt;Drive 查詢／上傳&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;auth.go&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;264&lt;/td&gt;
      &lt;td&gt;OAuth、token、撤銷&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main()&lt;/code&gt; 從 564 行變成 62 行。有趣的是&lt;strong&gt;主程式碼總行數幾乎沒變&lt;/strong&gt;（1039 → 1123），真正大幅增加的是測試：從 88 行變成 468 行，測試數從 1 個變成 16 個，全部在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-race&lt;/code&gt; 下通過。&lt;/p&gt;

&lt;p&gt;效能上，搜尋功能原本是「先找根資料夾，再一個一個查每個月份子資料夾」，也就是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1 + N&lt;/code&gt; 次 Drive API 呼叫；改成把所有 parent 用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;or&lt;/code&gt; 串成一次查詢之後，固定 2 次。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;go-modern-guidelines 的價值不在於它教了我沒見過的語法。&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cmp.Or&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;slices.SortFunc&lt;/code&gt; 這些我大致都知道，問題是寫程式的當下不會主動想起來——尤其在改一支既有檔案的時候，周圍的舊寫法會形成一種引力，很自然地就照著旁邊的樣子繼續寫下去。skill 文件裡有一句話講得很準：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;If a guideline applies, follow it even when nearby code or repository convention uses an older pattern.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;這句是在對抗前面講的 frequency bias，而且對人也一樣成立。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;它的邊界也很清楚。&lt;/strong&gt; 那五個實際會咬到使用者的 bug——寫錯層級的 switch、會 panic 的型別斷言、回傳資料夾的查詢、沒跳脫的 query、被誤判的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/quit&lt;/code&gt;——沒有一個是 go-modern-guidelines 抓出來的，那不是它的守備範圍。它管的是「這段 Go 寫得夠不夠當代」，不是「這段邏輯對不對」。把它當成 linter 的補充，而不是 code review 的替代品。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;這篇文章的前半段，是在部署之前就寫完的。&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/healthz&lt;/code&gt; 那個坑是文章都收尾了、PR 也合併了，我去 gcloud 查 build 狀態時才發現的。當下的狀況是：45 條規範都查過、該套的都套了、17 個測試在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-race&lt;/code&gt; 下全過、CI 三道檢查全綠、Cloud Build SUCCESS、Cloud Run 顯示 Ready 而且 100% 流量已經切過去。這一整排綠燈裡面，沒有任何一盞會告訴你有個端點是死的。&lt;/p&gt;

&lt;p&gt;我在&lt;a href=&quot;/help-handle-cloudflare/&quot;&gt;上一篇處理 Cloudflare 那次&lt;/a&gt;寫過「build 成功不能當作驗證通過」，本來以為自己記住了，結果這次還是在同一個地方繳學費，只是換了一層——上次是 build 成功但容器跑起來會 crash，這次是容器跑得好好的，被更外面那層基礎設施吃掉。工具管語法，測試管邏輯，CI 管這兩件事有沒有退步，但沒有任何一個管得到「這東西部署到那個特定環境上會怎樣」。那一段只能自己去戳。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;輸出會隨專案狀態變動這件事，要放在心上。&lt;/strong&gt; 踩坑三那個「改完 go.mod 之後多出四條建議」的現象，是這次最實用的體會。它不是一份查一次就好的靜態文件，而是一個依專案當下狀態回答的查詢介面。修改內容的性質改變時（從主程式轉到測試、升了語言版本、換了專案），值得重跑一次。&lt;/p&gt;

&lt;p&gt;最後，這次的改動在 &lt;a href=&quot;https://github.com/kkdai/linebot-file/pull/4&quot;&gt;PR #4&lt;/a&gt;，部署之後補的兩個修正在 &lt;a href=&quot;https://github.com/kkdai/linebot-file/pull/5&quot;&gt;#5&lt;/a&gt; 和 &lt;a href=&quot;https://github.com/kkdai/linebot-file/pull/6&quot;&gt;#6&lt;/a&gt;，程式碼在 &lt;a href=&quot;https://github.com/kkdai/linebot-file&quot;&gt;kkdai/linebot-file&lt;/a&gt;。go-modern-guidelines 的原始碼在 &lt;a href=&quot;https://github.com/JetBrains/go-modern-guidelines&quot;&gt;JetBrains/go-modern-guidelines&lt;/a&gt;，Apache 2.0 授權。&lt;/p&gt;
</description>
				<pubDate>Tue, 25 Aug 2026 00:00:00 +0000</pubDate>
				<link>https://www.evanlin.com/go-modern-guidelines/</link>
				<guid isPermaLink="true">https://www.evanlin.com/go-modern-guidelines/</guid>
			</item>
		
	</channel>
</rss>
