使用 MLflow Tracking 和 TrustyAI Evaluation 的每日 Fine-Tuning Pipeline

一个完整的 Kubeflow Pipeline,用于 fine-tune 一个 LLM(此示例中为 Qwen3-0.6B),在 MLflow 中跟踪运行,将生成的模型注册到 MLflow Model Registry,将其部署为 KServe InferenceService,使用 TrustyAI LMEvalJob 进行评估,将评估结果写回同一个 MLflow experiment,并清理临时 serving 资源。随后,该 pipeline 会连接到 KFP Recurring Run,从而每天触发一次——为你提供一条不断增长的 train → eval 运行历史,MLflow 的 compare 视图会将其转化为回归信号。

本指南将已经分别记录的各个部分整合到一起——Use Kubeflow PipelinesKubeflow Pipeline + MLflow IntegrationEvaluate LLM——形成一个可运行的方案。如果某一步不熟悉,请先阅读这些文档。

该 pipeline 的作用

                     ┌───────────────┐
   Qwen3-0.6B  ─────►│  fine-tune    │──► loss/eval_loss/perplexity ─► MLflow parent run
      base model     │  (SFTTrainer) │──► fine-tuned model artifacts ─► MLflow Model Registry
                     └──────┬────────┘        (registered_model_name="qwen3-0.6b-sft")


                     ┌───────────────┐
                     │ deploy-for-   │──► KServe InferenceService (temporary)
                     │ evaluation    │
                     └──────┬────────┘


                     ┌───────────────┐
                     │ evaluate      │──► TrustyAI LMEvalJob (arc_easy, mmlu, …)
                     │ (LM-Eval)     │──► eval metrics ─► MLflow nested "eval" run
                     └──────┬────────┘                    + tag on model version


                     ┌───────────────┐
                     │  cleanup      │──► delete InferenceService + LMEvalJob
                     └───────────────┘

每天的运行都会向 MLflow experiment 追加一行。由于训练和评估指标都位于同一个父运行下,MLflow 的 CompareChart 视图可以直接绘制按日变化的 loss 和评估准确率,而无需额外的胶水代码。

前置条件

需求详情
Alauda AI 2.5 或更高版本已安装 Kubeflow Pipelines、MLflow(已配置 Model Registry artifact store)、TrustyAI、KServe。
预构建的 pipeline runtime image下方的四个 KFP component 使用 docker.io/alaudadockerhub/finetune-pipeline-runtime-cu126-amd64:v0.1.0(linux/amd64)。它内置了完整的 CUDA / HF / trl / MLflow / KServe / kubernetes 技术栈,因此 pod 启动时无需执行 pip install——在离线隔离集群中,只要将该镜像镜像到内部 registry,同样可以工作。请参见下方的 Bake a custom image 以推导你自己的镜像,以及 Training Runtime Images 获取源 Containerfile 目录。
MLflow artifact store在 MLflow 插件中配置了 S3 兼容的对象存储。mlflow.transformers.log_model() 需要它来持久化模型文件,以便 KServe 后续拉取。请参见 MLflow install 中的 High availability and storage 部分。
名为 finetune 的 namespace,并带有 MLflow label在 namespace 上设置 mlflow-enabled=true,使其显示为 workspace。
共享 PVC finetune-shared(RWX)用于在 component 之间缓存基础模型和 fine-tuned checkpoint。任何支持 RWX 的 StorageClass 都可以(CephFS、NFS、JuiceFS)。
一个 GPU 节点对于小型数据集,1 × NVIDIA GPU 且显存 ≥ 16 GiB 即可对 Qwen3-0.6B 进行全精度 SFT;对于小型 LoRA 运行,8 GiB 也足够。
MLflow token Secret名为 mlflow-tokenSecret,其中 key 为 token,保存一个 Dex id token,用于访问 finetune MLflow workspace 的 service account。请参见 Get a token from the command line
finetune namespace 中的 RBACpipeline 的 ServiceAccount 可以对 inferenceservices.serving.kserve.iolmevaljobs.trustyai.opendatahub.io 执行 get/create/delete
训练数据集(可选)fine_tune component 默认使用一个小型合成的进程内语料库——冒烟运行无需准备数据集。对于真实工作负载,请先在共享 PVC、seaweedfs 或 S3 上预先放置一个 JSONL 文件,并在 dataset_path 中传入其路径;请参见下方的 Using a real dataset
到 Hugging Face 的出站访问(或离线 PVC)默认有两个 component 会从 Hugging Face 拉取内容:(a)fine_tune 通过 AutoModelForCausalLM.from_pretrained(...) 下载基础模型 Qwen/Qwen3-0.6B;(b)evaluate component 的 LMEvalJob 会获取 tokenizer 和 dataset——即使 tokenized_requestsFalseAutoTokenizer.from_pretrained(...) 也会执行。对于(a),请先将基础模型镜像到共享 PVC 一次(见 Using a real dataset → 基础模型说明)。对于(b),请将 eval job 切换到离线模式(见 Evaluate LLM):预先在 PVC 中放入 tokenizer + dataset cache,设置 spec.offline.storage.pvcName,并将 tokenizermodelArgs 指向挂载路径。

步骤 1 — 创建 MLflow token Secret 和共享 PVC

NS=finetune

# 1. Mint a Dex id token (browser-free) — see the SDK guide for ID_TOKEN.
kubectl -n $NS create secret generic mlflow-token --from-literal=token="$ID_TOKEN"

# 2. Shared PVC used by fine-tune (write) and deploy (read).
kubectl -n $NS apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: finetune-shared
spec:
  accessModes: ["ReadWriteMany"]
  resources:
    requests:
      storage: 50Gi
  storageClassName: cephfs   # any RWX-capable StorageClass
EOF

