仅预算成功调用是自欺欺人;重试是第二次计费而非免责,墙钟等待、队列占用都是真实成本,需纳入容量规划。
你只为成功调用做预算的那一刻,免费容量就在欺骗你。一个重试、后退、重新进入队列的 Agent 并不是在获得一次免费重来。它是在为第二张发票买单、在熟食柜台买第二张票,而第一个三明治仍然从未出现。
你早已熟悉快乐路径的算法。一条 prompt、一些完成令牌、一个可以接受的延迟。那份电子表格令人安心。但它同样也是不完整的。真正烧掉你一下午的路径是:模型超时、工具调用返回 500,或者免费端点把你排在一个你看不见的人群后面。
重试本身就是一种容量。把它当容量来处理,否则它会把你的日历当建议。
那张你没有逐项列出的账单
想象一条结账队伍,每次收银员掉了一个袋子就把你踢回队尾。你并没有多拿杂货,但你仍然多花了时间。如果这家店还对每次扫描尝试收费,你就还多花了钱。当重试无界且队列是共享的时候,LLM 端点的行为就像那家店。
令牌只是其中一个计量器。挂钟等待时间是另一个。在别人队列中的占用时间是第三个。在周末的玩具项目上你可以忽略其中两个计量器。但对于一个有合并窗口、有客户演示、或者有一个批次必须在早高峰前完成的工作,你不能忽略它们。
失败调用并非免费的——即使标价是零。它们消耗速率限制配额、连接槽位、日志量,以及注意力。如果你的 Agent 陷入循环,每次循环都会放大这次失误。这就是重试税:预期令牌数和预期等待分钟数,基于失败路径而非宣传路径。
一个你可以实际运行的小估算器
不要在 Slack 里争论这个。用价格说话。下面的脚本是一个可运行的示例,不是生产基准,也不是对任何厂商硬件的声明。填入你实测的数字。如果你还没有实测过,输出的是一个假设,不是预算。
#!/usr/bin/env python3
"""retry_tax.py — expected tokens and wait under a retry policy.
Label: unexecuted template. Replace LATENCY_S, TOKENS, and P_FAIL
with values from your own traces before you trust the printout.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class CallShape:
tokens_per_attempt: float
latency_s: float
p_fail: float # independent per attempt; refine if your failures cluster
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int
backoff_s: float # constant delay between attempts; swap for exp if needed
queue_wait_s: float # extra wait before each attempt reaches a worker
def expected_cost(shape: CallShape, policy: RetryPolicy) -> dict[str, float]:
if not 0.0 <= shape.p_fail <= 1.0:
raise ValueError("p_fail must be in [0, 1]")
if policy.max_attempts < 1:
raise ValueError("max_attempts must be >= 1")
p_ok = 1.0 - shape.p_fail
tokens = 0.0
seconds = 0.0
p_still_going = 1.0
p_success = 0.0
for attempt in range(policy.max_attempts):
tokens += p_still_going * shape.tokens_per_attempt
seconds += p_still_going * (policy.queue_wait_s + shape.latency_s)
if attempt < policy.max_attempts - 1:
seconds += p_still_going * shape.p_fail * policy.backoff_s
p_success += p_still_going * p_ok
p_still_going *= shape.p_fail
return {
"expected_tokens": tokens,
"expected_seconds": seconds,
"p_eventual_success": p_success,
"p_hard_fail": p_still_going,
"attempts_budgeted": float(policy.max_attempts),
}
def hourly_drag(expected_seconds: float, loaded_hourly_rate: float) -> float:
"""Turn wait into money using YOUR loaded rate, not a vendor quote."""
return (expected_seconds / 3600.0) * loaded_hourly_rate
if __name__ == "__main__":
shape = CallShape(tokens_per_attempt=2_400, latency_s=8.0, p_fail=0.22)
generous = RetryPolicy(max_attempts=4, backoff_s=3.0, queue_wait_s=12.0)
tight = RetryPolicy(max_attempts=2, backoff_s=1.0, queue_wait_s=12.0)
g = expected_cost(shape, generous)
t = expected_cost(shape, tight)
print("generous", g, "time_cost", hourly_drag(g["expected_seconds"], 90))
print("tight ", t, "time_cost", hourly_drag(t["expected_seconds"], 90))
用占位符跑一次,然后再用真实轨迹跑一次。第一次运行告诉你函数的形态。第二次运行才是唯一应该影响买与等决策的依据。
python3 retry_tax.py
# generous {'expected_tokens': 5491.2, ...}
# tight {'expected_tokens': 4272.0, ...}
那些打印出来的令牌不是承诺。在 22% 的独立失败率下,如果你允许四次尝试,这就是 2,400 令牌请求所付出的代价。把 p_fail 改成你日志里的实际值。如果你的失败在区域性故障期间聚集,独立假设就过于乐观,重试税会更严重。
测量你实际拥有的计量器
你需要三个不是来自落地页的数字:每次尝试的令牌数,包括失败的那些;从入队到响应的耗时,包括排队等待;以及无法产生可用结果的尝试占比。其他都是装饰。
一个记录这些计量器的探测脚本可以很无聊。无趣才是重点。把它指向一个临时端点,而不是那个会给你发告警的路径。
#!/usr/bin/env python3
"""probe_attempt.py — one instrumented call. Template, not a load test."""
import json, os, time, urllib.request
URL = os.environ["LLM_URL"] # your endpoint; do not hardcode secrets
BODY = json.dumps({
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
"max_tokens": 8,
}).encode()
req = urllib.request.Request(
URL,
data=BODY,
headers={"Content-Type": "application/json"},
method="POST",
)
started = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
status = resp.status
except Exception as exc:
elapsed = time.monotonic() - started
print(json.dumps({"ok": False, "seconds": elapsed, "error": type(exc).__name__}))
raise SystemExit(1)
elapsed = time.monotonic() - started
payload = json.loads(raw.decode() or "{}")
usage = payload.get("usage") or {}
print(json.dumps({
"ok": 200 <= status < 300,
"seconds": elapsed,
"prompt_tokens": usage.get("prompt_tokens"),
"completion_tokens": usage.get("completion_tokens"),
}))
把这个探测脚本包装在一个以人类节奏运行二十次的循环中。二十次足以看出 p_fail 是舍入误差还是生活方式。这不足以认证一个平台。如果你需要统计上的安心,你需要更长的窗口和变更记录,而不是一个更热的take。
当免费容量是错误的赌注时
免费模型访问是一个排练室。免费服务器是一个学习你的重试策略是计划还是迷信的地方。两者都不是高可用性合约。如果工作不能推迟,共享队列中的备用容量是一种希望,而不是控制。
当以下任意一项为真时,你应该拒绝免费路径。合并窗口比你在重试下的预期等待更短。失败尝试仍然留下了无法撤销的副作用,比如半应用的工单评论或重复的扣款意图。你的 Agent 会在超时时重试,但没有幂等性密钥。你无法观察失败路径上的令牌。别人的积压可以跳到你前面,而你没有任何杠杆除了等待。
最后一点是排队的精髓。你无法控制的吞吐量不是备用容量。它是剩余容量,而剩余容量会在其他人有同样想法的那一刻消失。
披露:本文是 MonkeyCode 产品推广的一部分。MonkeyCode 提供免费模型访问和免费服务器选项,你可以用它作为排练室:运行估算器、运行探测器,看看你的重试税是脚注还是整篇文章。把披露放在心上。临时车道是用来测量的。不是跳过断路器的理由。
如果你想要一条单一的操作规则,用这个:没有重试策略可以在不附带尝试次数上限、退避不会坍缩成踩踏、以及不请求模型许可的硬停止的情况下发货。估算器告诉你这个上限的代价。探测器告诉你上限是否生效。日历告诉你免费是否在讨论范围内。
局限,以及谁应该离开
数学假设每次尝试是独立的。真实的停机是相关的。相关失败会让预期令牌数看起来很有礼貌,而挂钟等待时间却会飙升。所以在事件频道已经很嘈杂的时候,把脚本当作下限。
它也为一种调用形态定价。向外发散工具调用然后重试这个发散过程的 Agent 不是一个税。它们是一棵树。如果你在日志中没有把那棵树拍平,你会以一个你只在发票之后或站会之后才会注意到的因子少算。
当工作是面向客户的、受监管的、或有时间表的发布时,不要用这种方法代替付费的、有 SLA 保障的端点。不要用它来为删除可观测性找借口,因为令牌是免费的。不要把四尝试策略当作一种性格特征。在你确认可以对该共享免费池进行压测之前,不要对它进行负载测试。礼貌是成本纪律的一部分。
如果你无法说出排队等待的人的loaded时薪,也应该离开。令牌贴纸没有时间,是团队说服自己两小时的重试螺旋是高效的的方式。
先为重试路径做预算。快乐路径明天早上还会在那里。队列不会为你保留位置。