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 的默认配置:
- 有人手工加了
non_idempotent——一些"提升可用性"的高可用教程会教这个,在普通 Web 服务上无害,在按 token 计费的接口上是直接烧钱。 - 客户端 SDK 自己在重试——这是绝大多数情况的元凶。OpenAI 官方 Python SDK 的
max_retries默认是 2,重试触发条件包含连接错误、408、409、429 和所有 5xx。nginx 吐的 504 正好落在 5xx 里。
双倍计费是怎么发生的
流式请求几乎不会撞上这个问题
这是很多人不知道的一点,也是"默认就该用流式"的一个纯运维理由。proxy_read_timeout 的官方定义是:
该超时只作用于两次连续读操作之间,而不是整个响应的传输时长。
所以:
- 非流式请求:从发出到收到完整 JSON,中间是一个长长的空隙。生成花 75 秒,就是一个 75 秒的读间隔 → 撞上 60 秒超时。
- 流式请求:token 一个接一个到达,每两次读之间只隔几百毫秒 → 无论总时长多久都不会触发超时。
正确的超时分层
关键是区分"连不上"和"生成慢":前者应该快速失败,后者必须耐心等。用一个统一的大数字把三个超时都拉长,是把两种完全不同的故障混在一起处理。
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 "";
}
客户端侧要做的事
服务端配对了,客户端如果还在盲目重试,问题只是变少而不是消失。
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
更可靠的信号是把上游用量与自己记录的成功响应数对账:如果上游统计的请求数明显高于你返回给客户端的成功数,差额基本就是被白烧掉的重试。
相关阅读
- 反代把大模型 API 的 SSE 流式响应缓冲了:五个缓冲点与三层排查法 —— 本文说流式不容易超时,前提是流式真的没被缓冲掉
- Caddy 还是 nginx 给 API 中转做反代:证书自动化是决定性差异 —— 另一类会让全部请求一起失败的运维故障
- new-api 部署选型:2G 小内存机器上裸二进制比 Docker 省多少 —— 超时配置所在的那层反代怎么部署
- 给 API 中转站选数据库:SQLite / MySQL / PostgreSQL 在小机器上的取舍 —— 本文说的"拿用量日志对账",那张表本身也会变成问题