下方的 pipeline component 假定这两个对象都存在于 pipeline 运行所在的 namespace 中。

步骤 2 — pipeline

将以下内容保存为 finetune_pipeline.py。该 pipeline 被拆分为四个 component,因此失败可以更精确地定位,而且重跑成本较低;component 通过 PVC 以及 模型版本上的 MLflow tags 共享状态。

# finetune_pipeline.py
from kfp import dsl, compiler, kubernetes

BASE_MODEL      = "Qwen/Qwen3-0.6B"
REGISTERED_NAME = "qwen3-0.6b-sft"
WORKSPACE       = "finetune"                       # MLflow workspace (= namespace)
EXPERIMENT      = "qwen3-0.6b-daily-sft"
PVC_NAME        = "finetune-shared"
PVC_MOUNT       = "/mnt/shared"

MLFLOW_URI      = "http://mlflow-tracking-server.kubeflow:5000"

# Pre-built runtime image with torch (CUDA 12.6) + HF + trl + mlflow + kserve
# + kubernetes baked in. No pip install runs at pod start, so the same tag
# works on air-gapped clusters once it's mirrored into an internal registry.
RUNTIME_IMAGE   = "docker.io/alaudadockerhub/finetune-pipeline-runtime-cu126-amd64:v0.1.0"


# ---------------------------------------------------------------------------
# 1. Fine-tune with the HF Trainer, autolog to MLflow, register the model.
# ---------------------------------------------------------------------------
@dsl.component(base_image=RUNTIME_IMAGE)
def fine_tune(
    workspace: str,
    experiment: str,
    base_model: str,
    registered_name: str,
    pvc_mount: str,
    run_id: str,
    # Empty = generate a tiny synthetic instruction corpus in-process (no
    # network egress; smoke-shape only, not a meaningful fine-tune). Non-empty
    # = a local JSONL path (e.g. "/mnt/shared/datasets/alpaca.jsonl") or an
    # "s3://…" URI to a JSONL file with a `text` column. See the "Using a
    # real dataset" section for how to prepare the file.
    dataset_path: str = "",
    num_train_epochs: int = 1,
    learning_rate: float = 2e-4,
    per_device_train_batch_size: int = 2,
    max_samples: int = 512,
) -> str:
    """Full SFT of `base_model` on a small synthetic corpus (or a JSONL file
    at `dataset_path`). Logs metrics + registers the fine-tuned model.
    Returns the model version as a string."""
    import operator, os, random, mlflow, mlflow.transformers
    from datasets import Dataset, load_dataset
    from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
    from trl import SFTTrainer

    # 1) MLflow (auth via MLFLOW_TRACKING_TOKEN injected from the Secret).
    mlflow.set_tracking_uri(MLFLOW_URI)
    mlflow.set_workspace(workspace)
    mlflow.set_experiment(experiment)
    mlflow.transformers.autolog(log_models=False)          # models are logged manually below

    # 2) Data. Synthetic by default so the pipeline's smoke run needs no
    # network egress and no dataset pre-staging. Swap in a real dataset by
    # passing `dataset_path` (JSONL file with a `text` column) — either a
    # PVC path or an S3 URI. See "Using a real dataset" below.
    if dataset_path.startswith("s3://"):
        ds = load_dataset(
            "json", data_files=dataset_path, split=f"train[:{max_samples}]",
            # storage_options / credentials come from AWS_* env; see the docs.
        )
    elif dataset_path:
        ds = load_dataset(
            "json", data_files=dataset_path, split=f"train[:{max_samples}]",
        )
    else:
        rng = random.Random(42)
        ops = [("+", operator.add), ("-", operator.sub), ("*", operator.mul)]
        rows = []
        for _ in range(max_samples):
            a, b = rng.randint(0, 99), rng.randint(0, 99)
            sym, op = rng.choice(ops)
            rows.append({"text":
                f"### Instruction:\nWhat is {a} {sym} {b}?\n"
                f"### Response:\nThe answer is {op(a, b)}."
            })
        ds = Dataset.from_list(rows)

    # 3) Model & tokenizer.
    tokenizer = AutoTokenizer.from_pretrained(base_model)
    model     = AutoModelForCausalLM.from_pretrained(base_model)

    output_dir = f"{pvc_mount}/models/{registered_name}/{run_id}"

    # 4) Train. `report_to="mlflow"` streams loss / eval_loss / lr per step.
    args = TrainingArguments(
        output_dir=output_dir,
        num_train_epochs=num_train_epochs,
        learning_rate=learning_rate,
        per_device_train_batch_size=per_device_train_batch_size,
        logging_steps=10,
        save_strategy="no",
        report_to="mlflow",
        run_name=f"train-{run_id}",
    )
    with mlflow.start_run(run_name=f"pipeline-{run_id}") as parent:
        mlflow.set_tag("pipeline_run_id", run_id)
        mlflow.set_tag("base_model", base_model)
        with mlflow.start_run(run_name=f"train-{run_id}", nested=True):
            # TRL 0.12+ renamed the `tokenizer` kwarg to `processing_class`.
            trainer = SFTTrainer(model=model, args=args, train_dataset=ds,
                                 processing_class=tokenizer)
            trainer.train()
            trainer.save_model(output_dir)

        # 5) Register the fine-tuned model.
        # The artifact upload uses the MLflow plugin's artifact store (S3).
        # KServe then pulls the model back from that S3 URI in the deploy step.
        # `task="text-generation"` is required because MLflow cannot infer the
        # task from a locally-loaded (non-Hub) model.
        mv = mlflow.transformers.log_model(
            transformers_model={"model": trainer.model, "tokenizer": tokenizer},
            artifact_path="model",
            registered_model_name=registered_name,
            task="text-generation",
        )

        # 6) Record the PVC path on the model version so downstream steps
        #    can serve it from the shared PVC without another S3 round-trip.
        client = mlflow.MlflowClient()
        version = client.get_latest_versions(registered_name, stages=["None"])[0].version
        client.set_model_version_tag(registered_name, version, "pvc_path", output_dir)
        client.set_model_version_tag(registered_name, version, "pipeline_run_id", run_id)

    print(f"registered {registered_name} version {version} @ {output_dir}")
    return version


