GeminiLLM模型遷移MindThread備援鏈

模型停用通知來了怎麼辦:我們把 gemini-2.5-flash 遷移拆成四步

· · 16 分鐘閱讀
目錄
  1. 第一步:盤點,把每個呼叫分成三種
  2. 第二步:實測參數相容表
  3. 第三步:改成設定檔,一個功能一個功能切
  4. 第四步:用真實輸入做品質對照
  5. 順手抓到:備援鏈裡早就壞掉的棒
  6. 照做清單

11 月 4 日收到 Google 的通知:gemini-2.5-flash 在 2026-12-01 停用,之後送來的請求會自動轉到 gemini-3.5-flash-lite。通知也寫了,單純文字請求會自動過渡,帶進階參數(thinking budget、cached content)的呼叫可能會壞。

第一個念頭是「會自動轉,那就不用管」。打一個請求就覺得不妙:直接呼叫 3.5-flash-lite 帶 thinkingBudget,連 thinkingBudget: 0 也一樣,回的是 400。轉址時會不會替我們改參數,通知沒寫。而我們的程式幾乎每個 2.5 呼叫都帶著 thinkingBudget(多半是 0,用來關掉思考省 token)。

我們把遷移拆成四步:盤點、實測參數、改成設定檔逐功能切換、拿真實輸入做品質對照。四步的程式和對照在同一天做完,合併上線和切換模型則分階段進行。下面每一步都附我們實際用的東西,你可以照著做。

第一步:盤點,把每個呼叫分成三種

先找出所有指定模型的地方。我們用 git grep,不依賴有沒有裝別的搜尋工具:

git grep -nE "gemini-2\.5|thinkingBudget|thinkingConfig|cachedContent|responseSchema|GEMINI_MODEL"

每一筆記五欄:檔案與行號、功能、模型從哪來、帶了哪些進階參數、上個月呼叫幾次。「模型從哪來」分三種:

  • 寫死:字串直接寫在程式裡。要改程式、要部署。
  • env:讀環境變數。改設定加重新部署。
  • config:讀資料庫設定。改了幾十秒內生效,不用部署。

呼叫量決定先後。以 MindThread 為例,十月雲巡留言判斷呼叫 83,333 次,內容安全分類是 0 次(目前停用中)。但風險不只看量。盤完我們發現,MindThread 主流程走 9 月底上線的備援鏈,12/1 之後不會停,最壞是每次先撞一次 400 再換下一棒。真正會壞的是兩處「寫死又沒有備援」的路:一個是內容安全分類,現在停用、重開就壞;另一個是本機凌晨 3:30 的內容品檢排程,帶著 thinkingBudget: 1024,12/1 之後會整晚失敗。

還有一批已經退役的舊後端,全部寫死 2.5。我們查過工作排程器,沒有任何排程在跑它們,所以這次不改,只在報告寫一句:誰要復活它們,12/1 之後一定 400。

第二步:實測參數相容表

不要猜哪個模型吃哪個參數,同一題直接打一輪。11/4 我們實測的結果:

模型 thinkingBudget: 0 thinkingLevel: low thinkingLevel: minimal 不帶
gemini-2.5-flash 200 400 400 200
gemini-2.5-flash-lite 200 400 400 200
gemini-3.5-flash 200 200(思考 61 token) 200 200
gemini-3.5-flash-lite 400 200 200 200
gemini-3.7-flash 200 200 400 200

這張表推翻了兩個直覺。第一,不能把所有呼叫一口氣改成 thinkingLevel,2.5 不吃。第二,3.x 也不是一套規則:3.5-flash-lite 不吃 budget,3.7-flash 不吃 minimal,3.5-flash 帶 low 還會思考約 60 個 token,多花錢。

探針很短,換成你要測的模型清單就能跑(金鑰從環境變數讀):

// probe.mjs:node probe.mjs
const key = process.env.GEMINI_API_KEY
const models = (process.env.MODELS || 'gemini-2.5-flash,gemini-3.5-flash-lite,gemini-3.7-flash').split(',')
const variants = {
  none: undefined,
  budget0: { thinkingBudget: 0 },
  levelLow: { thinkingLevel: 'low' },
  levelMinimal: { thinkingLevel: 'minimal' },
}
for (const m of models) for (const [name, tc] of Object.entries(variants)) {
  const generationConfig = { temperature: 0, maxOutputTokens: 64, ...(tc && { thinkingConfig: tc }) }
  const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
    body: JSON.stringify({ contents: [{ parts: [{ text: '只回答兩個字:你好' }] }], generationConfig }),
  })
  const j = await r.json()
  console.log(m, name, r.status, 'thoughts=' + (j.usageMetadata?.thoughtsTokenCount ?? 0), r.ok ? '' : j.error?.message)
}

