一句话结论:图片数据是完整的,能正常解码出图,卡住的只是 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 处理、断连计费口径
自实现异步队列
把同步调用包进任务队列,用重试与落库消化偶发异常