image-20260903123726193

前情提要

我有一個自己每天在用的 LINE Bot,linebot-helper-python。丟網址給它會回摘要跟四個平台的社群文案,丟 YouTube 連結會回影片摘要,還有書籤、地點查詢、語音助理那些。跑在 Cloud Run 上,用 Vertex AI。

八月底 Google 發了一篇 Introducing agentic video in Gemini,看完我第一個念頭是:這東西能讓我的 bot 多做一件現在做不到的事——針對影片提問,而不是只給一份摘要。

結果做下來,公告裡沒寫的部分比寫了的更值得記。


Agentic video 是什麼

image-20260903123748730

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

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

官方公告給的數字是長片場景 token 少 88%、成本降 66%、正確率高 7%。開啟方式就是在 Part 上多一個參數:

video_part = types.Part(
    file_data=types.FileData(file_uri=..., mime_type="video/mp4"),
    media_processing="AGENTIC",   # 或 "STATIC"
)

支援的模型有三個:gemini-3.7-flashgemini-3.6-flashgemini-3.5-flash-lite。輸入來源可以是 YouTube 網址、Cloud Storage URI,或內嵌的 base64。

它實際能做到什麼

我拿兩小時的 Google I/O ‘25 keynote 測,問「他有講到定價嗎?如果有,請給我幾分幾秒」,回來的是:

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

然後正確描述了 Google AI Pro 與 Google AI Ultra 兩個方案的差異。再問 Android XR 眼鏡那段在哪,回 1:36:31 至 1:50:30,內容也對得上。

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


前置條件:四個參數,少一個都不會報錯

這是這次最貴的一課,所以放前面講。

在 Vertex AI 上要讓 agentic 真的生效,四件事必須同時成立

# 條件 少了會怎樣
1 api_version="v1beta1" agentic 只在 v1beta1 提供,用 v1 會靜默退回 STATIC
2 media_processing="AGENTIC" 預設就是 STATIC,不設等於沒開
3 模型在支援名單內 不支援的模型會靜默降級為 STATIC
4 thinking_level 有設定 見下一節,這個最麻煩

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

還有兩個環境面的前提:

  • SDK 版本media_processing 這個欄位是 google-genai 2.20.0 才加進去的。我逐版下載驗證過,2.19.0 沒有、2.20.0 有。版本太舊的話這個參數會被當成未知欄位丟掉,一樣不報錯。
  • Vertex 專屬:官方文件的多輪對話範例是寫給 Gemini Developer API 的,Vertex 這條路上行為不同,後面會講。

我的作法是把這四項各寫一支測試,斷言送進 SDK 的參數:

def test_thinking_level_is_low(captured):
    youtube_tool.summarize_youtube_video(VIDEO_URL)
    config = captured["call_kwargs"]["config"]
    assert config.thinking_config is not None
    assert str(config.thinking_config.thinking_level).upper().endswith("LOW")

這四支測試存在的唯一理由,就是「這四件事錯了,程式照跑、答案照出,只有成本默默變動」。這種 bug 靠人工 review 抓不到。


踩坑一:Vertex AI 上留不住影片 context

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

官方文件確實這樣寫:回應會帶 tool_call / tool_response parts,把它們原樣放回 history,下一輪就不用重新處理影片。

實測結果是拿不到。

Vertex 回的 parts 裡只有光禿禿的 thought_signature,沒有 tool_call 也沒有 tool_response。把 history 傳回去:

400 INVALID_ARGUMENT: Invalid thought signature.

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

thinking_level 設成 LOW 之後不報錯了,但第二輪的 tool_use_prompt_token_count2,163,跟第一輪的 2,163 一模一樣。影片被完整重跑了一遍,context 一點都沒留下。

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

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

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


踩坑二:thinking token 才是成本主角,而且擋不住

這一坑我修正了三次因果推論才搞對,過程比結論有意思。

第一版結論(錯的)

最初的 spike,同一支 10 分鐘影片:

模式 in out(含 thinking) 成本
STATIC 54,546 291 $0.0171
AGENTIC,未設 thinking_level 2,216 37,574 $0.0946
AGENTIC + thinking_level="LOW" 2,216 638 $0.0023

看起來很清楚:agentic 把影片從 input 拿掉了(54,546 → 2,216),但模型改用「思考」去導航,而 thinking token 按 output 計價($2.50/M,是 input 的 8.3 倍)。所以不設 thinking_level 反而比 static 貴 5.5 倍,設了就便宜 7.4 倍。

然後我拿兩小時的影片跑,同樣設 LOW,thinking token 是 359,961,單次 $0.91。

我的結論是:「長片上 thinking_level 會被忽略」。

第二版結論(也是錯的)

實作階段,負責那個 task 的 agent 照我要求跑了驗證,撞到門檻停下來回報:同一支影片、同一組設定,連續五次的 thinking token 是

0, 0, 34911, 35122, 37410

它還去撈了 DEBUG log,確認每一次送出的請求都確實帶著 v1beta1 + AGENTIC + thinking_level=LOW,不是客戶端漏設。

所以不是影片長度——我每個設定只跑一次,把抽樣雜訊當成了因果關係

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

第三版(實際的樣子)

27 次呼叫,三種設定各 6 次加問答路徑各 3 次:

