第 2 部分:把评估当作部署门禁——以及如何察觉漂移

Stack Overflow Blog 2026-10-08T06:01:27.595524

如果改一个 prompt 就能上线、而没有评估能让构建失败,那你手里就不是评估,只是一个 notebook 而已。而且就算门禁一路绿灯,隐患也会慢慢渗进来。下面要讲的就是门禁、基线,以及能同时抓住这两类问题的影子评估(shadow-eval)循环。

这是成熟度模型的第 2 级:评估。原则可以一句话说清——别靠祈祷上线,要靠门禁上线,上线之后还要盯着漂移。门禁守住的是部署那一刻,漂移检测守住的是部署之后的那几周。两者都需要,但用到的工具完全不同。

大多数团队“做评估”的方式,和他们当年在引入 CI 之前“做测试”的方式一样:偶尔做一次,手动做,在 notebook 里做,而且往往是在出事之后才做。对 LLM 系统来说,这远远不够。因为最可能悄悄搞坏你产品的,不是某次代码改动,而是一次 prompt 微调、一次模型升级,或者一个看起来无害的 temperature 调整——它会在 8% 的输入上悄悄拉低质量。解决办法是把评估当作测试来对待:一道在 CI 里运行、一旦出现回归就让构建失败的门禁。

黄金用例本身就是数据

{
  "id": "case-0412",
  "slice": "tier1/en",
  "input": { "request": "...", "context": { } },
  "expect": { "decision": "approve", "min_confidence": 0.80 },
  "must_not": { "decision": "auto_resolve" },
  "tags": ["edge-case", "previously-broke"]
}

像维护测试一样维护它:覆盖常见情况、已知的难点情况,以及每一个你已经修过的历史失败(回归用例永远不要删)。它和代码放在同一个版本控制里,因此对它的任何改动都是一份可审查的 diff。

打分器

对每个用例,跑一遍当前的 prompt 或模型,给结果打分。打分逻辑要写清楚、带类型——既要包含精确断言,也要包含容差范围。

from dataclasses import dataclass
@dataclass
class CaseResult:
    id: str; slice: str; passed: bool; checks: list

def score_case(case, output) -> CaseResult:
    exp = case.get("expect", {})
    checks = []
    if "decision" in exp:                                                       # symmetric .get on both sides
        checks.append(("decision", output.get("decision") == exp["decision"]))
    if "min_confidence" in exp:
        checks.append(("min_conf", output.get("confidence", 0) >= exp["min_confidence"]))
    for k, v in case.get("must_not", {}).items():
        checks.append((f"not_{k}", output.get(k) != v))
    assert checks, "empty expect + no must_not would vacuously pass"            # now this CAN actually fire
    return CaseResult(case["id"], case["slice"], all(ok for _, ok in checks), checks)
def test_scorer_catches_regression():                  # sensitivity: it MUST fail a wrong decision case = {"id": "c1", "slice": "tier1/en", "expect": {"decision": "approve", "min_confidence": 0.8}} assert     score_case(case, {"decision": "approve", "confidence": 0.9}).passed assert not score_case(case, {"decision": "reject",  "confidence": 0.9}).passed

门禁

一条命令接进 CI,只要通过率(或任一关键指标)跌破门槛,就让构建失败。

# .ci/eval-gate.yml  (illustrative) eval-gate: steps: - run: eval run --suite golden --gate --min-pass 0.98 --baseline artifacts/baseline.json # exit code != 0 fails the pipeline → deploy blocked
$ eval run --suite golden --gate --min-pass 0.98 cases: 240   passed: 236   failed: 4   pass-rate: 98.3% gate(min-pass>=0.98): PASS failures: case-0412 (decision), case-0511 (min_conf), ...

这样一来,如果改提示词让某个场景变好了、却把另外两个场景弄坏了,就没法悄悄发版——作者在提 PR 时就会看到问题,而不是等三周后由客户来发现。

自动把用例分发到合适的分片

系统变大之后,所谓的「黄金集」会拆成许多子套件,分别对应不同的请求类型、用户群或语言地区。别让人来决定跑哪个套件——给每个用例打上 slice 标签,让运行器用系统运行时所用的同一套属性,把用例分发到匹配的分片。

