一句话结论:图片 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 处理