《炊想》是个小程序:输入一道菜名,后端让大模型写出一整份菜谱——食材、步骤、配菜、汤品、营养,再配一张成品图。上线第一天,点一道站里没有的新菜,连续两次都失败了,小程序收到的是网关的 504。
这篇记录排查过程:一份菜谱为什么要写 90 秒,为什么 75 秒的读超时注定会断,以及后端、nginx、小程序三道超时应该怎么排。
一、两次失败的时间线
后端日志和 nginx 访问日志对起来,第一次是这样的:
| 时刻 | 发生了什么 |
|---|---|
| 15:32:20 前后 | 小程序发起搭配 |
| 15:33:36 | 后端读上游 75 秒超时,自动重试 |
| 15:34:20 | nginx 等满 120 秒,给小程序 504 |
| 15:34:52 | 后端重试也超时,返回 502 |
第二次一模一样,每次尝试都卡满 76 秒左右。注意最后一行:后端 152 秒时才给出 502,而 nginx 在 120 秒就已经替它回了 504,这个 502 根本没人收。
二、慢在哪里
先排除网络。从服务器直接调上游接口,连接不到 1 秒;让它用一句话推荐一道菜,7 秒就回来了。
再按线上完全一样的请求测:同一段系统提示词、要求输出 JSON、max_tokens 3000。结果是:
- 整份写完用了 89.9 秒;
- 输出 1602 个 token,每秒约 18 个;
- 内容合格,能解析成菜谱。
慢在输出量。提示词要的东西很多:6~12 个步骤、每步 60~160 字,食材分主料辅料调料三组,配菜汤品主食饮品四项搭配,营养数据,再加一段 80~120 词的英文图片描述。按每秒十几个 token 算,90 秒是正常速度,不是故障。
三、读超时不等于总时长
同一次测试里,curl 报的首字节时间是 0.8 秒、总时间 89.9 秒。响应头很快就回来了,正文却要等模型全部写完才一次性给出。
后端用的是 Spring 的 SimpleClientHttpRequestFactory,它的 readTimeout 是 socket 每次读的等待上限,也就是两次收到数据之间最多等多久。正文 90 秒后才到,中间一个字节都没有,所以在 75 秒处断开。
对非流式请求来说,读超时实际上就是「整份必须在多少秒内写完」。输出一变长,它迟早会断。
换成流式(请求里带 stream: true),上游每写出一小段就发一段 SSE。同样的请求实测:
- 一共 1597 段;
- 第一段正文 6.2 秒到;
- 两段之间最长隔 4.1 秒;
- 98.4 秒写完,拼起来的 JSON 同样合格。
这时读超时只需要覆盖「两段之间的最长间隔」,30 秒绰绰有余;整次生成要多久,交给另一个总时限去管。
四、三道超时要排好队
一次搭配从小程序到上游经过三层,每层都有自己的超时。改前改后:
- 后端读上游:原来 75 秒、失败再试一次;改成两段输出之间最多等 30 秒,整次最多 140 秒;
- nginx:120 秒改成 150 秒;
- 小程序:150 秒,不变。
原则是:越靠近上游的越先放弃。后端先结束,小程序拿到的是一条明确的「搭配师走神了,再试一次」,而不是网关的 504。
原来的顺序正好反了:后端 75 秒加一次重试,最长要 150 秒,比 nginx 的 120 秒还长,后端的失败信息永远送不到用户手里。
重试也跟着改:只有在开头 20 秒内就失败(比如上游直接报 5xx)才重试;写到一半、或者写完才发现解析不了的,不再重来。生成一次就要一分半,再来一遍必然超过总时限。
五、代码
后端用 RestTemplate.execute 自己读响应流,不再用 postForObject 一次取回整段:
content = restTemplate.execute(url, HttpMethod.POST, request -> {
request.getHeaders().putAll(headers);
try (OutputStream out = request.getBody()) {
out.write(body);
}
}, response -> readContent(response, deadline));
readContent 先看 Content-Type:是 text/event-stream 就逐行读,把每段的 choices[0].delta.content 拼起来;万一上游没按流式回、直接给了整段 JSON,就按原来的方式解析。逐行读的部分大致是:
while ((line = reader.readLine()) != null) {
checkDeadline(deadline);
if (!line.startsWith("data:")) continue;
String data = line.substring(5).trim();
if ("[DONE]".equals(data)) break;
JsonNode delta = objectMapper.readTree(data).path("choices").path(0).path("delta");
if (delta.path("content").isTextual()) content.append(delta.path("content").asText());
}
每读一行都检查一次总时限,过了就不再读。只有推理过程、没有正文,或者流里出现 error,都按失败处理。拼好的全文交给原来的菜谱解析器,返回给小程序的接口格式一个字没变,小程序不用改。
nginx 这边只改一行:
location ^~ /recipe/api/ {
proxy_read_timeout 150s;
}
服务器上的环境变量也要跟着改:RECIPE_TEXT_TIMEOUT_MS 的含义从「读超时」变成了「总时限」,值从 75000 改成 140000,并新增两段间隔的 RECIPE_TEXT_IDLE_TIMEOUT_MS(30000)。只改代码不改配置,140 秒的总时限会被旧值 75 秒顶掉,等于没改。
六、上线以后
当晚有 3 次真实的新菜生成,文本部分分别用了 92、86、81 秒,全部成功,成品图也都出来了(其中一次图片上游返回 503,30 秒后自动重试成功)。以前这三次会全部卡在 75 秒。
后来又把搭配改成了异步任务:提交后马上返回,小程序轮询结果,发版时也会先等进行中的搭配做完再重启。这样连「一个请求挂一分半」本身也没有了。
七、换个更快的模型?
等一分半毕竟太久,第二天上午把同一个菜谱请求同时发给 9 个模型,每个流式读取,看合不合格、要多久:
| 模型 | 合格 / 耗时 |
|---|---|
| gpt-6-luna(现用) | 3/3,21~34 秒 |
| gpt-5.6-luna | 3/3,27~35 秒 |
| deepseek-v4-flash | 1/3,14~16 秒 |
| claude-haiku-4-5 | 2/3,19~26 秒 |
| qwen3.7-plus | 1/1,83 秒 |
| doubao-seed-2.1-turbo | 1/1,123 秒 |
| MiniMax-M3-highspeed | 0/1 |
| glm-5.3-flash | 0/1 |
| step-3.7-flash | 0/1 |
不合格的几个:claude-haiku 有一次 JSON 写坏;MiniMax 写满 3000 个 token 仍不成 JSON;glm 第一段正文要等 86 秒,写出来的 JSON 也是坏的;step 的 JSON 同样坏了。qwen 和 doubao 虽然合格,但第一段正文分别要等 57 秒和 107 秒。
deepseek-v4-flash 是最快的,但 3 次里有 2 次把 3000 个 token 的输出额度用满了:一次正文一个字都没有(额度花在了正文以外的输出上),一次 JSON 写到一半被截断。速度快一倍,换来三分之二的失败率,不划算。
最有意思的是现用的 gpt-6-luna:同一个模型,上午只要 21~34 秒,前一天晚上却要 80~92 秒。等得久主要是上游晚高峰慢,跟选哪个模型关系不大,所以模型没换。
小结
- 非流式请求的读超时,实际上就是「整份响应必须在多少秒内写完」。输出一长就会断,而且断得很规律。
- 流式读取把两件事拆开:读超时管两段之间的间隔,另设一个总时限管整次生成。
- 一条请求经过几层,超时就要排好队:越靠近上游越短,让离用户最近的那层收到的是明确的失败信息,而不是网关超时。
- 重试要看剩下的时间够不够,晚失败的不重试。
- 改了超时的含义,服务器上的配置要一起改,否则旧值会把新代码顶掉。
- 嫌慢想换模型,先在不同时段多测几轮:这次慢的是时段,不是模型。
相关:SSE与Nginx流式传输排障实战 讲的是另一头——nginx 缓冲会把流式响应攒成一整块再发出去。

评论区 0