针对某个切片的评估只跑属于该切片的用例:无需人工挑选,“测什么”和“跑什么”之间也不会出现偏差。另外,别拿一个全局通过率当门禁:整体 99% 可能掩盖掉某个只有 70% 的切片,所以每个切片也要单独设门禁。

两类回归

上面这道门禁擅长一件事,对另一件事却视而不见:

你可能门禁一路绿灯,系统却在悄悄变差。门禁只能测你想到的输入,且只在部署那一刻测。生产环境会在你脚下变化:输入分布偏移、模型供应商更新了模型、一处提示词调整让你测过的用例变好,却让没测过的用例变差。漂移检测防的是第二类问题,需要另一套机制。

基线:和已知良好版本对比,而不只是看底线

门禁问的是“是否高于底线?”,漂移检测问的是“是否比之前更差?”。所以要记录一份基线——上一个已知良好版本的各项指标——然后让每次运行都和它对比。

// baseline.json — captured deliberately when you bless a release as known-good
{
  "release": "2026-01-09",
  "metrics": {
    "exact_match":         { "mean": 0.942, "n": 2400, "std": 0.012 },
    "mean_confidence":     { "mean": 0.871, "n": 2400, "std": 0.030 },
    "human_override_rate": { "mean": 0.060, "n": 2400 }
  }
}
import math

# per-metric minimum meaningful change — a 0.02 move matters for a 0.06 rate, trivial for confidence
MIN_EFFECT = {"exact_match": 0.01, "human_override_rate": 0.02, "mean_confidence": 0.03}
HIGHER_IS_WORSE = {"human_override_rate", "judge_disagreement_ratio", "abstention_rate"}  # 对这几项来说,数值上升 = 变差
def compare_to_baseline(metric, cur, base, is_proportion=True):
    # cur/base are {"mean":.., "n":.., "std":..}. Use BOTH runs' n — not just the baseline's.
    delta = cur["mean"] - base["mean"]
    if is_proportion:                                   # rate metrics → two-proportion z-test
        p  = (base["mean"]*base["n"] + cur["mean"]*cur["n"]) / (base["n"] + cur["n"])   # pooled
        se = math.sqrt(p*(1-p) * (1/base["n"] + 1/cur["n"]))
    else:                                               # continuous → SE of a DIFFERENCE of means
        se = math.sqrt(base["std"]**2/base["n"] + cur["std"]**2/cur["n"])
    z = delta/se if se else 0.0

    worse = delta > 0 if metric in HIGHER_IS_WORSE else delta < 0   # direction is per-metric, not always "lower"
    drifted = worse and abs(delta) >= MIN_EFFECT.get(metric, 0.02) and abs(z) > 2   # meaningful + ~2σ the bad way
    return {"metric": metric, "delta": round(delta, 4), "z": round(z, 1), "drift": drifted}

常见的 bug 是:拿 baseline 的标准差去除以 sqrt(n),却忽略了当前这次运行自身的样本量和方差——用 50 条线上决策去跟一个 2400 行的 baseline 比,你只会把纯噪声也标成异常。差值标准误(SE)要把两边的样本量和方差都用上;比例类指标用合并的双比例形式,而不是套一个存下来的 std。

某个变化可能高于你的最低阈值,但相对 baseline 仍然明显偏低——这正是 gate 会漏掉的信号:

metric         baseline  current  delta
exact-match    0.942     0.913   -0.029  ⚠ regression vs baseline (still > floor, but flagged)

重新设定 baseline 要刻意为之(在你验证出一个新的 known-good 时),绝不能自动进行,否则 drift 就会变成新的常态。

影子评估:给线上流量打分

你的 golden set 是有限的、精心挑选的;而生产环境是无限的、充满意外。

Part 2:把评估当作部署门禁——以及怎么知道它什么时候开始漂移

影子评估(shadow eval)会抽样真实的(已脱敏)输入,给实际决策打分,并长期跟踪通过率。

查看原文