一句話結論:圖片資料是完整的,能正常解碼出圖,卡住的只是 HTTP 傳輸的最後一個動作 ——「告訴客戶端傳完了」。所以正確的處理不是把超時調長、也不是重試,而是在資料已經到齊時主動收尾,把圖取出來用。
現象
呼叫原生出圖介面(POST /v1beta/models/{model}:generateContent)時,可能遇到這樣一組現象:
- 後臺呼叫日誌顯示請求成功、也已經計費;
- 客戶端卻一直掛著,直到自己的讀超時才報錯;
- 報錯形如
Read timed out、ETIMEDOUT、UND_ERR_BODY_TIMEOUT。
它是成時間窗發作的
這一點很重要,直接決定你怎麼復現、怎麼判斷:- 窗內:連續多次呼叫全部卡住,不分先後;
- 窗外:幾十次連續呼叫一次都不出現,完全正常。
流式請求(
: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 實現
用後臺執行緒做流式讀取,主執行緒靠佇列超時來實現寬限期:這個寫法只在寬限期到點時解析一次,而不是每收到一塊就試一次,天然滿足上表第 5 條 —— 十幾 MB 的內容不會被反覆解析。
Node.js 實現
Node.js 這邊不需要額外執行緒 ——reader.read() 本身就是 Promise,用 Promise.race 就能給「等下一塊」加上寬限期上限:
超時怎麼設
最容易踩的坑是用一個超時值管兩件事:「等上游把圖生成出來」和「首位元組之後兩塊資料之間的靜默」。這兩段的正常時長差著一個數量級,混成一個值,要麼把生成慢誤殺成故障,要麼讓真正卡住的請求白等好幾分鐘。✅ 建議
兩段分開設:首位元組留足生成時間,
塊間靜默壓到幾秒,靠上面的主動收尾兜底。
故障幾秒內就能感知,正常請求一個都不誤傷。
❌ 不要這樣
用一個 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 處理、斷連計費口徑
自實現非同步佇列
把同步呼叫包進任務佇列,用重試與落庫消化偶發異常