延迟预算怎么分:TTFT、单步与总时长的分配与分层超时

本文定位:「单 Agent 成本与性能工程」系列第 4 篇。前三篇算的是「这笔调用花多少钱」,本篇算「用户等了多久、每一段该分到多少」。

上篇回顾:第 1 篇给出轨迹成本模型,其中步数 n 的重尾分布既是成本长尾的根源,也是延迟长尾的根源。第 3 篇处理语义缓存的阈值。本篇处理另一类阈值:时间阈值。
下一篇:成本归因与分摊:一笔账单怎么拆给三个部门

小提一句: 由于平台每日发布笔记数量限制,目前系列笔记还没全部上传。后续内容会持续更新,大家可以先点个关注、收藏,以免错过。 谢谢!


引子:「统一设 60 秒超时」这句话错在哪

给用户一个等待上限,整个系统配一个超时值——最常见的做法,也最容易两头不讨好。

设 60 秒:多数正常任务 20 秒就完成了,剩下 40 秒的额度从来没被用过;真正卡死的任务要占满 60 秒才被杀,这 60 秒里占着并发槽、上下文和一串已经花出去的 token。

设 30 秒:卡死任务早早被杀,但正常任务的长尾(第 1 篇说过,步数分布是重尾的)开始被误杀,值班的人再把超时调回 60 秒,然后回到上一段。

本篇只回答一个问题:一次调用的等待时间,怎么按段切预算、按段设超时、按段配重试与降级。排队论推导、推理引擎内部机制、前端耐受设计,都不在范围内。


一、40 秒到底该怎么切?

先把一次 Agent 调用拆成四段。下表是 20,000 条合成轨迹(步数按第 1 篇的重尾分布采样)的实测分位:

环节P50P95P99P95/P50占总时长
TTFT(首 token)1.42s3.52s5.16s2.486.0%
单步生成1.16s2.43s3.28s2.0937.5%
工具等待1.35s5.47s9.84s4.0656.5%
总时长20.78s70.64s92.22s3.40100%

三行读法:

  • 钱和时间花在不一样的地方。 工具等待占了 56.5% 的时间,但它几乎不花钱(不产生 token);单步生成占 37.5% 的时间,却是 token 的主要来源。优化时间和优化成本作用于不同的环节,别指望一个动作同时解决两个问题。
  • P95/P50 就是这一段的方差倍数。 工具等待是 4.06,远高于其它两段——它对外依赖、受下游服务影响、偶尔会卡死。方差大的环节最需要单独设阈值,也最不该被一个全局值管着。
  • 总时长的 P95/P50 是 3.40。 这意味着「平均 20 秒」这个印象对预测 P95 毫无帮助,第 1 篇那个 Jensen 亏空在延迟上同样存在。

判定流程按段走,不按总时长走:

是

否

是

否

是

否

是

否

是

否

请求进入

TTFT > T1 ?

杀:prefill 异常
动作 = 降级到短提示重试

进入单步循环

单步生成 > T2 ?

杀:生成异常
动作 = 限流 + 换小模型

工具等待 > T3 ?

杀:工具卡死
动作 = 记为工具失败,交给下一步决策

累计 > T_total ?

杀:整体超预算
动作 = 终止 + 返回部分结果

还有下一步 ?

正常完成

记录:哪一段、哪个工具、哪个版本

这张图和「一个 T 管到底」的差别不只是精度,更是失败后能做的动作不同:TTFT 超了查提示长度或冷启动,工具卡死了记工具健康度,两者不该共用一个出口。

TTFT 超标时,慢在哪一段?

TTFT 是四段里最短的(P50 1.42s),但流式下它就是用户感知等待的全部(第八节算这笔账)。超标先做三段归因,再定动作——三段的解法互不通用:

子段判据(怎么区分)对应动作
排队全实例 TTFT 同步抬升;ρ 接近临界、平均排队 >0(第四节)扩容或削峰;调超时没有用
prefillTTFT 随 prompt 长度增长;前缀命中率下降时它同步变差(第五节)压提示长度、修装配顺序(第 2 篇)
网络分发同一请求跨接入点复现;连接不复用时首包明显变慢连接复用、就近接入、关代理缓冲

区分排队和 prefill 的快查:排队是全系统性的,所有请求一起变慢;prefill 是请求相关的,提示长的首当其冲、短提示的没事。网络分发最好认:同一提示同一模型,换接入点前后差一大截。三段里只有 prefill 受第 2 篇那个命中率支配。


二、统一超时为什么必然两头不讨好?

把两种策略调到误杀率相当的水平,再比它们对卡死任务浪费了多少时间——这是唯一公平的比法。

策略参数误杀率卡死任务平均浪费
统一超时T = 100s0.45%99.6s
分层超时T_ttft=10s, T_gen=8s, T_tool=20s0.57%33.7s
分层把卡死浪费降到统一超时的 33.8%(省下 65.9 秒 / 条)

误杀率只差 0.12 个百分点,浪费差三倍。原因很直接:卡死几乎总在工具等待这一段,分层能在那一步就杀掉、只损失到该段上限;统一超时必须等满 T,而 T 受长尾约束只能设很大。

