Skip to main content
一句話結論:圖片資料是完整的,能正常解碼出圖,卡住的只是 HTTP 傳輸的最後一個動作 ——「告訴客戶端傳完了」。所以正確的處理不是把超時調長、也不是重試,而是在資料已經到齊時主動收尾,把圖取出來用

這是相容,不是替換

下面給的程式碼是在你現有呼叫邏輯之外加一層保護,不是讓你換一套接入方式:
  • 不需要換端點、換模型、換 SDK,也不需要改請求引數;
  • 正常請求走的還是原來的路徑,行為完全不變 —— 這段相容邏輯在正常請求下根本不會觸發;
  • 只有當「資料已經到齊、但連線遲遲不結束」時,它才會介入,把已經拿到的圖交給你。
換句話說:**加上它,壞的情況能救回來;不加它,壞的情況只能等到超時報錯。**其餘一切照舊。

現象

呼叫原生出圖介面(POST /v1beta/models/{model}:generateContent)時,可能遇到這樣一組現象:
  • 後臺呼叫日誌顯示請求成功、也已經計費
  • 客戶端卻一直掛著,直到自己的讀超時才報錯;
  • 報錯形如 Read timed outETIMEDOUTUND_ERR_BODY_TIMEOUT
體感就是「後臺日誌裡 30 秒就好了,我這邊 5 分鐘都拿不到圖」。
同一套程式碼以前一直是好用的,現在會在收尾這一步卡住不放。 這是近期新出現的場景,不是你的整合方式一直有問題 —— 所以你不需要懷疑自己的呼叫寫法,只需要按下面的方式加一層相容。

它是成時間窗發作的

這一點很重要,直接決定你怎麼復現、怎麼判斷:
  • 窗內:連續多次呼叫全部卡住,不分先後;
  • 窗外:幾十次連續呼叫一次都不出現,完全正常。
所以它既不是「必現」,也不是「小機率偶發」。如果你測的時候剛好錯開了視窗,會 100% 正常,很容易得出「已經好了」的錯誤結論。反過來,如果你正好撞進視窗,會覺得「全掛了」。兩種體感都是真的,別用其中一次的結果去下長期結論。
流式請求:streamGenerateContent)和純文本模型一般不受影響。本頁針對的是非流式出圖這一類響應體很大的請求 —— 一張 2K 圖的 JSON 響應體在 13 MB 量級。

成因

出圖響應用 Transfer-Encoding: chunked 分塊傳輸。按 HTTP/1.1 規範,服務端把最後一個數據塊發完之後,還必須再發一個終止塊(長度為 0 的塊),用來告訴客戶端「到此為止,傳完了」。 問題就出在這一步:資料塊全部到齊了,終止塊卻沒有發出來,連線也沒有關閉。 於是客戶端手裡握著一份完整可用的 JSON(圖片能正常 base64 解碼),但它無從知道這份資料已經收完了,只能繼續等 —— 一直等到自己的讀超時。 打個比方:快遞已經放到你門口了,但快遞員忘了點「已送達」。 你守在系統前等狀態更新,東西其實就在門外。
鏈路在某些時間窗內沒有給響應做這個收尾動作。服務端側的根治我們會繼續推進,本頁給的是在此之前的客戶端兜底方案這層兜底有它自己獨立的價值,而且不需要等服務端修好再回滾:有結束訊號時它永遠不會被觸發,服務端修復之後會自動靜默,零開銷、零維護負擔。
三個關鍵判斷,直接決定該怎麼處理:

資料是完整的

不是丟包、不是網路品質問題、更不是傳到一半斷了。已收到的位元組能完整解析,圖片可以正常使用。

繼續等沒有意義

卡住之後服務端一個位元組都不會再發。實測持續等待 330 秒仍無任何變化,把超時調到幾百秒只是白白拖長故障感知時間。

不繫結某臺機器

故障窗內多個落點同時出現、又同時恢復,所以換域名、換入口都繞不開,只能在客戶端處理。

判別方法

同時滿足下面三條,基本可以確定就是這個場景:
1

響應頭帶 Transfer-Encoding: chunked,且沒有 Content-Length

這說明響應體的長度不是預先宣告的,客戶端只能靠終止塊判斷「傳完了」。
2

已收到的位元組能被完整解析成 JSON

把已收位元組做一次 json.loads,能成功;並且裡面的 inlineData.data 做 base64 解碼後是一張完整可用的圖片。
3