表測完,就把「關掉思考」寫成依模型產生的函式,不要散在各個呼叫裡:

// 依模型給「關掉思考」的寫法,展開進 generationConfig
export function thinkingOff(model) {
  if (/^gemini-2\./.test(model)) return { thinkingConfig: { thinkingBudget: 0 } }
  if (/^gemini-3\.[0-9.]*-flash-lite/.test(model)) return { thinkingConfig: { thinkingLevel: 'low' } }
  if (/^gemini-3\./.test(model)) return { thinkingConfig: { thinkingBudget: 0 } }
  return {} // 別名或沒測過的模型不帶,帶錯就是 400
}

另一個細節:3.x 的思考 token 算在輸出上限裡,所以 MindThread 切到 3.x 時多給 2,048 的輸出空間,避免回答被截斷。

第三步:改成設定檔,一個功能一個功能切

盤點完最直接的做法是全部改成新模型名。我們沒有這樣做,因為各功能對品質的要求不同,應該分開切、分開退。

MindThread 新增一份資料庫設定 config/geminiModels:

{
  "features": { "haixun-reply": "gemini-3.5-flash-lite" }
}

這是只切一個功能的寫法。另外兩個欄位:primary 會一次換掉所有主力是 2.5 的鏈,等每個功能都驗完才設;disabled 是停用日前一晚用的。

每個呼叫本來就帶一個功能標籤(例如 cloud-patrol/judge),解析規則是:

  1. features 先比完整標籤,再一層層往前比(cloud-patrol/judge 比不到就比 cloud-patrol)。它的優先順序最高。
  2. primary 只換「主力本來是 2.5」的鏈,已經在做灰度測試的鏈不動。
  3. 最後套 disabled 把指定模型拿掉;如果拿光了,退回原本的鏈,寧可多一棒也不要整條沒模型。
  4. 名字只收鏈上的釘版名稱,別名或沒實測過的一律忽略。

簡化版長這樣(省略讀設定、快取和名稱清單的細節):

export function resolveChain(cfg, label, defaultChain, chainWithPrimary) {
  const pinned = (m) => typeof m === 'string' && defaultChain.includes(m)
  let override = null
  const parts = String(label || '').split('/')
  for (let n = parts.length; n > 0 && !override; n--) {
    const m = cfg?.features?.[parts.slice(0, n).join('/')]
    if (pinned(m)) override = m
  }
  let chain = override ? chainWithPrimary(override)
    : pinned(cfg?.primary) && defaultChain[0] === 'gemini-2.5-flash' ? chainWithPrimary(cfg.primary)
    : [...defaultChain]
  const off = Array.isArray(cfg?.disabled) ? cfg.disabled : []
  const kept = chain.filter((m) => !off.includes(m))
  return kept.length ? kept : chain
}

設定讀取有 60 秒快取,讀不到就照程式預設。這份文件不存在時,行為跟改之前一模一樣,所以相容的程式可以先上線,模型之後再一個一個切。我們補了 9 項測試接在 build 前面跑,其中一項是靜態檢查:那兩支曾經寫死的檔案,不准再出現「2.5 加 thinkingBudget」。

比較小的程式庫(Ultra Advisor、ultralab.tw 官網、內部腳本)沒有資料庫設定,就改成讀環境變數,預設仍是 2.5。合併不會改變行為,要切時設一個變數、重新部署。

第四步:用真實輸入做品質對照

規則很簡單:用十月的真實輸入,呼叫正式站同一支函式(同提示、同後處理、同品質閘),新舊模型各跑一次。對照程式把所有資料庫寫入攔成什麼都不做,兩批一共攔了 151 次。費用走我們自己的內部額度,不扣會員的。提前切的門檻是一致率 95%。

