一句話結論:圖片 API 的響應體動輒十幾到幾十 MB,斷點幾乎總在下載響應資料這一程(不是請求體太大——純文生圖同樣會中斷)。排查時先看後臺日誌屬於哪一類,再按 macOS / Linux / Node.js 分別自查。
報錯長什麼樣
同一個根因,在閘道側和客戶端側會呈現成兩副完全不同的面孔。閘道側返回
客戶端側丟擲
先判斷方向:是誰先斷的
write_response_body_failed 這個 code 是關鍵線索——它的含義是閘道在向呼叫方回寫響應體的過程中失敗,屬於下行方向,不是上游模型報錯。換句話說,結果已經生成出來了,正往你這邊推的時候連線斷了。
這不是「請求體太大」造成的。 圖片編輯要上傳參考圖,容易讓人誤以為是上傳體積的問題;但純文生圖(請求體只有幾百位元組)同樣會中斷。斷點在下載響應資料的這一程——圖片響應動輒十幾到幾十 MB,是全鏈路上最脆弱的一段。
下行斷開
write_response_body_failed、connection reset by peer、客戶端 SSL EOF。
閘道在推 body 時連線消失。平臺返回 500 的這一類不計費,詳見下面的計費一節。上游失敗(渠道側)
上游超時、
upstream_error、5xx 帶上游原始報文,或 HTTP 200 但 finishReason 異常。
這類才是渠道問題,可以拿 x-request-id 找客服核查。最強判據:後臺日誌裡這次是什麼狀態
在動手排查之前先看後臺呼叫日誌。這條零成本,而且比任何客戶端操作都更快縮小範圍:四步自證
按這個順序做,絕大多數情況在前兩步就能定位:1
先排除應用層誤讀:你可能收到了但沒存住
「沒收到圖」往往是程式碼拋異常後被 catch 成「請求失敗」的結論,而不是真的沒收到位元組。最常見的一種:各系列的欄位與字首差異見 base64 字首差異對照。
gpt-image-2-all 預設返回 b64_json 且不帶 data: 字首,程式碼若按 data[0].url 取值會拿到 undefined,後續處理直接拋錯 → 被判定為失敗 → 觸發重試 → 重複計費。現象與網路故障一模一樣,但根本不需要任何網路故障。先列印一行自查:2
看是不是「所有渠道 / 所有模型一起報」
同一時間窗內,如果你在測的兩個不同渠道、不同模型都在報同一個錯,那幾乎可以直接排除渠道特異性——上游不會這麼整齊地同時出問題。
3
查客戶端的執行時:TLS 棧(Python)或 undici 超時(Node.js)
Python 看 TLS 棧版本,Node.js 看 undici 的三個超時——見下面兩節。這是實測中最高頻的根因,且完全在你本地,一條命令就能確認。
4
降併發 / 改序列 / 關掉本地代理再跑一遍
把併發降到 1-2、並關掉 VPN 或代理重跑同樣的請求。如果這樣完全不復現,問題在客戶端的連線管理、本地資源(連線池、檔案描述符、記憶體)或網路路徑,而不是渠道。
頭號元兇:客戶端 TLS 棧(macOS 尤其高發)
macOS 系統自帶的 Python(/usr/bin/python3)連結的是 LibreSSL 2.8.3,而不是 OpenSSL。這個組合在配合 urllib3 v2 做併發大響應體下載時會穩定丟擲 SSLEOFError,表現為客戶端單方面斷連——於是閘道側記錄下一片 connection reset by peer。
一條命令自查
匯入
requests 時如果看到這行告警,同樣是中招訊號:
修復:換一個直譯器
不要去降級 urllib3,直接換用帶正常 OpenSSL 的 Python:實測對照(2026-07-29,UTC+8)
在 Nano Banana 系列(gemini-3-pro-image / gemini-3.1-flash-image)上做雙渠道對比測試時的真實資料:
結論很清楚:換直譯器前後差了一個數量級,且換之前兩個渠道同時報錯這一點本身就說明與渠道無關。
Linux 伺服器要查什麼(和 macOS 完全不是一回事)
上一節的 TLS 棧自查在 Linux 上基本都會通過——各發行版自帶的 Python 鏈的都是正常 OpenSSL,不存在 LibreSSL 那個坑。所以別在這裡停下:伺服器環境的坑在出網路徑和容器限制上,和本機開發完全是兩套問題。
1. 雲 NAT 閘道 / 負載均衡的空閒超時(伺服器上最高頻)
這是生產環境connection reset by peer 的頭號來源。以 AWS NAT Gateway 為例:它有一個固定 350 秒、不可調的空閒超時,而且超時後發的是 RST 不是 FIN——於是客戶端拿到的正好就是 ECONNRESET。
坑在於它會連鎖:連線池裡的連線閒置超過 350 秒後集體失效,你發請求時第一條被 RST,客戶端自動重試換池裡下一條——那條也閒置超時了,照樣 RST。表現就是「一段時間沒呼叫,然後突然連續幾發全掛,之後又恢復正常」。
修法(任選,推薦前兩個):
- 把 TCP keepalive 調到小於 350 秒,讓靜默期也有包在走;
- 限制連線池的空閒存活時間,讓它主動丟棄可能已失效的連線(Node:
new Agent({ keepAliveTimeout: 60_000 });Pythonrequests用HTTPAdapter控制連線池); - 走 VPC 端點等繞開 NAT 閘道的路徑。
2. TCP keepalive 預設值等於沒開
Linux 的tcp_keepalive_time 預設是 7200 秒(2 小時),遠大於上面任何一個空閒超時,等於完全不起作用:
3. 容器網路的 MTU
Docker / K8s 的 overlay 網路(flannel VXLAN 等)MTU 常被設成 1450 而不是 1500,一旦和路徑上的 PMTUD 黑洞疊加,就是典型的「小請求全正常、大響應必掛」:4. 容器記憶體上限 → 程序被 OOMKilled
4K 出圖的 base64 單條可達 20-30MB,resp.json() 一次性載入再疊加併發,很容易超過容器的 memory limit 被核心殺掉,表現同樣是「連線莫名其妙斷了」:
5. 環境變數裡的代理(伺服器上最隱蔽的一個)
伺服器上經常有全域性HTTP_PROXY / HTTPS_PROXY / NO_PROXY(寫在 /etc/environment、systemd unit 或 Dockerfile 裡),你自己都忘了它的存在。更麻煩的是各語言對它的處理並不一致:
這個不一致會造成非常迷惑的現象:同一臺機器上 curl 和 Python 走代理、Node 直連(或反過來),兩者行為不同,排查時容易得出矛盾結論。先確認一下:
api.apiyi.com 加進 NO_PROXY,或乾脆確認沒有代理變數。
伺服器側一鍵自查
Node.js:三個超時互相獨立,SDK 的 timeout 管不到
Node 18+ 的內建fetch 底層是 undici,它有三個各自獨立的超時,分別對應請求的三個階段。「我 timeout 設了 5 分鐘」通常只調到了其中一個都不是的第四個值:
正確的配置寫法
要放寬 undici 的三個超時,必須配Agent(全域性或按請求):
maxRetries 預設是 2,而且會自動重試連線錯誤
openai-node 預設 maxRetries: 2,並且連線錯誤和超時都在自動重試範圍內。也就是說一次業務呼叫最多會產生 3 次實際請求(其中會不會計費,取決於每一次分別屬於下面「計費影響」的哪一類),而你的程式碼裡可能一次重試都沒寫。
圖片介面單價高、又是同步長請求,一律顯式設 maxRetries: 0,把重試收到自己手裡,配合自己的退避與次數上限。計費口徑見 重試策略。
keep-alive 複用了一條已經死掉的連線
undici 預設啟用連線池 + keep-alive。VPN、NAT、代理軟體把空閒連線靜默回收之後,客戶端並不知情,仍然從池裡取出這條連線來發下一個請求——寫入的瞬間收到 RST,表現就是read ECONNRESET。
這是 ECONNRESET 在長間隔呼叫場景下最常見的來源,也能解釋「錯誤在某個時間窗內密集出現」「重試的第一發也失敗」。驗證方法是關掉複用再跑:
本地代理 / VPN:圖片介面最容易暴露的一跳
API易 國內可直連,不需要代理或 VPN(見 使用 API 介面需要代理網路嗎?)。所以排查時,關掉代理直連複測是成本最低、資訊量最大的一發。但要說清楚:代理只是嫌疑最大的變數之一,不等於根因。下面的判別矩陣才是用來定位的。
fake-ip / 分流規則不命中
代理軟體的 fake-ip 模式下,若規則沒命中,會連到
198.18.x.x 這類不可路由地址,表現是精確 10 秒的 connect timeout。注意這不是「建連慢」,是根本沒有路由——放大 connect.timeout 也救不回來。務必記錄實際連到的 remote_ip。生成期被當成空閒連接回收
請求發出後有 30-60 秒零位元組流動,代理按空閒連線策略回收。特徵是失敗時刻是 30 / 60 / 120 這類圓整值,且與圖片大小無關。
MTU / PMTUD 黑洞
隧道 MTU 小於路徑 MTU,而 ICMP「需要分片」被丟棄導致 PMTUD 失效。典型表現是小請求全正常、大響應必掛,已收位元組停在幾 KB 到幾十 KB 就不動了。把隧道 MTU 降到 1400 左右常能解決。
MITM 解密 + 全量緩衝
開了 HTTPS 解密的代理常對大 body 做整體緩衝,可能撞上體積上限;也可能把 chunked 重寫成
Content-Length 而長度算錯,直接 RST。同樣只打圖片這種 MB 級響應,不打文本呼叫。判別矩陣
這是本節的核心。判別軸只有兩條:失敗發生在首位元組之前還是之後、已經收到了多少位元組。
最後一行是價效比最高的一發:把響應體從數 MB 壓到 1KB 左右,如果 URL 模式穩定成功而 base64 模式穩定失敗,就說明問題與傳輸體量相關,可以直接排掉建連和空閒回收兩列。
一條命令看清時間剖面
connect 沒值 → 建連階段;ttfb 沒值且 total 是圓整數 → 空閒回收;bytes 是全量但 total ≈ ttfb + 300 並報 curl: (18) → 服務端沒發終止塊;bytes 卡在幾十 KB → MTU。
其它常見誘因
中途手動中斷
除錯時 Ctrl+C、重啟程序、熱過載、kill 掉正在跑的指令碼——所有正在傳輸的大響應體都會在閘道側留下一條
write_response_body_failed。這是最容易被誤讀成「渠道不穩」的假警報。外層超時先到
任務佇列 worker 超時、Serverless 函式執行上限、閘道/CDN 的回源超時(預設普遍 60 秒)。任何一層小於生成時間都會先掐斷連線,詳見必讀&最佳實踐。
連線池與併發過高
連線池上限、本地檔案描述符上限、NAT / 防火牆對長連線的靜默回收。大響應體持續時間長,撞上這些限制的機率遠高於文本介面。
記憶體扛不住響應體
4K 出圖的 base64 單條可達 20-30MB,一次性
resp.json() 全量載入再加併發,容器記憶體打滿會導致程序被 OOM 殺掉,表現同樣是「連線莫名斷開」。計費影響:哪些斷連收費,哪些不收費
這兩種情況經常被混為一談,但計費結果完全相反:平臺返回 500 write_response_body_failed —— 不計費
這個錯誤表示閘道在向你回寫圖片資料時連線斷了。平臺側會自動內部重試 2-3 次,重試全部失敗之後才把 500 拋給你。這種情況不產生任何費用。 所以即使你在日誌裡看到一連串這樣的報錯、而且是同一個請求反覆失敗,賬單上不會有對應的扣費,不用擔心「失敗了還被收錢」。write_response_body_failed。
重試策略也要相應剋制:傳輸層異常值得重試,但每次重試都可能是一次新的計費(取決於它屬於上面哪一類)。不要寫無上限的重試迴圈。
正確的重試寫法
關鍵原則:只對傳輸層異常重試,不對 HTTP 層錯誤重試。4xx 重發一萬次也還是 4xx,而且浪費時間。流式讀取,別一次性載入
大響應體建議用stream=True 逐塊讀取,既能降低記憶體峰值,也能在出問題時看清是在傳輸的哪個階段斷的:
什麼時候才該找客服
自證走完之後,如果滿足下面任一條,就帶著材料找客服核查:- 換了正常 OpenSSL 的直譯器、併發降到序列,仍然穩定復現;
- 只有某一個特定渠道 / 模型在報,其它渠道同時段正常;
- 響應體已經完整收到(位元組數對得上
Content-Length)但連線遲遲不關閉,直到超時——這是上游缺 chunked 終止塊,屬於渠道側問題; - 報錯是明確的上游方向(
upstream_error、上游 5xx 原文)。
x-request-id、呼叫時間(帶時區,如 2026-07-29 14:32 (UTC+8))、模型名、imageSize 等關鍵引數、客戶端異常原文、以及你已經做過的自證步驟。
相關文件
必讀&最佳實踐
同步呼叫、timeout 分檔配置、base64 處理、斷連計費口徑
自實現非同步佇列
把同步呼叫包進任務佇列,用重試與落庫消化偶發斷連
需要代理網路嗎?
API易 國內直連、無需代理;證書與 DNS 類問題的自查方法
Gemini 圖片錯誤處理
Gemini 系出圖的錯誤碼與 finishReason 處理