解析成功之後,連線長時間沒有任何新位元組

既沒有收到終止塊,連線也沒有被關閉 —— 它就那樣一直開著。

與另外兩種形態的區別

三種情況報錯很像,但根因和處理方式完全不同,不要混用同一套判據 如果你的報錯是 ECONNRESET,那屬於另一類問題,判別方式見連線中斷排查

相容改造:客戶端主動收尾

思路很簡單:不要死等連線結束,而是在已收資料能被完整解析時就主動收尾。

關鍵:必須留一個寬限期

不能一解析成功就立刻收尾。正常情況下,終止塊往往就在下一個 TCP 分段裡,只差幾毫秒。如果解析成功就馬上斷開,會把「終止塊晚到幾毫秒」誤判成「服務端沒發」。 正確做法是:解析成功後再等一小段時間(建議 3~5 秒)。這期間收到任何位元組就按正常流程繼續走;等不到才判定為卡住並主動收尾。
這一步不是可選最佳化。 省掉寬限期會讓判定完全失真 —— 每一個正常請求都會被誤判成故障。我們在實測中第一版就踩了這個坑,一整批正常請求全被誤報。

收尾前要過的幾道判定

按從便宜到昂貴的順序排,任何一條不滿足就繼續等,不要收尾 第 6、7 條合起來讓誤判機率接近於零:響應是單個 JSON 物件,資料沒收全時解析必然失敗。換句話說,只有真的收全了才可能收尾

Python 實現

用後臺執行緒做流式讀取,主執行緒靠佇列超時來實現寬限期:
為什麼要多起一個執行緒? 因為 requests 的讀超時只有一個值,它同時管著「等首位元組」和「塊間等待」這兩段。而卡住時讀迴圈會一直阻塞在下一次讀上,寬限期根本沒有機會跑 —— 直接寫成 for chunk in ... 加計時的版本,在真正卡住時是不會觸發的。用後臺執行緒讀、主執行緒 q.get(timeout=term_grace),才能把這兩段超時真正拆開。這個坑我們自己踩過:一個超時值管兩件事,會把「生成慢」和「不收尾」混成同一種失敗,根本沒法歸因。
這個寫法只在寬限期到點時解析一次,而不是每收到一塊就試一次,天然滿足上表第 5 條 —— 十幾 MB 的內容不會被反覆解析。

Node.js 實現

Node.js 這邊不需要額外執行緒 —— reader.read() 本身就是 Promise,用 Promise.race 就能給「等下一塊」加上寬限期上限:
兩段程式碼裡都留意一下正常路徑那一行註釋:服務端正常收尾時,迴圈靠 done / 迭代結束自然退出,寬限期分支根本不會進。這就是「相容而不是替換」的具體含義 —— 你原來的成功路徑一個位元組都沒變。

超時怎麼設

最容易踩的坑是用一個超時值管兩件事:「等上游把圖生成出來」和「首位元組之後兩塊資料之間的靜默」。這兩段的正常時長差著一個數量級,混成一個值,要麼把生成慢誤殺成故障,要麼讓真正卡住的請求白等好幾分鐘。

✅ 建議

兩段分開設:首位元組留足生成時間, 塊間靜默壓到幾秒,靠上面的主動收尾兜底。 故障幾秒內就能感知,正常請求一個都不誤傷。

❌ 不要這樣

用一個 300 秒的大超時兜一切「以防萬一」。 卡住時服務端一個位元組都不會再發, 等多久都一樣,只是白白拖長故障感知時間。
如果你的產品裡有 4K 這類更耗時的檔位:總超時可以更長(模型確實要算那麼久),但塊間靜默的判定不該跟著變長 —— 這是兩件事,不要一起放大。
Node.js 使用者注意:undici(Node 18+ 內建 fetch 的底層)有三個互相獨立的超時,SDK 的 timeout 選項管不到它們。配置寫法見連線中斷排查的「Node.js:三個超時互相獨立」一節。

重試與計費

判定為「服務端沒有收尾」之後,按這個順序處理:
1

先用已經拿到的圖 —— 絕大多數情況到這一步就結束了

資料是完整的,圖片可以正常使用,不需要重試。這既是最省事的路徑,也避免了重複計費。
2

解析確實失敗了,才重試

如果已收位元組真的解析不出完整 JSON(資料確實不完整),再重試。建議換一條新連線,並在重試之間留 2~3 秒間隔。
3

連續失敗就退避,別貼著重試