設定 thinking 尖峰 tool_use>0 平均成本
thinking_level="LOW" 0/6 $0.00146
thinking_budget=0 0/6 $0.00137
完全不設 0/6 $0.00137
STATIC 0/6 ✗(=0) $0.01659

全部 0 尖峰。但先前用同一組正式 prompt 的那批,是 4 次中 3 次尖峰。

所以 prompt 複雜度也不是。三種客戶端設定的行為完全一樣,唯一的差別是何時呼叫

原因與解法:這是伺服器端的非決定性,客戶端沒有任何槓桿能控制。順帶確認兩件事:thinking_budgetthinking_level 不能並用(server 直接回 400);STATIC 是唯一決定性的選項,但它的 tool_use_tokens 是 0。那不是「同樣功能換一種穩定模式」,是把 agentic 整個關掉。

我的決定是維持 thinking_level="LOW",並加一道警告 log:

# 實測 thinking token 只有兩群:~0 或 ~35,000-37,000,中間沒有值。
# 成因是伺服器端非決定性,沒有任何客戶端設定能防。
THINKING_TOKENS_WARN_THRESHOLD = 5000

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

所以誠實的成本圖像是:正常約 $0.0014 一次,尖峰約 60 倍,無法預測也無法預防。公告說的「成本降 66%」在不尖峰時成立,尖峰時反過來。

thinking_level 這個參數在我的實作裡是必填、沒有預設值

def _generate_video(youtube_url: str, prompt: str, *, thinking_level: str) -> dict:

給它預設值就是給人漏掉的機會,而漏掉不會有任何徵兆。


接進 LINE Bot:功能長什麼樣

流程是這樣:

使用者貼 YouTube 連結
  → 收到摘要 + 社群文案(原有功能)
  → 多一顆「🎬 問這部影片」
  → 按下後直接打字問:「他有講到定價嗎?」
  → 回答帶時間戳
  → 貼新網址 → 自動離開影片模式

三個新模組,各自獨立可測:

  • tools/youtube_tool.py(改)— 唯一跟 Gemini 影片 API 溝通的地方,四個必要參數集中在一個函式裡
  • services/video_qa.py(新)— 記住「使用者 → 影片」,TTL 30 分鐘
  • services/usage_meter.py(新)— 記錄每次呼叫的 token 與成本

main.py 只增加兩個接點:訊息攔截,跟一個 postback 分支。

判斷順序是行為契約

使用者在影片模式中打字,判斷順序是:

脫離條件檢查 → 配額檢查 → 呼叫 Gemini

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

這條有專屬測試,因為它是契約不是實作細節。

脫離模式用啟發式,不用 LLM

判斷使用者是否想離開影片模式,我用的是最笨的方法:訊息含網址、或以 / 開頭,就離開。

刻意不用 LLM 判斷意圖,因為那要在每則訊息上多打一次 Gemini,而誤判的代價很低(使用者再問一次就好)。不值得那個成本和延遲。

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

改成共用同一個函式之後,兩者永遠一致;日後 find_url 若被加寬,影片模式會自動跟上。


開發流程:用 subagent 分工,讓審查者去抓我的錯

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

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

實際抓到的東西,比我預期的有價值得多。

它們抓到我三次錯誤的因果推論

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

第三次最有意思。我在改文件時,把被推翻的說法換成「gemini-3.5-flash-lite 是唯一成本穩定的模型」。審查的 agent 指出:那 27 次非決定性實驗就是在 3.5-flash-lite 上跑的,尖峰正是在它身上量到的,這句話跟它自己引用的段落直接矛盾。

我把一個沒根據的說法換成了另一個沒根據的說法,而且犯了三次(spec、設定檔註解、測試 docstring 各一次)。

它抓到一個八輪審查都漏掉的 Critical

最後的整支分支審查抓到一件事:整個功能的入口按鈕根本不會顯示

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

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

這是單一 task 的審查在結構上看不到的東西,只有把整條路徑攤開才看得見。

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

我自己犯的錯

流程再好也擋不住我在指示裡寫錯東西:

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

寫下來有點難看,但這些正是這套流程真正的產出:每一個錯誤都在合併前被攔下來了


成果與效益

  數字
commit 18
測試 192 → 275 passed
收斂的模型字面值 28 處
修好的線上故障 1(智慧對話 404)
實測花費 約 6–8 美元(大部分在三輪成本量測上)

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

幾個我會帶走的東西

先查限制,再設計。 這次幾乎每個設計決定都是被限制逼出來的:Vertex 留不住 context 逼出 stateless 查詢、thinking token 不可控逼出警告 log、thinking_budgetthinking_level 不能並用直接砍掉一個選項。先把限制查清楚再開始設計,比先設計再撞牆省事得多。

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

測試守的是 bug class,不是字串。 gemini-3-pro-preview 下架讓招牌功能壞掉那件事,如果我只是換一個模型名稱,下次還會再發生。改成「不准用 preview 模型」之後,同一類問題才真的被擋住。

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

程式碼在 kkdai/linebot-helper-python,這次的實作在 PR #20。官方文件是 Video understanding,但那份是寫給 Gemini Developer API 的;如果你跟我一樣走 Vertex AI,多輪對話那一節請當作參考而不是保證。


Buy Me A Coffee

Evan

Attitude is everything