模型能上線之後,下一個問題是:它是怎麼來的?

如果答案是「某個人在某台機器上跑了一個 notebook」,那你沒有交付鏈, 你有的是一個口述歷史。

這篇是在 OpenShift AI 上把它變成 pipeline 的最小可用版本。


平台這邊要有什麼

OpenShift AI 的 pipeline 引擎是 Kubeflow Pipelines v2, 在 DSC 裡的元件名叫 aipipelines(2.x 叫 datasciencepipelines,改名了)。

開了之後,每個 project 要建一個 DataSciencePipelinesApplication(DSPA), 它會生出一整組東西:

oc get deploy -n llm-serve-demo | grep ds-pipeline
ds-pipeline-dspa                      1/1   ← API server
ds-pipeline-metadata-envoy-dspa       1/1   ← lineage 的 proxy
ds-pipeline-metadata-grpc-dspa        1/1   ← lineage 儲存
ds-pipeline-persistenceagent-dspa     1/1   ← 把 run 狀態寫回 DB
ds-pipeline-scheduledworkflow-dspa    1/1   ← 排程
ds-pipeline-workflow-controller-dspa  1/1   ← 實際執行(Argo Workflows)
mariadb-dspa                          1/1   ← metadata DB

七個 pod。 你只是要跑四個步驟,平台幫你把「誰跑過什麼、產出了什麼、 現在跑到哪」這些事管起來——代價是這七個東西。

⚠️ 那個內建的 MariaDB 有一個字元集陷阱,但不是你以為的那個。 我實際去查過:資料表與欄位是 utf8mb4_unicode_ci中文存得進去、 dashboard 也顯示得正確(我的 run 描述就是中文)。

陷阱在連線端

SHOW VARIABLES LIKE 'character_set_%';
# character_set_client      latin1
# character_set_connection  latin1
# character_set_results     latin1

所以你自己寫的查詢、報表、或 mysqldump 沒指定 --default-character-set=utf8mb4 就會拿到 ?????——而且不會報錯。備份尤其危險:你會得到一份看起來成功、 但中文全毀的 dump。

(只有 8 個欄位真的是 latin1,全部是 UUID 欄位——那些只存十六進位字元, 用 latin1 是刻意的省空間,不影響中文。)


Pipeline 本身:用 Python 寫

KFP v2 的寫法是宣告容器步驟,然後宣告它們的順序:

from kfp import dsl

@dsl.container_component
def prepare_data(run_id: str, sample_mb: str):
    return dsl.ContainerSpec(image=IMAGE, command=["bash", "-c", PREPARE, "bash"],
                             args=[run_id, sample_mb])

@dsl.container_component
def train_model(run_id: str, max_iters: str): ...

@dsl.container_component
def evaluate_model(run_id: str): ...

@dsl.container_component
def promotion_gate(run_id: str, max_val_loss: str): ...


@dsl.pipeline(name="llm-lifecycle")
def llm_lifecycle(run_id: str = "run1", sample_mb: str = "2",
                  max_iters: str = "300", max_val_loss: str = "6.0"):
    p = prepare_data(run_id=run_id, sample_mb=sample_mb)
    t = train_model(run_id=run_id, max_iters=max_iters).after(p)
    e = evaluate_model(run_id=run_id).after(t)
    g = promotion_gate(run_id=run_id, max_val_loss=max_val_loss).after(e)

.after() 就是 DAG 的邊。編譯:

python pipeline_llm.py        # → llm-lifecycle.yaml

然後在 ODH dashboard 上傳那個 YAML,或用 kfp SDK 推上去。

Pipeline definitions


三個當初卡住我的設定

1. 關掉 caching,不然你會以為它跑了

task.set_caching_options(False)

KFP 預設會 cache——輸入沒變就直接沿用上次的結果,秒回成功。

在 CI/CD 的情境這是優點。在「我要驗證這條鏈真的會跑」的情境, 這是災難:你會看到一條全綠的 run,實際上什麼都沒執行。

(這跟我在另一篇講的假綠是同一類問題。 做 lab 或驗收時,先把所有 cache 關掉。)

