← Back to DevBytes

Building a CI/CD Pipeline for Fine-Tuned Models

Building a CI/CD Pipeline for Fine-Tuned Models

Fine-tuning large language models and other foundation models has become a cornerstone of modern AI development. However, the workflow that surrounds fine-tuning—experimentation, evaluation, versioning, deployment, and monitoring—is often ad hoc and error-prone. Applying CI/CD (Continuous Integration and Continuous Deployment) principles to fine-tuned models brings the same rigor to ML engineering that software engineering has enjoyed for decades. This tutorial walks through what a CI/CD pipeline for fine-tuned models looks like, why it matters, and how to build one end to end.

What Is a CI/CD Pipeline for Fine-Tuned Models?

A CI/CD pipeline for fine-tuned models is an automated workflow that takes a fine-tuning job from code commit to production deployment, with quality gates along the way. Unlike traditional software CI/CD, which focuses on compiling code and running unit tests, an ML pipeline must also handle datasets, training runs, model artifacts, evaluation metrics, and inference infrastructure.

The pipeline typically consists of these stages:

Why It Matters

Without CI/CD, fine-tuned models are typically built through manual notebook workflows. This leads to reproducibility problems, silent regressions, and slow iteration cycles. A proper pipeline gives you several concrete benefits:

How to Build It

In this tutorial, we will build a pipeline using GitHub Actions for orchestration, Hugging Face Transformers and PEFT for fine-tuning, Weights & Biases for experiment tracking, and the Hugging Face Hub as a model registry. The same patterns apply to GitLab CI, Jenkins, or Argo Workflows.

1. Project Structure

Start with a clean repository layout that separates concerns:

finetune-pipeline/
├── .github/
│   └── workflows/
│       ├── train.yml
│       └── deploy.yml
├── src/
│   ├── train.py
│   ├── evaluate.py
│   └── data.py
├── configs/
│   └── base.yaml
├── tests/
│   └── test_data.py
├── requirements.txt
└── README.md

2. The Training Script

The training script should be parameterized so the pipeline can override hyperparameters. Here is a minimal fine-tuning script using LoRA on a small causal language model:

# src/train.py
import argparse
import yaml
import torch
from datasets import load_dataset
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    TrainingArguments,
    Trainer,
)
from peft import LoraConfig, get_peft_model
import wandb


def load_config(path):
    with open(path, "r") as f:
        return yaml.safe_load(f)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", required=True)
    parser.add_argument("--model-id", required=True)
    parser.add_argument("--dataset-repo", required=True)
    args = parser.parse_args()

    cfg = load_config(args.config)
    wandb.init(project=cfg["wandb_project"], config=cfg)

    tokenizer = AutoTokenizer.from_pretrained(cfg["base_model"])
    model = AutoModelForCausalLM.from_pretrained(
        cfg["base_model"],
        torch_dtype=torch.bfloat16,
        device_map="auto",
    )

    lora_config = LoraConfig(
        r=cfg["lora_r"],
        lora_alpha=cfg["lora_alpha"],
        target_modules=cfg["target_modules"],
        lora_dropout=cfg["lora_dropout"],
        bias="none",
        task_type="CAUSAL_LM",
    )
    model = get_peft_model(model, lora_config)

    dataset = load_dataset(args.dataset_repo, split="train")

    def tokenize(example):
        return tokenizer(example["text"], truncation=True, max_length=512)

    tokenized = dataset.map(tokenize, batched=True)

    training_args = TrainingArguments(
        output_dir="./outputs",
        num_train_epochs=cfg["epochs"],
        per_device_train_batch_size=cfg["batch_size"],
        learning_rate=cfg["learning_rate"],
        logging_steps=10,
        save_strategy="epoch",
        report_to="wandb",
    )

    trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=tokenized,
    )
    trainer.train()

    # Save the adapter
    model.save_pretrained("./outputs/adapter")
    tokenizer.save_pretrained("./outputs/adapter")
    wandb.finish()


if __name__ == "__main__":
    main()

3. The Evaluation Script

Evaluation is the quality gate. The pipeline should fail if the model does not meet a minimum threshold. Here we compute perplexity and a simple accuracy metric on a held-out set:

# src/evaluate.py
import argparse
import json
import math
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel


def compute_perplexity(model, tokenizer, texts, max_length=512):
    model.eval()
    total_loss = 0.0
    count = 0
    with torch.no_grad():
        for text in texts:
            enc = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
            enc = {k: v.to(model.device) for k, v in enc.items()}
            outputs = model(**enc, labels=enc["input_ids"])
            total_loss += outputs.loss.item()
            count += 1
    avg_loss = total_loss / max(count, 1)
    return math.exp(avg_loss)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--base-model", required=True)
    parser.add_argument("--adapter-path", required=True)
    parser.add_argument("--eval-dataset", required=True)
    parser.add_argument("--max-perplexity", type=float, default=50.0)
    parser.add_argument("--output", default="metrics.json")
    args = parser.parse_args()

    tokenizer = AutoTokenizer.from_pretrained(args.base_model)
    base = AutoModelForCausalLM.from_pretrained(
        args.base_model, torch_dtype=torch.bfloat16, device_map="auto"
    )
    model = PeftModel.from_pretrained(base, args.adapter_path)

    eval_ds = load_dataset(args.eval_dataset, split="test")
    texts = eval_ds["text"][:100]

    ppl = compute_perplexity(model, tokenizer, texts)
    metrics = {"perplexity": ppl, "passed": ppl < args.max_perplexity}

    with open(args.output, "w") as f:
        json.dump(metrics, f, indent=2)

    print(json.dumps(metrics, indent=2))
    if not metrics["passed"]:
        raise SystemExit("Evaluation failed: perplexity above threshold")


