多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

重试机制才是省token的最大黑洞,如何设计调用层防成本失控?

重试机制才是省token的最大黑洞,如何设计调用层防成本失控? 省 token 这个事我问过不少做 AI 应用的朋友十有八九都拍着胸脯说我一个月把 token 成本砍了 40%。但你再追问一句你失败重试一次会多花多少钱大多数人会愣住。这个愣住就是问题所在大家把精力全放在 prompt 压缩上却忘了重试机制才是账单上的隐形炸弹。我见过一个很典型的案例某团队把 system prompt 从 800 字砍到 200 字历史消息从 10 轮压成 3 轮摘要单次请求输入 token 从 5000 降到 3000成就感爆棚。结果上线第一天一次网关超时触发自动重试同样的请求原封不动又发了一遍输入 token 直接变 6000把省下来的那 2000 token 全赔回去还不够。这不是段子是每天都在发生的账。这篇文章不聊怎么把 prompt 压得更狠只聊一件事你省 token 省得很开心的时候重试机制是怎么把利润全吞掉的以及怎么设计一套不会让 token 预算失控的调用层。1. 一场典型的省 token 翻车现场先看账怎么算1.1 一次超时为什么等于两次完整计费先还原一个很常见的场景。你做了一个 RAG 问答机器人用户问了一个超长问题你的代码把检索到的上下文、聊天历史、工具定义全部拼进 prompt拼出来 6000 token 的输入请求。请求发出去网关没有在预期时间内返回触发了你写好的重试逻辑于是客户端立刻把同样的 6000 token 又发了一遍。表面上看这次交互只是一次失败 一次成功实际账单呢至少是两笔输入费用甚至可能更多。为什么因为很多 API 的计费规则是服务端收到请求并开始处理就算钱而不是响应成功返回才算钱。第一次请求如果已经到达模型服务、已经开始解析 prompt那么哪怕最后超时了、响应没回到你手里那 6000 token 的输入已经进了账单。重试又发了 6000 token于是输入侧就是 12000 token 的费用比正常情况多了整整一倍。更惨的是输出侧。如果第一次请求其实已经在服务端生成了 800 token 的结果只是回传超时你重试后模型又生成了 800 token那么输出侧也可能被算两遍。我不是在吓唬你我见过真实的账单里一次超时重试导致某次调用的总 token 消耗是正常情况的三倍。你的 prompt 优化再猛也很难抵消这种倍率。1.2 失败请求不是没产生费用而是生效但没人接收很多人心理上默认一个错误的等式请求失败 服务端没干活 不扣 token。在本地调用一个函数、写个脚本这个等式成立但调云端 LLM API 不是这个逻辑。云端模型的计费判定点通常发生在请求被服务端接受、开始进行推理的时刻它不会因为你客户端超时就把这笔推理退款。打个比方你在餐厅点了菜后厨已经下锅了但你等不及走了还去隔壁重新点了一份一样的。厨房不会因为你人走了就不收钱两桌菜的成本都算在你头上。模型推理这件事同样不可撤销你收到的超时往往是结果回传超时而不是服务端没算。真正的请求根本没到服务端只发生在连接建立失败、DNS 解析失败、TLS 握手失败这类阶段。但很多重试库没有区分这两类情况一律无脑重发这就给账单埋了一颗大雷。我自己后来养成了一个习惯看 token 用量报表时会把成功请求的 token 消耗和失败后重试的 token 消耗分成两个字段统计。不看不知道一看才发现有些项目的重试消耗能占到总 token 的 15% 到 30%。如果这些项目之前还拼命压 prompt那真的是省小钱、亏大钱。2. 重试为什么会吞掉这么多 token三种隐性重复计费2.1 重发完整 payload输入 token 原样再扣一遍这是最直接的重复计费。你的请求体里如果带着完整上下文——历史对话、检索结果、工具定义、system prompt——那么每次重试都会把这整包内容重新发送、重新计费。重试 N 次输入 token 就是 N1 份算上原始请求。有人可能想那我重试的时候把历史对话砍掉一部分不就省了吗这恰恰是另一个坑。我在后面第四章会详细讲重试时的请求体最好是完全幂等的也就是字段、顺序、内容跟原请求保持一致。一旦你在重试时改动了 prompt比如重新压缩历史消息、重新生成时间戳、临时去掉某个工具定义后果是缓存命中率下降、响应分布变化甚至业务逻辑出错。为了省 token 去改重试请求通常只会让问题更复杂。正确的省法是在一开始设计 prompt 的公共部分时就把 system prompt、工具定义这些高频固定内容放到请求体的固定前缀位置让服务端的上下文缓存机制能够命中。重试时保持原样靠缓存折扣来降低重复计费的价格而不是靠删内容。2.2 超时后服务端已经开跑输出 token 被算两遍的风险前面讲到服务端可能已经完成了部分甚至全部生成只是响应回传失败。这种场景下重试会导致输出 token 双算。很多 SDK 的超时时间默认很短比如 10 秒而一些复杂的 agent 请求可能需要 60 秒甚至更长才能完成一次完整推理。你设了 10 秒超时模型第 11 秒生成完了客户端已经进入重试流程。服务端那笔输出照记新请求又开始一轮新的生成。如果你接的是流式接口情况还要微妙一点。SSE 流可能已经传了一部分内容客户端因为某一段网络抖动断开了流这时重试的话已经收到的部分和重试重新生成的部分都会计算 token。而且大模型的输出没有幂等性同样的 prompt 重试两次生成结果可能完全不同你不能指望重试得到一份更好的答案只能得到一份更贵的账单。要避免这种情况超时阈值必须根据模型的实际推理时长来设计而不是拍脑袋定一个 5 秒 10 秒。我见过一个项目调的是比较重的模型平均完成时间 40 秒超时却设了 15 秒导致高峰期几乎每次都触发重试token 花费直接翻了几倍。这就是典型的重试设计没跟上模型特性。2.3 并发翻倍、限流升级、长上下文二次展开连锁反应才是大头比单次双算更可怕的是重试引发的系统性连锁反应。当你在高并发场景下统一重试时失败请求会和新请求叠加在一起形成短暂的并发尖峰。服务端本来是因为过载才给你 429 或 5xx你的重试又给它踩了一脚油门结果就是更严重的限流然后更多请求失败更多重试最后触发熔断。这一轮下来token 的额外消耗不是简单的两倍三倍可能是整段时间窗口内的全部请求都被迫重试成本按小时计都在暴涨。另一个隐藏点是长上下文在重试时的二次展开。比如一个 agent 应用每轮工具调用都会重新拼接完整对话历史一次任务里调 8 次工具每次的输入都包含前面所有轮次的输出。如果其中某一轮调用失败触发重试那么从这一轮往后所有后续轮次都会在更长的上下文基础上重新计算。这就像滚雪球重试的不只是一次请求而是把整条推理链后面的上下文长度都撑大了。有些 optimizer 还喜欢把历史消息每次全量重新编码不做增量缓存遇到重试这个缺陷会被放大得非常明显。顺便说一个不花钱但花时间的版本本地部署开源模型比如你在 6GB 显存上跑一个量化过的 27B 模型表面上 token 免费但模型加载和 KV cache 的分配都在显存里一次请求失败后如果你在代码里做了简单重试往往要先把之前的结果丢弃、重新加载权重、重新处理整个输入。这里赔的不是 token是延迟和 GPU 利用率。所谓token 自由是有代价的很多人没算这笔账。3. token 失效、403 和 refresh_token重试陷阱的另一半3.1 鉴权 token 和计费 token 是两回事但它们的坑会叠加这里要先分清两个token。一个是 OAuth/OIDC 体系里的 access_token用来证明你有权限调用这个 API另一个是大模型计费里的 token是文本长度的计量单位。这两个东西经常在同一段代码里出现但它们的失效机制完全不同access_token 会过期刷新失败会导致 401、403计费 token 没有失效概念只有数量高低。问题在于当 access_token 失效时你的 API 客户端通常会尝试刷新 token 并重放原始请求。这个重放不会因为只是刷新了凭据就变便宜LLM API 请求体里有多少 token重放照样按多少 token 算。所以你会看到一种很冤枉的账单请求失败原因是权限过期失败本身没消耗多少但自动 refresh 之后重试的请求把整段 prompt 重新计费了一遍。我见过一个更隐蔽的案例某个 agent 框架会把 access_token 存到上下文里算子调用时把 token 字符串拼到工具参数中结果 token 刷新后旧 token 仍然留在历史消息里重试请求带着一整串过期的凭据信息发出去既浪费计费 token又增加了凭据暴露面。这是设计上的双重失误但很常见。3.2 403 token exchange failed这类错误重试一百次也是白烧热搜词里反复出现sign-in could not be completed token exchange failed: token endpoint returned status 403 forbidden这类问题在接入企业身份提供商或 SSO 登录时非常典型。token exchange 这一步通常发生在登录阶段它和 LLM 调用还隔着一层。它的 403 往往意味着认证策略层就拒绝了这次交换比如国家/地域限制、客户端配置错误、授权码已用、redirect_uri 不匹配。对于这类错误最忌讳的就是在用户点击登录时自动无限重试。因为错误根源不在服务端临时故障而在客户端配置或用户会话状态。重试只会让用户看到持续转圈让认证服务器多接几次无效请求如果你在登录成功后才开始调 LLM API那还谈不上一开始的 token 消耗但如果你的登录流程里包裹了其他调用或者用户已登录后 access_token 刷新失败然后你用重新登录 重放业务请求的粗暴方式兜底业务请求的重放就会烧掉 LLM token。正确做法是分类处理。401 Unauthorized和403 Forbidden必须立刻停止重试进入重新认证或提示用户流程只有那些429 Too Many Requests、5xx Server Error、连接超时、临时网络抖动才值得走重试队列。3.3 refresh_token 为空字符串这类 Bug重试多少次都不会成功再看另一个热搜词failed to refresh token: 400 bad request: invalid refresh_token: empty string. expected a string with minimum length 1, but got an empty string instead.这个错误有意思它明确告诉你 refresh_token 是个空字符串。这意味着什么意味着你根本没有拿到有效的 refresh_token可能是初始授权流程没走完可能是存储层把空值写进去了也可能是并发登录时两个请求互相覆盖了凭据。这种错误和无脑重试是绝配。很多封装库的逻辑是调用 API 遇到 401 - 调用刷新逻辑 - 刷新失败 - 再刷新 - 再失败。如果刷新失败的原因是 refresh_token 本身为空那你重试一千次也拿不到新 token因为问题出在凭据状态不是网络瞬时故障。这段期间如果业务逻辑还在不断重发 LLM 请求每一发都是带完整 prompt 的付费调用但每一发都会在鉴权层被拦下来。更麻烦的是有些请求会在提示词里包含用户输入每次重试都会重新计费输入 token哪怕模型根本没机会真正生成答案。所以我在代码里会把错误分成两类一类是重试可能有效的一类是重试必定无效的。重试必定无效的错误应当直接抛出并进入人工处理或者状态修复流程而不是放进重试循环。判断依据不复杂去看看这个错误是不是因为你自己的代码状态坏了。自己状态坏了修好状态再重放才有意义自己状态没问题、服务端抖动才用指数退避重试。4. 设计一个舍不得让你赔钱的调用层重试与预算策略4.1 把可重试错误和不可重试错误写进代码而不是写在文档里第一步不是在代码里加try...except而是先建立一张错误分类表。不同 API 的错误类型略有差异但通常可以这样分错误类别典型场景是否值得重试重试策略429限流、并发超限可以重试但必须退避指数退避 抖动参考 Retry-After 头500/502/503/504服务端瞬时故障、网关错误可以重试指数退避最大 2~3 次连接超时 / 连接重置网络抖动、代理不稳定可以重试短退避但注意超时阈值要合理400请求参数错误、prompt 格式非法不重试检查参数401/403access_token 失效、权限不足不重试刷新凭据后重放一次最多一次404模型名错、路由不存在不重试检查 API 配置409状态冲突、并发写冲突视业务而定一般不上自动重试400 refresh_token 为空会话状态损坏绝对不重试进入重新登录流程我把这套分类直接做成一个should_retry函数而不是靠每个人记文档RETRYABLE_STATUS_CODES {429, 500, 502, 503, 504} CONNECTION_ERRORS (TimeoutError, ConnectionError, APIConnectionError) def should_retry(error) - bool: if isinstance(error, (TypeError, ValueError, KeyError)): # 自己代码问题重试没有意义 return False if isinstance(error, CONNECTION_ERRORS): return True if hasattr(error, status_code): return error.status_code in RETRYABLE_STATUS_CODES return False这个函数是整个重试策略的地基。没有这个分类后续所有手段都是空中楼阁。我见过太多项目直接for attempt in range(3)然后无差别重试这种代码在 happy path 上没问题一出事故就变成 token 粉碎机。4.2 指数退避是重试的底线而不是可选项如果你已经确认这个错误值得重试那下一步就是怎么控制重试节奏。最粗的做法是立刻重试或者固定 1 秒后重试。在低并发场景下可能没事但一旦流量上来立刻重试会撞在服务端故障的枪口上所有客户端同时重试打出一波远超原峰值的请求洪峰。指数退避的核心思想是每多一次失败就把等待时间翻倍并且加上随机抖动避免重试请求在时间上扎堆import random import time def backoff_delay(attempt: int, base_seconds: float 0.5, max_seconds: float 8.0) - float: delay min(max_seconds, base_seconds * (2 ** attempt)) jitter random.uniform(0, delay * 0.3) return delay jitter加上抖动是因为如果 100 个客户端都严格按照0.5s - 1s - 2s退避它们的重试时间点依然会重叠抖动就是给这个共振加一点噪声。你可以看到重试策略里最难的不是代码而是对何时该放弃的把握。我的经验是LLM API 调用通常最多重试 2 次也就是总请求数最多 3 次。再往上边际成功概率极低边际成本却很高尤其是在你用的是大上下文模型时。3 次请求每次都是完整输入这个成本多数业务扛不住。4.3 预算熔断与 token 拨备给重试留出明确的位置单次请求的 token 消耗好算难的是你没法预知今天会不会出故障。所以我习惯在调用层做两道保险。第一道是重试拨备。在做 token 预算规划的时候不要按理想成功率来计算。如果你平时请求失败率是 5%那么预算至少要留出 10% 到 15% 的余量给重试和降级。很多团队做成本预估时只用单次成功请求的 token 数 × 调用次数然后拿着这个数字去要预算一旦出现故障周实际账单超出预期就只能砍功能或砍模型。按我经验把重试拨备写进预算模型里比事后解释超支要省心得多。第二道是滑动窗口熔断。我推荐在调用层维护一个 token 用量计数器按分钟和小时两个粒度统计。当单位时间的 token 消耗超过阈值时不再发起新请求而是走降级逻辑。这比单纯依赖 API 返回 429 再被动退避更主动class TokenBudget: def __init__(self, limit_per_hour: int): self.limit limit_per_hour self.consumed 0 def can_request(self, estimated_tokens: int) - bool: return self.consumed estimated_tokens self.limit def record(self, tokens: int): self.consumed tokens事先估算请求 token 并不难输入 token 可以提前统计输出 token 给个上限预估即可。熔断的阈值可以设为预算的 80%剩下的 20% 留给正在途中的请求和最后的收尾清理。一旦熔断触发宁可让这次请求失败返回友好提示也不要让它在重试循环里把预算烧穿。记住一个原则重试的目的是让有价值的请求成功不是让失败请求永远不落地。4.4 把缓存落在请求结构里让重试命中缓存而不是全价计费很多主流 API 对重复的输入前缀有提示词缓存机制命中缓存后这部分 token 会有折扣价甚至大幅降低。这里面有一个关键点很容易被人忽略缓存能否命中取决于重复请求的前缀是否完全一致。也就是说你的 system prompt、工具定义、公共指令这些固定内容必须放在 prompt 的最前面中途绝对不要插入随机值。很多人为了调试方便会把时间戳、随机 request_id 生成一个字符串拼在 prompt 某个位置或者每次重试时重新压缩历史消息摘要这都会让缓存前缀失效。重试时如果缓存全部落空那重试请求就是全价重算损失更大。我自己的做法是system prompt 和全局工具定义放在 prompt block 的最前面内容永远不变量用户问题、检索结果这类每次不同的动态内容放在固定内容之后重试时保持请求体与原始请求完全一致不因为重试而重新组织 prompt。这样安排即使失败了重试的公共部分大概率能命中缓存输入侧的成本从两份全价变成一份全价 一份缓存折扣差距非常可观。4.5 失败降级优先于无限重试最后一步也是很多人最容易忽略的不是所有请求都值得重试成功。你要给调用层定义一个降级路径。当一个请求在第一次失败后第二、第三次还失败这时候正确的动作不是继续消耗 token而是降级。举几个实际的降级例子如果调用的是一个昂贵的超大模型失败后可以降级到一个小模型重新尝试输出质量略降但 token 成本可能只有原来的十分之一而且大概率不触发同样的限流如果是非核心的摘要、打标签功能失败后可以直接返回空结果或默认文本让主流程继续没必要为一个辅助功能耗尽重试预算如果是交互式问答失败后可以给出服务端繁忙请稍后重试的提示把重试决策交给用户而不是让系统在后台自动烧 token。降级和重试是配合使用的第一次重试用指数退避第二次重试换小模型第三次直接放弃走兜底。这套阶梯式策略既保证了核心请求的成功率也控制了最坏情况下的 token 消耗。我见过太多团队把降级做成if error: return None却忘了返回 None 之后业务逻辑可能产生更诡异的行为。降级路径的返回值设计也要像主路径一样仔细保证下游能接收到一个合理但弱化的结果。5. 实测账本省下的和赔掉的到底谁说了算5.1 用真实价格算一笔请求级流水账光讲道理不够我拿一个实际的定价模型算一遍。假设你用的是按输入和输出分开计费的 API输入 token 单价 1 元/百万 token输出 token 单价 3 元/百万 token。一个典型的业务请求优化前输入 3500 token输出 800 token单次成本输入3500 / 1_000_000 × 1 0.0035 元输出800 / 1_000_000 × 3 0.0024 元单次合计0.0059 元优化后你把 prompt 压到输入 2000 token输出不变单次成本输入2000 / 1_000_000 × 1 0.002 元输出800 / 1_000_000 × 3 0.0024 元单次合计0.0044 元看起来省了 25% 左右对吧现在我们把重试加进来。假设请求失败率 5%每次失败后自动重试一次重试请求完整重发。优化后的实际期望成本是0.0044 × (1 0.05) 0.00462 元再考虑一种更常见的情况超时导致服务端已部分计费失败的那次请求虽然没返回结果但输入侧的 2000 token 已经被计费然后在重试里又产生一次正常成本。单次期望成本变成0.0044 0.05 × (0.002 0.0044) 0.0044 0.05 × 0.0064 0.00472 元如果失败率是 10%重试一次那么成本变成 0.0044 × 1.1 0.00484 元。再看优化前的无重试成本 0.0059 元好像还是省了。但别急重试往往会带来更长的输出。失败重试的请求因为模型重新生成输出通常不会恰好等于第一次的 800 token很多情况下会更长比如 1200 token。以失败率 10% 计算新增成本就变成 0.05 × (0.002 0.0036) 0.00056 元单次期望 0.00496 元。如果失败率到 15%重试一次的期望成本已经到 0.00544 元逼近你没做任何优化的数字。5.2 一百次请求的账本对比重试率比 prompt 长度更致命我把四种情况放到 100 次请求的规模上看会更直观。下表按输入 1 元/百万 token、输出 3 元/百万 token、每次输出 800 token 计算场景单次输入 token单次输出 token失败重试率100 次请求总成本优化前无重试35008000%0.59 元优化后无重试20008000%0.44 元优化后重试一次20008005%0.462 元优化后重试一次200080010%0.484 元优化后重试一次并叠加失败部分计费200080010%0.516 元优化后重试两轮200080010%0.5324 元你看当重试率达到 10% 并算上服务端部分计费后你辛苦压 prompt 省下来的 0.15 元被重试机制吃掉了一大半。如果重试两轮基本等于白干。更不用说那些失败输出更长、上下文缓存又没命中的场景翻车几乎是必然的。这告诉我们一个反直觉的结论prompt 优化的空间是有上限的通常也就 20% 到 40%但重试机制的蝴蝶效应是没有下限的可以把单次成本打到 2 倍、3 倍甚至更高。所以在成本治理上重试策略的优先级应该排在 prompt 压缩前面。先把不该重试的不重试、该重试的按指数退避、重试有预算熔断、失败有降级路径这四件事做齐再去抠 prompt 的字数才是正确的顺序。5.3 报表里必须能区分有效 token和灭火 token前面算的账最终都要靠数据来验证。我强烈建议你在所有 LLM 调用里埋点至少记录这些字段请求时间、模型名、输入 prompt_tokens、输出 completion_tokens、total_tokens、HTTP 状态码或错误类型、重试次数、request_id、业务标签。有了这些数据你会发现一个特别有用的分析维度把 token 消耗按是否发生在重试路径上切成两堆。我习惯给报表加两个指标一是有效 token 占比 成功请求的 token / 总 token二是重试放大系数 总 token / 成功场景下的理论 token。有效 token 占比越接近 100%说明你的调用层越干净重试放大系数大于 1.2说明重试策略已经失控。我见过一个项目有效 token 占比只有 71%也就是三个 token 里有一个是浪费在失败重试上的这个项目再怎么压 prompt 也救不回来因为病根在调用策略不在提示词长度。排查的时候把命中429、5xx、超时的请求按小时聚合画个趋势图再和 token 消耗趋势画在一起你会发现它们长得几乎一模一样。这就是灭火 token的真实形态。看到这个图形之后任何人都会明白先治重试再谈省 token。6. 最后一点个人体会写了这么多其实我最想说的是一个顺序问题很多人把省 token当成一个 prompt 工程问题整天研究怎么压缩上下文、怎么改写提示词但刷起账单来看真正让成本失控的往往不是 prompt 太长而是重试、超时、鉴权失败这些稳定性问题。prompt 优化省的是乐观情况下的钱重试策略省的是故障情况下的底裤。我自己的项目经验是先建好错误分类和重试策略再谈压缩 prompt。压缩 prompt 是锦上添花重试策略是保命底线。另外有一个很土但很有效的办法别只在脑子里想象重试成本直接把上游服务改成每 3 次请求故意挂掉 1 次的故障模式跑到一套压测环境里盯一天 token 用量曲线。你会非常直观地看到哪部分代码在真正烧钱。这个故障注入习惯比开一百次会议都有用。再分享一个最近养成的习惯每次上报 token 用量的时候都把重试次数和错误码一并带上。这个改动很小但它让省 token这件事从一个模糊的方向感变成了可以逐条核对的数据。后来我能在一个下午里找出三个隐藏的重试黑洞靠的就是这种报表。希望这篇内容能帮你在下一次看账单的时候先想到重试再想到 prompt 长度。
返回列表