2. S3 憑證用 secret 注入,不要進 image

kubernetes.use_secret_as_env(
    task, secret_name="minio-s3",
    secret_key_to_env={"AWS_ACCESS_KEY_ID": "AWS_ACCESS_KEY_ID",
                       "AWS_SECRET_ACCESS_KEY": "AWS_SECRET_ACCESS_KEY"})
task.set_env_variable("S3_ENDPOINT", S3_ENDPOINT)

理由很直接:image 會進 registry,密碼不該跟著走。 image 可能被複製到別的環境、被別人拉、被掃描工具展開—— 任何進了 image layer 的東西都要當成已經公開。

3. 資源要一步一步給,不要整條開大

t.set_cpu_request("1").set_cpu_limit("3") \
 .set_memory_request("2Gi").set_memory_limit("6Gi")

只有 train 這一棒吃資源,其他三棒是複製檔案和算數字。 整條 pipeline 開大會在資源緊的叢集上直接排不進去(我的 CRC 只有 10 CPU)。


讓 lineage 有東西可查

最後一棒有一行值得單獨講:

task.set_env_variable("PIPELINE_IMAGE", IMAGE)

因為容器裡沒有 git 可以問。你想在台帳裡記「這顆模型是哪一版程式碼訓練的」, 就得在編譯 pipeline 的時候把身份帶進去

gate 那一棒收到之後寫進台帳:

entry["lineage"]["code_image"] = os.environ.get("PIPELINE_IMAGE", "unknown")
entry["lineage"]["run_id"] = run_id

這是 lineage 最容易漏掉的一段:大家都會記「用了哪份資料」, 但「用了哪一版程式碼」常常沒人記——而它同樣會改變模型。 image digest 是最誠實的答案,因為它不會被改寫。


跑起來長這樣

四棒依序執行,每一格綠了才走下一格:

prepare-data → train-model → evaluate-model → promotion-gate

參數在建立 run 的時候填:

run_id=run1  sample_mb=2  max_iters=300  max_val_loss=6.0

⚠️ 最後那個 max_val_loss 是 run 參數——誰都能填。 這件事的後果我另外寫了一篇, 簡短版是:三顆能力幾乎相同的模型,只因為門檻從 6.0 改成 8.0,一顆上線兩顆被擋。


你們的模型訓練有沒有進 pipeline?還是還在 notebook 或某台機器上手動跑?

🧪 這篇的實驗環境與 lab 檔案(最後更新 2026-08-30)

叢集

  • CRC 2.63.0 · OpenShift 4.22.7 · Kubernetes v1.35.6
  • 單節點:13 vCPU / 40 GiB RAM / 120 GB disk
  • 宿主:Framework Laptop 16(Ryzen AI 7 350 · 8C/16T · 64 GB RAM · 1 TB NVMe · RTX 5070 顯卡模組)
  • ⚠️ 叢集內看不到 GPU(CRC 是 VM,RTX 5070 未 passthrough)

Operator

  • opendatahub-operator.v3.5.0 ← 即 RHOAI 3.x 的上游開源版
  • cert-manager-operator.v1.20.0(3.x 的必要相依;2.x 不需要)
  • openshift-pipelines-operator-rh.v1.23.2openshift-gitops-operator.v1.21.3

DataScienceCluster 開啟的元件

  • kserveaipipelinesdashboardworkbenchesmodelregistrykueue(Unmanaged)
  • 其餘(raytrustyaifeastaigateway…)為 Removed

叢集外的依賴(跑在宿主的 podman 上,crc start 不會帶起來)

  • Harbor v2.15.2(私有 registry)· MinIO(S3)

模型端

  • Python 3.12.13 · PyTorch 2.11.0+cu128 · FastAPI + uvicorn · prometheus-client

lab 檔案

⚠️ ODH ≠ RHOAI:元件同源,但 namespace 與部分名稱不同 (我這裡是 opendatahub,商用版是 redhat-ods-* 那一套)。 指令的邏輯可以照用,字串要自己對一次。