🤖
AI审核中

一份菜谱要写 90 秒:流式读取与三道超时的先后

文章摘要

炊想小程序上线第一天,点一道新菜连续两次收到 504。排查发现:一份菜谱约 1600 个 token,上游每秒十几个,要写 90 秒;非流式请求的读超时实际等于「整份必须多少秒内写完」,75 秒必断,重试又等 75 秒,而 nginx 120 秒就替后端回了 504。本文记录改成流式读取(读超时只管两段间隔、另设总时限)、按「越靠近上游越先放弃」重排后端、nginx、小程序三道超时、晚失败不重试,以及上线后的真实耗时和 9 个模型的实测对比。

《炊想》是个小程序:输入一道菜名,后端让大模型写出一整份菜谱——食材、步骤、配菜、汤品、营养,再配一张成品图。上线第一天,点一道站里没有的新菜,连续两次都失败了,小程序收到的是网关的 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 条评论

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧

评论区 0

头像
 0 条评论