# ---------------------------------------------------------------------------
# 2. Deploy the just-registered model as a temporary KServe InferenceService.
# ---------------------------------------------------------------------------
@dsl.component(base_image=RUNTIME_IMAGE)
def deploy_for_evaluation(
    workspace: str,
    registered_name: str,
    model_version: str,
    pvc_name: str,
    pvc_mount: str,
    run_id: str,
) -> str:
    """Create an InferenceService that serves the model straight off the PVC.
    Returns the InferenceService name."""
    import time, mlflow
    from kubernetes import client, config
    from kserve import KServeClient, constants
    from kserve import (V1beta1InferenceService, V1beta1InferenceServiceSpec,
                        V1beta1PredictorSpec, V1beta1ModelSpec, V1beta1ModelFormat)

    # Resolve the PVC path from the tag set by fine_tune().
    mlflow.set_tracking_uri(MLFLOW_URI); mlflow.set_workspace(workspace)
    tags = mlflow.MlflowClient().get_model_version(registered_name, model_version).tags
    pvc_path = tags["pvc_path"]

    isvc_name = f"{registered_name}-eval-{run_id[-8:]}"
    config.load_incluster_config()
    ns = open("/var/run/secrets/kubernetes.io/serviceaccount/namespace").read().strip()

    isvc = V1beta1InferenceService(
        api_version=constants.KSERVE_GROUP + "/v1beta1", kind=constants.KSERVE_KIND,
        metadata=client.V1ObjectMeta(name=isvc_name, namespace=ns, labels={
            "app.kubernetes.io/part-of": "finetune-pipeline",
            "pipeline-run-id": run_id,
        }),
        spec=V1beta1InferenceServiceSpec(predictor=V1beta1PredictorSpec(
            model=V1beta1ModelSpec(
                model_format=V1beta1ModelFormat(name="huggingface"),
                runtime="kserve-huggingfaceserver",
                storage_uri=f"pvc://{pvc_name}{pvc_path.removeprefix(pvc_mount)}",
                resources=client.V1ResourceRequirements(
                    limits={"nvidia.com/gpu": "1", "memory": "16Gi"},
                    requests={"nvidia.com/gpu": "1", "memory": "8Gi"},
                ),
            ),
        )),
    )
    KServeClient().create(isvc)

    # Wait for READY.
    deadline = time.time() + 15 * 60
    while time.time() < deadline:
        got = KServeClient().get(isvc_name, namespace=ns)
        cond = {c["type"]: c["status"] for c in (got.get("status", {}).get("conditions") or [])}
        if cond.get("Ready") == "True":
            print(f"InferenceService {isvc_name} is Ready")
            return isvc_name
        time.sleep(10)
    raise TimeoutError(f"{isvc_name} did not become Ready in 15 min")


# ---------------------------------------------------------------------------
# 3. Evaluate the InferenceService with a TrustyAI LMEvalJob, log to MLflow.
# ---------------------------------------------------------------------------
@dsl.component(base_image=RUNTIME_IMAGE)
def evaluate(
    workspace: str,
    experiment: str,
    registered_name: str,
    model_version: str,
    isvc_name: str,
    run_id: str,
    tasks: list = ["arc_easy", "hellaswag"],
    limit: str = "50",
) -> dict:
    """Create an LMEvalJob against the InferenceService, wait for it to
    Complete, log the metrics into the parent MLflow run + as a nested run,
    and tag the model version with the primary accuracy."""
    import json, time, mlflow
    from kubernetes import client, config

    config.load_incluster_config()
    ns = open("/var/run/secrets/kubernetes.io/serviceaccount/namespace").read().strip()
    api = client.CustomObjectsApi()

    job_name = f"eval-{isvc_name}"
    body = dict(
        apiVersion="trustyai.opendatahub.io/v1alpha1", kind="LMEvalJob",
        metadata=dict(name=job_name, labels={"pipeline-run-id": run_id}),
        spec=dict(
            model="local-completions",
            modelArgs=[
                dict(name="model", value=isvc_name),
                dict(name="base_url",
                     value=f"http://{isvc_name}-predictor.{ns}.svc/v1/completions"),
                dict(name="num_concurrent", value="1"),
                dict(name="max_retries", value="3"),
                dict(name="tokenized_requests", value="True"),
                dict(name="tokenizer", value="Qwen/Qwen3-0.6B"),
            ],
            taskList=dict(taskNames=list(tasks)),
            allowOnline=True, allowCodeExecution=False,
            batchSize="1", limit=limit, logSamples=False,
            chatTemplate=dict(enabled=False),
            outputs=dict(pvcManaged=dict(size="100Mi")),
            # The ta-lmes-job image runs as UID 65532; a fresh PVC is root-owned,
            # so the driver's `open(stdout.log)` fails with permission denied
            # unless fsGroup gives the pod's group write access to the mount.
            pod=dict(securityContext=dict(fsGroup=65532)),
        ),
    )
    api.create_namespaced_custom_object(
        "trustyai.opendatahub.io", "v1alpha1", ns, "lmevaljobs", body)

    # Wait for a terminal state. `state` reaches `Complete` on both success
    # and failure — the reason field is what actually distinguishes them.
    deadline = time.time() + 60 * 60
    while time.time() < deadline:
        got = api.get_namespaced_custom_object(
            "trustyai.opendatahub.io", "v1alpha1", ns, "lmevaljobs", job_name)
        status = got.get("status") or {}
        state, reason = status.get("state"), status.get("reason")
        if state == "Complete" and reason == "Succeeded":
            break
        if state in {"Cancelled", "Failed"} or (state == "Complete" and reason in {"Failed", "Cancelled"}):
            raise RuntimeError(
                f"LMEvalJob {job_name} ended: state={state} reason={reason} "
                f"message={status.get('message')}")
        time.sleep(20)
    else:
        raise TimeoutError(f"LMEvalJob {job_name} did not finish in 1 h")

    results = json.loads(got["status"]["results"])["results"]

    # Log each task's metrics back to MLflow.
    mlflow.set_tracking_uri(MLFLOW_URI)
    mlflow.set_workspace(workspace)
    mlflow.set_experiment(experiment)
    parent = mlflow.search_runs(filter_string=f"tags.pipeline_run_id = '{run_id}'",
                                order_by=["attributes.start_time DESC"], max_results=1)
    parent_run_id = parent.iloc[0]["run_id"]

    flat = {}
    with mlflow.start_run(run_id=parent_run_id):
        with mlflow.start_run(run_name=f"eval-{run_id}", nested=True):
            for task, metrics in results.items():
                for k, v in metrics.items():
                    if isinstance(v, (int, float)) and "stderr" not in k:
                        name = f"{task}/{k.replace(',', '_')}"
                        mlflow.log_metric(name, float(v))
                        flat[name] = float(v)
            mlflow.set_tag("model_version", model_version)
            mlflow.set_tag("isvc_name", isvc_name)

    # Tag the primary accuracy on the model version so it shows up on the
    # Model Registry page next to the version number.
    primary = next((k for k in flat if k.endswith("/acc_none")), None)
    if primary is not None:
        mlflow.MlflowClient().set_model_version_tag(
            registered_name, model_version, "eval_acc", f"{flat[primary]:.4f}")
    return flat


