資料有版本(Day 09)、實驗有紀錄(Day 10),今天加上最重要會擋人的門,把它們串成一條自動跑也安心的管線。
這是 Level 0 到 Level 1 的關鍵一步,也是 Day 08 診斷表第 1、6、7 題的內容。
先假設一個可能發生的災難:
團隊做了自動重訓,每週一自動用最新資料重訓並部署。某個週一,上游資料管線出錯導致某個關鍵特徵全變成 0,模型照樣訓練完成(不會報錯)、照樣部署上線。到週四才有人發現,四天的預測全是垃圾。
自動化本身沒有錯,錯在自動化的路上沒有設關卡。今天要做的,就是在這條路上設三道關卡。
📊 職缺訊號
「訓練管線與 CI/CD」出現在 42.6% 的 MLOps 職缺中,是第三高的任務訊號。而在面試裡,這題常以「你會怎麼確保一個爛模型不會上線」的形式出現——答得出「品質門檻」三個字並能舉例,通常就過了。
先看管線該長什麼樣:

圖 11-1:訓練管線的六個步驟與三道關卡(示意架構)。每個步驟都可獨立重試、可快取、可分配不同資源;三道關卡分別擋資料、擋模型、擋部署。
拆成步驟的效益,不是「看起來比較專業」,而是三件很實際的事:
這是今天的核心。三道關卡擋的是不同的東西:
| 關卡 | 位置 | 擋什麼 | 沒設會怎樣 |
|---|---|---|---|
| 關卡一:資料驗證 | 訓練之前 | schema 變更、缺值暴增、分布異常 | 用壞資料訓練出壞模型(開頭那個災難) |
| 關卡二:品質門檻 | 註冊之前 | 指標未達標、比現行模型差、切片退化 | 爛模型進了生產環境 |
| 關卡三:部署驗證 | 上線之後 | 服務健康、金絲雀期間的業務指標 | 模型好但服務掛了 |
關卡二是今天的重點,它的判斷邏輯應該長這樣:
放行條件(必須全部成立):
1. 絕對門檻:test AUC ≥ 0.85 ← 不管怎樣都不能低於這條線
2. 相對門檻:test AUC ≥ 現行冠軍 − 0.005 ← 允許微幅波動,但不能明顯退步
3. 切片門檻:所有通路的 AUC ≥ 0.75 ← 防止「整體好、局部爛」
4. 行為測試:不變性測試全數通過 ← 改無關欄位不該影響預測
第 2 條的設計值得說明:為什麼是「≥ 冠軍 − 0.005」而不是「> 冠軍」?因為要求每次都更好,會讓管線在正常波動下卡死。容忍一個小的退步區間,同時擋住明顯退化,這是實務上可運作的設定。
"""gate.py —— 品質門檻。回傳非零狀態碼會讓 CI 失敗,模型就進不了 registry。"""
import json, sys
import mlflow
from mlflow import MlflowClient
ABSOLUTE_MIN = 0.85 # 絕對門檻
REGRESSION_TOLERANCE = 0.005 # 允許的退步幅度
SLICE_MIN = 0.75 # 各切片的最低要求
metrics = json.load(open("artifacts/metrics.json"))
new_auc = metrics["test"]["auc"]
failures = []
# 1) 絕對門檻
if new_auc < ABSOLUTE_MIN:
failures.append(f"AUC {new_auc:.4f} < 絕對門檻 {ABSOLUTE_MIN}")
# 2) 相對門檻——與現行冠軍比較
client = MlflowClient()
try:
champ = client.get_model_version_by_alias("fraud-model", "champion")
champ_auc = client.get_run(champ.run_id).data.metrics["auc"]
if new_auc < champ_auc - REGRESSION_TOLERANCE:
failures.append(f"AUC {new_auc:.4f} 低於冠軍 {champ_auc:.4f} 超過容忍值")
except Exception:
print("尚無冠軍模型,略過相對門檻(首次上線)")
# 3) 切片門檻
for name, value in metrics.get("slices", {}).items():
if value < SLICE_MIN:
failures.append(f"切片 {name} 的 AUC {value:.4f} < {SLICE_MIN}")
if failures:
print("❌ 品質門檻未通過:")
for f in failures:
print(" -", f)
sys.exit(1) # ← 這一行就是「擋下來」
print(f"✅ 通過品質門檻(AUC {new_auc:.4f})")
# .github/workflows/train.yml
name: 訓練與品質門檻
on:
push:
branches: [main]
paths: ["src/**", "pipelines/**", "data.dvc"] # 只有相關檔案變動才觸發
schedule:
- cron: "0 18 * * 0" # 每週一凌晨 2 點(UTC+8)定期重訓
workflow_dispatch: # 允許手動觸發
jobs:
train:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安裝環境
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
uv sync --frozen # 用鎖定版本,確保可重現
- name: 取得資料(DVC)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: uv run dvc pull
- name: 程式碼檢查與單元測試
run: |
uv run ruff check .
uv run pytest tests/ -v
- name: 關卡一 — 資料驗證
run: uv run python src/validate.py # Day 09 的驗證邏輯
- name: 訓練
env:
MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
run: uv run python src/train_with_mlflow.py
- name: 模型行為測試
run: uv run pytest tests/test_model_behavior.py -v
- name: 關卡二 — 品質門檻
run: uv run python src/gate.py # 沒過就在這裡停住
- name: 通過門檻才註冊模型
run: uv run python src/register.py
- name: 上傳報告(無論成敗都保留,方便事後追查)
if: always()
uses: actions/upload-artifact@v4
with:
name: training-report
path: artifacts/
"""tests/test_model_behavior.py —— 模型的「單元測試」。"""
import joblib, pandas as pd
model = joblib.load("artifacts/model.pkl")
def test_invariance_to_irrelevant_field():
"""不變性測試:改動與預測無關的欄位,結果不該變。"""
base = pd.read_csv("tests/fixtures/sample.csv")
modified = base.copy()
modified["user_id"] = modified["user_id"] + 100000 # 換 ID 不該影響風險判斷
assert (model.predict(base) == model.predict(modified)).all()
def test_directional_expectation():
"""方向性測試:金額提高,詐欺分數不該下降(符合業務常識)。"""
base = pd.read_csv("tests/fixtures/sample.csv")
higher = base.copy(); higher["amount"] = higher["amount"] * 10
assert (model.predict_proba(higher)[:, 1]
>= model.predict_proba(base)[:, 1] - 1e-6).all()
def test_no_nan_output():
"""健全性測試:任何輸入都不該產生 NaN。"""
edge = pd.read_csv("tests/fixtures/edge_cases.csv") # 含極值與缺值
assert not pd.isna(model.predict_proba(edge)).any()
這三種測試分別對應三種真實的失效:模型偷學了 ID(洩漏)、模型學到與業務常識相反的關係(資料問題)、模型遇到邊界輸入就爆掉(前處理缺陷)。

