Skip to main content

先说计费:write_response_body_failed 这类 500 不收费

网关返回 500 + write_response_body_failed / connection reset by peer 时,平台侧已经自动内部重试了 2-3 次,全部失败才把错误抛给你。这种情况不产生任何费用。所以哪怕你在日志里看到一连串这样的报错,账单上不会有对应扣费——不用担心「失败了还被收钱」。会计费的是另一种情况(客户端自己提前断开),详见下面的「计费影响」一节。
一句话结论:图片 API 的响应体动辄十几到几十 MB,断点几乎总在下载响应数据这一程(不是请求体太大——纯文生图同样会中断)。排查时先看后台日志属于哪一类,再按 macOS / Linux / Node.js 分别自查。

报错长什么样

同一个根因,在网关侧和客户端侧会呈现成两副完全不同的面孔。

网关侧返回

客户端侧抛出

Node.js 的报错要分清「掐」和「关」ECONNRESET 表示收到了 TCP RST,是连接被中间设备掐断,指向网络路径上的某一跳;SocketError: other side closed / ERR_STREAM_PREMATURE_CLOSE 表示对端优雅关闭(FIN),指向服务端收尾问题(如少发了 chunked 终止块)。两者方向完全不同,不要混为一谈。另外 UND_ERR_* 只可能来自 undici(Node 18+ 的内置 fetch 底层),read ECONNRESET 则是 libuv 的顶层文案,axios / node-fetch / http 模块都会给。如果两类错误同时出现,先确认你的应用里是不是有两条不同的 HTTP 路径——那样的话它们根本不是同一件事。

先判断方向:是谁先断的

write_response_body_failed 这个 code 是关键线索——它的含义是网关在向调用方回写响应体的过程中失败,属于下行方向,不是上游模型报错。换句话说,结果已经生成出来了,正往你这边推的时候连接断了。
这不是「请求体太大」造成的。 图片编辑要上传参考图,容易让人误以为是上传体积的问题;但纯文生图(请求体只有几百字节)同样会中断。断点在下载响应数据的这一程——图片响应动辄十几到几十 MB,是全链路上最脆弱的一段。

下行断开

write_response_body_failedconnection reset by peer、客户端 SSL EOF。 网关在推 body 时连接消失。平台返回 500 的这一类不计费,详见下面的计费一节。

上游失败(渠道侧)

上游超时、upstream_error、5xx 带上游原始报文,或 HTTP 200 但 finishReason 异常。 这类才是渠道问题,可以拿 x-request-id 找客服核查。

最强判据:后台日志里这次是什么状态

在动手排查之前先看后台调用日志。这条零成本,而且比任何客户端操作都更快缩小范围:
由此可以推出一条经常被搞反的结论:UND_ERR_CONNECT_TIMEOUT 这类建连阶段的失败不可能产生计费,因为请求根本没到网关。所以如果你看到「大量 connect timeout」同时又「被扣了很多次费」,这两件事一定不是同一批请求,必须分开排查,不要用一个根因去解释全部现象。

四步自证

按这个顺序做,绝大多数情况在前两步就能定位:
1

先排除应用层误读:你可能收到了但没存住

「没收到图」往往是代码抛异常后被 catch 成「请求失败」的结论,而不是真的没收到字节。最常见的一种:gpt-image-2-all 默认返回 b64_json不带 data: 前缀,代码若按 data[0].url 取值会拿到 undefined,后续处理直接抛错 → 被判定为失败 → 触发重试 → 重复计费现象与网络故障一模一样,但根本不需要任何网络故障。先打印一行自查:
各系列的字段与前缀差异见 base64 前缀差异对照
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。表现就是「一段时间没调用,然后突然连续几发全挂,之后又恢复正常」。
这和前面 Node.js 一节讲的「keep-alive 复用死连接」是同一个机制,只不过在服务器上元凶通常是云厂商的 NAT 网关,而不是本地代理软件。
修法(任选,推荐前两个):
  • 把 TCP keepalive 调到小于 350 秒,让静默期也有包在走;
  • 限制连接池的空闲存活时间,让它主动丢弃可能已失效的连接(Node:new Agent({ keepAliveTimeout: 60_000 });Python requestsHTTPAdapter 控制连接池);
  • 走 VPC 端点等绕开 NAT 网关的路径。
其它云厂商与自建 LB 的空闲超时值各不相同,但思路一样:找出链路上最短的那个空闲超时,把 keepalive 调得比它更小

2. TCP keepalive 默认值等于没开