# ---------------------------------------------------------------------------
# 4. Cleanup: delete the temporary InferenceService and LMEvalJob.
# ---------------------------------------------------------------------------
@dsl.component(base_image=RUNTIME_IMAGE)
def cleanup(isvc_name: str):
    from kubernetes import client, config
    config.load_incluster_config()
    ns = open("/var/run/secrets/kubernetes.io/serviceaccount/namespace").read().strip()
    api = client.CustomObjectsApi()
    for gv, plural, name in [
        ("serving.kserve.io/v1beta1", "inferenceservices", isvc_name),
        ("trustyai.opendatahub.io/v1alpha1", "lmevaljobs", f"eval-{isvc_name}"),
    ]:
        group, version = gv.split("/")
        try:
            api.delete_namespaced_custom_object(group, version, ns, plural, name)
        except Exception as exc:
            print(f"delete {plural}/{name} skipped: {exc}")


# ---------------------------------------------------------------------------
# Pipeline: wire the four components together.
# ---------------------------------------------------------------------------
@dsl.pipeline(name="qwen3-sft-mlflow-trustyai",
              description="Daily SFT + MLflow tracking + TrustyAI evaluation")
def sft_mlflow_trustyai(
    workspace: str = WORKSPACE,
    experiment: str = EXPERIMENT,
    base_model: str = BASE_MODEL,
    registered_name: str = REGISTERED_NAME,
    # Default = synthetic in-process data (offline smoke). Override with a
    # JSONL path on the shared PVC or an s3:// URI to fine-tune on a real
    # dataset — see the "Using a real dataset" section.
    dataset_path: str = "",
    num_train_epochs: int = 1,
    learning_rate: float = 2e-4,
    max_samples: int = 512,
    eval_limit: str = "50",
):
    train = fine_tune(
        workspace=workspace, experiment=experiment,
        base_model=base_model, registered_name=registered_name,
        pvc_mount=PVC_MOUNT, run_id=dsl.PIPELINE_JOB_ID_PLACEHOLDER,
        dataset_path=dataset_path, num_train_epochs=num_train_epochs,
        learning_rate=learning_rate, max_samples=max_samples,
    ).set_accelerator_type("nvidia.com/gpu").set_accelerator_limit(1)
    train.set_memory_limit("32Gi").set_cpu_limit("8")

    deploy = deploy_for_evaluation(
        workspace=workspace, registered_name=registered_name,
        model_version=train.output, pvc_name=PVC_NAME, pvc_mount=PVC_MOUNT,
        run_id=dsl.PIPELINE_JOB_ID_PLACEHOLDER,
    )

    ev = evaluate(
        workspace=workspace, experiment=experiment,
        registered_name=registered_name, model_version=train.output,
        isvc_name=deploy.output, run_id=dsl.PIPELINE_JOB_ID_PLACEHOLDER,
    )

    with dsl.ExitHandler(cleanup(isvc_name=deploy.output)):
        ev  # cleanup runs whether evaluate() succeeds or not

    # Mount the shared PVC on training + deploy.
    for task in (train, deploy):
        kubernetes.mount_pvc(task, pvc_name=PVC_NAME, mount_path=PVC_MOUNT)

    # Inject the Dex id token into every component that talks to MLflow.
    for task in (train, deploy, ev):
        kubernetes.use_secret_as_env(
            task, secret_name="mlflow-token",
            secret_key_to_env={"token": "MLFLOW_TRACKING_TOKEN"})


compiler.Compiler().compile(sft_mlflow_trustyai, "pipeline.yaml")

编译:

pip install "kfp>=2.7" "kfp-kubernetes>=1.3"
python finetune_pipeline.py
# → pipeline.yaml

在离线隔离集群上使用真实数据集