該問題成時間窗發作,短時間內連續重試很可能仍然落在同一個視窗內。若連續 3 次都卡住,建議退避到 30 秒後再試。
計費口徑:這類請求上游已經把圖生成出來、也開始正常回傳了,屬於交付已完成,會照常計費。所以「客戶端超時報錯」不等於「沒花錢」—— 這正是第一步「直接用已拿到的圖」價值最大的地方:圖已經付過費了,白白丟掉才是真的浪費。完整的斷連計費對照(哪些收費、哪些不收費)見連線中斷排查的「計費影響」一節。

工程落地建議

下面這些與語言、框架無關,是我們自己落地時總結的:
  • 落在網路層的統一入口,不要散在業務呼叫點。 把它做成「發請求」這個動作的一部分。這樣所有出圖路徑一次性覆蓋,業務程式碼完全無感知,將來服務端修好了也只需要動一個地方。
  • 你真正需要的是「增量讀取」能力。 關鍵前提是能在響應還沒結束時就看到已收到的內容。絕大多數 HTTP 客戶端都提供這個能力(流式讀取、分塊回撥、進度事件),但預設用法通常不是 —— 預設那個「直接拿完整響應體」恰恰就是會卡死的那條路。這是改造的主要工作量所在。
  • 計時以「最後一次收到資料」為準,不是請求開始時間。 每收到一塊資料就重置寬限期計時。這樣既不會誤傷慢速網路,也能準確捕捉「徹底不動了」的狀態。
  • 加一個開關。 把這層行為放在一個可以隨時關閉的開關後面。上線初期出現任何非預期情況,關掉即可回到原有行為,不用緊急發版。
  • 加埋點。 每次觸發收尾都記一條(時間、資料量、等待時長)。它有三個用途:量化故障實際發生頻率、驗證這層邏輯確實在起作用、以及在服務端修復之後確認埋點歸零 —— 這是判斷「可以下線這層邏輯」的唯一客觀依據。
  • 順帶可以改善的體驗。 既然已經拿到了增量讀取能力,就可以順便把「正在接收資料 X.X MB」這類真實進度展示給使用者。大響應體下載期間的等待,原本對使用者是完全黑盒的。

我們自己的落地情況

這套改造我們已經在自家的 AI 圖片大師(imagen.apiyi.com)上完成並驗證。用一個會復現該故障的模擬服務(完整發完資料後既不髮結束訊號、也不關連線)跑了一組對照: 結論:正常請求零影響,故障請求從「等到超時然後失敗」變成「幾秒內正常出圖」。

常見疑問

不會。收尾的前提是已收資料能解析成一份完整的 JSON —— 資料沒收全時解析必然失敗。再加上 3~5 秒的寬限期,正常請求不會被誤判。上面「我們自己的落地情況」那張表裡,前兩行就是這兩種情況的對照。
不會。判定的是整個響應體的完整性,不是圖片本身。JSON 解析通過就意味著圖片資料是完整的 —— 半張圖對應的是解析失敗,那種情況不會觸發收尾。
不算。它不替代服務端修復,只是把已經產生、並且已經計費的結果交付到使用者手裡,同時避免了盲目重試帶來的重複扣費。埋點資料反過來還能幫助定位故障的發作規律。
不需要急著拆。有結束訊號時這段邏輯永遠不會被觸發,零開銷。可以等埋點連續歸零一段時間之後再考慮清理。

什麼時候找客服

加了上面的相容之後,如果仍然滿足下面任一條,帶材料找客服核查:
  • 已收位元組始終解析不出完整 JSON(說明不是本頁場景,是真的傳輸中斷);
  • 加了主動收尾之後仍然長時間拿不到任何響應頭(那是上游還沒開始回傳,屬於生成慢或上游故障,不是收尾問題);
  • 卡住的比例持續偏高,不是集中在某個時間窗內,而是長時間穩定復現。
提工單時附上:x-request-id、呼叫時間(帶時區,如 2026-08-03 13:15 (UTC+8))、模型名與 imageSize 等關鍵引數、客戶端異常原文,以及卡住時已收到的位元組數。

相關文件

連線中斷排查

ECONNRESET、SSL EOF、undici 三個超時與本地代理判別矩陣

必讀&最佳實踐

同步呼叫、timeout 分檔配置、base64 處理、斷連計費口徑

自實現非同步佇列

把同步呼叫包進任務佇列,用重試與落庫消化偶發異常