凌晨两点手机连着震了三次。我迷迷糊糊点开告警某AI对话应用的后端监控弹出一行红色提示日账单费用较昨日上涨了183%。第一时间以为是模型服务涨价或者某个活动带来了流量激增。等早上打开计费明细看到一行行重复的request_id和一模一样的prompt计费记录时我才意识到——不是流量涨了是我自己写的超时重试逻辑在替我烧钱。这个坑不是个例。身边好几个做AI应用的朋友都踩过类似的调用大模型接口超时本能地重试一次结果服务端其实已经处理完了只是响应回来得慢。重试这一下等于同一笔请求付了两份钱。今天这篇文章就把我踩进去又爬出来的全过程摊开讲清楚包括重试计费的底层逻辑、SDK和网关里藏着的自动重试机制以及真正能兜住成本的幂等方案。1. 请求超时不代表服务端没干活1.1 那个让我“多付钱”的瞬间先说事故现场。我们的AI接在某个大模型开放平台上业务代码逻辑很简单调用接口拿到生成结果返回给用户。某天下午接口开始零星超时客户端偶尔报出Request timed out异常。我当时的处理方式是很多后端会做的“标准动作”捕获超时异常延迟300毫秒重试一次。代码上也就几行for attempt in range(2): try: result model_service.chat(prompt, timeout10) return result except TimeoutError: if attempt 0: time.sleep(0.3) continue raise然后账单就炸了。后来去平台后台拉了日志发现那批“超时”请求里相当一部分在服务端都成功生成了完整回复HTTP响应码是200。只是因为模型推理耗时超过了我们设置的10秒客户端超时阈值响应还没回到客户端客户端这边就先断开了连接。客户端断开服务端可不会把已经算出来的结果扔进回收站。计算发生了token消耗了计费自然也就记上了。然后我重试的那一次平台又重新算了一遍又记了一笔。1.2 一次请求的四段旅程要理解为什么“超时重试重复计费”得先把一次大模型接口请求的完整生命周期拆开看提交阶段客户端发起HTTP请求请求被路由到模型服务。排队阶段请求进入推理队列等待算力资源。高峰期这一步可能耗时好几秒。计算阶段GPU开始跑模型逐字生成内容。这是最耗时也最烧钱的阶段。响应阶段生成结束服务端把完整结果返回给客户端。超时发生在哪个阶段性质完全不同。如果在提交阶段超时比如连接都还没建立那服务端很可能确实没收到请求重试是合理的。但多数在线API超时发生在等待计算和响应的阶段——请求已经进去了GPU已经在吭哧吭哧算了只是没在客户端限定的时间内算完。这就像你在外卖平台点餐骑手跑了40分钟还没到你以为商家没接单又下了一单。结果半个多小时后两份一模一样的餐都送到了你还要付两份钱。1.3 为什么“超时失败”这个直觉在这里不成立普通HTTP接口超时通常可以近似认为服务器没处理完或处理失败因为大部分普通接口的响应就是毫秒级。但大模型推理是秒级甚至分钟级的耗时加上排队时间响应时间方差极大。今天的响应可能只要3秒明天同样的问题可能要30秒。把客户端超时阈值定成10秒本质上是给服务端立了个“10秒内必须回话”的规矩。但大模型服务不会因为客户端没了耐心就停止计算也不会因为响应没送达就不计费。所以真正要建立的第一条认知是超时是一个客户端视角的状态不是服务端视角的事实。在没查清服务端实际处理结果之前把超时当成失败去重试就是在赌——赌输一次多付一份钱。2. 计费点和你想的不一样算完就算钱2.1 token计费的本质消耗发生在服务端大模型接口的计费模型和传统API完全不同。传统API按“调用次数”计费服务器收到请求、返回响应算一次调用。大模型接口按“token消耗”计费——输入和输出的所有token加起来乘上单价。token在哪里消耗在服务端的GPU显存里。只要模型跑了一次token就消耗了账单就产生了。这和客户端有没有收到响应没有关系。平台方也有一套自己的账本。对平台来说它对一次请求的处理可以分解为接收请求、排队、推理、返回结果。计费点在推理完成那一刻——模型已经生成了完整的回复文本这笔钱就该收了。至于客户端有没有收到那是网络问题不是模型服务的问题。2.2 服务端实际发生的三种情况当客户端报“超时”时服务端其实可能处在三种状态服务端实际状态是否产生计费重试后果请求根本没有进入服务端网络中断/路由失败否重试正常无额外成本请求进入排队但还没开始计算通常否部分平台排队即计费重试可能产生第二次排队和计算请求已计算完成响应在返回途中丢失或超时是重试导致重复计费最亏最扎心的是第三种。尤其当模型输出很长、生成速度慢时服务端其实已经生成了大部分内容只是最后一段还没落地客户端等不及断开了。这种情况重试等于让服务端把同一段话重新生成一遍。2.3 部分平台其实提供了“失败不计费”的承诺写这篇文章前我特地去查了几家大模型平台的计费文档发现有些平台明确写了“请求失败不计费”或“HTTP状态码非200不计费”。听着像福利但这里有个文字游戏客户端超时不等价于服务端失败。平台判断“失败”看的是服务端有没有完成推理不是看客户端有没有等到响应。哪怕客户端已经超时断开只要服务端完成了推理、产出了结果对平台来说这就是一次成功调用照常计费。所以“失败不计费”这条规则并不能保护盲目重试的人。它能保护的是真正发生的服务端错误比如5xx、推理中断而不是客户端网络层面的超时。2.4 和传统接口“重试安全”的对照传统API世界里很多接口是幂等的或者至少重试的代价很小。比如查询接口重复请求顶多多花点带宽。但大模型生成接口是典型的非幂等接口——同一个prompt每次生成的完整响应都可能是不同的即使temperature为0在不同批次或版本下也可能有细微差异更重要的是每次都消耗全新的推理资源。重试的成本不再是“多了一次网络往返”而是“多跑了一次完整的模型推理”。一次推理的成本可能是几厘钱也可能是几块钱取决于模型规模和输出长度。把这么贵的操作当成免费重试的保险账是算不平的。3. 三个重试叠加你根本不知道付了几份钱3.1 SDK层的默认重试很多人没关过大模型平台的官方SDK大多内置了自动重试机制。以某常见Python SDK为例默认max_retries2遇到连接错误、5xx错误会自动重试。这个设计本身是为了照顾网络抖动但它和业务代码里的重试一叠加问题就来了。我在排查账单时发现我们用的Python SDK底层会自动重试一次而我又在业务代码里手动重试了一次网关层因为配置了高可用策略也会对超时请求重试一次。三个重试逻辑互相不知道对方的存在同一笔业务请求最多可能被发出3到4次。最典型的情况是这样的串联链路业务代码层重试1次 → 新请求发给SDK SDK层对连接异常默认重试2次 → 每个请求又可能重发 网关/负载均衡层对上游超时重试1次算下来极端情况下一个业务请求背后最多有4次甚至更多的真实模型调用。平时不出事一旦接口出现抖动层层的重试会同时被触发账单瞬间多出几倍。3.2 网关和代理层的重试最容易被忽略微服务架构里业务代码通过网关转发到大模型平台。很多网关尤其是基于负载均衡的配置默认对上游连接超时做重试。这个配置平时是为了保证高可用但在大模型场景里它成了重复消费的帮凶。网关层看不到业务语义它只知道“上游没在3秒内给我响应我就换一台重发一次”。它不知道这个请求对应的计算可能已经开始也不知道重发会造成重复计费。我们当时就是被网关层悄悄重试了几百次日志里全是重复的prompt。排查方法也不难在网关访问日志里按request_id分组看同一个请求是否出现了多次上游调用记录。3.3 业务代码里的“补一刀”是最危险的最危险的是业务代码层不加判断的重试。比如我开头写的那个例子catch TimeoutError就直接重发。这种代码逻辑上毫无防备一旦触发了一定会产生新请求。更隐蔽的是有些开发者会把重试包在“生成任务”里比如一个异步任务队列任务执行失败后就重新入队。表面看着没问题实际上同一个用户问题可能被队列重复消费了好几次每次消费都是一次完整的模型调用。3.4 一个真实账单的拆解重复率高达31%事故当天我们把计费明细和业务日志按request_id关联统计出一个触目惊心的数字当天重复计费的比例占到总调用量的31%。这些重复调用不是因为用户重复提问而是因为各种重试机制叠加产生的。拆开看里面有两类重复一类是客户端超时服务端已经算完重试导致二次计费另一类是排队时间过长网关先超时了但服务端最终把结果算完了网关重试的请求又给服务端加了一次计算量。事后我们做了个实验完全关掉业务代码重试、SDK重试、网关重试把超时时间从10秒拉长到30秒再看一天的账单费用直接降了40%左右。这个数字让我意识到之前花掉的钱有多少是白白送出去的。4. 幂等键从源头掐断重复计费的唯一正解4.1 幂等键是怎么工作的解决重复计费问题靠“不要重试”是不够的因为有些场景真的需要重试。正解是让每次业务请求拥有唯一身份服务端通过这个身份识别重复请求返回第一次的结果而不是重新计算。这个机制俗称幂等键。原理和支付系统里的防重令牌一样客户端生成一个UUID附在请求里发过去服务端记录这个UUID和对应的响应结果如果相同UUID的请求再次到达服务端不再重新计算直接把缓存的结果返回。对使用者来说效果就是重试一万次也只收一次钱。4.2 请求头里怎么传大模型的HTTP接口通常预留了幂等控制字段常见的有两种通用做法在HTTP头部加Idempotency-Key字段。有些平台兼容风格使用X-Request-ID字段。需要说明的是并非所有平台都支持幂等键。接入前要先看平台的API文档确认它支持哪种幂等控制方式。如果平台原生不支持可以自己做一层幂等代理。用Python的requests库举例import requests import uuid payload { prompt: 写一篇关于成本控制的短文, max_tokens: 500, } idempotency_key str(uuid.uuid4()) headers { Authorization: Bearer your_api_key, Idempotency-Key: idempotency_key, } resp requests.post( https://your-model-api.example.com/v1/completions, jsonpayload, headersheaders, timeout30, )4.3 幂等键的一生生成、传递、命中幂等键不是随便传一个固定值就行要理解它完整的生命周期生成每次业务请求生成唯一ID通常用UUID也可以用“业务订单号用户ID请求序号”拼出来。关键是全局唯一。传递随第一次请求发送到服务端。记录服务端发现这个ID是新的正常执行计算然后把完整响应内容和ID一起缓存起来缓存时间一般是几分钟到24小时。命中如果客户端因为超时重试第二次请求带着同一个ID进来服务端在缓存里找到了直接把第一次的响应结果返回不再触发模型计算。失效超过缓存窗口期后相同ID再次请求会被当作新请求处理。这里有个细节容易踩坑幂等键相同但请求内容不能变。有些平台会校验“同ID不同参数”的请求直接拒绝有些平台则可能当作新请求处理幂等等于失效。所以如果业务场景里重试时需要改参数幂等键也要跟着参数变化重新生成。4.4 自己封装一个带幂等键的重试函数实操中我建议把幂等重试封装成一个独立的组件避免到处散落重试逻辑。下面是一个简化的Python封装思路import requests import uuid import time def call_with_idempotent_retry(make_request_func, max_attempts3): key str(uuid.uuid4()) last_exception None for attempt in range(max_attempts): try: return make_request_func(idempotency_keykey) except TimeoutError as exc: last_exception exc # 超时后先别急着重发短暂退避给服务端时间返回缓存结果 time.sleep(2 ** attempt) except requests.HTTPError as exc: # 5xx可以重试4xx别重试 if exc.response.status_code 500: last_exception exc time.sleep(2 ** attempt) else: raise raise last_exception注意两点。第一每次重试都带同一个idempotency_key这是它起作用的根本。第二退避时间不能用太短的固定值。服务端已经算完但响应还在路上的场景退避1到3秒往往就能让第一次的响应结果先回到客户端重试请求到达服务端时直接命中幂等缓存一分钱都不会多花。4.5 自建服务的幂等网关方案如果你用的是开源自建的模型服务比如基于vLLM或TGI部署的推理服务思路更直接——在推理服务前面挂一层幂等网关。常见做法是Redis做缓存请求进来时计算请求参数的哈希加上请求ID作为key查Redis如果存在就直接返回缓存内容不存在就让请求通过推理完成后把结果写回Redis设置TTL比如10到30分钟。这样即使推理服务偶尔超时网关也能兜住重复计费问题。这个方案实现成本不高效果却非常直接尤其适合自建服务的团队能彻底摆脱“重试就多花钱”的困境。5. 分清哪些错误值得重试哪些重试就是送钱5.1 先看一张错误分类表不是所有错误都需要重试。不同错误背后的含义不同重试的收益和代价也天差地远。我整理了一份决策表供参考错误类型典型表现服务端可能发生了什么要不要重试连接错误无法建立TCP连接/DNS解析失败请求大概率没到服务端值得重试5xx服务端错误HTTP 500/502/503服务端处理失败或过载可重试需控制次数429限流HTTP 429 Retry-After响应头请求被限流排队中按Retry-After等待后重试客户端超时连接阶段请求发出后connect阶段超时服务端可能没收到可尝试重试客户端超时读取阶段等待响应超时服务端可能已算完先查状态别直接重试4xx客户端错误400/401/403/404请求本身有问题不要重试判断核心就是一条请求有没有真的到服务端并触发计算。如果无法判断优先用幂等键配合重试而不是盲重重发。5.2 超时和超时还不一样连接超时 vs 读取超时超时异常也要细分。连接阶段超时和服务端计算阶段超时的处理策略截然不同。connect timeout表示建立连接失败请求大概率没进入服务端重试的代价很低。read timeout表示连接建立成功、请求已经发出去只是在等待响应时超时——这个超时包含的变数就大了服务端可能还在跑推理也可能已经跑完只是响应回来得晚。读取超时的重试必须带上幂等键。大多数语言和库都支持分别设置连接超时和读取超时。比如Python的requests库timeout参数可以传一个元组(connect_timeout, read_timeout)。建议连接超时不要太长比如3到5秒读取超时要给足空间比如30到60秒大模型场景尤其要给足。5.3 先查后补用请求ID核对真实状态当读取超时发生时比直接重试更稳妥的做法是先调用平台的“查询请求状态”接口拿到这次请求的真实处理结果再决定下一步。很多大模型平台提供按请求ID查询结果的能力。流程变成发起请求前生成request_id随请求发出。发生读取超时后调用查询接口传入request_id。如果查询结果显示“处理完成”直接用查询到的结果不重发。如果查询结果显示“未处理”或“处理失败”才重新发起请求。这个方案比单纯靠幂等键更保险尤其适合结果生成时间不固定、超时频繁的场景。缺点是依赖平台提供查询能力且代码稍微复杂一点。5.4 重试参数别拍脑袋退避、上限、超时一起调重试不是“有就行”参数设计直接影响成本和效果。我常用的一组参数给个参考超时时间读取超时建议30秒起步最长可以到60秒。大模型输出长文时响应时间很容易超过10秒。重试次数不要超过2到3次。再多边际收益很低反而会放大重复消费风险。退避策略指数退避比如1秒、2秒、4秒每次加一点随机抖动jitter否则大量重试会在同一秒涌进服务端加重拥堵。总时长限制整个“超时重试”的循环控制在2分钟以内超过就放弃返回错误给用户。一句话总结这部分的经验宁可让用户多等一下也不要让系统多算一次。等待的成本为零算错的成本是账单上的真金白银。6. 从接入到上线一份省钱调用检查清单6.1 接入前把计费文档当成必读需求文档很多团队接入大模型接口时只看鉴权方式和请求参数不看计费规则。这一步省不了。需要确认的关键点至少有计费单位是什么按token还是按请求失败请求到底计不计费文档里的“失败”是怎么定义的是否支持幂等键/幂等请求头是否提供“按请求ID查询结果”的API排队时间超时算谁的有没有排队超时的单独策略这些信息直接决定你的重试策略怎么设计。文档没写清楚就问客服客服不清楚就用最小cost的实验测一把不要用生产流量去试探。6.2 开发中把幂等键纳入接口规范接入代码一旦写起来幂等逻辑最容易漏。建议把“调用大模型接口必须带幂等键”写进代码评审检查项里和“必须记录日志”一样成为硬性规范。如果团队有统一的API封装层尽量把幂等键逻辑收敛在封装层内部。这样业务同学调用时不需要关心幂等底层自动生成和传递。等发现重复计费问题时排查范围也更小。6.3 上线后账单金额除以调用量的比值要盯住压垮我们的不是“偶尔重试”而是“重试率在一个没人注意的地方悄悄上涨”。上线后建议盯一个简单指标单次调用平均成本。算法是单次调用平均成本 账单总金额 / 业务层实际成功次数正常情况这个值应该在模型单价附近小幅波动。如果某天开始这个值明显上升大概率是出现了重复消费或无效消费。配合request_id去重统计能快速定位重复率。每天跑一个定时任务把当天的调用日志和计费明细做一次关联统计重复调用比例超过阈值就告警。这个任务看起来不起眼但它能省下的钱比你调优prompt省下的钱多得多。6.4 成本观测一屏看出有没有多付钱最后分享我们改善后的可观测性做法。核心是三类指标调用分布按模型、按接口、按业务场景分类的调用量和费用。重试与重复按request_id聚合后重复出现的次数和比例、超时触发的重试次数。异常消耗账单中关联不到业务请求的费用以及超出预期的单次峰值消耗。不需要复杂的系统ELK或PrometheusGrafana就能实现。关键是建立了“账单异常上涨”和“重试率变化”之间的关联指标一旦异常能快速定位是流量涨了、价格涨了还是逻辑写错了。如果有团队在看板里加一个“幂等命中率”指标更好——它直接反映你的重试请求有多少被服务端拦截并复用命中率越高重复计费风险越低。踩过这次坑我个人最深的体会是大模型接口的重试写起来越顺手越危险。重试这个动作本身不贵贵的是它背后可能触发的重复推理。接入任何一个AI接口前先把幂等机制和计费规则搞明白比优化网络、调优超时都更重要。代码可以慢慢重构账单可是第二天就要付的。