结论先行:别建"分数→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 反推,分数只留在报表里。