SA-C-04 Agent成本与性能 延迟预算
延迟预算怎么分:TTFT、单步与总时长的分配与分层超时
本文定位:「单 Agent 成本与性能工程」系列第 4 篇。前三篇算的是「这笔调用花多少钱」,本篇算「用户等了多久、每一段该分到多少」。
上篇回顾:第 1 篇给出轨迹成本模型,其中步数
n的重尾分布既是成本长尾的根源,也是延迟长尾的根源。第 3 篇处理语义缓存的阈值。本篇处理另一类阈值:时间阈值。
下一篇:成本归因与分摊:一笔账单怎么拆给三个部门
小提一句: 由于平台每日发布笔记数量限制,目前系列笔记还没全部上传。后续内容会持续更新,大家可以先点个关注、收藏,以免错过。 谢谢!
引子:「统一设 60 秒超时」这句话错在哪
给用户一个等待上限,整个系统配一个超时值——最常见的做法,也最容易两头不讨好。
设 60 秒:多数正常任务 20 秒就完成了,剩下 40 秒的额度从来没被用过;真正卡死的任务要占满 60 秒才被杀,这 60 秒里占着并发槽、上下文和一串已经花出去的 token。
设 30 秒:卡死任务早早被杀,但正常任务的长尾(第 1 篇说过,步数分布是重尾的)开始被误杀,值班的人再把超时调回 60 秒,然后回到上一段。
本篇只回答一个问题:一次调用的等待时间,怎么按段切预算、按段设超时、按段配重试与降级。排队论推导、推理引擎内部机制、前端耐受设计,都不在范围内。
一、40 秒到底该怎么切?
先把一次 Agent 调用拆成四段。下表是 20,000 条合成轨迹(步数按第 1 篇的重尾分布采样)的实测分位:
| 环节 | P50 | P95 | P99 | P95/P50 | 占总时长 |
|---|---|---|---|---|---|
| TTFT(首 token) | 1.42s | 3.52s | 5.16s | 2.48 | 6.0% |
| 单步生成 | 1.16s | 2.43s | 3.28s | 2.09 | 37.5% |
| 工具等待 | 1.35s | 5.47s | 9.84s | 4.06 | 56.5% |
| 总时长 | 20.78s | 70.64s | 92.22s | 3.40 | 100% |
三行读法:
- 钱和时间花在不一样的地方。 工具等待占了 56.5% 的时间,但它几乎不花钱(不产生 token);单步生成占 37.5% 的时间,却是 token 的主要来源。优化时间和优化成本作用于不同的环节,别指望一个动作同时解决两个问题。
P95/P50就是这一段的方差倍数。 工具等待是 4.06,远高于其它两段——它对外依赖、受下游服务影响、偶尔会卡死。方差大的环节最需要单独设阈值,也最不该被一个全局值管着。- 总时长的
P95/P50是 3.40。 这意味着「平均 20 秒」这个印象对预测 P95 毫无帮助,第 1 篇那个 Jensen 亏空在延迟上同样存在。
判定流程按段走,不按总时长走:
这张图和「一个 T 管到底」的差别不只是精度,更是失败后能做的动作不同:TTFT 超了查提示长度或冷启动,工具卡死了记工具健康度,两者不该共用一个出口。
TTFT 超标时,慢在哪一段?
TTFT 是四段里最短的(P50 1.42s),但流式下它就是用户感知等待的全部(第八节算这笔账)。超标先做三段归因,再定动作——三段的解法互不通用:
| 子段 | 判据(怎么区分) | 对应动作 |
|---|---|---|
| 排队 | 全实例 TTFT 同步抬升;ρ 接近临界、平均排队 >0(第四节) | 扩容或削峰;调超时没有用 |
| prefill | TTFT 随 prompt 长度增长;前缀命中率下降时它同步变差(第五节) | 压提示长度、修装配顺序(第 2 篇) |
| 网络分发 | 同一请求跨接入点复现;连接不复用时首包明显变慢 | 连接复用、就近接入、关代理缓冲 |
区分排队和 prefill 的快查:排队是全系统性的,所有请求一起变慢;prefill 是请求相关的,提示长的首当其冲、短提示的没事。网络分发最好认:同一提示同一模型,换接入点前后差一大截。三段里只有 prefill 受第 2 篇那个命中率支配。
二、统一超时为什么必然两头不讨好?
把两种策略调到误杀率相当的水平,再比它们对卡死任务浪费了多少时间——这是唯一公平的比法。
| 策略 | 参数 | 误杀率 | 卡死任务平均浪费 |
|---|---|---|---|
| 统一超时 | T = 100s | 0.45% | 99.6s |
| 分层超时 | T_ttft=10s, T_gen=8s, T_tool=20s | 0.57% | 33.7s |
分层把卡死浪费降到统一超时的 33.8%(省下 65.9 秒 / 条)
误杀率只差 0.12 个百分点,浪费差三倍。原因很直接:卡死几乎总在工具等待这一段,分层能在那一步就杀掉、只损失到该段上限;统一超时必须等满 T,而 T 受长尾约束只能设很大。
完整扫描数据(目标误杀率 0.6%):
统一 T | 误杀率 | 卡死浪费 | 分层 T_tool | 误杀率 | 卡死浪费 | |
|---|---|---|---|---|---|---|
| 40s | 19.77% | 40.0s | 8s | 13.37% | 20.5s | |
| 60s | 7.80% | 60.0s | 12s | 4.02% | 25.5s | |
| 80s | 2.90% | 79.9s | 15s | 1.76% | 28.6s | |
| 100s | 0.45% | 99.6s | 20s | 0.57% | 33.7s | |
| 120s | 0.03% | 118.6s | 25s | 0.20% | 38.8s |
看统一超时:把误杀率从 2.90% 压到 0.45%,T 从 80s 加到 100s,每条卡死任务多浪费 20 秒。看分层:15s 到 20s,误杀率降 1.19 个百分点,浪费只增 5.1 秒。同样的误杀率改善,分层只付四分之一的代价。
这条曲线就是「统一超时必然两头不讨好」的量化形式:T 同时承担两个冲突职责——兜住正常任务的长尾,又要尽快掐死异常任务,而这两件要求相反方向。
三、单步阈值该怎么反推?
分层超时有个陷阱:把「工具等待的 P99」直接当单步超时,误杀率会被放大约 E[n_tool] 倍——一次任务调用很多次工具,每一步都有独立的误杀概率。任务级误杀率是:
任务级误杀率 = 1 − (1 − p_step) ^ E[n_tool]
反推:p_step = 1 − (1 − 任务级误杀率) ^ (1 / E[n_tool])
本例合成数据的 E[n_tool] = 8.03。代入:
| 任务级误杀率目标 | 反推单步 p | 正确:反推后单步超时 | 错误:直接取任务级分位 |
|---|---|---|---|
| 0.50% | 0.0006 | 20.37s | 12.08s |
| 1.00% | 0.0013 | 17.35s | 9.84s |
| 2.00% | 0.0025 | 14.44s | 7.72s |
| 5.00% | 0.0064 | 11.23s | 5.47s |
最后一列就是那个常见错误:想要 0.5% 的任务级误杀率,直接取工具等待分布的 99.5% 分位(12.08s)当超时。但 12.08s 对应的是单步误杀率 0.5%,8 步的任务里任务级会变成 1 − 0.995^8.03 ≈ 3.9%——放大约 8 倍。
正确值比直觉值宽松得多(20.37s vs 12.08s),这就是「每步都要留余量」的数学表达:步数越多,单步阈值越要宽松,反直觉,但从公式里直接读得出来。
超时之后做什么,也要预先定好:
| 触发段 | 判定 | 动作 | 是否重试 |
|---|---|---|---|
| TTFT | prefill 异常 / 实例冷启动 | 降级到短提示重试一次 | 是(1 次) |
| 单步生成 | 生成卡住 | 终止本步,记为模型侧失败 | 否(换小模型重跑整步) |
| 工具等待 | 下游卡死 | 记为工具失败,交回循环让模型改走别的工具 | 否(由模型决定) |
| 累计总时长 | 超出预算 | 终止并返回已得部分结果 | 否 |
第三行值得强调:工具超时不该直接终止任务,应该把失败交还给循环。第 1 篇说过 stop_reason 要分四档,这里对应的是「工具失败」——它是模型可以从中恢复的状态,而前两行不是。
重试和超时,为什么会互相放大?
超时决定「什么时候杀」,重试决定「杀了之后来不来第二次」。两个旋钮拧在一起,最常见的是三种拧法:
重试放大。 客户端自动重试、循环内重试、工具 SDK 自带的重试是三层嵌套。每层配 3 次,最坏情况一次用户请求变成 27 次下游调用(构造示例)——而且重试都发生在系统已经不健康的时候:超时的背景就是下游卡死,重试等于继续加压,把一次卡死拖成雪崩。
退避和阈值互相吃掉。 退避基线大于该段阈值时,第二次请求还没发出去,外层已经判了超时;反过来,重试把外层累计预算吃光,正常请求被误杀。退避必须递增,总退避时间要能塞进该段阈值。
幂等是重试的前提。 读操作(查订单、查库存)重试是安全的;写操作(改配置、下单)没有幂等键就不许重试,否则一次超时重试就是一次重复扣款。
一张重试策略表(次数与退避为构造示例,按你自己下游的 SLA 调):
| 段 | 允许重试 | 次数 | 退避 | 前提 |
|---|---|---|---|---|
| TTFT | 是 | 1 | 固定 1s 后降级短提示 | 提示未变;冷启动场景有效 |
| 单步生成 | 否 | 0 | — | 换小模型重跑整步由循环决定 |
| 工具等待 | 看操作 | 读 2 / 写 0 | 指数 0.5s→1s→2s + 抖动 | 写操作必须带幂等键 |
| 累计超时 | 否 | 0 | — | 整体已超预算,重试只会更糟 |
它和「超时之后做什么」那张表的分工:那张管超时瞬间的动作,这张管之后还来不来第二次。工具那一行两个结论同时成立——先退避重试,次数用尽再交还循环。
四、并发与吞吐的非线性在哪?
固定并发槽数 c=24,扫利用率 ρ(服务时间取上面的轨迹时长分布,E[S]=27.3s):
利用率 ρ | 到达率 | P50 延迟 | P95 延迟 | 平均排队 | 排队占比 |
|---|---|---|---|---|---|
| 0.50 | 0.440/s | 20.6s | 68.1s | 0.0s | 0.0% |
| 0.70 | 0.616/s | 20.7s | 68.4s | 0.1s | 0.3% |
| 0.85 | 0.748/s | 22.0s | 68.9s | 0.9s | 3.1% |
| 0.92 | 0.810/s | 23.9s | 70.8s | 2.4s | 8.1% |
| 0.96 | 0.845/s | 27.4s | 73.2s | 5.3s | 16.5% |
| 0.99 | 0.872/s | 37.4s | 82.7s | 13.6s | 33.5% |
从 ρ=0.85 到 ρ=0.99,到达率只涨 17%,P50 涨 70%,平均排队从 0.9 秒涨到 13.6 秒(15 倍)——低于 0.85 时加流量几乎不影响延迟,过了 0.85 延迟陡增,而吞吐不再增长。
两条推论:
- 容量要按 P95 目标反推,不能按平均吞吐反推。按平均算出来的容量,会把系统恰好停在
ρ≈0.9这个最难受的位置。 ρ超过 0.85 就该扩容或削峰,不是继续调超时。看到延迟上升就去调超时值,是用错误的旋钮解决排队问题——调超时只改变「谁被杀」,不改变「队列有多长」。
⚠️ 压测必须用真实轨迹回放,不能用随机请求。随机请求的服务时间分布是窄的,会测出一条根本不存在的平缓曲线;真实轨迹的 P95/P50 = 3.40 才是把 ρ 推过临界点的因素。
五、四段预算的结构与各自的承担组件
一个跨系列的耦合点:① 号段的 prefill 与前缀命中率 h 直接相关(第 2 篇的主题)。命中率高,prefill 量小,TTFT 就低——前缀缓存同时改善成本和 TTFT,这是少数让两个目标同向的动作。
另一个是 ④ 号段:步数上限既是延迟闸门也是成本闸门,要设成共享参数;两边各设一个,行为不一致时排查会互相干扰。
六、数据怎么流:从压测到一张超时配置表
两个容易做错的细节:
S2必须分段计时。 只记一个总时长,后面四段预算、方差倍数、反推全都没法做,你又退回到「拍一个 T」。S10最后要落在「任务级误杀率」上,不是单步。 单步误杀率看起来很低(0.06%)会让人误以为没问题,而任务级已经是 0.5%。
七、一份能跑的压测与标定脚本
无参数运行时用内置合成数据跑通,换成你自己的轨迹计时数据就有你自己的数。
脚本吃的是一条轨迹一个顺序列表:[("ttft", 1.4), ("gen", 1.2), ("tool", 3.8), ...]。是顺序而不是三段总和,因为分层超时逐段判、逐段截断,只剩三个聚合值就做不了。调用点埋点(SDK 回调或 span 属性)只要产出这个列表,下面几个函数不用改。
合成数据的三处设定直接决定表里的数:耗时分段用对数正态(取正值、右偏,参数是「对数中位数」与「对数标准差」),所以 P50 就是 e^0.35≈1.42s、e^0.15≈1.16s、e^0.30≈1.35s,和第一节那张表对得上;卡死不塞进长尾,单独按 uniform(60,600) 生成——它是另一种机制,混进分布会把分位数整体拽歪;浪费按 min(t, T) 计量,算的是「被杀之前已经消耗掉的时间」,不是任务本该花多久。
真实轨迹做不了反事实:同一条请求只能被杀一次,没法既看统一超时又看分层。合成数据让同一批轨迹在两种杀法下各跑一遍,差距才算得成一个数——这是宁可先造数据的原因,换成真实数据这一步仍要保留。代码按四块走:造轨迹、两种杀法、反推阈值、排队模拟。
#!/usr/bin/env python3
"""latency.py —— 延迟预算分解、分层超时标定、排队模拟(只依赖标准库)。
用法:python latency.py
"""
import heapq
import random
import statistics
N, STUCK_RATE = 20000, 0.03 # 轨迹条数;卡死任务占比
def sample_traj(rng):
"""返回 (明细, 是否卡死)。明细形如 [('ttft',t), ('gen',t), ('tool',t), ...]"""
# 步数:60% 短 / 30% 中 / 10% 长,用三段均匀抽样近似第 1 篇的重尾
u = rng.random()
n = rng.randint(3, 6) if u < 0.60 else (rng.randint(7, 14) if u < 0.90
else rng.randint(15, 28))
stuck = rng.random() < STUCK_RATE
stuck_at = rng.randrange(n) if stuck else -1 # 卡死落在第几步,位置影响浪费量
# 对数正态:参数是 (对数中位数, 对数标准差),故中位数 = e^μ
d = [("ttft", max(0.15, rng.lognormvariate(0.35, 0.55)))]
for i in range(n):
d.append(("gen", max(0.10, rng.lognormvariate(0.15, 0.45))))
d.append(("tool", rng.uniform(60, 600)) if i == stuck_at # 卡死:分钟级,不在长尾里
else ("tool", max(0.05, rng.lognormvariate(0.30, 0.85))))
return d, stuck
def run_uniform(trajs, T):
"""统一超时:总时长 > T 就杀。返回 (误杀率, 卡死平均浪费秒数)"""
killed = normal = 0
waste = []
for d, stuck in trajs:
t = sum(x for _, x in d)
if stuck:
waste.append(min(t, T)) # 浪费算到被杀时刻,不是任务真实时长
else:
normal += 1 # 误杀率的分母只算正常任务
killed += 1 if t > T else 0
return killed / normal if normal else 0.0, statistics.fmean(waste)
def run_layered(trajs, T_ttft, T_gen, T_tool):
"""分层超时:任一段超过该段阈值就杀。"""
killed = normal = 0
waste = []
for d, stuck in trajs:
acc, hit = 0.0, False
for kind, t in d:
lim = {"ttft": T_ttft, "gen": T_gen, "tool": T_tool}[kind] # 按段取阈值
if t > lim:
acc += lim # 只浪费到该段上限:分层省时间的全部来源
hit = True
break # 一条轨迹只被杀一次
acc += t
if stuck:
waste.append(acc)
else:
normal += 1
killed += 1 if hit else 0
return killed / normal if normal else 0.0, statistics.fmean(waste)
def reverse_threshold(tool_times, e_n, target):
"""从任务级误杀率反推单步阈值(而不是直接取任务级分位)"""
p_step = 1 - (1 - target) ** (1 / e_n) # 几何分布的反函数:步数越多,单步越宽松
srt = sorted(tool_times)
# 经验分位点;下标夹到 len-1,样本量小时不夹会越界
return p_step, srt[min(int(len(srt) * (1 - p_step)), len(srt) - 1)]
def simulate(c, lam, svc, n_req=3000, seed=7):
"""M/G/c 离散事件模拟"""
rng = random.Random(seed)
heap = [(0.0, i) for i in range(c)] # c 个并发槽,值为各自的下次空闲时刻
heapq.heapify(heap)
t, done = 0.0, []
for _ in range(n_req):
t += rng.expovariate(lam) # 泊松到达:相邻请求间隔服从指数分布
free_t, i = heapq.heappop(heap) # 取最早空出来的槽
start = max(t, free_t) # 有槽就等槽:排队
s = svc[rng.randrange(len(svc))] # 服务时间从分布抽样,不取均值
heapq.heappush(heap, (start + s, i))
done.append((start - t, s)) # 记下 (排队时长, 服务时长)
tot = [w + s for w, s in done] # 端到端 = 排队 + 服务
return (statistics.median(tot), sorted(tot)[int(len(tot) * 0.95)],
statistics.fmean(w for w, _ in done))
逐段读这份脚本
sample_traj 只造一条带顺序的轨迹。步数按 60/30/10 抽样,卡死位置随机落在某一步——落在第一步和最后一步,白等的时间差出好几倍。接真实数据时整个删掉,把轨迹表读进来。
run_uniform 和 run_layered 是同一批轨迹上的两个反事实,误杀率的分母都只算正常任务,所以「同样误杀率下浪费多少」才可比。run_layered 超限时记 acc + lim 而不是 acc + t,这一行的差别就是「分层把浪费截在段内」的全部算术;break 保证一条轨迹只被杀一次,不会在后续段里重复计。
reverse_threshold 第一行是几何分布的反函数,第二行才取经验分位。simulate 用堆维护 c 个槽的最早空闲时刻,start = max(到达, 空闲) 一行就把排队算出来了;服务时间从分布里抽而不取均值,否则测出来的曲线是平的——这正是第四节那句「压测不能用随机请求」在代码里的位置。
拿自己的数据跑之前先知道两处偏差:simulate 没有 warmup,前几十条请求还没排队,扫 ρ 时丢掉前 5%;statistics.fmean([]) 会抛 StatisticsError,卡死率为 0 的数据集要在 waste 上先判空。
输出长什么样
真实输出(内置合成数据,仅用于演示输出格式)。逐块对上前面各节:分段的方差、两种杀法的浪费差、反推出来的阈值、ρ 的临界点:
--- 四段预算分解(正常任务,20000 条)---
环节 P50 P95 P99 P95/P50 占总时长
TTFT 1.42 3.52 5.16 2.48 6.0%
单步生成 1.16 2.43 3.28 2.09 37.5%
工具等待 1.35 5.47 9.84 4.06 56.5%
总时长 20.78 70.64 92.22 3.40 100.0%
统一超时:T=100s,误杀率 0.45%,卡死任务平均浪费 99.6s
分层超时:T_tool=20s,误杀率 0.57%,卡死任务平均浪费 33.7s
→ 误杀率相当时,分层把浪费降到 33.8%(省 65.9s / 条)
--- 反推(E[n_tool]=8.03)---
任务级误杀率 反推单步 p 正确单步超时 错误:直接取任务级分位
0.50% 0.0006 20.37 12.08
--- 并发与利用率(c=24,E[S]=27.3s)---
利用率 ρ 到达率 P50 P95 平均排队 排队占比
0.99 0.872/s 37.4 82.7 13.6s 33.5%
⚠️ 全部数字来自本文声明的参数(步数分布、各段对数正态参数、卡死率 3%)。用法是:把自己的分段计时数据喂进去,重跑一遍。
一份可直接抄的分层超时配置
把前面各节的结论落成一份配置:四段阈值对齐第二节的分层参数(T_ttft=10s, T_gen=8s, T_tool=20s,总段 100s),重试与退避为构造示例,抄之前用第三节的公式代入自己的数据。字段名和形参一一对应:limit 是反推出的阈值,retries / backoff 是重试表的两列,on_timeout 对应流程图里四个「杀」分支的动作。
# latency_budget.yaml —— 分层超时:四段阈值 + 重试 + 降级动作
# 阈值对齐本文第二节(T_ttft=10s, T_gen=8s, T_tool=20s,总段取 100s);
# 重试次数与退避为构造示例,按下游 SLA 调整。
timeouts:
ttft:
limit: 10s
retries: 1 # 仅冷启动 / 降级短提示时重试
backoff: fixed 1s
on_timeout: "降级到短提示重试一次"
per_step:
limit: 8s
retries: 0 # 生成段不重试,换小模型重跑整步由循环决定
backoff: none
on_timeout: "记为模型侧失败"
tool:
limit: 20s
retries: 2 # 读操作 2 次;写操作无幂等键则为 0
backoff: exponential # 0.5s → 1s → 2s,加 full jitter
on_timeout: "记为工具失败,交还循环改走别的工具"
total:
limit: 100s # 构造示例:与第二节统一超时对照组的 T 对齐
retries: 0
backoff: none
on_timeout: "终止并返回已得部分结果"
retry:
max_layers: 3 # 客户端 × 循环 × SDK 的嵌套层数上限(构造示例)
idempotency_required: true # 写操作重试前必须确认幂等键
degrade:
on_ttft_spike: "切换实例或降级短提示"
on_tool_stuck: "换备用工具或返回部分结果"
on_total_exceeded: "返回部分结果并标记未完成"
三点落地提醒:total 要大于 per_step × 步数上限,否则总闸门比循环还早杀;retry.max_layers 是给 review 看的——三层各配 3 次就是 27 倍放大(构造示例);degrade 的动作要和第一节流程图四个「杀」分支一一对应,对不上的就是没人执行的死配置。
八、流式到底值不值得开?
流式经常被当成性能优化来讲,但它不改变总时长——同一个任务,流式和非流式的完成时间一样。它改变的是用户感知等待:
非流式:感知等待 = 总时长 P50 = 20.8s
流式 :感知等待 = TTFT + 耐心系数 × 剩余时间 ≈ 1.4 + 0.3 × 19.4 = 7.2s
代入一条随时间上升的放弃率曲线(Weibull, t_half=45s, k=1.5,演示构造):
| 场景 | 感知等待 | 放弃率 |
|---|---|---|
| 非流式(盯总时长) | 20.8s | 26.9% |
| 流式(首字后持续输出) | 7.2s | 6.2% |
| 流式 + 分步进度反馈 | 3.4s | 2.0% |
第三行是容易被忽略的增量:把每一步在做什么告诉用户,收益和开流式本身同量级。Agent 天然分步,这个信息不显示就是浪费。
⚠️ 放弃率曲线为要看的是替换关系而不是具体百分比。
代价也要说清楚:
| 项 | 说明 |
|---|---|
| 无法在返回前拦截 | 流式一旦开始吐字,内容安全/合规的后置过滤只能截断,用户已经看到了 |
| 失败语义变复杂 | 中途失败要区分「已吐了多少」和「能不能续」,客户端状态机更复杂 |
| 对 TTFT 更敏感 | 流式的全部收益来自 TTFT,如果 TTFT 本来就高(长提示未命中缓存),先去解决第 2 篇的问题 |
| 个别场景反而拖慢 | 结构化输出要等完整 JSON 才能解析,边吐边等没有意义 |
九、什么时候这套方法不成立
| 条件 | 为什么会失效 | 该改用什么 |
|---|---|---|
| 任务是纯批处理的(没人等) | 没有放弃率,只有吞吐 | 只看 ρ 与排队,放弃率那套去掉 |
| 工具等待占 90% 以上 | 时间不在你这边,优化自有系统没用 | 去压下游 SLA,或改用第 11 篇的降级队列 |
| 模型侧延迟受厂商波动支配 | 你自己测出的分位数第二天就变 | 按版本 + 时段分组标定,缩短复标周期 |
| 单步生成很长(长文生成) | T2 与 T_total 高度重叠,分层意义下降 | 按输出长度分档设 T2,或改用「已生成 token 数」做闸门 |
| 用户等待由前端决定 | 呈现与耐受不在本篇范围 | 本篇给耗时分配,前端呈现与耐受设计见交互那一簇 |
十、小结
延迟预算和成本预算都不能用一个平均值管到底。差别在于延迟的方差集中在工具等待(P95/P50 = 4.06,是另两段的两倍),最需要单独设阈值的就是它——而且不该拍,要从「可接受的任务级误杀率」反推。
几句话带走:
- 超时按段设,与该段的方差成比例。 误杀率相当时,分层把卡死浪费压到统一超时的 33.8%(99.6s → 33.7s)。
- 单步阈值从任务级误杀率反推,不直接取分位数。 直接取会放大约
E[n_tool]倍(本例 8 倍)。 - 重试和超时一起设计。 只给超时不给重试,嵌套重试会在下游卡死时成倍放大请求量;写操作没有幂等键就不许重试。
- 容量按 P95 目标反推,
ρ超过 0.85 就扩容或削峰。 延迟上升先查排队,调超时是两个旋钮按错一个。
你现在的超时是一个值还是按段设的?如果是一个值,去翻上个月被超时杀掉的任务,数一数有多少是「正常但偏长」——那个比例就是你的误杀率,多半比你以为的高。
⚠️ 注意事项:本文所有数字来自合成轨迹(步数分布、各段对数正态参数、卡死率 3%、放弃率曲线 Weibull(45, 1.5))。「33.8%」「放大 8 倍」「ρ=0.85 临界」在本例参数下成立,换分布会有所漂移。第七节 yaml 里的重试次数、退避与总段时长同样是构造示例;使用之前用第三节的公式代入自己的数据。本文基于 2026-Q1 的 API 调用形态(流式 SSE、可配置客户端超时、服务端排队不可见),Python 3.11+;自部署推理(vLLM / SGLang)的排队与批处理调度不同,需另行标定;前端呈现与耐受不在本篇范围。
下一步
在调用点加上分段计时(ttft / gen / tool 各记一份),跑一周,然后用第七节的脚本做三件事:算各段的 P95/P50、从任务级误杀率反推单步阈值、扫 ρ 找你的容量临界点。这三张表出来之前,不要改任何超时配置。
上一篇:语义缓存。
下一篇:成本归因与分摊:按功能 / 部门 / 用户的一本账 - 还在写,可以先关注下,以免错过。前面四篇都在算「一共花多少、等了多少」,下一篇开始算「这笔钱算谁的」。
参考
- MDN, Server-sent events(流式传输的标准做法与客户端状态机)。
- OpenTelemetry, GenAI 语义约定(第六节分段计时的字段对齐对象)。
- vLLM, Automatic Prefix Caching(prefill 与命中率对 TTFT 的影响,第五节耦合点)。
- Zheng et al., SGLang(prefill / decode 分离与前缀复用,自部署侧的延迟构成)。
- Yao et al., ReAct, ICLR 2023(「一步一工具」结构,是单步超时需要反推的根源)。
- Lewis et al., Retrieval-Augmented Generation, NeurIPS 2020(检索作为工具等待的一部分,其 SLA 直接进入
T3)。 - Traceloop, OpenLLMetry(现成埋点,可据此扩展分段计时)。
- LiteLLM, Observability Callbacks(统一适配口,便于在调用点插桩计时)。
- Anthropic, Building Effective Agents(工具失败交还循环的处理方式,第三节动作表)。
更多推荐

所有评论(0)