模型停用通知來了怎麼辦:我們把 gemini-2.5-flash 遷移拆成四步
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),解析規則是:
features先比完整標籤,再一層層往前比(cloud-patrol/judge比不到就比cloud-patrol)。它的優先順序最高。primary只換「主力本來是 2.5」的鏈,已經在做灰度測試的鏈不動。- 最後套
disabled把指定模型拿掉;如果拿光了,退回原本的鏈,寧可多一棒也不要整條沒模型。 - 名字只收鏈上的釘版名稱,別名或沒實測過的一律忽略。
簡化版長這樣(省略讀設定、快取和名稱清單的細節):
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 會不會偷偷升到最貴,那是成本的方向;這次是另一面,備援鏈會把壞掉的棒藏起來。功能看起來一直正常,所以沒人去看。
照做清單
git grep盤出所有模型名與進階參數,每筆標寫死、env 或 config,附上月呼叫量。- 先找「寫死又沒備援」的路,它們是停用日唯一會整個壞掉的地方。
- 用探針把新舊模型和每種參數打一輪,做成相容表。
- 把「關掉思考」寫成依模型產生的函式,刪掉散落各處的寫死參數。
- 模型名改讀設定,預設維持舊模型,先上線確認行為不變。
- 每個功能拿真實輸入跑新舊對照,看一致率,也看長度和數量;達標的先切。
- 備援鏈每一棒單獨測,並記錄每次實際是哪個模型回答的。
- 停用日前一晚,把舊模型從鏈上拿掉。
常見問題
停用通知說會自動轉到新模型,為什麼還要自己改?
因為通知沒說轉址時會不會替你改寫請求內容。單純文字請求可以過渡,但我們幾乎每個呼叫都帶 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 參數。