if __name__ == "__main__":
    main()

Note the explicit SystemExit when the metric fails. This non-zero exit code is what allows the CI runner to halt the pipeline before deployment.

4. The GitHub Actions Workflow

The workflow below triggers on pushes to main, runs tests, launches a GPU training job, evaluates the result, and pushes the adapter to the Hugging Face Hub only if evaluation passes.

# .github/workflows/train.yml
name: Fine-Tune Pipeline

on:
  push:
    branches: [main]
    paths:
      - "src/**"
      - "configs/**"
      - ".github/workflows/train.yml"

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install -r requirements.txt ruff pytest
      - run: ruff check src/
      - run: pytest tests/

  train-and-evaluate:
    needs: validate
    runs-on: self-hosted-gpu
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install -r requirements.txt

      - name: Fine-tune model
        env:
          WANDB_API_KEY: ${{ secrets.WANDB_API_KEY }}
        run: |
          python src/train.py \
            --config configs/base.yaml \
            --model-id run-${{ github.sha }} \
            --dataset-repo my-org/finetune-dataset

      - name: Evaluate model
        run: |
          python src/evaluate.py \
            --base-model mistralai/Mistral-7B-v0.1 \
            --adapter-path ./outputs/adapter \
            --eval-dataset my-org/finetune-eval \
            --max-perplexity 45.0

      - name: Upload metrics artifact
        uses: actions/upload-artifact@v4
        with:
          name: metrics
          path: metrics.json

      - name: Push adapter to Hub
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
        run: |
          pip install huggingface_hub
          python -c "
          from huggingface_hub import HfApi
          api = HfApi()
          api.create_branch('my-org/finetuned-model', branch='run-${{ github.sha }}', exist_ok=True)
          api.upload_folder(
              folder_path='./outputs/adapter',
              repo_id='my-org/finetuned-model',
              revision='run-${{ github.sha }}',
              commit_message='Auto-deploy from ${{ github.sha }}'
          )
          "

5. The Deployment Stage

Deployment is a separate workflow that promotes a specific model revision to a production endpoint. Keeping it separate lets you decouple training cadence from release cadence.

# .github/workflows/deploy.yml
name: Deploy Model

on:
  workflow_dispatch:
    inputs:
      revision:
        description: "Model revision (commit sha) to deploy"
        required: true
      environment:
        description: "Target environment"
        type: choice
        options: [staging, production]
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.event.inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to inference server
        env:
          INFERENCE_API_KEY: ${{ secrets.INFERENCE_API_KEY }}
        run: |
          curl -X POST https://inference.my-org.internal/v1/models \
            -H "Authorization: Bearer $INFERENCE_API_KEY" \
            -H "Content-Type: application/json" \
            -d "{
              \"model_repo\": \"my-org/finetuned-model\",
              \"revision\": \"run-${{ github.event.inputs.revision }}\",
              \"environment\": \"${{ github.event.inputs.environment }}\"
            }"
      - name: Run smoke test
        run: |
          python tests/smoke_test.py --endpoint https://inference.my-org.internal/v1

6. Smoke Testing the Endpoint

After deployment, a smoke test confirms the endpoint is actually serving the new model and producing sane outputs:

# tests/smoke_test.py
import argparse
import requests


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--endpoint", required=True)
    args = parser.parse_args()

    payload = {
        "inputs": "Explain CI/CD in one sentence.",
        "parameters": {"max_new_tokens": 50, "temperature": 0.1},
    }
    resp = requests.post(args.endpoint, json=payload, timeout=30)
    resp.raise_for_status()
    output = resp.json()
    text = output[0]["generated_text"] if isinstance(output, list) else str(output)
    assert len(text) > 10, "Output too short, model may be broken"
    print("Smoke test passed:", text[:120])


if __name__ == "__main__":
    main()

Best Practices

Conclusion

Building a CI/CD pipeline for fine-tuned models transforms ML development from a fragile, manual craft into a disciplined engineering practice. By automating validation, training, evaluation, registration, and deployment—with hard quality gates at each stage—you gain reproducibility, safety, and speed simultaneously. The pipeline shown in this tutorial is intentionally simple, but the same architecture scales to multi-GPU distributed training, canary deployments, A/B testing of model variants, and automated retraining triggered by drift detection. Start small, enforce your evaluation thresholds ruthlessly, and iterate on the pipeline itself just as you iterate on your models.

— Ad —

Google AdSense will appear here after approval

← Back to all articles