完整扫描数据(目标误杀率 0.6%):

统一 T误杀率卡死浪费分层 T_tool误杀率卡死浪费
40s19.77%40.0s8s13.37%20.5s
60s7.80%60.0s12s4.02%25.5s
80s2.90%79.9s15s1.76%28.6s
100s0.45%99.6s20s0.57%33.7s
120s0.03%118.6s25s0.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.000620.37s12.08s
1.00%0.001317.35s9.84s
2.00%0.002514.44s7.72s
5.00%0.006411.23s5.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),这就是「每步都要留余量」的数学表达:步数越多,单步阈值越要宽松,反直觉,但从公式里直接读得出来。

超时之后做什么,也要预先定好:

触发段判定动作是否重试
TTFTprefill 异常 / 实例冷启动降级到短提示重试一次是(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.500.440/s20.6s68.1s0.0s0.0%
0.700.616/s20.7s68.4s0.1s0.3%
0.850.748/s22.0s68.9s0.9s3.1%
0.920.810/s23.9s70.8s2.4s8.1%
0.960.845/s27.4s73.2s5.3s16.5%
0.990.872/s37.4s82.7s13.6s33.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 才是把 ρ 推过临界点的因素。


五、四段预算的结构与各自的承担组件

④ 累计段

③ 工具等待段

② 单步生成段

① TTFT 段

prefill 计算
∝ 1−h(前缀命中率)

排队等待
∝ ρ/(1−ρ)

decode 速度
token/秒

输出长度控制

下游服务 SLA

超时 T3
方差最大的一段

总时长 T_total

步数上限
与成本闸门共用

终止 / 返回部分结果

一个跨系列的耦合点:① 号段的 prefill 与前缀命中率 h 直接相关(第 2 篇的主题)。命中率高,prefill 量小,TTFT 就低——前缀缓存同时改善成本和 TTFT,这是少数让两个目标同向的动作。

另一个是 ④ 号段:步数上限既是延迟闸门也是成本闸门,要设成共享参数;两边各设一个,行为不一致时排查会互相干扰。


六、数据怎么流:从压测到一张超时配置表

是

否

真实轨迹回放
不是随机请求

分段计时
ttft / gen / tool 各自记

分位统计
按段 × 任务类型 × 模型

方差倍数
P95/P50,找出最该分段的一环

反推单步阈值
p_step = 1−(1−p)^(1/E[n])

分层超时配置表

并发压测
扫 ρ 找临界点

容量建议
按 P95 目标反推 c

上线 + 线上误杀率监控

任务级误杀率
是否超目标?

冻结配置,季度复标

两个容易做错的细节:

  • 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.8s26.9%
流式(首字后持续输出)7.2s6.2%
流式 + 分步进度反馈3.4s2.0%

第三行是容易被忽略的增量:把每一步在做什么告诉用户,收益和开流式本身同量级。Agent 天然分步,这个信息不显示就是浪费。

⚠️ 放弃率曲线为要看的是替换关系而不是具体百分比。

代价也要说清楚:

项说明
无法在返回前拦截流式一旦开始吐字,内容安全/合规的后置过滤只能截断,用户已经看到了
失败语义变复杂中途失败要区分「已吐了多少」和「能不能续」,客户端状态机更复杂
对 TTFT 更敏感流式的全部收益来自 TTFT,如果 TTFT 本来就高(长提示未命中缓存),先去解决第 2 篇的问题
个别场景反而拖慢结构化输出要等完整 JSON 才能解析,边吐边等没有意义

九、什么时候这套方法不成立

条件为什么会失效该改用什么
任务是纯批处理的(没人等)没有放弃率,只有吞吐只看 ρ 与排队,放弃率那套去掉
工具等待占 90% 以上时间不在你这边,优化自有系统没用去压下游 SLA,或改用第 11 篇的降级队列
模型侧延迟受厂商波动支配你自己测出的分位数第二天就变按版本 + 时段分组标定,缩短复标周期
单步生成很长(长文生成)T2 与 T_total 高度重叠,分层意义下降按输出长度分档设 T2,或改用「已生成 token 数」做闸门
用户等待由前端决定呈现与耐受不在本篇范围本篇给耗时分配,前端呈现与耐受设计见交互那一簇

十、小结

延迟预算和成本预算都不能用一个平均值管到底。差别在于延迟的方差集中在工具等待(P95/P50 = 4.06,是另两段的两倍),最需要单独设阈值的就是它——而且不该拍,要从「可接受的任务级误杀率」反推。

几句话带走:

  1. 超时按段设,与该段的方差成比例。 误杀率相当时,分层把卡死浪费压到统一超时的 33.8%(99.6s → 33.7s)。
  2. 单步阈值从任务级误杀率反推,不直接取分位数。 直接取会放大约 E[n_tool] 倍(本例 8 倍)。
  3. 重试和超时一起设计。 只给超时不给重试,嵌套重试会在下游卡死时成倍放大请求量;写操作没有幂等键就不许重试。
  4. 容量按 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(工具失败交还循环的处理方式,第三节动作表)。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