我们团队的NLP模型迭代流程曾经是一场混乱的手工作坊。算法工程师在本地Jupyter环境中用Transformers
和TensorFlow
调优模型,完成后将一堆脚本和权重文件打包,通过内部聊天工具发给运维工程师。运维再手动编写Dockerfile
,构建镜像,部署上线。这个过程充满了沟通成本、版本错误和不可复现的“在我机器上是好的”问题。我们需要一个系统,一个能将模型从“待调优”到“已部署”的整个生命周期自动化、可追溯、且由Git驱动的系统。这就是我们构建这套基于Git的MLOps看板流的起点。
核心构想是将Git仓库本身作为一个结构化的Kanban(看板)。目录即状态,文件的移动即流程推进。一个git mv
操作,就应该能触发背后一整套复杂的CI/CD流水线,完成模型的调优、打包、验证和部署。
技术选型决策
- Hugging Face Transformers & TensorFlow: 这是我们团队的技术标准。
Transformers
提供了丰富的预训练模型,而TensorFlow
则是在生产环境中经过验证的后端。 - Podman: 我们选择Podman而非Docker,主要出于两个生产环境的考量:无守护进程(Daemonless)和无根(Rootless)模式。在CI/CD环境中,无守护进程减少了单点故障,而无根模式则极大地提升了安全性,避免了容器内部的潜在提权风险。
- Kanban via Git: 我们不引入额外的项目管理工具(如Jira或Trello)来增加系统的复杂度。Git作为唯一的真相来源(Single Source of Truth),其提交历史就是所有操作的审计日志。这种模式让整个流程对工程师来说极其透明和直观。
第一步:定义看板式Git仓库结构
我们的项目仓库结构就是看板本身。每个模型由一个唯一的YAML配置文件定义。
.
├── kanban/
│ ├── 1-ready-for-tuning/ # 状态1: 待调优
│ │ └── sentiment-classifier-v2.yaml
│ ├── 2-tuning-in-progress/ # 状态2: 调优中 (由CI自动移动)
│ ├── 3-tuning-failed/ # 状态3: 调优失败 (由CI自动移动)
│ ├── 4-ready-for-deployment/ # 状态4: 待部署 (调优成功后由CI移动)
│ └── 5-deployed/ # 状态5: 已部署 (部署成功后由CI移动)
├── .gitlab-ci.yml # CI/CD 流水线定义
├── scripts/
│ ├── pipeline-trigger.sh # 流水线核心触发与状态机逻辑
│ ├── run_finetuning.py # 模型调优执行脚本
│ └── model_server.py # 模型推理API服务
├── templates/
│ ├── Containerfile.tuning # 调优环境的容器定义
│ └── Containerfile.serving # 推理服务的容器定义
└── tuning-data/ # 示例调优数据集
└── sentiment-dataset.csv
模型配置文件 sentiment-classifier-v2.yaml
是驱动一切的核心:
# kanban/1-ready-for-tuning/sentiment-classifier-v2.yaml
apiVersion: mlops.kanban/v1alpha1
kind: ModelJob
metadata:
name: sentiment-classifier-v2
description: "Fine-tune distilbert for improved sentiment analysis on user reviews."
spec:
# Hugging Face Transformers 参数
baseModel: "distilbert-base-uncased"
tokenizer: "distilbert-base-uncased"
task: "text-classification"
# TensorFlow Keras.fit 参数
trainingParams:
epochs: 3
batchSize: 16
learningRate: 5.0e-5
optimizer: "adam"
# 数据集与输出
dataset:
path: "tuning-data/sentiment-dataset.csv"
textColumn: "review_text"
labelColumn: "sentiment"
output:
# {MODEL_NAME} 和 {CI_COMMIT_SHA} 是CI系统提供的变量
modelPath: "/artifacts/models/{MODEL_NAME}/{CI_COMMIT_SHA}"
imageRepo: "registry.internal.corp/ml-models/sentiment-classifier"
imageTag: "{CI_COMMIT_SHA}"
当工程师准备好调优一个新模型时,他们只需在1-ready-for-tuning
目录下创建一个YAML文件并发起一个Merge Request。一旦合并到主分支,自动化流程便开始了。
第二步:构建可复现的Podman调优环境
一个常见的坑是CI/CD环境与本地开发环境不一致导致的问题。我们通过一个专门的Containerfile.tuning
来定义一个完全锁定的、可复现的调优环境。
# templates/Containerfile.tuning
FROM tensorflow/tensorflow:2.13.0-gpu
# 1. 系统依赖与配置
# 在真实项目中,这里应使用公司内部的镜像源
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# 2. Python 依赖
# 使用固定版本的requirements.txt是生产级实践
WORKDIR /app
COPY requirements.tuning.txt .
RUN pip install --no-cache-dir -r requirements.tuning.txt
# requirements.tuning.txt 内容:
# transformers==4.34.0
# datasets==2.14.5
# scikit-learn==1.3.1
# pyyaml==6.0.1
# pandas==2.0.3
# 3. 复制核心脚本
COPY scripts/run_finetuning.py .
# 4. 创建非root用户以增强安全性
# Podman 的 rootless 模式与之是最佳拍档
RUN useradd -m -s /bin/bash appuser
USER appuser
WORKDIR /home/appuser/app
# 5. 定义入口点
# 容器启动时,我们将代码和数据挂载进来
ENTRYPOINT ["python", "/app/run_finetuning.py"]
这个Containerfile
不仅仅是安装依赖。它考虑到了镜像分层缓存、清理不必要的包以减小镜像体积,并创建了一个非root用户来运行任务,这些都是生产环境的最佳实践。
第三步:核心驱动脚本 pipeline-trigger.sh
这个脚本是整个自动化流程的大脑。它在CI流水线的每个阶段被调用,通过分析上一次和当前Git提交的差异,来决定需要执行什么动作。
#!/bin/bash
set -eo pipefail # 保证脚本的健壮性
# 日志函数,增加可读性
log_info() {
echo "INFO: [$(date +'%Y-%m-%d %H:%M:%S')] $1"
}
log_error() {
echo "ERROR: [$(date +'%Y-%m-%d %H:%M:%S')] $1" >&2
exit 1
}
# 检查环境变量,确保在CI环境中运行
check_ci_vars() {
if [[ -z "$CI_COMMIT_SHA" || -z "$CI_PROJECT_DIR" ]]; then
log_error "This script must be run inside a GitLab CI/CD pipeline."
fi
}
# 主要函数:分析Git变更并触发相应动作
process_kanban_changes() {
log_info "Analyzing git changes between HEAD~1 and HEAD..."
# 获取变更文件列表,格式为:状态<TAB>路径
# R100 表示 100% 重命名,也就是 git mv
git diff --name-status --find-renames=100% HEAD~1 HEAD | while read -r status from_path to_path; do
# 我们只关心文件移动 (Rename)
if [[ $status == R* ]]; then
# 模式匹配,捕获从哪个阶段移动到哪个阶段
# from_path 是移动前的完整路径
# to_path 是移动后的完整路径
# 案例1: 从 'ready-for-tuning' 移动到 'tuning-in-progress'
# 这是我们期望CI系统自动完成的第一步,但逻辑必须健壮
if [[ $from_path == kanban/1-ready-for-tuning/* && $to_path == kanban/2-tuning-in-progress/* ]]; then
local model_config_file="$to_path"
log_info "Detected model config moved to tuning: ${model_config_file}"
# 设置下游作业所需的环境变量,导出到 .env 文件
echo "MODEL_CONFIG_PATH=${model_config_file}" > trigger.env
echo "MODEL_NAME=$(basename ${model_config_file} .yaml)" >> trigger.env
# 理论上,这里可以触发调优作业
log_info "Generated trigger.env for tuning job."
# 其他状态流转... (例如从 ready-for-deployment 到 deployed)
# else if ...
fi
fi
done
}
# 手动触发的主逻辑:找到所有在待调优区的文件
trigger_pending_tuning_jobs() {
local tuning_dir="kanban/1-ready-for-tuning"
if [ -d "$tuning_dir" ] && [ "$(ls -A $tuning_dir)" ]; then
for model_config_file in "$tuning_dir"/*.yaml; do
[ -e "$model_config_file" ] || continue
local model_name
model_name=$(basename "${model_config_file}" .yaml)
log_info "Found pending model for tuning: ${model_name}"
# 1. 将文件移动到 in-progress 状态,这是一个原子操作
local target_path="kanban/2-tuning-in-progress/${model_name}.yaml"
log_info "Moving config from ${model_config_file} to ${target_path}"
git mv "${model_config_file}" "${target_path}"
# 2. 创建一个提交
git commit -m "chore(ci): Start tuning for model ${model_name}"
# 在真实的CI环境中,需要配置git user和处理认证
# git push origin HEAD:$CI_COMMIT_REF_NAME
# 3. 导出环境变量供后续CI Job使用
# 这一步至关重要,它将动态发现的任务信息传递给下一个阶段
echo "MODEL_CONFIG_PATH=${target_path}" > job_vars.env
echo "MODEL_NAME=${model_name}" >> job_vars.env
log_info "Prepared job for ${model_name}. Exiting to let CI pipeline continue."
# 每次只处理一个,防止CI并行处理时发生冲突
# 更复杂的系统会使用锁或者队列
return 0
done
else
log_info "No models in '1-ready-for-tuning'. Nothing to do."
# 创建一个空的 job_vars.env 文件,让CI job可以正常结束
touch job_vars.env
return 1 # 返回非0表示没有任务
fi
}
main() {
check_ci_vars
# 我们选择一个更主动的模式:扫描待办区,而不是被动响应移动
trigger_pending_tuning_jobs
}
main "$@"
这个脚本的设计体现了几个关键的工程思想:
- 幂等性:重复运行不会产生副作用。
- 原子性:通过
git mv
和git commit
将状态变更和触发捆绑。 - 解耦:脚本只负责发现任务和传递参数(通过
job_vars.env
),具体的调优工作由下游的CI Job负责。 - 单任务处理:为简化起见,每次流水线运行只处理一个待调优模型。在真实世界中,这里可能需要一个分布式锁或消息队列来处理并发。
第四步:模型调优脚本 run_finetuning.py
这个Python脚本运行在Podman容器内部,是真正执行机器学习任务的地方。它被设计成一个通用的、由YAML配置驱动的工具。
# scripts/run_finetuning.py
import os
import yaml
import argparse
import logging
import pandas as pd
from datetime import datetime
import tensorflow as tf
from transformers import AutoTokenizer, TFAutoModelForSequenceClassification, DataCollatorWithPadding
from datasets import Dataset
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, precision_recall_fscore_support
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def load_config(config_path):
"""加载并验证模型YAML配置文件"""
if not os.path.exists(config_path):
logging.error(f"Config file not found at: {config_path}")
raise FileNotFoundError(f"Config file not found at: {config_path}")
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
# 生产级的代码必须有验证逻辑
required_keys = ['metadata', 'spec']
if not all(key in config for key in required_keys):
raise ValueError("Config YAML is missing required top-level keys: 'metadata', 'spec'")
logging.info(f"Successfully loaded config for model: {config['metadata']['name']}")
return config
def preprocess_data(config):
"""加载、预处理并分词数据"""
spec = config['spec']
dataset_path = spec['dataset']['path']
text_col = spec['dataset']['textColumn']
label_col = spec['dataset']['labelColumn']
logging.info(f"Loading dataset from {dataset_path}")
df = pd.read_csv(dataset_path)
# Label encoding (simple example)
df['label'] = df[label_col].astype('category').cat.codes
labels = dict(enumerate(df[label_col].astype('category').cat.categories))
num_labels = len(labels)
logging.info(f"Found {num_labels} labels: {labels}")
tokenizer = AutoTokenizer.from_pretrained(spec['tokenizer'])
def tokenize_function(examples):
return tokenizer(examples[text_col], truncation=True, padding=True)
dataset = Dataset.from_pandas(df)
tokenized_datasets = dataset.map(tokenize_function, batched=True)
# 将Hugging Face Dataset转换为TensorFlow Dataset
data_collator = DataCollatorWithPadding(tokenizer=tokenizer, return_tensors="tf")
tf_dataset = tokenized_datasets.to_tf_dataset(
columns=[col for col in tokenized_datasets.column_names if col != text_col],
label_cols=["label"],
shuffle=True,
batch_size=spec['trainingParams']['batchSize'],
collate_fn=data_collator,
)
return tf_dataset, num_labels, tokenizer
def main():
parser = argparse.ArgumentParser(description="Fine-tune a Hugging Face model using TensorFlow.")
parser.add_argument("--config", type=str, required=True, help="Path to the model YAML config file.")
# CI系统会通过环境变量传入这些值
parser.add_argument("--model-name", type=str, required=True)
parser.add_argument("--commit-sha", type=str, required=True)
args = parser.parse_args()
try:
config = load_config(args.config)
spec = config['spec']
params = spec['trainingParams']
tf_dataset, num_labels, tokenizer = preprocess_data(config)
logging.info(f"Loading base model: {spec['baseModel']}")
model = TFAutoModelForSequenceClassification.from_pretrained(spec['baseModel'], num_labels=num_labels)
# 编译模型
optimizer_name = params.get("optimizer", "adam").lower()
if optimizer_name == "adam":
optimizer = tf.keras.optimizers.Adam(learning_rate=params['learningRate'])
else:
# 在真实项目中,这里会支持更多优化器
raise ValueError(f"Unsupported optimizer: {optimizer_name}")
model.compile(
optimizer=optimizer,
loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
logging.info("Starting model fine-tuning...")
model.fit(tf_dataset, epochs=params['epochs'])
# 保存模型、Tokenizer和配置文件
output_path_template = spec['output']['modelPath']
output_path = output_path_template.format(MODEL_NAME=args.model_name, CI_COMMIT_SHA=args.commit_sha)
os.makedirs(output_path, exist_ok=True)
model.save_pretrained(output_path)
tokenizer.save_pretrained(output_path)
# 将原始配置文件也一并保存,便于追溯
with open(os.path.join(output_path, 'source_config.yaml'), 'w') as f:
yaml.dump(config, f)
logging.info(f"Fine-tuned model and tokenizer saved to: {output_path}")
except Exception as e:
logging.error(f"An error occurred during the fine-tuning process: {e}", exc_info=True)
# 异常退出,CI会捕获到这个失败
exit(1)
if __name__ == "__main__":
main()
第五步:整合为CI/CD流水线
最后一步,我们将所有组件用 .gitlab-ci.yml
串联起来。这个流水线文件是整个系统的执行蓝图。
# .gitlab-ci.yml
variables:
# 使用 Podman 作为容器运行时
DOCKER_HOST: "tcp://localhost:1234" # 只是为了兼容一些工具,实际不使用
# Podman 存储路径,配置在CI Runner上
PODMAN_STORAGE_OPTS: "--storage-driver=vfs"
stages:
- setup
- tuning
- build_and_push
- update_kanban
# 流水线可视化
# ┌──────────┐ ┌─────────┐ ┌────────────────┐ ┌───────────────┐
# │ discover │──▶│ run-job │──▶│ build-serving │──▶│ move-to-deploy│
# └──────────┘ └─────────┘ └────────────────┘ └───────────────┘
discover-tuning-job:
stage: setup
image: alpine:latest
script:
- apk add --no-cache bash git
# 运行触发脚本,它会检查是否有待处理模型并生成job_vars.env
- bash ./scripts/pipeline-trigger.sh
artifacts:
# 将包含模型信息的环境变量文件传递给下一个stage
reports:
dotenv: job_vars.env
rules:
# 仅在主分支上运行
- if: '$CI_COMMIT_BRANCH == "main"'
run-finetuning-job:
stage: tuning
image: docker.io/library/podman:latest
# 需要一个能够运行GPU任务的GitLab Runner
tags:
- gpu
script:
- log_info() { echo "INFO: $1"; }
# 检查 discover 阶段是否真的发现了一个任务
- |
if [ -z "$MODEL_NAME" ]; then
log_info "No model to tune in this pipeline run. Skipping."
exit 0
fi
- log_info "Starting tuning for model: $MODEL_NAME"
- log_info "Building tuning container..."
- podman build -f templates/Containerfile.tuning -t tuning-env:${CI_COMMIT_SHA} .
- log_info "Running tuning container..."
- >
podman run --rm \
--env NVIDIA_VISIBLE_DEVICES=all \
--security-opt label=disable \
-v ./:/home/appuser/app:z \
-w /home/appuser/app \
tuning-env:${CI_COMMIT_SHA} \
--config "${MODEL_CONFIG_PATH}" \
--model-name "${MODEL_NAME}" \
--commit-sha "${CI_COMMIT_SHA}"
# 将训练好的模型产物作为artifacts传递
artifacts:
paths:
- artifacts/models/${MODEL_NAME}/${CI_COMMIT_SHA}/
expire_in: 1 week
rules:
# 仅当 discover-tuning-job 成功并且输出了 MODEL_NAME 时才运行
- if: '$CI_COMMIT_BRANCH == "main" && $MODEL_NAME'
build-serving-image:
stage: build_and_push
image: docker.io/library/podman:latest
needs: ["run-finetuning-job"]
script:
- log_info() { echo "INFO: $1"; }
- log_info "Building serving image for model: $MODEL_NAME"
# 登录到内部镜像仓库
- podman login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
# 从YAML中获取仓库地址和标签
- IMAGE_REPO=$(grep 'imageRepo:' $MODEL_CONFIG_PATH | awk '{print $2}')
- IMAGE_TAG=$(grep 'imageTag:' $MODEL_CONFIG_PATH | awk '{print $2}' | sed "s/{CI_COMMIT_SHA}/$CI_COMMIT_SHA/g")
- >
podman build \
--build-arg MODEL_ARTIFACTS_PATH="artifacts/models/${MODEL_NAME}/${CI_COMMIT_SHA}" \
-f templates/Containerfile.serving \
-t ${IMAGE_REPO}:${IMAGE_TAG} .
- log_info "Pushing image ${IMAGE_REPO}:${IMAGE_TAG}"
- podman push ${IMAGE_REPO}:${IMAGE_TAG}
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $MODEL_NAME'
# 后续阶段...
# update_kanban_to_deploy:
# stage: update_kanban
# ...
# script:
# - git mv kanban/2-tuning-in-progress/${MODEL_NAME}.yaml kanban/4-ready-for-deployment/
# - git commit -m "chore(ci): Model ${MODEL_NAME} tuned successfully"
# - git push ...
# ...
流程可视化
为了更清晰地展示这个工作流,我们可以用Mermaid来绘制其状态机。
graph TD A[Start: MR Merged] --> B{discover-tuning-job}; B -->|Model found in '1-ready'| C[git mv to '2-tuning']; B -->|No model found| E[Pipeline End]; C --> D[run-finetuning-job]; D -->|Success| F[build-serving-image]; D -->|Failure| G[Move to '3-failed']; F --> H[Push image to registry]; H --> I[Move to '4-ready-for-deployment']; I --> J[Trigger Deployment Pipeline]; J --> K[Move to '5-deployed']; G --> E; K --> E;
局限性与未来迭代
这个系统虽然解决了我们最初的痛点,但它并非一个完备的MLOps平台。在真实的大规模生产环境中,它还有几个需要改进的地方:
- 制品库缺失:训练好的模型权重应该被存储在专门的制品库(如MLflow, Nexus, or Artifactory)中,而不是作为CI的artifacts传来传去。这能提供更好的版本管理、元数据追踪和依赖管理。
- 串行处理瓶颈:目前的
pipeline-trigger.sh
脚本一次只处理一个模型,这在高吞吐量的团队中会成为瓶颈。引入基于消息队列(如RabbitMQ或Kafka)的事件驱动机制,或者使用更专业的流水线编排工具(如Argo Workflows或Tekton)是解决这个问题的方向。 - 缺乏自动评估和门禁:调优成功并不意味着模型质量就达标。流水线中应该加入一个自动化的评估阶段,用测试集验证模型性能(如Accuracy, F1-score等),只有达到预设阈值的模型才能被移动到
4-ready-for-deployment
状态。 - 配置与密钥管理:仓库的登录密码等敏感信息不应硬编码或直接使用CI变量,而应通过Vault等密钥管理工具进行动态注入。
尽管存在这些局限,这个基于Git的看板流为我们团队建立了一套坚实、可追溯且高度自动化的MLOps基础。它将最佳的DevOps实践——基础设施即代码、GitOps——创造性地应用于机器学习的生命周期管理中,让算法工程师能更专注于模型本身,而非繁琐的工程细节。