fine_tune component 默认使用一个合成的进程内 instruction 语料库(几百条算术问答对)。这可以让冒烟运行完全离线——不需要 HuggingFace、ModelScope、S3——但它不会产出有意义的 fine-tune 结果。对于真实工作负载,请先将数据集一次性预置到集群中,然后在每次运行时通过 dataset_path 传入其路径。fine_tune component 可以接受本地 JSONL 路径(最简单)或 s3://… URI(可供多个 pipeline 共享)。

1. 在可出站访问的机器上下载数据集

任何可以访问 Hugging Face 的机器都可以——笔记本电脑、跳板机、或者某个 namespace 中可出站访问的 Workbench:

pip install huggingface_hub datasets
huggingface-cli download tatsu-lab/alpaca --repo-type dataset --local-dir ./alpaca-raw

如果 Hugging Face 被阻断,ModelScope 会镜像大多数常用数据集,并且在中国大陆网络环境下可用:

pip install modelscope
modelscope download --dataset AI-ModelScope/alpaca-gpt4-data-en --local_dir ./alpaca-raw

2. 转换为带有 text 列的 JSONL 文件

TRL 的 SFTTrainer 要求每一行只有一个文本字段。请将原始数据集转换为 pipeline 所消费的格式:

from datasets import load_dataset

raw = load_dataset("./alpaca-raw")["train"]

def format_alpaca(row):
    prompt = row["instruction"]
    if row.get("input"):
        prompt += "\n\n" + row["input"]
    return {"text": f"### Instruction:\n{prompt}\n### Response:\n{row['output']}"}

raw.map(format_alpaca).to_json("alpaca.jsonl", orient="records", lines=True)

3a. 上传到共享 PVC(最简单)

如果你的集群中有一个挂载了 finetune-shared PVC 的 jump-pod,可以使用 kubectl cp 将 JSONL 复制进去:

kubectl -n finetune apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: dataset-uploader, namespace: finetune }
spec:
  restartPolicy: Never
  containers:
  - name: sh
    image: busybox
    command: ["sh", "-c", "mkdir -p /mnt/shared/datasets && sleep 3600"]
    volumeMounts: [{ name: shared, mountPath: /mnt/shared }]
  volumes:
  - name: shared
    persistentVolumeClaim: { claimName: finetune-shared }
EOF
kubectl -n finetune wait --for=condition=Ready pod/dataset-uploader
kubectl -n finetune cp alpaca.jsonl dataset-uploader:/mnt/shared/datasets/alpaca.jsonl
kubectl -n finetune delete pod dataset-uploader

然后在提交 pipeline 时使用 dataset_path="/mnt/shared/datasets/alpaca.jsonl"(见下方步骤 3)。

3b. 上传到 S3 / seaweedfs(多个 pipeline 共享)

对于运行大量 pipeline 的团队,建议将数据集托管在与 MLflow 插件 artifact store 相同的 S3 兼容 bucket 中——集群内部的 seaweedfs endpoint 可被每个 pipeline pod 访问,而客户端只需要 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY 环境变量。

请从能够访问 S3 endpoint 的机器上传——通常是同一集群中的 Workbench。Alauda AI Workbench 镜像已经自带 mc(MinIO Client)和 boto3,因此无需额外安装。

使用 mc(交互式 / 单行命令):

mc alias set seaweedfs http://seaweedfs.mlops-demo-e2e:8333 <ACCESS_KEY> <SECRET_KEY>
mc mb --ignore-existing seaweedfs/mlops-datasets
mc cp alpaca.jsonl seaweedfs/mlops-datasets/alpaca/alpaca.jsonl

使用 boto3(脚本化 / 在 notebook 中):

import boto3

s3 = boto3.client(
    "s3",
    endpoint_url="http://seaweedfs.mlops-demo-e2e:8333",
    aws_access_key_id="<ACCESS_KEY>",
    aws_secret_access_key="<SECRET_KEY>",
)
# Idempotent bucket create.
try:
    s3.create_bucket(Bucket="mlops-datasets")
except s3.exceptions.BucketAlreadyOwnedByYou:
    pass
s3.upload_file("alpaca.jsonl", "mlops-datasets", "alpaca/alpaca.jsonl")

(请根据你的 seaweedfs / MinIO Service 调整 endpoint。凭据来自 MLflow 插件的 artifact-store Secret,或来自你的本地 S3 提供商。)

然后创建一个包含 S3 凭据的 Secret,并将其与 mlflow-token 一起注入到 fine_tune 中:

kubectl -n finetune create secret generic mlflow-s3 \
  --from-literal=AWS_ACCESS_KEY_ID=<key> \
  --from-literal=AWS_SECRET_ACCESS_KEY=<secret> \
  --from-literal=MLFLOW_S3_ENDPOINT_URL=http://seaweedfs.mlops-demo-e2e:8333

将以下内容添加到 pipeline function 中(紧接在现有的、挂载 MLflow token 的 use_secret_as_env block 旁边):

kubernetes.use_secret_as_env(train, secret_name="mlflow-s3", secret_key_to_env={
    "AWS_ACCESS_KEY_ID":       "AWS_ACCESS_KEY_ID",
    "AWS_SECRET_ACCESS_KEY":   "AWS_SECRET_ACCESS_KEY",
    "MLFLOW_S3_ENDPOINT_URL":  "AWS_ENDPOINT_URL",   # what s3fs / fsspec read
})

然后使用 dataset_path="s3://mlops-datasets/alpaca/alpaca.jsonl" 提交 pipeline。s3fs 已经包含在 runtime image 中,load_dataset("json", data_files="s3://…") 会在底层使用 fsspec——AWS_* 环境变量会自动被拾取。

4. 关于基础模型的说明

AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-0.6B") 在首次运行时也会访问 Hugging Face。对于完全离线隔离的 fine-tune,请先镜像一次模型(同样的模式:huggingface-cli download Qwen/Qwen3-0.6B --local-dir ./qwen3-0.6b,然后复制到 PVC 或 S3),并设置 BASE_MODEL = "/mnt/shared/models/qwen3-0.6b"。随后 MLflow model registry 会像之前一样将该 fine-tune 注册为 qwen3-0.6b-sft

步骤 3 — 提交一次性运行

Kubeflow Dashboard → Pipelines → Upload Pipeline 中上传 pipeline.yaml,然后点击 Create Run——或者从 Workbench 中执行:

from kfp.client import Client

client = Client(host="<MY-KFP-ENDPOINT>")
run = client.create_run_from_pipeline_package(
    "pipeline.yaml",
    arguments=dict(
        # Omit for the synthetic-smoke default; set to a JSONL path or an
        # s3:// URI to fine-tune on a real dataset (see the section above).
        # dataset_path="/mnt/shared/datasets/alpaca.jsonl",
        num_train_epochs=1, eval_limit="50",
    ),
)
print("Run:", run.run_id)

当运行进行中时:

  • 打开 Alauda AI → Tools → MLFlow,并选择 finetune workspace。
  • qwen3-0.6b-daily-sft experiment 会显示一个名为 pipeline-<pipeline-run-id>运行,并带有两个嵌套运行:train-<...>(由 HF Trainer callback 流式写入)以及稍后的 eval-<...>(由 evaluate 步骤写入)。
  • Models 下,qwen3-0.6b-sft registered model 会新增一个 version。

步骤 4 — 在 MLflow 中可视化训练指标

  1. 在 MLflow UI 中,打开父运行 → Metrics tab。
  2. 选择 losseval_loss 以及你添加的任何自定义指标(perplexitylearning_rategrad_norm)→ 点击 Chart
  3. 若要实时查看 loss 曲线,请在图表面板中将 Refresh 间隔设置为 10s——随着 Trainer 写入新的 step,MLflow 会重新获取指标流。

对于分类类目标,如果 accuracy 很重要,可以在 TrainingArguments 中添加一个 compute_metrics callback,返回 {"accuracy": ...}report_to="mlflow" 会把 compute_metrics 返回的内容直接流式写入同一张图表。

步骤 5 — 将其安排为每日 Recurring Run

KFP 原生支持 cron 调度。将同一个 pipeline.yaml 连接到 Recurring Run,使其每天触发一次。

通过 UI

  1. Pipelines → 选择 qwen3-sft-mlflow-trustyaiCreate Run
  2. 将运行类型选择为 Recurring Run
  3. TriggerCron,表达式 0 2 * * *(每天 UTC 02:00 )。
  4. Max concurrent runs1——该 pipeline 会占用 1 个 GPU;重叠运行会排队。
  5. Catchupoff——跳过错过的时间窗口,而不是在周一早上补跑积压任务。
  6. Parameters:对于每日任务,设置 num_train_epochs=1eval_limit=200(比一次性默认值更具信号)。
  7. Start

通过 SDK

from kfp.client import Client

client = Client(host="<MY-KFP-ENDPOINT>")

# One-time: upload the pipeline as a versioned resource.
uploaded = client.upload_pipeline(
    "pipeline.yaml", pipeline_name="qwen3-sft-mlflow-trustyai")

# Attach a daily schedule.
client.create_recurring_run(
    experiment_id=client.create_experiment("qwen3-daily").experiment_id,
    job_name="qwen3-sft-daily-02utc",
    cron_expression="0 0 2 * * *",     # KFP uses 6-field cron (with seconds)
    max_concurrency=1,
    no_catchup=True,
    enabled=True,
    pipeline_id=uploaded.pipeline_id,
    version_id=uploaded.pipeline_version_id,
    params=dict(num_train_epochs=1, eval_limit="200"),
)
WARNING

Secret/mlflow-token 中的 MLflow token 会过期(Dex id token 默认有效期为 24 小时)。对于运行时间超过 token 生命周期的计划任务,可以选择:在 fine_tune component 内使用 service-account 凭据生成 token 并在其中刷新它(请参见 SDK token flow),或者运行一个小型 CronJob,以略短于 token TTL 的周期轮换 mlflow-token Secret。

步骤 6 — 在 MLflow 中比较每日评估结果

运行几天之后,experiment 中就会出现每天一行的记录。MLflow 可以让你一键完成比较:

  1. 在 MLflow UI 中,打开 qwen3-0.6b-daily-sft experiment。
  2. 在 runs 表中,筛选嵌套的 eval runs:tags.mlflow.runName LIKE 'eval-%'
  3. attributes.start_time DESC 排序。
  4. 勾选你要比较的 runs(建议先看最近 7 天)→ 点击 Compare
  5. Chart tab 中,按 tags.mlflow.runName 分组绘制 arc_easy/acc_nonehellaswag/acc_none。稳定的曲线表示 fine-tune 是稳定的;明显下跌通常意味着训练数据集或基础 checkpoint 发生了变化。
  6. Parallel Coordinates 视图会把训练超参数和评估指标分组显示,便于你肉眼判断哪些超参数与最大的评估收益相关。

对于程序化比较——例如将结果发送到 Slack 告警,或者在 accuracy 日环比下降超过 2% 时让 CI gate 失败——可以使用 search API:

import mlflow, pandas as pd

mlflow.set_tracking_uri("http://mlflow-tracking-server.kubeflow:5000")
mlflow.set_workspace("finetune")

df = mlflow.search_runs(
    experiment_names=["qwen3-0.6b-daily-sft"],
    filter_string="tags.mlflow.runName LIKE 'eval-%'",
    order_by=["attributes.start_time DESC"],
    max_results=14,
)[["run_id", "start_time", "metrics.arc_easy/acc_none",
   "metrics.hellaswag/acc_none"]]

