最初的模型交付流程混乱不堪。机器学习团队训练完一个新模型,导出一个 .tflite 文件,然后通过即时通讯工具扔给Android团队。版本控制依赖于文件名,例如 model_v3_final_final.tflite,回滚则是一场灾难。更糟糕的是,当线上出现问题时,我们无法准确追溯到某个App版本具体打包的是哪个模型的哪个训练批次。这种纯手动的交接方式在项目初期还能勉强维持,但随着模型迭代速度加快,它成了整个开发流程中最脆弱、最容易出错的环节。
我们必须建立一个自动化的、可追溯的、可靠的模型交付管道。目标很明确:
- 集中式模型注册与版本管理:所有模型必须有一个单一可信源。
- 按环境隔离模型:能够清晰区分
Staging和Production环境的模型。 - 构建时自动拉取:Android应用在构建时,应自动获取指定环境的最新、最稳定的模型版本。
- 工具链质量保障:用于实现自动化的脚本或工具,其本身的代码质量必须得到保证。
技术选型上,我们很快确定了核心组件。MLflow 因其开源、轻量和完善的模型注册(Model Registry)功能,成为模型管理的不二之选。Android端自然是使用 Gradle 来编排构建流程。而连接这两者的“胶水层”——自动化脚本,我们决定采用团队技术栈中更为熟悉的 Node.js。在真实项目中,CI/CD的辅助脚本常常因为“能跑就行”而被忽略了代码质量,最终演变成难以维护的定时炸弹。因此,一个非协商项是:所有Node.js工具链代码必须通过 ESLint 的严格检查。
第一步:搭建生产级的 MLflow 服务
一个常见的错误是直接使用 MLflow 默认的、基于本地文件系统的后端。这在本地实验时很方便,但在团队协作和CI/CD环境中是不可行的。我们需要一个集中式的服务。我们使用 Docker Compose 搭建一个更健壮的 MLflow 实例,它包含三个关键部分:
- MLflow Tracking Server: 核心服务。
- PostgreSQL: 作为后端存储,用于保存实验元数据、模型版本等信息,比默认的SQLite更稳定。
- MinIO (S3兼容对象存储): 作为模型文件、数据集等大文件的制品库(Artifact Store)。
# docker-compose.yml
version: "3.8"
services:
postgres:
image: postgres:13
container_name: mlflow_postgres
environment:
- POSTGRES_USER=mlflow
- POSTGRES_PASSWORD=mlflow
- POSTGRES_DB=mlflowdb
ports:
- "5432:5432"
volumes:
- mlflow_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mlflow"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:RELEASE.2022-03-17T06-34-49Z
container_name: mlflow_minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- mlflow_minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 2s
retries: 5
mlflow:
image: python:3.9-slim
container_name: mlflow_server
command: >
bash -c "pip install mlflow boto3 psycopg2-binary &&
mlflow server
--backend-store-uri postgresql://mlflow:mlflow@postgres:5432/mlflowdb
--default-artifact-root s3://mlflow/
--host 0.0.0.0"
ports:
- "5000:5000"
environment:
- AWS_ACCESS_KEY_ID=minioadmin
- AWS_SECRET_ACCESS_KEY=minioadmin
- MLFLOW_S3_ENDPOINT_URL=http://minio:9000
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
volumes:
mlflow_postgres_data:
mlflow_minio_data:
启动后,我们还需要手动在MinIO(通过 http://localhost:9001 访问)中创建一个名为 mlflow 的 bucket。至此,我们有了一个可靠的MLflow服务。
接下来,模拟一次模型训练与注册。这个Python脚本的核心在于,它不仅记录了实验,更重要的是将训练好的 .tflite 文件作为制品(artifact)上传,并将其注册到Model Registry中。
# train_and_register.py
import mlflow
import tensorflow as tf
import numpy as np
import os
# 配置MLflow客户端
os.environ['MLFLOW_TRACKING_URI'] = 'http://localhost:5000'
os.environ['MLFLOW_S3_ENDPOINT_URL'] = 'http://localhost:9000'
os.environ['AWS_ACCESS_KEY_ID'] = 'minioadmin'
os.environ['AWS_SECRET_ACCESS_KEY'] = 'minioadmin'
MODEL_NAME = "android-image-classifier"
TFLITE_MODEL_PATH = "model.tflite"
def create_and_convert_model():
# 创建一个简单的Keras模型用于演示
model = tf.keras.models.Sequential([
tf.keras.layers.InputLayer(input_shape=(28, 28)),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(10, activation=tf.nn.softmax)
])
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
# 转换为 TFLite 格式
converter = tf.lite.TFLiteConverter.from_keras_model(model)
tflite_model = converter.convert()
with open(TFLITE_MODEL_PATH, 'wb') as f:
f.write(tflite_model)
print(f"Model converted and saved to {TFLITE_MODEL_PATH}")
def main():
create_and_convert_model()
mlflow.set_experiment("Android Models")
with mlflow.start_run(run_name="initial_training_run") as run:
# 记录参数
mlflow.log_param("epochs", 5)
mlflow.log_param("optimizer", "adam")
# 记录指标
mlflow.log_metric("accuracy", 0.95)
# 将TFLite模型作为制品记录
mlflow.log_artifact(TFLITE_MODEL_PATH, artifact_path="model")
run_id = run.info.run_id
print(f"Run ID: {run_id}")
# 将本次运行的模型注册到Model Registry
model_uri = f"runs:/{run_id}/model"
mlflow.register_model(model_uri, MODEL_NAME)
print(f"Model '{MODEL_NAME}' registered.")
if __name__ == "__main__":
main()
运行此脚本后,我们可以在MLflow的UI(http://localhost:5000)中看到名为 android-image-classifier 的模型。我们可以手动将其版本1提升(promote)到 Staging 或 Production 阶段。这是后续自动化脚本拉取模型的依据。
第二步:构建高质量的 Node.js 交付脚本
这是连接MLflow和Android构建的关键。这个脚本需要足够健壮,能够处理网络错误、模型不存在等异常情况。首先,是项目的初始化和ESLint配置。
mkdir model-delivery-tool
cd model-delivery-tool
npm init -y
npm install axios yargs @mlflow/[email protected] --save
npm install eslint eslint-plugin-promise eslint-plugin-import --save-dev
ESLint的配置是保障脚本质量的第一道防线。在CI/CD环境中运行的脚本,我们特别关注几个点:必须处理Promise的拒绝状态,避免使用同步API,以及强制代码风格一致性。
// .eslintrc.js
module.exports = {
env: {
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
],
parserOptions: {
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['promise', 'import'],
rules: {
'no-console': 'off', // CI脚本中需要console输出日志
'semi': ['error', 'always'],
'quotes': ['error', 'single'],
'promise/catch-or-return': 'error',
'promise/always-return': 'error',
'no-async-promise-executor': 'error',
'no-await-in-loop': 'warn',
'import/no-unresolved': ['error', { commonjs: true }],
},
};
接下来是核心的交付脚本 fetch-model.js。这个脚本通过MLflow的JavaScript客户端,获取指定模型在特定阶段(如Production)的最新版本信息,然后解析出其在S3存储中的路径,最后下载到指定位置。
// fetch-model.js
const { MlflowServiceClient } = require('@mlflow/mlflow/build/js/mlflow_service');
const axios = require('axios');
const path = require('path');
const fs = require('fs/promises');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
// 从环境变量读取配置,这是在CI环境中配置的最佳实践
const MLFLOW_TRACKING_URI = process.env.MLFLOW_TRACKING_URI;
const MLFLOW_S3_ENDPOINT_URL = process.env.MLFLOW_S3_ENDPOINT_URL; // MinIO 的外部访问地址
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY;
const S3_SECRET_KEY = process.env.S3_SECRET_KEY;
/**
* 日志记录器,方便在CI日志中区分脚本输出
* @param {string} message - 日志信息
*/
const log = (message) => console.log(`[ModelDelivery] ${message}`);
/**
* 主执行函数
* @param {string} modelName - MLflow中注册的模型名称
* @param {string} modelStage - 模型的阶段 (e.g., 'Production', 'Staging')
* @param {string} outputDir - 模型文件输出目录
*/
async function main(modelName, modelStage, outputDir) {
if (!MLFLOW_TRACKING_URI || !MLFLOW_S3_ENDPOINT_URL) {
throw new Error('MLFLOW_TRACKING_URI and MLFLOW_S3_ENDPOINT_URL must be set in environment variables.');
}
log(`Connecting to MLflow at ${MLFLOW_TRACKING_URI}...`);
const client = new MlflowServiceClient({
serviceUrl: MLFLOW_TRACKING_URI.replace(/^http:\/\//, ''), // JS client 需要移除 http://
});
try {
log(`Fetching latest version of model '${modelName}' in stage '${modelStage}'...`);
// 1. 获取模型最新版本
const response = await client.getLatestVersions({
name: modelName,
stages: [modelStage],
});
if (!response.model_versions || response.model_versions.length === 0) {
throw new Error(`No model version found for '${modelName}' in stage '${modelStage}'.`);
}
const latestVersion = response.model_versions[0];
const { version, run_id, source } = latestVersion;
log(`Found version: ${version}, from Run ID: ${run_id}`);
log(`Artifact source URI: ${source}`);
// source 的格式是 s3://<bucket>/<run_id>/artifacts/model
// 我们需要下载的是 model.tflite, 路径是 s3://<bucket>/<run_id>/artifacts/model/model.tflite
// 这里硬编码了模型文件名,更好的做法是在MLflow中用tag标记主文件名
const modelFileName = 'model.tflite';
const artifactPath = new URL(source).pathname;
const downloadUrl = `${MLFLOW_S3_ENDPOINT_URL}${artifactPath}/${modelFileName}`;
log(`Downloading model from ${downloadUrl}`);
// 2. 下载模型文件
// 在真实项目中,这里应该使用 AWS S3 SDK 并传入认证信息
// 为简化示例,假设MinIO的bucket是公开可读的,或者使用预签名URL
// 此处我们直接用axios,并假设没有复杂的认证
const modelResponse = await axios({
method: 'get',
url: downloadUrl,
responseType: 'arraybuffer',
});
// 3. 写入文件到Android项目目录
const outputPath = path.join(outputDir, modelFileName);
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(outputPath, modelResponse.data);
log(`Model successfully saved to ${outputPath}`);
// 4. (可选) 写入版本信息文件,用于App内展示或调试
const versionInfo = {
modelName,
modelVersion: version,
modelStage,
mlflowRunId: run_id,
downloadTimestamp: new Date().toISOString(),
};
const versionInfoPath = path.join(outputDir, 'model_version.json');
await fs.writeFile(versionInfoPath, JSON.stringify(versionInfo, null, 2));
log(`Model version info saved to ${versionInfoPath}`);
} catch (error) {
console.error('[ModelDelivery] FATAL: Failed to fetch model.');
if (error.response) {
console.error(`Status: ${error.response.status}`);
console.error(`Data: ${error.response.data}`);
} else {
console.error(error.message);
}
// 抛出错误,让CI/CD过程失败
process.exit(1);
}
}
// 使用 yargs 解析命令行参数
const argv = yargs(hideBin(process.argv))
.option('name', {
alias: 'n',
type: 'string',
description: 'Name of the model in MLflow Model Registry',
demandOption: true,
})
.option('stage', {
alias: 's',
type: 'string',
description: 'Stage of the model to fetch',
default: 'Production',
})
.option('output', {
alias: 'o',
type: 'string',
description: 'Output directory for the model file',
demandOption: true,
})
.help()
.argv;
main(argv.name, argv.stage, argv.output).catch(() => process.exit(1));
这个脚本包含了参数解析、错误处理和清晰的日志输出,完全符合一个生产级工具的要求。我们可以在 package.json 中添加一个脚本来运行它,并先进行lint检查。
// package.json (部分)
"scripts": {
"lint": "eslint ./**/*.js",
"fetch-model": "node fetch-model.js"
}
第三步:集成到 Android Gradle 构建流程
目标是让Android项目在构建时,自动执行上述Node.js脚本。这可以通过在 build.gradle.kts (或 build.gradle) 中定义一个自定义任务来实现。
// app/build.gradle.kts
// ... (android block)
// 自定义任务,用于下载ML模型
tasks.register("downloadMlModel", Exec::class.java) {
group = "MLOps"
description = "Downloads the latest ML model from MLflow for a given stage."
// 通过项目属性-P来传递模型阶段,例如 ./gradlew assembleRelease -PmodelStage=Production
val modelStage = project.properties["modelStage"]?.toString() ?: "Staging"
// Android项目根目录
val projectRoot = rootDir
// Node.js工具链的路径
val toolDir = File(projectRoot, "../model-delivery-tool")
// 模型输出到Android module的assets目录
val outputDir = File(projectDir, "src/main/assets/ml_models")
// 设置工作目录
workingDir = toolDir
// 定义要执行的命令
commandLine(
"node",
"fetch-model.js",
"--name", "android-image-classifier",
"--stage", modelStage,
"--output", outputDir.absolutePath
)
// 配置环境变量,传递给Node.js脚本
// 在真实的CI环境中,这些值应该来自CI/CD的Secrets
environment("MLFLOW_TRACKING_URI", "http://192.168.1.100:5000") // 替换为你的MLflow服务器IP
environment("MLFLOW_S3_ENDPOINT_URL", "http://192.168.1.100:9000") // 替换为你的MinIO IP
// environment("S3_ACCESS_KEY", "minioadmin") // 如果需要
// environment("S3_SECRET_KEY", "minioadmin") // 如果需要
}
// 让 preBuild 任务依赖我们的下载任务
// 这样在任何构建开始前,都会先执行模型下载
tasks.named("preBuild") {
dependsOn("downloadMlModel")
}
这里的关键点:
-
Exec类型任务: 它允许执行任意外部命令,非常适合调用我们的Node.js脚本。 - 项目属性 (
-PmodelStage): 允许我们在命令行动态指定要拉取哪个环境的模型,这对于构建不同环境的包(如测试包用Staging模型,正式包用Production模型)至关重要。 - 任务依赖: 通过
dependsOn("downloadMlModel"),我们确保了在编译Java/Kotlin代码或打包资源之前,模型文件就已经被正确地放置到了assets目录中。 - 环境变量: 这是向子进程传递配置的安全方式,避免了将敏感信息硬编码在构建脚本里。
第四步:整合到 CI/CD 流水线
最后一步是将所有环节串联起来,形成一个完整的自动化流程。下面是一个使用GitHub Actions的示例工作流程,它展示了整个流程。
graph TD
A[Git Push on main] --> B{Checkout Code};
B --> C[Setup Node.js & Java];
C --> D[Install Tooling Dependencies];
D --> E[Lint Tooling Script];
E --> F[Build Release APK];
F --> G[Upload APK Artifact];
subgraph "Build Release APK"
direction LR
F1(Run ./gradlew assembleRelease) --> F2(Gradle triggers 'preBuild');
F2 --> F3('downloadMlModel' task runs);
F3 --> F4(Node script fetches model);
F4 --> F5(Model placed in assets);
F5 --> F6(App compilation continues);
end
对应的 workflow.yml 文件片段如下:
# .github/workflows/android-ci.yml
name: Android CI with Model Delivery
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
# 我们需要同时checkout两个项目
repository: 'my-org/android-app'
path: 'android-app'
- name: Checkout model delivery tool
uses: actions/checkout@v3
with:
repository: 'my-org/model-delivery-tool'
path: 'model-delivery-tool'
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: 'model-delivery-tool/package-lock.json'
- name: Install Node.js dependencies for tooling
run: |
cd model-delivery-tool
npm ci
- name: Lint model delivery script
run: |
cd model-delivery-tool
npm run lint
- name: Build Release APK with Production Model
run: |
cd android-app
chmod +x ./gradlew
# 从GitHub Secrets中获取MLflow服务器地址
./gradlew assembleRelease -PmodelStage=Production
env:
MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
MLFLOW_S3_ENDPOINT_URL: ${{ secrets.MLFLOW_S3_ENDPOINT_URL }}
- name: Upload Release APK
uses: actions/upload-artifact@v3
with:
name: release-apk
path: android-app/app/build/outputs/apk/release/app-release.apk
这个CI流程实现了我们的全部目标:
- 每次向
main分支推送代码,都会触发构建。 - 首先对交付脚本进行
lint检查,不通过则直接失败,保证了工具链的质量。 - 执行Gradle构建命令,并传入
Production阶段。 -
downloadMlModel任务被触发,Node.js脚本从环境变量获取服务器地址,拉取生产模型。 - 构建成功后,将包含最新生产模型的APK作为制品上传。
当前方案的局限性与未来展望
这套流程解决了最初的手动交付混乱问题,但它并非完美。
首先,构建过程强依赖MLflow服务的可用性。如果MLflow服务或网络出现故障,Android的CI/CD会构建失败。未来的一个优化方向是在CI/CD和MLflow之间引入一个缓存层或代理,比如将下载的模型缓存到CI平台的缓存中,只有当MLflow中有更新的版本时才重新下载。
其次,模型版本的确定性。目前脚本总是拉取latest版本,这在大多数情况下是期望的行为。但在需要精确复现某个历史构建时,可能会遇到困难。一个改进方案是,在Android项目的代码中维护一个文件,明确指定要使用的模型版本号(或run_id),构建脚本读取这个文件去拉取特定版本,而不是永远拉取最新版。这样,模型版本就和代码版本一起被Git管理起来,实现了完全的构建可复现性。
最后,安全性。当前脚本中环境变量的传递和管理相对简单。在更复杂的企业环境中,需要使用更安全的凭证管理系统(如HashiCorp Vault)来动态地为构建任务提供访问S3的临时凭证,而不是使用静态的Access Key。