Linux 的 tcp_keepalive_time 默认是 7200 秒(2 小时),远大于上面任何一个空闲超时,等于完全不起作用:
更稳妥的做法是在应用层的 HTTP 客户端上开 keepalive,不依赖全局 sysctl——容器里改内核参数往往受限。

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易 国内可直连,服务器上通常应该把 api.apiyi.com 加进 NO_PROXY,或干脆确认没有代理变量。

服务器侧一键自查

Node.js:三个超时互相独立,SDK 的 timeout 管不到

Node 18+ 的内置 fetch 底层是 undici,它有三个各自独立的超时,分别对应请求的三个阶段。「我 timeout 设了 5 分钟」通常只调到了其中一个都不是的第四个值:
openai-node 的 timeout 选项是基于 AbortController 的「总请求超时」,不会传导到上面三个中的任何一个。 你把 timeout 从 60 秒调到 300 秒,connectTimeout 依然是 10 秒。裸 fetch()AbortSignal.timeout() 同理。这就是「明明 timeout 设得很大却还是报超时」最常见的原因——调错了层。

正确的配置写法

要放宽 undici 的三个超时,必须配 Agent(全局或按请求):

maxRetries 默认是 2,而且会自动重试连接错误

openai-node 默认 maxRetries: 2,并且连接错误和超时都在自动重试范围内。也就是说一次业务调用最多会产生 3 次实际请求(其中会不会计费,取决于每一次分别属于下面「计费影响」的哪一类),而你的代码里可能一次重试都没写。 图片接口单价高、又是同步长请求,一律显式设 maxRetries: 0,把重试收到自己手里,配合自己的退避与次数上限。计费口径见 重试策略
排查时先确认你到底用的是什么栈:node -vnpm ls openai undici axios node-fetchUND_ERR_* 只证明底层走的是 undici,不能证明你用的是 openai SDK——裸 fetch() 同样会抛这些错误码,而裸 fetch() 没有 maxRetries 这回事。

keep-alive 复用了一条已经死掉的连接

undici 默认启用连接池 + keep-alive。VPN、NAT、代理软件把空闲连接静默回收之后,客户端并不知情,仍然从池里取出这条连接来发下一个请求——写入的瞬间收到 RST,表现就是 read ECONNRESET 这是 ECONNRESET 在长间隔调用场景下最常见的来源,也能解释「错误在某个时间窗内密集出现」「重试的第一发也失败」。验证方法是关掉复用再跑:

本地代理 / VPN:图片接口最容易暴露的一跳

API易 国内可直连,不需要代理或 VPN(见 使用 API 接口需要代理网络吗?)。所以排查时,关掉代理直连复测是成本最低、信息量最大的一发但要说清楚:代理只是嫌疑最大的变量之一,不等于根因。下面的判别矩阵才是用来定位的。
图片接口有两个特征让它比文本接口敏感得多:生成期有 30-60 秒零字节流动,以及响应体是 MB 级的一次性突发。普通聊天接口跑得好好的,图片接口挂掉,往往就卡在这两点上。

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。
做代理开 / 关的 A/B 对比时必须交替执行,不能分块。 先连跑 5 次代理、再连跑 5 次直连,这种顺序分组会被时间窗性质的故障污染出完全错误的结论——实测中确实遇到过某段时间全挂、隔几分钟全好、再过一会儿又复发的情况。正确做法是 代理 → 直连 → 代理 → 直连 交替,并记录每次的 remote_ip

其它常见诱因

中途手动中断

调试时 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 抛给你。这种情况不产生任何费用。 所以即使你在日志里看到一连串这样的报错、而且是同一个请求反复失败,账单上不会有对应的扣费,不用担心「失败了还被收钱」。

客户端自己提前走开 —— 照常计费

另一种情况是网关正常完成了交付,是你这边先断的:客户端 timeout 到点主动断开、调试时 Ctrl+C、进程被重启或被 OOM 杀掉。这类请求服务端与上游的生成已经完成照常计费——「我没拿到图」不等于「没花钱」。所以排查期间反复重试大图请求,这部分账单是实打实在走的。
区分方法就是上面那张表:看后台日志记的是正常调用还是 500 write_response_body_failed 重试策略也要相应克制:传输层异常值得重试,但每次重试都可能是一次新的计费(取决于它属于上面哪一类)。不要写无上限的重试循环。

正确的重试写法

关键原则:只对传输层异常重试,不对 HTTP 层错误重试。4xx 重发一万次也还是 4xx,而且浪费时间。
把每次尝试单独记下来(上面的 attempts)。否则客户端重试成功后,日志里只剩一条漂亮的 200,你会永远看不到底层到底断了多少次——排查渠道质量时这份数据是关键,也能避免把自己的重试误读成渠道行为。

流式读取,别一次性载入

大响应体建议用 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 处理