df.sort_values("start_time")\
  .assign(delta=lambda d: d["metrics.arc_easy/acc_none"].diff())\
  .to_string(index=False)

若要将每日得分最高的版本提升到下游 serving 栈,可以使用 Model Registry 的别名 API——pipeline 已经为每个 version 标记了 eval_acc

client = mlflow.MlflowClient()
best = max(client.search_model_versions("name='qwen3-0.6b-sft'"),
           key=lambda v: float(v.tags.get("eval_acc", "0")))
client.set_registered_model_alias("qwen3-0.6b-sft", "champion", best.version)
# Downstream InferenceService references models:/qwen3-0.6b-sft@champion.

故障排查

症状检查项
log_modelNoCredentialsError / Endpoint URL error 失败MLflow artifact store 未配置为 S3。请参见 MLflow install 中的 High availability and storage 部分,并在 MLflow 插件中设置 artifact bucket。然后重新运行。
deploy_for_evaluation 一直看不到 Ready=True检查 InferenceService:kubectl -n finetune describe isvc <name>。最常见的原因是 pvc_path tag 中的 PVC 路径不存在,或者集群中没有可用的 KServe HF runtime image。
LMEvalJob 一直停留在 Scheduledeval job pod 处于 Pending。请检查 pod 的 node selector 和 PVC 绑定:kubectl -n finetune get pods -l app=<job-name>,并对 Pending pod 执行 kubectl describe
LMEvalJob 无法访问 InferenceServicemodelArgs 中的 base_url 必须是 -predictor Service,而不是顶层 InferenceService。示例使用的是 http://<isvc>-predictor.<ns>.svc/v1/completions——-predictor 后缀和 /v1/completions 路径对于 OpenAI-compatible endpoint 都是必需的。
LMEvalJob 结束为 state=Complete, reason=Failed, message="open …/output/stdout.log: permission denied"由 operator 管理的 outputs PVC 属于 root 用户,但 ta-lmes-job 容器以 UID 65532 运行,因此 driver 无法创建 stdout.log。请设置 spec.pod.securityContext.fsGroup: 65532(示例中已这样做),以便挂载对 pod 的 supplemental group 可写。
state=Complete,但 pipeline 将其视为成功,即使运行失败了对于任何终态,state 都会转为 Complete;真正的结果在 status.reason 中(Succeeded / Failed / Cancelled)。请同时检查二者,就像上面的 evaluate component 一样。
local-completions 失败并报 OSError: We couldn't connect to 'https://huggingface.co' to load this file … it looks like <name> is not the path to a directory containing a file named config.json.即使 tokenized_requests"False",lm-evaluation-harness 客户端仍然会调用 AutoTokenizer.from_pretrained(...)——tokenizermodelArgs 条目必须是 eval pod 可以访问的 repo id,或者是离线 PVC 下的本地路径。在离线隔离集群上,请参照 Evaluate LLM 中的 Optional: offline storage and PVC 部分——在 spec.offline.storage.pvcName 挂载一个 PVC,预先填充 tokenizer 和 dataset cache,并将 tokenizer 设置为挂载路径。
嵌套 MLflow runs 在父运行下缺失set_experimentsearch_runs 解析父运行之前就被调用了——但实际上没有任何内容被记录,因为父 tag 是从另一个 component 的进程中写入的。请在父运行内部使用 set_tag("pipeline_run_id", run_id),并按该 tag 搜索,就像 evaluate component 所做的那样。
Recurring Run 已触发,但运行一开始就因 401 UNAUTHENTICATED 失败Secret/mlflow-token 中的 token 已在上一次成功的 pipeline 和今天的运行之间过期。请轮换它(或者把 token 的生成移到 component 内部);参见步骤 5 中的警告。
KFP v2 pod 失败,并提示 container has runAsNonRoot and image will run as root(发生在 argoexec init init-container 和 kfp-launcher init-container 上)KFP-v2 pod template 在 pod 级别设置了 runAsNonRoot: true,但 argoexec / kfp-launcher image 没有设置非 root 的 USER。在会暴露该问题的后端(例如 DSPO 基于 Argo 的 pipeline 栈)上,请在运行时通过 spec.podSpecPatch 修补 Workflow,在 pod 级别以及每个 init-container(init + kfp-launcher)上都设置 runAsUser: 1001
fine-tune component 失败,并报 PermissionError: [Errno 13] Permission denied: '/mnt/shared/...'共享 PVC 的根目录属于 root 用户。你可以在同一个 podSpecPatch 中添加 fsGroup: 1001,或者先使用一个以 root 运行的临时 pod 启动一次 PVC,并执行 chown -R 1001:1001 /mnt
fine-tune component 失败,并报 modelscope_hub.errors.CacheError: [E1022] Failed to create SDK directories: [Errno 13] Permission denied: '/.modelscope'python:3.12-slim container 将 HOME=/ 保持不变,而 ModelScope 和 Hugging Face 默认将缓存放在 ~/。非 root pod 无法在此写入。在 fine-tune component 中,请在第一次执行 from modelscope import … / AutoModel.from_pretrained 之前,将 MODELSCOPE_CACHEHF_HOMEHOME 设置为共享 PVC 上的路径(或 /tmp)。
runtime image tag 在 worker node 上无法访问离线隔离或受限出站的集群无法直接从 docker.io 拉取。请使用 skopeo copycrane copydocker.io/alaudadockerhub/finetune-pipeline-runtime-cu126-amd64:v0.1.0 镜像到你的内部 registry,然后将 RUNTIME_IMAGE 改为镜像后的引用,并将相应的 imagePullSecrets 添加到 pipeline ServiceAccount。
在 KFP API server 上提交 pipeline 失败,报 unknown component implementation: comp-exit-handler-1后端(例如 DSPO 基于 Argo 的 pipeline 栈)不接受 KFP v2 dsl.ExitHandler 子图。请将 with dsl.ExitHandler(cleanup(...)): ev 替换为普通的 cleanup(...).after(ev),并使 cleanup 幂等化(通过 label selector 删除,使其在 evaluate 已经在成功时删除资源的情况下也能工作)。
尽管 PVC 中存在该文件,KServe pod 仍然崩溃,并报 FileNotFoundError: No such file or directory: /mnt/models/model.safetensorsKServe 容器以 UID 1000 运行(在 ClusterServingRuntime 中定义),而 fine_tune component 使用默认 umask 以 UID 1001 写入,这会生成模式 0660——对 group 可写,但对其他用户不可读。在 save_pretrained 之后增加一个 chmod 处理(如示例所示):将每个目录设置为 0755,每个文件设置为 0644。仅在 fine-tune pod 上设置 fsGroup: 1001 不够——它只会更改挂载根目录的 group,不会回填子文件的权限模式。
evaluate component 内部出现 mlflow.exceptions.RestException: RESOURCE_DOES_NOT_EXIST: No Experiment with id=0 existsstart_run(run_id=parent) 会附加到现有运行,但嵌套start_run(run_name=...) 需要一个 experiment 上下文。请在打开任何 run 之前,于 component 顶部一次性调用 mlflow.set_experiment(experiment)。否则 MLflow 会默认使用 experiment 0,而多租户 server 不允许这样做。
mlflow.create_model_version 失败,并报 INVALID_PARAMETER_VALUE: Invalid model version source: '/mnt/…'. To use a local path as a model version source, the run_id request parameter has to be specified and the local path has to be contained within the artifact directory of the run specified by the run_id.,随后切换到 mlflow.log_artifacts 又触发 PermissionError: [Errno 13] Permission denied: '/mlflow'Alauda MLflow 插件默认将 artifact root 设为 /mlflow/artifacts——这是 tracking server pod 上 emptyDir 中的本地路径。客户端侧的 log_artifacts / log_model 无法写入该位置,而 run 的 artifact 目录之外的 file:// version source 会被拒绝。你有两个选择:(a)将 MLflow 部署重新配置为 S3 artifact backend(参见 MLflow install 中的 High availability and storage 部分);或者(b)跳过 model registry,通过 父运行上的 tags 协调下游步骤——pipeline 仍然可以做到每次运行在 MLflow 中只有一行记录,并且可以通过 mlflow.get_run(...).data.tags 访问 pvc_pathfinal_losseval_acc
TypeError: SFTTrainer.__init__() got an unexpected keyword argument 'tokenizer'TRL 0.12+ 已移除了 tokenizer 关键字——请改为将 tokenizer 作为 processing_class=tokenizer 传入。参考 runtime image 通过 LLaMA-Factory 0.9.4 间接包含了 post-0.12 版本的 TRL。上面的示例使用的是 processing_class=;仍然传递 tokenizer= 的旧分支需要做同样的重命名。
mlflow.transformers.log_model 失败,并报 MlflowException: The task could not be inferred from the model. If you are saving a custom local model that is not available in the Hugging Face hub, please provide the 'task' argument to the log_model or save_model function.MLflow 可以从 Hub 模型推断 pipeline task,但不能从本地加载(路径镜像)的模型推断——fine-tune pod 的 trainer.modelAutoModelForCausalLM.from_pretrained("/mnt/shared/models/…") 之后属于后者。请在 log_model 中传入 task="text-generation"(或者针对其他模型类型传入相应的 task 字符串)。
MLflow 客户端调用失败,并报 UNAUTHENTICATED: Authentication with the Kubernetes API failed. The provided token may be invalid or expired.mlflow-tracking-server 的 oauth2-proxy sidecar 没有转发 bearer token——插件配置界面没有暴露该项,必须在 Deployment 上打补丁。向 oauth2-proxy 容器的 env 中添加 OAUTH2_PROXY_SKIP_JWT_BEARER_TOKENS=true,等待 rollout 完成,然后重新运行。请注意,mlflow-plugin controller 会协调该 Deployment,并可能回滚此 env;每次 operator 升级或手动重新安装后都需要重新应用。