功能 筆數 一致率 看到的差異
擴充留言 30 100% 中位數 30 字對 26 字,沒有系統性退化
雲巡留言判斷 30 90% 3 筆不一致都是 3.5 比較嚴,判「值得留言」從 18 筆變 15 筆
自動回覆 30 90% 不一致的 3 筆都是 3.5 可用、2.5 被品質閘擋;但 3.5 有一則外層多了一對引號沒剝乾淨
發文生成 14 100% 文章明顯變短,中位數 274 字變 177 字

發文生成只有 14 筆,因為十月同時有自動生成又有人設的帳號就這麼多,我們沒有湊數。

結果:擴充留言達標,已經先切了。另外三個留給人工再看。這裡最值得記的是,一致率不是唯一指標。發文生成 100% 一致,但短了約三分之一,人設要求 150 字以上的帳號可能不夠;雲巡判斷變嚴,估計送出量少約兩成,會員設定的量可能跑不到。這兩件事一致率都看不出來。

最後一步排在 11/30 晚上:設定加上 disabled: ["gemini-2.5-flash"],讓 12/1 之後不再每次先撞一棒 400。

順手抓到:備援鏈裡早就壞掉的棒

做參數表時,順便把官網的備援鏈逐棒打了一次。第 4 棒 gemini-flash-lite-latest 現在指到 3.5-flash-lite,我們帶著 thinkingBudget: 0 呼叫它,一直是 400;第 5 棒 gemini-2.0-flash 在 6/1 就關了。等於主模型一忙,鏈上只剩 gemini-flash-latest 一棒能用,而它當天實測是 503。修法是把釘版的 3.5-flash-lite 排第一棒,拿掉已經關掉的 2.0;這個修正跟相容版程式一起走上線流程。

另一個發現在 MindThread 的夜間補貨:設定寫的主力是 2.5,實際上從 10/28 起,每晚九成以上的批次早就掉到 3.5-flash-lite 了(11/4 是 86 批對 6 批),原因是 2.5 的額度很快用完。

這兩件事是同一個教訓。我們之前寫過 fallback 會不會偷偷升到最貴,那是成本的方向;這次是另一面,備援鏈會把壞掉的棒藏起來。功能看起來一直正常,所以沒人去看。

照做清單

  1. git grep 盤出所有模型名與進階參數,每筆標寫死、env 或 config,附上月呼叫量。
  2. 先找「寫死又沒備援」的路,它們是停用日唯一會整個壞掉的地方。
  3. 用探針把新舊模型和每種參數打一輪,做成相容表。
  4. 把「關掉思考」寫成依模型產生的函式,刪掉散落各處的寫死參數。
  5. 模型名改讀設定,預設維持舊模型,先上線確認行為不變。
  6. 每個功能拿真實輸入跑新舊對照,看一致率,也看長度和數量;達標的先切。
  7. 備援鏈每一棒單獨測,並記錄每次實際是哪個模型回答的。
  8. 停用日前一晚,把舊模型從鏈上拿掉。

常見問題

停用通知說會自動轉到新模型,為什麼還要自己改?

因為通知沒說轉址時會不會替你改寫請求內容。單純文字請求可以過渡,但我們幾乎每個呼叫都帶 thinkingBudget,而直接呼叫 gemini-3.5-flash-lite 帶 thinkingBudget(連 0 也算)就是 400。如果轉址不改參數,有備援鏈的路會先撞一次 400 再換棒,寫死又沒備援的路會整個壞掉。我們不賭,在停用日前自己把模型名和參數一起換好。

新舊模型的一致率要多少才能提前切?

我們定 95%。用十月的真實輸入,走正式站同一支函式,新舊模型各跑一次。擴充留言 30 筆 100% 一致,先切了;雲巡判斷和自動回覆都是 90%,發文生成雖然 14 筆全部一致,但文章短了約三分之一,這三個留給人工再看。一致率之外,也要看長度和送出量這類一致率看不出來的變化。

用 gemini-flash-latest 這種別名,不就永遠不用遷移了?

別名會在你不知情的時候跨代換模型,參數規則也跟著換。這次就抓到 gemini-flash-lite-latest 已經指到 3.5-flash-lite,我們的備援鏈帶著 thinkingBudget 呼叫它,一直是 400。現在主力模型的設定只收釘版名稱,換代由自己決定、自己測;備援鏈尾端還留著別名當最後手段,但呼叫別名時不帶任何 thinking 參數。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

想自己動手試試?

UltraProbe 免費、免註冊,掃一次網站就知道 Google 與 AI 找不找得到你。