nginx 的 proxy_read_timeout 默认是 60 秒,而长上下文或推理模型的单次生成经常超过这个数。超时之后 nginx 返回 504,看起来只是"一次失败"——但真实后果比这严重:上游很可能已经把生成做完并计了费,而客户端 SDK 收到 504 会自动重试,于是上游又生成一次、又计一次费。

客户端最终只拿到一个响应,你的账单上是两笔。更麻烦的是这件事在监控里几乎看不出来:错误率只涨了一点,成本却在悄悄翻倍。

先纠正一个常见的误解.

网上很多文章会告诉你 nginx 的 proxy_next_upstream 会把超时请求转给下一个上游、导致重复计费。这个说法对 LLM API 来说是不准确的,值得先说清楚,否则你会去改一个本来没问题的配置。

proxy_next_upstream 的默认值确实是 error timeout,但 nginx 从 1.9.13 起有一条额外规则:

非幂等方法(POST、LOCK、PATCH)的请求,一旦已经发送给上游,就不会再转给下一台服务器——除非你显式加上 non_idempotent 选项。

LLM 的 chat completions 走的是 POST,所以 nginx 默认行为是安全的。真正的重复计费来源有两个,都不是 nginx 的默认配置:

  1. 有人手工加了 non_idempotent——一些"提升可用性"的高可用教程会教这个,在普通 Web 服务上无害,在按 token 计费的接口上是直接烧钱。
  2. 客户端 SDK 自己在重试——这是绝大多数情况的元凶。OpenAI 官方 Python SDK 的 max_retries 默认是 2,重试触发条件包含连接错误、408、409、429 和所有 5xx。nginx 吐的 504 正好落在 5xx 里。

双倍计费是怎么发生的

客户端nginx上游 APIt=0sPOST /v1/chat/completions生成中第 1 次已计费 ①t=60s504 Gateway Timeoutproxy_read_timeout 到点上游不知道nginx 放弃了,继续把生成做完t=60sSDK 自动重试(max_retries=2)生成中第 2 次又计费 ②t=135s200 OK客户端拿到 1 个响应 · 账单上 2 笔 · 监控里只是错误率轻微上升
链条的关键一环是上游并不知道 nginx 已经放弃——它按自己的节奏把生成做完。是否计费取决于供应商策略, 但安全假设是"已经计了":连接中断后仍收费的供应商很常见,你无法事后核对。

流式请求几乎不会撞上这个问题

这是很多人不知道的一点,也是"默认就该用流式"的一个纯运维理由。proxy_read_timeout 的官方定义是:

该超时只作用于两次连续读操作之间,而不是整个响应的传输时长。

所以:

  • 非流式请求:从发出到收到完整 JSON,中间是一个长长的空隙。生成花 75 秒,就是一个 75 秒的读间隔 → 撞上 60 秒超时。
  • 流式请求:token 一个接一个到达,每两次读之间只隔几百毫秒 → 无论总时长多久都不会触发超时。
nginx 眼中的「两次读操作之间的间隔」60s 超时线非流式一个 75s 的读间隔 → 在 60s 处被切断流式每个间隔都远小于 60s → 穿过超时线也毫发无伤本图为示意,用于说明超时的计量方式
同一个总时长、同一个超时配置,结果完全不同。非流式请求才是超时的重灾区。 如果你的中转站还有大量非流式调用,这就是优先要治的地方——而不是一味把超时往上调。

正确的超时分层

关键是区分"连不上"和"生成慢":前者应该快速失败,后者必须耐心等。用一个统一的大数字把三个超时都拉长,是把两种完全不同的故障混在一起处理。

location /v1/ {
    proxy_pass http://upstream_pool;

    # 连不上 → 快速失败。请求还没发出去,此时重试是安全的
    proxy_connect_timeout  5s;

    # 发送请求体 → 大 prompt 也就几百 KB,给足但不必夸张
    proxy_send_timeout     60s;

    # 读响应 → 生成慢是正常的,不是故障。这里必须放宽
    proxy_read_timeout     600s;

    # LLM 是非幂等的:绝不要开 non_idempotent
    # 保守起见连 timeout 重试也去掉,只保留连接阶段的重试
    proxy_next_upstream    error;
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 10s;

    # 流式必须配套,否则响应被攒起来又变回一个长读间隔
    proxy_buffering        off;
    gzip                   off;
    proxy_http_version     1.1;
    proxy_set_header       Connection "";
}
按故障性质分层,而不是统一拉长proxy_connect_timeout5s连不上 → 快速失败请求未发出,重试安全 ✓proxy_send_timeout60s上传 prompt已发出,重试即重复计费 ⚠proxy_read_timeout600s生成慢 → 不是故障这一层千万别按 60s 算 ⚠SDK max_retries0 或 1客户端侧默认 2,是双倍计费主因
灰底那一行是唯一需要显著放宽的;虚线那一行不在服务端,但它才是账单翻倍的直接原因,容易被漏掉。

客户端侧要做的事

服务端配对了,客户端如果还在盲目重试,问题只是变少而不是消失。

from openai import OpenAI

client = OpenAI(
    base_url="https://your-gateway/v1",
    api_key="...",
    max_retries=0,   # 默认是 2,对按 token 计费的接口必须显式关掉
    timeout=600.0,   # 客户端超时也要放宽,否则轮到它先放弃
)

关掉自动重试之后,重试逻辑要自己写,并且只在明确安全的错误上重试:

错误能否重试原因
连接失败 / DNS 失败可以请求没发出去,上游没开始生成
429 限流可以上游明确拒绝了,没有生成
400 参数错误不该重试重试一万次也是同样结果
401 / 403不该重试凭据问题
504 / 502危险上游可能已经生成完了,重试就是第二笔账单
流式中途断开危险已经生成了一部分,且已产生费用

更彻底的办法:在中转层做请求去重

如果你控制中转层(自建 new-api 之类就是这种情况),可以在网关上加一层短窗口去重,把重复计费在源头掐掉:

思路是给每个请求算一个指纹——模型名 + 消息体 + 关键参数的哈希——配上客户端标识,在 Redis 里存一个几十秒的短 key。窗口内出现相同指纹时,不再打上游,直接返回上一次的结果或一个明确的"重复请求"错误。

这个做法的取舍要讲清楚:

  • 好处:无论客户端怎么重试,上游只被打一次,账单可控。
  • 代价:LLM 本身是有随机性的(temperature > 0 时同样输入本该有不同输出),去重会让窗口内的重复请求拿到相同结果。对聊天场景通常无害,对"故意重新生成一次"的功能就是 bug。
  • 所以:窗口要短(几十秒量级),并且给客户端一个显式绕过的办法(比如一个请求头或一个随机 nonce 参数)。

怎么发现自己已经在被双倍计费

从日志里找证据,不用等账单。特征是:同一客户端在很短时间内发出内容完全相同的请求,且其中至少一个返回了 504。

# 找 504:如果这个数不是 0,就值得往下查
awk '$9==504 {print $1, $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

# 找 504 之后紧跟着重试的模式:同 IP、同路径、几秒内连续多次
awk '$9==504 || $9==200 {print $1, $4, $9, $7}' /var/log/nginx/access.log \
  | grep '/v1/chat/completions' | tail -50

更可靠的信号是把上游用量与自己记录的成功响应数对账:如果上游统计的请求数明显高于你返回给客户端的成功数,差额基本就是被白烧掉的重试。

相关阅读