构建自定义 image

参考 image 基于 llamafactory0.9-cu126-amd64 构建,仅额外添加了 mlflow>=3.10kserve>=0.13kubernetes>=29。如果你想自行构建——例如切换 CUDA 版本、添加专有数据处理库,或者使基础镜像符合 FIPS——可以编写一个单层 Containerfile

# syntax=docker/dockerfile:1.7
ARG BASE_IMAGE=docker.io/alaudadockerhub/llamafactory0.9-cu126-amd64:v0.1.0
FROM ${BASE_IMAGE}

USER 0
RUN uv pip install --no-cache-dir \
        "mlflow>=3.10" \
        "kubernetes>=29" \
        "kserve>=0.13" \
        # add anything your pipeline imports:
        # "your-private-etl-lib==1.2.3" \
        ;
USER 1000
WORKDIR /workspace

使用 buildctl(或 docker buildx build)通过你的 buildkitd 进行构建,推送到内部 registry,然后相应地设置 RUNTIME_IMAGE。pipeline 的其余代码保持不变。

如果你的集群已经在运行 Kubeflow Trainer v2,你可以复用已发布的某个 TrainingRuntime image——llamafactory0.9-cu126-amd64:v0.1.0 已经自带 torch / HF / trl / mlflow;对于 deploy_for_evaluation + evaluate + cleanup component,只需要再添加 kservekubernetes。参考 image 正是这样做的。

相关指南