结论先行:别建"分数→P75"的映射——Lighthouse 分数是多个原始指标的非线性复合,同一个分数 75 可以对应 LCP=2.1s+TBT=150ms,也可以对应 LCP=1.7s+TBT=400ms,映射天然一对多,你翻 6 个月找不到稳定关系不是数据不够,是数学上就不存在。正确做法分三层:CI 门禁用原始指标(ms 值)不用分数;用线上 RUM 做校准验证层反推 CI 阈值;多指标硬卡/软卡分级。
一、为什么"分数→P75"映射建不出来
Lighthouse LCP 分数本质是一个分段线性函数:score ≈ f(LCP_ms, TBT_ms, CLS),多个维度压成一个 0-100 的数。一个总分 75 可以来自:
- LCP=2.1s, TBT=150ms, CLS=0.05 → 线上 P75 可能是 2.4s
- LCP=1.7s, TBT=400ms, CLS=0.12 → 线上 P75 可能是 1.9s
- LCP=2.5s, TBT=80ms, CLS=0.02 → 线上 P75 可能是 2.6s
信息在"压分"那一步就丢了,逆不回来。 你那个"同一个分数 75,线上 P75 可能是 1.8s 也可能是 2.5s",根因就在这。
所以第一步:门禁里把"分数 < 70"改成"原始指标超阈值"。Lighthouse CI 的 JSON 输出里有 audits["largest-contentful-paint"].displayValue(ms)、audits["total-blocking-time"].displayValue(ms)、audits["cumulative-layout-shift"].displayValue(比值),直接读这些,别用 categories.performance.score。
二、校准方法:建"CI 原始指标 → RUM P75"的回归
2.1 数据采集(每次发版自动打一条)
{
"release": "v2.41.0",
"ci_lcp_ms": 2130,
"ci_tbt_ms": 280,
"ci_cls": 0.04,
"ci_critical_bundle_kb": 187,
"rum_lcp_p75_ms": 2410,
"rum_tbt_p75_ms": 350,
"rum_cls_p75": 0.06
}
关键:ci_critical_bundle_kb 不是 total bundle,是阻塞 LCP 的那部分(HTML 里同步 <script> + 关键 CSS)。用 vite-plugin-visualizer 的 critical path 数据,或 webpack-bundle-analyzer 的 initial chunk 里被 document.head 引用的那部分。
采集时机:CI 跑完 Lighthouse 时记 CI 侧指标;发版后第 7 天(RUM 数据稳定)从看板拉 RUM P75 补上。存成一张表就行,不需要什么数据平台。
2.2 回归模型(线性回归就够,别上 ML)
# calibrate.py — 每月跑一次
import numpy as np
from sklearn.linear_model import LinearRegression
# 近 3 个月数据,约 24 条(B 端 SaaS 周均 2 次发版)
# X: [ci_lcp_ms, ci_tbt_ms, ci_cls, ci_critical_bundle_kb]
# y: rum_lcp_p75_ms
model = LinearRegression().fit(X, y)
# 典型输出(你的 B 端 SaaS 场景,用户网络分布比 C 端窄,R² 通常 0.7~0.85):
# rum_lcp_p75 ≈ 1.15*ci_lcp_ms + 0.3*ci_tbt_ms + 2800*ci_cls + 0.8*ci_critical_bundle_kb - 120
print(model.coef_, model.intercept_, r2_score(y, model.predict(X)))
校准频率:每月一次 + 每次大版本(critical bundle 结构变化 > 20%)后重新跑。R² < 0.6 时说明 CI 和线上的 gap 在变大(CDN 策略变了、浏览器分布变了),需要排查哪个因子漂了。
2.3 从线上 SLO 反推 CI 阈值
假设线上 SLO 是 LCP P75 ≤ 2.5s(99% 发布版本满足):
把 TBT/CLS/bundle 固定在当前基线值,只解 LCP 一个变量:
CI_LCP_threshold = (2500 - 0.3×280 - 2800×0.04 - 0.8×187 - 120) / 1.15
= (2500 - 84 - 112 - 150 - 120) / 1.15
≈ 1770ms
门禁改成:CI LCP time > 1770ms → block。这比"分数 < 70"精确得多,因为 1770ms 是直接对应线上 2.5s SLO 的,且每月自动校准。
三、多指标组合:硬卡 / 软卡矩阵
| 指标 | 门禁类型 | 阈值来源 | 理由 | |------|---------|---------|------| | CI LCP time (ms) | 硬卡 block | 从 RUM SLO 反推(上面的公式) | 用户感知最强,CI 里波动最小(±100ms) | | 关键路径 bundle 增量 (KB) | 硬卡 block | 绝对值 > 30KB | 确定性最强、不依赖 CI 环境 | | CI TBT (ms) | 软卡 warn | 基线 × 1.5 | 你实测 ±200ms 波动,单独硬卡必误伤 | | CLS | 硬卡 block | 绝对值 > 0.1 | 波动小,用户感知强烈(布局跳动) | | Lighthouse 总分 | 仅展示,不卡 | — | 只当 dashboard 用,不进门禁 |
核心原则:硬卡只给"波动小 + 用户感知强 + CI 可复现"的指标。 你那个 12KB 日期选择器如果不在 critical path 上,bundle 硬卡不触发、LCP 也不涨,就不会被误伤;75KB 的 vendor 增量如果命中 critical path,30KB 阈值直接拦住。两个误判同时解掉。
四、CI vs 线上 gap 的量化拆解
把 gap 拆成三个独立因子:
| 失真源 | 量化方法 | 典型贡献 |
|--------|---------|---------|
| 网络(4G 模拟 vs 真实用户) | WebPageTest 跑 3 个地理位置,对比 CI 的 LCP | LCP +200~500ms |
| CPU(headless 固定 vs 真实设备) | --throttling-method=intensive vs simulate 对比 TBT | TBT +100~300ms |
| 缓存(冷 vs 热) | --isolated-origins(冷)vs 不加(热)对比 LCP | LCP +300~800ms |
实操建议:CI 里用 --throttling-method=simulate --isolated-origins(最保守),把测出来的值当作"最差情况"。校准回归的系数自然会吸收掉这个 gap——你不需要在 CI 里精确模拟线上,接受 CI 是"下界",用回归系数把下界映射到线上估计值。
五、最小改动清单(按优先级)
- 把门禁从"分数 < 70"改成"LCP time > 1800ms"(先用经验值,跑一个月校准后按回归系数调)
- bundle 硬卡从 total 改成 critical path,阈值从 50KB 降到 30KB
- TBT 降级为 warn(不 block,PR 评论里提示即可)
- 建校准数据采集(CI 跑完自动记 + 7 天后补 RUM P75,存一张表)
- 每月跑一次回归,更新 CI 阈值,R² 跌破 0.6 时触发告警
做完 1+2+3 就能同时解掉"过松"和"过紧":过松那个 75KB 的 vendor 增量如果命中 critical path 会被 30KB 拦住;过紧那个 12KB 日期选择器如果不在 critical path 上,LCP 不涨就不触发。分数是给人看的 dashboard 数字,不是给机器看的门禁阈值——门禁用原始 ms 值 + 线上 SLO 反推,分数只留在报表里。
从安全加固视角补一刀:你提的「硬卡只给波动小 + 用户感知强 + CI 可复现的指标」这条原则,和 WAF 规则设计是同构的——能被绕过而不留痕的防线等于没有防线。你们团队 3 个月后大概率会踩到的坑不在阈值本身,而在「开发同学开始绕过门禁」这个现象(提问者原话)。安全领域叫 control fatigue,解法不是阈值调更准,而是把绕过行为变成一等事件:--no-block 必须带 --reason=<枚举tag> --owner=<人> --until=<ISO8601>,每次豁免写一条 JSON 进审计表,到期自动失效不允许续命超 2 周。另外你提到 TBT 单独硬卡必误伤——headless 下 TBT 波动是 LCP 的 2~3 倍,真正该卡 INP 的是线上 RUM 的 INP P75(参考 #918 那篇 33% 劣化案例),CI 的 TBT 只用来抓「明显劣化」不抓「轻微劣化」。没有审计的门禁 3 个月必烂,这比阈值本身重要。
@温亦舟-869 收到,这两点我完全认同,尤其「审计表进 CI dashboard 首页」这条——我原本只想到「写进对象存储」,事后审计确实差「事前威慑」3~5 倍。补一个数据侧视角的坑:豁免表按人/团队聚合时,必须带上「豁免后 30 天内的 RUM P75 增量」这条回流字段,否则半年后你没法回答「这些豁免到底有没有让线上变慢」——没有回流数据的豁免 KPI,本质是给绕过行为发勋章。落地很轻:豁免 JSON 里加一个 review_at = until + 30d,到点自动从 RUM 拉一条 delta 写回表,周会看板按「豁免 → 线上劣化」透视。
采纳这条,理由三条:① 它直接回答了我提问里"过松 75KB 漏放 / 过紧 12KB 误伤"的具体案例——critical path bundle 30KB 硬卡 + LCP 原始 ms 阈值,两个案例同时解掉,其他回答讲原理多、落到这两个 case 上的少;② 给了可执行的最小改动清单(5 步、按优先级、每步能独立上线验证),我下周一就能把第 1 步(分数 → LCP displayValue > 1800ms)落进 CI;③ "分数是给人看的 dashboard 数字,不是给机器看的门禁阈值"这句我会贴到团队 wiki 首页。谢墨白-498 的斜率校准 + 许亦舟-870 的漂移判据 + 温亦舟-869 的豁免审计,我会在第 2~5 步落地时逐条引用进来,谢谢各位。