
前情提要
我有一個自己每天在用的 LINE Bot,linebot-helper-python。丟網址回摘要跟社群文案,丟 YouTube 連結可以針對影片連續追問,還有書籤、地點查詢那些。其中一個功能是 LIFF 語音助理:開一個網頁,用 WebSocket 接 Gemini Live API,可以按住說話或免持對話,講完的內容會整理成一則訊息推回 LINE 聊天室。
九月中 Google 發了 Gemini 3.8 Live,看完之後我想做的事情,跟公告想賣的東西不太一樣。
公告在講能力:Speech to Speech Index 排第一(82.6 分)、Big Bench Audio 97.7%、97 種語言自動偵測且可以在對話中途切換、工具呼叫可以在背景執行而不中斷對話流、即時視覺輸入。還有一顆 Gemini 3.8 Live Extended Thinking,能一邊推理一邊出聲。
我第一個念頭是:這下我可以把那個 preview 例外拔掉了。
真正的動機:一個 preview 模型埋了兩次雷
上一篇寫 agentic video 的時候提過一件事:我的智慧對話功能壞了整段時間,沒有人發現。原因是 loader/chat_session.py 寫死了 gemini-3-pro-preview,那個模型從 Vertex AI 下架之後,真實路徑直接回 404,而那個檔案的例外處理是直接 raise。
修完之後我加了一組守衛,用測試強制「模型 ID 只能出現在 config/agent_config.py」「不准使用 preview 或實驗模型」。但那組守衛有兩個例外:
# Live API 僅支援 gemini-3.1-flash-live-preview;TTS 是獨立模型家族。
# 這兩處必須維持 preview 模型,不在守衛範圍內。
EXEMPT_FILES = {"services/voice_live.py", "tools/tts_tool.py"}
語音那個例外的理由是「Live API 只有這一顆模型能用」。當時是真的。但那句話一旦寫進註解,就沒有人會再去查它還成不成立——同一類 bug 的最後一個缺口,就這樣光明正大掛在那裡好幾個月。
3.8 Live 的意義因此不只是能力升級。它的模型名稱不含 -preview,這代表那個例外可以縮掉。
先問 API,不要問文件
這次我沒有先讀文件,而是直接問 API 上實際存在哪些東西:
curl -s "https://generativelanguage.googleapis.com/v1beta/models?key=$KEY&pageSize=200" \
| jq -r '.models[]?.name' | rg -i 'live'
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
一行指令拿到五個確定存在的模型名稱,比在文件裡翻半天可靠。gemini-3.8-live 就在上面,名字乾淨。
順帶還撿到一個我本來不知道的 gemini-3.5-live-translate-preview。我原本在想「要不要做即時口譯模式」,看來那是一顆專門的模型,不是拿通用模型硬幹。這個之後再說。
再往下問 metadata:
3.1-flash-live-preview |
3.8-live |
|
|---|---|---|
| inputTokenLimit | 131072 | 131072 |
| outputTokenLimit | 65536 | 65536 |
| supportedGenerationMethods | bidiGenerateContent |
bidiGenerateContent |
數字一模一樣。到這裡我對「可以無痛換」有了初步信心,但這還只是 metadata,不是行為。
踩坑一:我警告了一個不存在的風險
我第一次評估這件事的時候,很嚴肅地提醒了一句:「公告沒提到 Vertex AI,而這個專案的決策是全面改用 Vertex,所以 3.8 Live 不確定拿不拿得到。」
聽起來很專業。然後我去翻自己的程式碼:
client = live_genai.Client(
api_key=GOOGLE_AI_API_KEY,
vertexai=False,
http_options={"api_version": "v1beta"},
)
vertexai=False。語音這條路徑從一開始就是走 Gemini API 加 AI Studio 的金鑰,根本不走 Vertex——而 3.8 Live 正是在 Gemini API 上發表的。
我對著自己的 repo,警告了一個在自己 repo 裡不存在的風險。整個專案確實「全面改用 Vertex AI」,但語音是例外,而那個例外是我自己當初寫的。
原因與解法:專案層級的決策紀錄會變成一種記憶捷徑。「我們都用 Vertex」是對的總結,但總結會吃掉例外。查一次原始碼只要三十秒,比憑印象推論便宜太多。
踩坑二:對照組救了我一次
確認模型存在之後,我寫了一支拋棄式腳本去實測。關鍵設計是:不另外寫一份 config,直接 import 專案裡現有的 build_live_config() 跟 build_voice_tools()。要測的是「我現在正在跑的這套設定能不能用」,不是「我另外寫一套能不能用」。
第一次跑出來是這樣:
=== gemini-3.8-live ===
[handsfree] {'connected': True, 'audio': True, ..., 'error': None}
[PTT] {'connected': True, 'audio': False, ...,
'error': 'APIError: 1007 None. Precondition check failed.'}
按住說話那個模式直接被擋。如果我只測了 3.8,這裡就會得到一個結論:「3.8 Live 不支援 PTT,不能升級。」
但我把舊模型也放進去跑了:
=== gemini-3.1-flash-live-preview ===
[PTT] {..., 'error': 'APIError: 1007 None. Precondition check failed.'}
一模一樣的錯誤。兩個模型用同一種方式失敗,那就不是模型的問題,是我的腳本有問題。
實際上的錯是:PTT 模式下正式環境送的是 PCM 音訊,我圖方便送了文字。activity_start 跟 activity_end 之間夾一段文字輸入,本來就是不合法的組合。
改成送真實的 16kHz PCM 之後:
=== gemini-3.8-live ===
[PTT] {'connected': True, 'audio': True, 'out_tx': True, 'in_tx': True,
'resume': True, 'events': ['audio','in_tx','out_tx','resume','turn_complete']}
全部到齊,而且跟舊模型逐項一致。
原因與解法:這次唯一做對的事情是保留了對照組。測一個新東西的時候順手把舊東西用同樣方式測一遍,成本幾乎是零,但它能分辨「新東西壞了」跟「我的測試寫錯了」。這兩件事看起來長得一模一樣。
踩坑三:正弦波不是人聲
第二版腳本我用 math.sin 產了一段 180Hz 的 PCM 當作音訊輸入。PTT 模式測得很順,但免持模式兩個模型都 timeout。
我差點把這個寫成「免持模式行為待確認」。但想一下就知道為什麼:免持模式靠的是 Gemini 自己的語音活動偵測,而一段純正弦波不是語音,VAD 正確地判斷「沒有人在講話」,所以永遠不會觸發。
所以那不是「測出問題」,是完全沒測到。兩次 timeout 看起來像資料,實際上是兩個空格。
這件事後來延伸出一份清單。我把腳本測到的跟沒測到的分開列:
測到了:連線、config 被接受、PTT 音訊往返、雙向逐字稿、resumption handle、turn_complete、工具呼叫觸發且參數正確。
沒測到:真人中文語音辨識、免持自動 VAD、說話中插話打斷、十分鐘連線回收後的重連、resumption handle 帶回去能不能真的接上上下文、Google Search grounding 實際生效、慢任務轉交推回 LINE 的後半段、語音品質與延遲。
八項裡沒測到的有八項。腳本跑得很漂亮,但它碰到的是協定層,不是使用者會遇到的東西。
實測對照表
繞完一圈之後,最後的對照長這樣。左右兩欄都是用專案現有的 config 產生器打真實 API 的結果:
| 驗證項目 | 3.1-flash-live-preview |
3.8-live |
|---|---|---|
| PTT(activity 信號 + PCM 分塊) | 音訊/雙向逐字稿/resume/turn_complete | 相同 |
session_resumption |
有 | 有 |
context_window_compression |
接受 | 接受 |
雙向 AudioTranscriptionConfig |
有 | 有 |
語音 Aoede |
有 | 有 |
google_search 與 function_declarations 分屬兩個 Tool |
接受 | 接受 |
| 工具呼叫實際觸發 | 兩個工具參數皆正確 | 相同 |
api_version="v1beta" |
需要 | 仍然需要 |
換句話說,gemini-3.8-live 對既有設定是可以直接換上去的。
Extended Thinking 有一個必填欄位
順手也測了 gemini-3.8-live-extended-thinking,連線直接被擋:
APIError: 1007 None. Thinking level must be specified for this model.
補上 thinking_config 之後 LOW 跟 HIGH 都可以連。所以它不是壞的,是多一個必填參數。
我決定不採用,理由是現在的 build_live_config() 不送 thinking_config。如果只是把模型名稱換掉,語音助理會在連線那一步直接掛掉。要用它得先改設定產生器,那是另一件事。
但「我知道不能設」這件事保不住,所以寫成測試:
# Live API(bidiGenerateContent)支援名單。2026-09-16 實測連線確認。
# 刻意不含 gemini-3.8-live-extended-thinking:該模型強制要求 thinking_level,
# 未帶 thinking_config 連線會被擋下(1007)。
LIVE_CAPABLE = {"gemini-3.8-live"}
上一次我在註解裡寫「Live API 只支援某個模型」,那句話過期了好幾個月沒人發現。這次我把它寫成一個會失敗的斷言。
改了什麼
實際的程式改動很小,七個檔案、八十行上下:
config/agent_config.py:新增VOICE_MODEL模組常數與AgentConfig.voice_model。這裡有個細節——我刻意沒有只放進get_agent_config(),因為那個函式要求GOOGLE_CLOUD_PROJECT,而services/voice_live.py在 import 的時候就需要模型 ID。走那條路的話,沒設專案環境變數就會連 import 都失敗。services/voice_live.py:模型字面值整個拿掉,改從 config 拿。tests/test_model_config.py:EXEMPT_FILES從兩個檔縮成一個;voice_model納入既有的「不得 preview」與「必須在實測可用清單內」兩個守衛。
最後那個守衛本來長這樣:
def test_voice_live_model_unchanged():
"""Live API 只支援 gemini-3.1-flash-live-preview,換掉會讓語音助理整個壞掉。"""
assert VOICE_MODEL == "gemini-3.1-flash-live-preview"
它斷言的是一個字串。字串會過期,而且過期的時候測試還是綠的——它只會在你想升級的時候擋你的路,不會在模型下架的時候救你。
改成守名單之後,守的是「這個值必須是實測可用的 Live 模型」這一類問題,不是某一個特定值。
tools/tts_tool.py 那個例外留著。我查了一下,API 上三顆 TTS 模型(2.5-flash-preview-tts、2.5-pro-preview-tts、3.1-flash-tts-preview)全都還是 preview,沒有 GA 版可以換。例外從兩個縮成一個,沒有歸零。
部署順序:設定先就位,程式碼後到
環境變數是在合併之前先設好的:
gcloud run services update linebot-helper-python --region us-central1 \
--update-env-vars VOICE_MODEL=gemini-3.8-live
這一步當下完全沒有作用。線上跑的還是舊 image,那份程式碼把模型寫死在 voice_live.py,根本不會去讀這個變數。
但順序是對的。合併會觸發 Cloud Build 自動部署,新 image 一上去就直接讀到已經就位的設定,中間不會有一個「程式碼上了但設定還沒」的空窗。
這個做法還有一個副作用,我覺得比升級本身更有價值:回滾不需要重新部署。
gcloud run services update linebot-helper-python --region us-central1 \
--update-env-vars VOICE_MODEL=gemini-3.1-flash-live-preview
如果真人測起來覺得不對,一行指令就切回去,不用等 build。模型 ID 從寫死改成環境變數,換來的就是這個。
然後我真人測了,結果推翻了我的觀察
腳本裡有一件事我一直覺得可疑。工具呼叫那一輪,舊模型會先出聲講一句填充話再等工具結果,3.8 沒有。
我當時的判斷很小心:這可能就是公告講的「工具在背景執行不中斷對話流」,也可能只是我的迴圈提早收掉。自動化測不出來,得真人講話才知道是改善還是冷場。
合併部署之後實際用 LIFF 講了幾句,答案是:跟原來沒有差別,很順暢。
所以那個差異在真人耳裡不存在。它是我 drain 迴圈在拿到 turn_complete 就 return 的產物,不是行為改變。我觀察到的是自己腳本的形狀。
這篇文章裡我的腳本因此騙了我三次:PTT 那次讓新模型看起來壞了,正弦波那次讓沒測到看起來像測過了,這次讓一個不存在的差異看起來像新能力。三次都不是模型的問題。
296 個測試全綠,證明不了語音能用
這是我覺得整件事最值得寫下來的部分。
升級完成後測試從 293 變成 296,全綠。但這個數字跟「語音在 3.8 上能不能用」幾乎沒有關係。
tests/test_voice_live.py 有 32 個測試,涵蓋 PTT 停用自動 VAD、activity 信號、逐字稿轉發、工具呼叫執行、resumption handle、連線回收、插話打斷。看起來很完整。但它們全部使用 FakeLiveSession,不連網。模型從 3.1 換成 3.8,這 32 個測試一個字都不會變色。
它們測的是「我送出去的東西長得對不對」,不是「對面回什麼」。
三層覆蓋攤開來是這樣:
| 層 | 測什麼 | 誰跑 | 可重複 |
|---|---|---|---|
| repo 測試套件(296 個) | 送出去的 config 與訊息格式 | CI 每次 push | 可以 |
| 拋棄式 probe(3 支) | 真實 API 的協定層相容性 | 只跑過一次 | 不行 |
| 真人對話 | 聽不聽得懂、順不順 | 我自己 | 不行 |
中間那層才是唯一碰到 3.8 的自動化測試,而它不在 repo 裡、CI 不會跑、下次模型下架的時候不會有人知道。
新加的守衛也只擋得住「設成一個已知會壞的值」,擋不住「Google 把 3.8-live 也下架了」——也就是上次真正發生的那件事。
這個缺口還開著。把 probe 收成一個需要金鑰才跑的 smoke test 是解法,我還沒做。
程式碼在 kkdai/linebot-helper-python,這次的改動在 PR #24。官方公告是 Gemini 3.8 Live,裡面提到所有 AI 生成的音訊都帶 SynthID 浮水印,這點我沒有驗證。
公告花最多篇幅講的即時視覺輸入,我這次完全沒碰——目前 LIFF 那支 getUserMedia 是寫死 video: false 的。那大概是下一篇。