一. 前言:總分沒掉,怎麼還是壞了? #
拍拍君替本機模型換了一版 Prompt,測試分數還是 75%。 看起來可以放心?先等等。 舊版答對的關鍵題被答錯,新版只是救回另一道普通題。 總分相同,不代表沒有回歸。
批次推論處理「把問題跑完」; 量化評測比較大小、速度與品質。 今天不再做一套泛用 runner,而是替升級流程加上可審核的煞車: 同一題以前過、現在不過,就必須留下證據。 也不做 JSON repair/retry; 評測時不偷偷修正模型回答,以免把真實退步藏起來。
二. 題庫、生成設定、評分器要分開版本化 #
我們建立一個很窄的 issue 分流器,只允許三個標籤:
BUG、FEATURE、REVIEW。資訊不足時應交給人工,不准猜。
| 產物 | 固定什麼 | 改動時怎麼辦 |
|---|---|---|
suite.jsonl |
題目、期待答案、切片、重要性 | 新舊版本都重跑 |
| 生成協定 | greedy、輸出長度、單輪獨立對話 | 不直接混比 |
| 評分程式 | 合法標籤與 Gate 政策 | 明確升協定,重新產生兩側產物 |
| baseline | 已審核版本的原始輸出 | 走明確 promotion review |
| candidate | 本次模型或 Prompt 的原始輸出 | 保留來源與差異 |
我們允許改模型、Prompt 或引擎版本,但一次最好只改一個因素。 它們是「受測變因」,不是可以省略的紀錄。 題庫、評分規則、抽樣方式則必須相同,否則根本不在比同一件事。
三. 安裝:Gate 不必載入模型 #
uv init mlx-regression-lab
cd mlx-regression-lab
uv add mlx-lm
uv add --dev pytest
mkdir -p artifacts
本機 Apple Silicon 負責生成;一般 Linux CI 可以只跑純 Python Gate。
把以下三段程式依序存成 gate.py,不需要額外評測框架。
先建立 suite.jsonl;這四題只是教學 fixture,不是品質代表樣本:
{"id":"triage-001","input":"按下儲存就出現錯誤,無法保存。","expected":"BUG","slice":"clear","critical":false}
{"id":"triage-002","input":"希望新增深色主題。","expected":"FEATURE","slice":"clear","critical":false}
{"id":"triage-003","input":"有點怪,但我不知道發生了什麼。","expected":"REVIEW","slice":"ambiguous","critical":true}
{"id":"triage-004","input":"可以增加批次匯出功能嗎?","expected":"FEATURE","slice":"question-form","critical":false}
ID 是長期身分,不是行號;排序改變不應把兩個不同問題配成一對。 但我們的題庫 hash 採原始檔 bytes,連換行改動都會要求重新產生產物。 這比較保守,卻很好理解。不要同一個 ID 改了答案還沿用舊輸出。
四. 第一段:先驗證評測材料 #
import hashlib
import json
from collections import Counter
from pathlib import Path
LABELS = {"BUG", "FEATURE", "REVIEW"}
CONFIG = {"temperature": 0.0, "max_tokens": 32}
def digest(data):
return hashlib.sha256(data).hexdigest()
def protocol_sha256():
# 教學版直接把整份評分程式和生成設定納入協定。
return digest(Path(__file__).read_bytes()
+ json.dumps(CONFIG, sort_keys=True).encode())
def load_suite(path):
rows = [json.loads(x) for x in Path(path).read_text().splitlines() if x.strip()]
result = {}
for row in rows:
key = row["id"]
if not isinstance(key, str) or not key or key in result:
raise ValueError("invalid or duplicate case ID")
if row["expected"] not in LABELS or type(row["critical"]) is not bool:
raise ValueError("invalid expected label or critical flag")
if not isinstance(row["input"], str) or not row["input"].strip():
raise ValueError("invalid input")
if not isinstance(row["slice"], str) or not row["slice"]:
raise ValueError("invalid slice")
result[key] = row
if not result:
raise ValueError("empty suite")
return result
def load_run(path, suite_path, cases):
run = json.loads(Path(path).read_text())
if run["suite_sha256"] != digest(Path(suite_path).read_bytes()):
raise ValueError("suite hash mismatch")
if run["protocol_sha256"] != protocol_sha256():
raise ValueError("protocol hash mismatch")
for key in ("model_revision", "prompt_sha256", "engine_version", "lock_sha256"):
if not isinstance(run["provenance"][key], str) or not run["provenance"][key]:
raise ValueError(f"missing provenance: {key}")
rows = {}
for row in run["rows"]:
key = row["id"]
if key in rows:
raise ValueError("duplicate result ID")
if not isinstance(row["raw"], str):
raise ValueError("raw output must be text")
if row["error"] is not None and not isinstance(row["error"], str):
raise ValueError("error must be text or null")
rows[key] = row
if rows.keys() != cases.keys():
raise ValueError("missing or extra result IDs")
return rows
缺題不是錯一題而已,而是評測不完整,必須阻止發佈。 也不能讓重複 ID 被 dictionary 靜默覆蓋。 兩份結果即使長度一樣,只要 ID 集合不同,也不能配對。 hash 能驗證內容身分,不能證明檔案可信;產物來源與權限稍後處理。
五. 第二段:重算答案,不相信舊的 ok
#
接在同一個 gate.py:
def judge(case, row):
if row["error"] is not None:
return "runtime"
label = row["raw"].strip()
if label not in LABELS:
return "invalid_label"
return "pass" if label == case["expected"] else "wrong_label"
def compare(cases, baseline, candidate):
rows = []
for key, case in cases.items():
before = judge(case, baseline[key])
after = judge(case, candidate[key])
rows.append({
"id": key, "slice": case["slice"],
"before": before, "after": after,
"regressed": before == "pass" and after != "pass",
"improved": before != "pass" and after == "pass",
"critical_fail": case["critical"] and after != "pass",
})
failures = Counter(f'{r["slice"]}/{r["after"]}'
for r in rows if r["after"] != "pass")
accuracy = sum(r["after"] == "pass" for r in rows) / len(rows)
runtime_error = any(judge(cases[k], run[k]) == "runtime"
for run in (baseline, candidate) for k in cases)
blocked = (runtime_error or accuracy < 0.75 or
any(r["regressed"] or r["critical_fail"] for r in rows))
return {"blocked": blocked, "accuracy": accuracy,
"failures": dict(failures), "rows": rows}
這裡的 75% 是方便展示的應用政策,不是業界標準。 真正的專案應在看 candidate 結果之前決定門檻與重要切片。 我們選的政策是:
- 任一舊通過題變失敗:阻擋。
- 任一 critical 題失敗:阻擋,即使 baseline 原本也錯。
- 任一側有 runtime error:本次比較無效,阻擋。
- 全體正確率低於門檻:阻擋。
- 非關鍵題的既有失敗可暫留,但仍出現在失敗清單。
strip() 只容忍標籤前後空白;不刪理由、不抽取 code fence。
BUG,因為無法儲存 仍算 invalid_label。
若正式產品會做正規化,請把規則版本化,不要評測時臨時放水。
六. 第三段:失敗也要有完整報告 #
最後接上 CLI:
if __name__ == "__main__":
import argparse
import sys
parser = argparse.ArgumentParser()
parser.add_argument("suite")
parser.add_argument("baseline")
parser.add_argument("candidate")
parser.add_argument("report")
args = parser.parse_args()
try:
cases = load_suite(args.suite)
baseline = load_run(args.baseline, args.suite, cases)
candidate = load_run(args.candidate, args.suite, cases)
report = compare(cases, baseline, candidate)
code = 1 if report["blocked"] else 0
except (OSError, ValueError, KeyError, TypeError) as exc:
report = {"blocked": True, "artifact_error": str(exc)}
code = 2
Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2))
sys.exit(code)
退出碼 0 代表過 Gate,1 是品質政策阻擋,2 是材料不合法。
三者都會留下 JSON,不要只留下終端機上一句「測試失敗」。
報告父目錄需先建立;輸出路徑本身不可寫時,程式同樣會失敗,不能視為通過。
七. MLX-LM 只負責提供未修飾輸出 #
以下存成 capture.py。MODEL_DIR 必須是已固定 revision 的本機模型快照;
環境變數 MODEL_REVISION 填該快照的實際 commit SHA,不要寫 main。
prompt.txt 存系統指令,例如「只回 BUG、FEATURE 或 REVIEW;資訊不足回 REVIEW」。
import json
import os
import sys
from importlib.metadata import version
from pathlib import Path
from mlx_lm import generate, load
from mlx_lm.sample_utils import make_sampler
from gate import CONFIG, digest, load_suite, protocol_sha256
cases = load_suite("suite.jsonl")
instruction = Path("prompt.txt").read_text()
model, tokenizer = load(os.environ["MODEL_DIR"])
rows = []
for key, case in cases.items():
messages = [{"role": "system", "content": instruction},
{"role": "user", "content": case["input"]}]
prompt = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True)
try:
raw = generate(model, tokenizer, prompt=prompt, verbose=False,
sampler=make_sampler(temp=CONFIG["temperature"]),
max_tokens=CONFIG["max_tokens"])
rows.append({"id": key, "raw": raw, "error": None})
except Exception as exc:
rows.append({"id": key, "raw": "", "error": type(exc).__name__})
run = {"suite_sha256": digest(Path("suite.jsonl").read_bytes()),
"protocol_sha256": protocol_sha256(), "rows": rows,
"provenance": {"model_revision": os.environ["MODEL_REVISION"],
"prompt_sha256": digest(instruction.encode()),
"engine_version": version("mlx-lm"),
"lock_sha256": digest(Path("uv.lock").read_bytes())}}
with Path(sys.argv[1]).open("x") as handle:
json.dump(run, handle, ensure_ascii=False, indent=2)
先審核一版生成的 artifacts/baseline.json,再改 Prompt 或模型,
生成全新的 artifacts/candidate.json,不能把 candidate 直接當答案。
open("x") 防止覆蓋舊紀錄;正式系統請把產物設為不可變並存 run ID。
此短版假設受控、合法的固定輸入;載入或模板出錯會直接中止,不能提交殘缺產物。
模型快照要連 tokenizer/chat template 一起固定;只鎖權重不夠。
八. 用合成輸出測出「同分不同命」 #
先不下載模型。在 test_gate.py 用可預期資料驗證政策:
from gate import compare, load_suite
def outputs(labels):
return {f"triage-{i:03d}": {"raw": label, "error": None}
for i, label in enumerate(labels, start=1)}
def test_same_score_still_regresses():
cases = load_suite("suite.jsonl")
baseline = outputs(["BUG", "FEATURE", "REVIEW", "BUG"])
candidate = outputs(["FEATURE", "FEATURE", "REVIEW", "FEATURE"])
report = compare(cases, baseline, candidate)
assert report["accuracy"] == 0.75
assert report["blocked"] is True
assert [r["id"] for r in report["rows"] if r["regressed"]] == ["triage-001"]
assert [r["id"] for r in report["rows"] if r["improved"]] == ["triage-004"]
兩版都是 3/4,但 triage-001 明確退步。
報告會把它歸到 clear/wrong_label,而不是讓另一題進步把它抵消。
接著要測缺題、重複 ID、錯 hash、關鍵題既有失敗、runtime error,
以及全部通過的正向路徑;否則 Gate 自己也可能讓壞產物過關。
九. CI 分兩層,不讓雲端 Runner 假裝有 Metal #
uv run pytest -q
uv run python capture.py artifacts/candidate.json
uv run python gate.py suite.jsonl artifacts/baseline.json artifacts/candidate.json report.json
第二行在具備 MLX/Metal 的受控 Mac 執行,第一、三行可獨立安排。
只有純 Gate 時,直接使用 Python 即可,不必在 Linux 安裝 MLX-LM。
CI 的判定 job 請設為必要檢查,並保存 report.json,即使 Gate 返回非零也要保留。
模型與 Prompt 升級 PR 應附 candidate artifact 的受信來源、commit、生成環境與 suite hash。
不要把未受信 PR 的程式碼送到持有憑證的 self-hosted runner。 也別讓 PR 自己修改 Gate、改期待答案,再宣稱已通過; 評分器、題庫及 baseline 的修改需額外 reviewer,生成與判定可拆成不同權限工作。 hash 是一致性工具,不是簽章,更不是授權。
十. 失敗分群與 Baseline 升版的紀律 #
先讀轉換清單,再讀 failures:是模糊題被硬猜、問句被錯當一般詢問,
還是輸出多了一句話?將格式失敗與任務錯判拆開,才知道要改哪一層。
不要因為報告很紅,就刪掉最難的那幾題。
Baseline promotion 應是一個獨立變更:
- 確認差異是預期改善,人工檢視每個新失敗。
- 若修正標註或政策,先改題庫/評分版本,再重跑兩側。
- 附上差異報告、選擇理由與 reviewer,才更新 baseline。
- 保存舊 baseline 與版本對應,讓 rollback 有依據。
不要在 Gate job 尾端自動複製 candidate 覆蓋 baseline。 那不叫回歸測試,叫每天忘記昨天發生什麼。
十一. Greedy 也不是跨版本逐字保證 #
make_sampler(temp=0) 使用 greedy 選擇,但不承諾不同硬體、引擎、
kernel 或 tokenizer 版本下逐字一致。本篇比較任務標籤,不做全文相等。
若產品使用非零溫度,應預先固定 seed 集合,重複生成、逐題估計失敗比例,
再定義容許變動;不要失敗後重跑到綠燈才停。
正式題庫也要有足夠的不同切片與獨立 holdout。 反覆針對同四題修 Prompt,只會把它變成背答案比賽。 題目若來自真實內容,先去識別化;本篇所有輸入都是合成資料。 今天測到的是 Gate 的工程行為,不是任何模型的品質排名。
結語 #
可靠的升級流程,不只問「新版平均幾分」。 它還會問:哪一題退步、哪一類出錯、材料完整嗎、誰批准換 baseline? 先讓這些問題都有可執行答案,才值得讓 CI 幫你擋下一次悄悄退步。 拍拍君的模型可以偶爾答錯,升級紀錄可不能失憶。