圖 11-2:ML 的 CI/CD 與環境晉升流程(示意架構)。程式碼、資料、模型三軸各有觸發條件,最終匯流到同一組品質門檻。
| 工具 | 適合 | 代價 |
|---|---|---|
| GitHub Actions/GitLab CI | 中小團隊、訓練時間 < 6 小時 | 執行時間與資源受限,GPU 需自架 runner |
| Airflow | 已有資料工程團隊、需複雜排程 | 維運成本高,非為 ML 設計 |
| Prefect/ZenML | 想要 Python 原生、輕量的管線 | 生態較小 |
| Kubeflow Pipelines | 已有 K8s、需要 GPU 排程 | 學習曲線陡,小團隊過重 |
| 雲端託管(Vertex/SageMaker Pipelines) | 已在該雲、要少維運 | 綁定平台 |
自動化程度的取捨更重要:
| 自動化程度 | 建議 |
|---|---|
| 自動訓練 + 自動註冊 + 人工按鈕部署 | ✅ 多數團隊的最佳落點 |
| 全自動(含自動部署) | 只在監控與回滾都成熟後才做(Day 14) |
| 全手動 | 只適合一年更新一兩次的模型 |
寫 gate.py 只要半小時,但決定「AUC 門檻訂在 0.85」這個數字,往往要吵很久。給三個實務上可用的定法:
定法一:從業務錯誤代價回推。 詐欺偵測中,漏掉一筆詐欺的損失是攔錯一筆正常交易的 50 倍,那門檻就該偏向高召回率,而不是追求整體 AUC。先算出兩種錯誤的成本比,再回推指標與閾值,這是最有說服力的定法。
定法二:從現況回推。 如果現在是人工審核,先量測人工的準確率當基準線——模型至少要不比人差。多數團隊跳過這一步,結果上線後才發現「模型 85 分,但人工其實有 92 分」。
定法三:從歷史波動回推。 把過去 10 次訓練的指標畫出來,看它的自然波動範圍。若歷史波動是 ±0.008,那容忍值訂 0.005 就會經常誤擋;訂 0.015 又太鬆。容忍值應該略大於歷史波動,略小於你在意的退步幅度。
三種定法可以併用,但一定要寫下理由——寫進 gate.py 的註解或 ADR(Day 28)。因為半年後一定有人會問「為什麼是 0.85」,而「當初這樣訂的」不是答案。
生成式 AI 應用沒有訓練管線,但完全有 CI 與品質門檻,而且更需要:
差別在於:傳統 ML 的門檻是一個數字(AUC ≥ 0.85),生成式 AI 的門檻是一組數字(忠實度、答對率、格式合規率、注入測試通過率),而且其中有些要靠 LLM 評審給分——這讓 gate 本身變得更複雜,但邏輯完全相同。
⚠️ 關於「全自動」的建議
很多人把「全自動部署」當成 MLOps 的終極目標。但實務上,保留一個人工按鈕的成本極低,而它能擋掉的災難很大。我的建議是:讓自動化做完所有「累但沒有判斷」的事(拉資料、訓練、評估、產報告、註冊),把最後一步「要不要換掉現在的冠軍」留給人——直到你的監控能在 10 分鐘內告訴你出事了,再考慮拿掉那個按鈕。
管線會跑了,但它跑在哪裡?明天進入 MLOps 職缺中出現頻率最高的領域(58.5%):容器與 Kubernetes。我會聚焦在 ML 服務特有的坑——GPU 排程,以及那個讓無數人以為服務壞掉、其實只是探針設定錯誤的假故障。