七个配置选哪个?成本、延迟、质量的帕累托前沿
先把被支配的配置划掉(又贵、又慢、还不更好),剩下的连成帕累托前沿,再按预算和延迟门槛在前沿上挑。但前沿是用估计值画的,质量的区间有好几个百分点宽,前沿本身也会跟着抖。
所有数据都是合成的:7 个配置在同一批 200 条 BugHunt-Bench 任务上各跑一次,生成过程是设定好的,所以知道每个配置的真实质量,可以检验"按点估计挑"到底挑得对不对。
1三根轴,各用什么数
场景:测试 Agent 在被测 Web 应用里找注入的 Bug。每条任务 = 一个应用快照 + 一个注入的 Bug,找到算成功。7 个配置是"模型档位(小 / 中 / 大)× 推理强度(低 / 高)",外加一个"大·高·关缓存"。BugHunt-Bench 的计划只注入 20~30 个 Bug;这里假设有 200 个这样的"快照 + Bug"组合,好让区间窄到能看出结构,后面会看到 200 条也常常不够。
| 轴 | 报什么 | 为什么 |
|---|---|---|
| 质量 | 找到率 k / n,加 Wilson 区间 | 200 条任务的找到率本身就有好几个百分点的抽样误差 |
| 成本 | 每条任务的平均花费;另报"每找到一个 Bug 的花费" | 预算是加起来的:总花费 = 任务数 × 均值,所以成本用均值正合适 |
| 延迟 | p50 和 p95,不报均值 | 延迟分布右偏、有长尾,门槛(SLO)管的是慢的那一头 |
为什么延迟不用均值
- 分布右偏。在这个合成设定下,7 个配置的均值都比 p50 高 7%~14%。均值描述不了"典型的一次",也描述不了"最慢的那些"。
- 没找到 Bug 的运行更慢。D(中·高)找到 Bug 的运行 p50 是 216 秒,没找到的是 386 秒:没找到的往往一直探索到轮数上限。均值把这两群混成一个不对应任何一次运行的数。
- 几次超时就能把均值拉走。把 D 的 200 次运行里 5 次换成 1800 秒超时:均值 275 → 312 秒,p50 244 → 245 秒,p95 449 → 471 秒。均值动了 13%,却说不清是"整体变慢"还是"几次卡死";p50 不动、p95 上升,一眼看出是尾巴变长了。
- 门槛写在尾巴上。"95% 的任务 10 分钟内跑完"就是 p95 ≤ 600 秒。Google 的 SRE 书第 4 章原文:We generally prefer to work with percentiles rather than the mean (arithmetic average) of a set of values.
分位数的算法也要写清楚:本页和 Python 代码都用线性插值(numpy.percentile 的默认算法)。200 个数的 p95 落在排序后第 190 和第 191 个之间,基本由最慢的 10 次决定,所以 p95 自己也有不小的误差,下面会给它也算 bootstrap 区间。
2成本:先把 token 口径对齐
一次运行的花费按三类 token 分别计价(价格是示例价格(合成),单位美元 / 百万 token,只为说明计算方法,不要当作任何真实价格;2026-10-01 对照过 Claude、OpenAI、Google 的公开价格表,这几个数没有和任何一档相同):
$$\text{cost} = \frac{(\text{input} - \text{cached})\cdot p_\text{in} + \text{cached}\cdot p_\text{cache} + \text{output}\cdot p_\text{out}}{10^6}$$| 档位 | 输入 \(p_\text{in}\) | 缓存命中 \(p_\text{cache}\) | 输出 \(p_\text{out}\) |
|---|---|---|---|
| 小 | 0.27 | 0.027 | 1.35 |
| 中 | 1.15 | 0.115 | 5.75 |
| 大 | 4.70 | 0.47 | 23.50 |
上面公式里的 input 包含缓存命中的部分,这是 Codex 的 TokenUsage 的口径:它的 non_cached_input() 就是 input_tokens - cached_input_tokens(codex-rs/protocol/src/protocol.rs:2426-2437);它的 input_tokens 原样取自 Responses API 返回的 input_tokens,cached_input_tokens 是其中的细分(codex-rs/codex-api/src/sse/responses.rs:128-134)。Claude Messages API 的 usage.input_tokens 则是没走缓存的剩余部分,总输入 = input_tokens + cache_creation_input_tokens + cache_read_input_tokens。字段名一样、口径不同:一个是总输入,一个是未走缓存的剩余部分。把两边的日志接进同一张成本报表时,口径弄混一次,成本就差好几倍。
Agent 的成本还有一层:每轮都重发全部历史(第 2 周「Agent 到底是什么?一个 while 循环」里的平方增长),所以轮数多的运行贵得多。没找到 Bug 的运行轮数多,"每找到一个 Bug 的花费" = 总花费 / 找到数,比"每条任务的平均花费"更能反映性价比。
3帕累托前沿:先划掉被支配的
配置 j 支配配置 i:j 不比 i 贵,质量也不比 i 低,并且至少一项严格更好:
$$\text{cost}_j \le \text{cost}_i,\quad q_j \ge q_i,\quad \text{且至少一个不等号严格}$$没有被任何配置支配的那些点,就是帕累托前沿。前沿上的点之间没有"谁更好",只有"多花多少钱、换多少质量"。延迟通常当成门槛:p95 超过 600 秒的配置先排除(不可行),在剩下的里面画前沿。三项都当目标也行,定义一样,只是多一个不等号。
在这份合成数据上(固定种子 7):
| 前沿上的配置 | |
|---|---|
| 点估计前沿 | A 小·低、B 小·高、D 中·高、E 大·低 |
| 真实前沿(总体值算出来的) | A 小·低、B 小·高、C 中·低、D 中·高 |
差了两个:C 的真实质量(63.8%)比 B(58.4%)高,这次观测却一样都是 121/200,于是被更便宜的 B 支配掉了;E 的真实质量(70.5%)比 D(72.1%)低、总体成本还高约 98%,这次观测却是 75.0% 对 71.0%,挤上了前沿。点估计前沿上的点,不一定在真实前沿上;被划掉的点,也不一定该划。
4给前沿也加上误差
每个质量估计先带上区间
第 1 周「30 次挂了 2 次,失败率在什么范围?」讲过:只报 k/n 不报区间,等于没交代这个数字有多可信。200 条任务、找到率 70% 左右时,Wilson 95% 区间宽约 12 个百分点(D:142/200 = 71.0%,[64.4%, 76.8%])。7 个区间里,F 的 [82.2%, 91.4%] 没罩住它的真实值 79.3%:95% 区间本来就有约 5% 的时候会漏,同时看 7 个,漏一个不稀奇。
两个配置比,用配对
7 个配置跑的是同一批任务,比较两个配置时是配对数据,要按第 1 周「新 prompt 从 82% 涨到 86%,是真的变好了吗?」的做法:按任务整条重抽(配对 bootstrap),或者对分歧的任务做 McNemar 检验。不要拿两个 Wilson 区间看重不重叠。
| 比较 | 观测差 | 配对 bootstrap 95% 区间 | McNemar 精确 p | 成本比 |
|---|---|---|---|---|
| E 大·低 − D 中·高 | +4.0 个点 | [−3.5, +11.5] | 0.358(分歧 33 : 25) | 1.89× |
| C 中·低 − B 小·高 | +0.0 个点 | [−8.5, +8.5] | 1.000(38 : 38) | 2.00× |
| G 关缓存 − F 大·高 | −6.5 个点 | [−13.0, +0.0] | 0.079(17 : 30) | 3.27× |
G 和 F 的真实质量完全相同(只差缓存),这次观测却差了 6.5 个点。E 和 D 的真实差是 D 高 1.58 个点,两个配置在约 31.7% 的任务上结果不一致;按第 1 周「要跑多少次才够?」里的配对样本量公式,要以 80% 的功效检出这 1.58 个点,大约需要 9,922 条任务。BugHunt-Bench 计划注入 20~30 个 Bug,离这个量差两个数量级:在几百条任务的规模下,质量相近的配置多半就是分不出来。
前沿概率:每次重抽都重画一次前沿
按任务配对重抽 2000 次,每次用重抽出的质量、平均成本、p95 重新判断可行性、重新画前沿,数每个配置落在前沿上的比例(下面演示里的"前沿概率"列)。200 条任务时:A 100%、B 99.0%、C 46.0%、D 99.4%、E 83.0%,F、G 因为 p95 超标始终不可行。只用前 50 条任务时 B 62.6%、C 72.8%、E 15.6%,F 的 p95 有 23.4% 的重抽里达标。
挑点时把不确定性算进去
"选可行配置里观测质量最高的那个"会选中 E:多花 89% 的钱,换一个证据不足的 4 个点,而真实质量反而低 1.58 个点。更稳妥的做法(笔者的建议,不是唯一标准):
- 先定业务能接受的质量下降界值 δ(比如 5 个百分点)和延迟门槛,写在跑评测之前。
- 从最便宜的可行配置往上看,第一个对"观测最好的那个"满足非劣效(配对差 95% 区间下界 > −δ)的,就是候选。这是第 1 周换便宜模型那一节的做法。
- 按字面执行,A、B、C、D 都过不了非劣效,第一个满足的就是 E 自己(和自己比,差恒为 0):规则在这份数据上给不出更便宜的选项。D 只能作为"质量证据不够、但愿意承担风险换成本"的候选单独讨论:D − E 的下界是 −11.5 个点,不满足 δ = 5,证据不够。要么加任务,要么明确写下"选 D,接受质量可能差到 11.5 个点的风险,换来省下 47% 的成本"。不要把"不显著"说成"一样好"。
把整个实验重做 1000 次
合成数据的好处是可以重来:每次重新抽任务、重新跑,看"按点估计"有多可靠。
| 任务数 n | 点估计前沿 = 真实前沿 | "选观测质量最高"选中 E | 选中配置的平均成本 |
|---|---|---|---|
| 50 | 28.6% | 34.4% | $0.2337 |
| 200 | 56.5% | 35.3% | $0.2427 |
| 800 | 79.1% | 20.4% | $0.2171 |
真实最优的可行配置是 D(每条任务平均 $0.1810)。在这个合成设定下,选错的代价主要在钱上:选中 E 时真实质量只低 1.58 个点,总体成本却高约 98%($0.3580 对 $0.1810);n = 200 时平均多花约 34%。任务数翻 4 倍,选错的比例也只从 35% 降到 20%,因为 D 和 E 本来就离得近。
▶互动演示 1:成本–质量散点与前沿
数据是合成的:7 个配置在同一批任务上各跑一次(固定种子 7)。横轴是每条任务的平均成本(示例价格(合成),对数刻度),竖轴是找到率,竖线是 Wilson 95% 区间。实心点是 p95 达标的配置,空心点不达标;蓝色折线连起点估计前沿。bootstrap 按任务配对重抽 2000 次,所有数字都在页面里实时算。
| 配置 | 找到率 | Wilson 95% | 平均成本 | 每找到 1 个 | p95 | p95 的 95% 区间 | 前沿概率 | 达标概率 |
|---|
配对比较两个配置
▶互动演示 2:延迟分布,均值、p50、p95
同一份合成数据。直方图按 20 秒分箱,绿色是找到了 Bug 的运行,灰色是没找到的。可以把这个配置的最后 5 次运行换成 1800 秒超时,看三个数各动多少。
✎练习
5面试要点与代码
一句话讲清楚
换模型或换配置,在同一批任务上把每个候选都跑一遍,量三样:找到率带 Wilson 区间,成本报每条任务的均值和每找到一个 Bug 的花费,延迟报 p50 和 p95。先按延迟门槛排除不可行的,再划掉被支配的,剩下的帕累托前沿上按预算挑。前沿是用估计值画的,所以两个配置之间要做配对比较,挑便宜的那个时要过非劣效,证据不够就加任务或者把风险写明白。
追问准备
- 为什么延迟不报均值?分布右偏有长尾,几次超时就能把均值拉走;门槛和用户体验看的是 p95 这样的尾部分位数。p50 看典型情况,p95 看最慢的 5%。
- p95 自己可信吗?200 次运行的 p95 基本由最慢的 10 次决定,要给它也算区间(按任务 bootstrap)。在这份合成数据里 F 的 p95 是 707 秒,区间 [644, 779] 秒;只用 50 条任务时,F 在 23.4% 的重抽里会"达标"。
- 两个配置的区间重叠,能说一样吗?不能。同一批任务是配对数据,用配对 bootstrap 或 McNemar;"不显著"也不等于"一样",要用非劣效。
- 成本怎么从 token 算?按未命中输入、缓存命中输入、输出三类分别计价。注意口径:Codex 的
TokenUsage.input_tokens是总输入(含缓存命中),Claude API 的usage.input_tokens是未走缓存的剩余部分。
常见错误说法
❌ "平均延迟 275 秒,满足 10 分钟的要求":门槛管的是 p95,不是均值。
❌ "C 和 B 都是 60.5%,C 贵,所以 C 被支配":两个点估计相等不代表真实质量相等,C 在 46.0% 的重抽里仍在前沿上。
❌ "差异不显著,所以便宜的配置一样好":要看差值区间的下界,过不了非劣效就是证据不够。
❌ "日志里 input_tokens 乘输入单价就是输入成本":先确认这个字段是总输入还是未走缓存的剩余部分。
核心代码(Python,无第三方依赖)
def pareto_front(costs, quals, feasible=None):
"""成本越低越好、质量越高越好。返回每个点是否在(可行点里的)帕累托前沿上。"""
n = len(costs)
ok = list(feasible) if feasible is not None else [True] * n
front = []
for i in range(n):
if not ok[i]:
front.append(False)
continue
dominated = any(
ok[j] and j != i and costs[j] <= costs[i] and quals[j] >= quals[i]
and (costs[j] < costs[i] or quals[j] > quals[i])
for j in range(n)
)
front.append(not dominated)
return front
def front_probability(boot, keys, slo):
"""boot[k] 里是按任务配对重抽得到的 q / cost / p95 数组。每次重抽都重新判断可行性、重画前沿。"""
n_boot = len(boot[keys[0]]["q"])
on = {k: 0 for k in keys}
for b in range(n_boot):
costs = [boot[k]["cost"][b] for k in keys]
quals = [boot[k]["q"][b] for k in keys]
feas = [boot[k]["p95"][b] <= slo for k in keys]
for k, o in zip(keys, pareto_front(costs, quals, feas)):
on[k] += o
return {k: on[k] / n_boot for k in keys}
完整代码在学习目录 week07_回放评测与模型对比/code/cost_latency_quality/:synthetic.py 生成合成数据,run_experiment.py 打印本页所有数字并导出 data.json,test_cost_latency_quality.py 是 18 个单元测试。本页的 bootstrap 用和 Python 逐位一致的 mulberry32 随机数,所以默认设置下页面和 Python 输出的数字相同。
公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。