AI Infra实战11:模型部署Pipeline,CI/CD自动化

本篇目标
设计完整的模型发布Pipeline:从模型训练完成到线上服务更新的全自动化流程。
学完本篇你将掌握:
- 模型CI/CD vs 代码CI/CD的核心差异
- 完整的模型发布Pipeline设计
- 模型质量门禁
- 灰度发布和回滚策略
- GitLab CI完整配置
模型CI/CD vs 代码CI/CD
| 维度 | 代码CI/CD | 模型CI/CD |
|---|---|---|
| 触发方式 | 代码push/MR合并 | MLflow版本变更 |
| 构建产物 | Docker镜像 | 模型文件(存S3) |
| 测试方式 | 单元测试、集成测试 | 性能测试(延迟、吞吐量) |
| 产物大小 | 几百MB | 几GB-几十GB |
| 发布速度 | 秒级 | 分钟级(下载模型+加载GPU) |
| 回滚方式 | 改镜像tag | 切MLflow版本+重新下载 |

代码CI/CD与模型CI/CD在触发、产物、发布速度上的对比
完整流程
MLflow标记Staging → 触发Pipeline → 性能测试 → 灰度10% → 观察 → 全量 → 标记Production
↓ 不通过 ↓ 异常
拒绝上线 自动回滚

验证到全量的四阶段主流程,以及不通过时的拒绝上线和自动回滚分支
Pipeline各阶段
阶段1:获取模型信息
从MLflow读取Staging版本的配置参数。
阶段2:质量门禁
| 检查项 | 通过条件 |
|---|---|
| 模型能加载 | 不报错 |
| P99延迟 | < 3000ms |
| 吞吐量(20并发) | > 10 req/s |
| 显存占用 | < 90% |
| 输出合理 | 不是乱码 |
阶段3:灰度发布
部署Canary Pod(10%流量),观察5分钟。
阶段4:全量发布
验证通过后更新生产Deployment,MLflow标记Production。
性能测试详解:怎么测一个还没上线的模型
核心问题
模型还没部署到生产,怎么做压测?
答案:在CI中自动部署一个临时的测试实例,专门用来跑压测。测试完就删掉。
完整流程
Pipeline的test阶段内部:
1. 自动创建测试Pod(独立namespace,不接生产流量)
↓
2. 等待测试Pod就绪(模型下载+加载,约2-3分钟)
↓
3. 对测试Pod跑压测(延迟、吞吐量)
↓
4. 判断结果:通过 → 继续下一阶段
不通过 → Pipeline失败,拒绝上线
↓
5. 删掉测试Pod,释放GPU

CI中临时测试实例的生命周期,独立命名空间、不接生产流量、测完释放GPU
测试实例的设计要点
性能测试沿用微服务发布中"先测试环境验证再上生产"的思路,但有几个模型场景特有的约束需要在Pipeline里明确:
- 测试GPU来源:使用集群预留的测试GPU,或在测试阶段临时扩一个GPU节点,测完释放。
- 流量隔离:测试实例部署在独立namespace(如ai-test),生产Service的selector不会选中它,因此不承接线上流量。
- 生命周期:由CI脚本负责创建和删除,测试结束后kubectl delete释放GPU,避免测试实例长期占卡。
- 模型来源:initContainer从对象存储下载Staging版本的模型权重,与生产实例使用同一份产物。
- 全程自动:创建、就绪等待、压测、判定、清理都写在CI脚本里,不需要人工介入。
GitLab CI配置
yaml
stages:
- validate
- test
- deploy-canary
- verify
- deploy-full
- rollback
variables:
MLFLOW_URI: "http://mlflow:5000"
MODEL_NAME: "qwen2-inference-service"
NAMESPACE: "ai-inference"
DEPLOYMENT: "vllm-service"
# 获取Staging模型信息
validate:
stage: validate
script:
- pip install mlflow -q
- |
python3 << 'EOF'
from mlflow import MlflowClient
import json, os
client = MlflowClient(os.environ["MLFLOW_URI"])
models = client.get_latest_versions(os.environ["MODEL_NAME"], stages=["Staging"])
if not models:
print("没有Staging版本")
exit(1)
model = models[0]
run = client.get_run(model.run_id)
config = {
"version": model.version,
"model_name": run.data.params.get("model_name", ""),
"max_model_len": run.data.params.get("max_model_len", "4096"),
"gpu_memory_utilization": run.data.params.get("gpu_memory_utilization", "0.9"),
}
with open("model_config.json", "w") as f:
json.dump(config, f)
print(f"Staging模型: v{model.version} - {config['model_name']}")
EOF
artifacts:
paths:
- model_config.json
# 性能测试(含临时测试实例的创建和销毁)
test:performance:
stage: test
needs: [validate]
script:
# ========== 第1步:部署临时测试实例 ==========
- |
echo "=== 部署测试实例 ==="
MODEL_FILE=$(cat model_config.json | python3 -c "import json,sys;print(json.load(sys.stdin)['model_name'])")
MAX_LEN=$(cat model_config.json | python3 -c "import json,sys;print(json.load(sys.stdin)['max_model_len'])")
kubectl -n ai-test apply -f - << YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-test
namespace: ai-test
spec:
replicas: 1
selector:
matchLabels:
app: vllm-test
template:
metadata:
labels:
app: vllm-test
spec:
initContainers:
- name: download-model
image: amazon/aws-cli
command: ["sh", "-c", "aws s3 cp s3://models/${MODEL_FILE}/ /models/ --recursive"]
volumeMounts:
- name: model-vol
mountPath: /models
containers:
- name: vllm
image: vllm/vllm-openai:latest
command: ["vllm", "serve", "/models/${MODEL_FILE}", "--host=0.0.0.0", "--port=8000", "--max-model-len=${MAX_LEN}"]
ports:
- containerPort: 8000
resources:
limits:
nvidia.com/gpu: 1
volumeMounts:
- name: model-vol
mountPath: /models
volumes:
- name: model-vol
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: vllm-test
namespace: ai-test
spec:
selector:
app: vllm-test
ports:
- port: 8000
YAML
# ========== 第2步:等待测试实例就绪 ==========
- |
echo "=== 等待测试实例就绪(最多5分钟)==="
kubectl -n ai-test wait --for=condition=ready pod -l app=vllm-test --timeout=300s
echo "测试实例就绪"
# ========== 第3步:跑压测 ==========
- |
echo "=== 延迟测试(10个串行请求)==="
total=0
for i in $(seq 1 10); do
start=$(date +%s%N)
curl -s http://vllm-test.ai-test:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"test","messages":[{"role":"user","content":"hi"}],"max_tokens":20}' > /dev/null
end=$(date +%s%N)
ms=$(( (end - start) / 1000000 ))
total=$((total + ms))
echo " 请求$i: ${ms}ms"
done
avg=$((total / 10))
echo " 平均延迟: ${avg}ms"
# ========== 第4步:判断结果 ==========
- |
if [ $avg -gt 3000 ]; then
echo "延迟超标: ${avg}ms > 3000ms,拒绝上线"
kubectl -n ai-test delete deployment vllm-test
kubectl -n ai-test delete service vllm-test
exit 1
fi
echo "延迟达标: ${avg}ms < 3000ms"
echo "=== 吞吐量测试(20并发)==="
start=$(date +%s%N)
for i in $(seq 1 20); do
curl -s http://vllm-test.ai-test:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"test","messages":[{"role":"user","content":"hello"}],"max_tokens":20}' > /dev/null &
done
wait
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
qps=$((20000 / elapsed))
echo " 吞吐量: ${qps} req/s"
if [ $qps -lt 10 ]; then
echo "吞吐量不足: ${qps} < 10 req/s,拒绝上线"
kubectl -n ai-test delete deployment vllm-test
kubectl -n ai-test delete service vllm-test
exit 1
fi
echo "吞吐量达标: ${qps} req/s"
# ========== 第5步:清理测试实例 ==========
- |
echo "=== 清理测试实例 ==="
kubectl -n ai-test delete deployment vllm-test
kubectl -n ai-test delete service vllm-test
echo "测试完成,GPU已释放"
# 灰度部署
deploy:canary:
stage: deploy-canary
needs: [test:performance]
script:
- |
echo "=== 部署Canary(10%流量)==="
kubectl -n $NAMESPACE apply -f canary-deployment.yaml
echo "Canary部署完成"
environment:
name: production-canary
# 灰度验证
verify:canary:
stage: verify
needs: [deploy:canary]
script:
- |
echo "=== 观察5分钟 ==="
sleep 300
# 查询canary的成功请求速率(vLLM暴露 vllm:request_success_total)
# 失败率建议结合网关或应用层的HTTP状态码统计,指标名以实际vLLM版本的 /metrics 为准
SUCCESS_RATE=$(curl -s "http://prometheus:9090/api/v1/query?query=sum(rate(vllm:request_success_total{track='canary'}[5m]))")
echo "Canary成功请求速率: ${SUCCESS_RATE}"
# 异常则回滚
kubectl -n $NAMESPACE delete deployment ${DEPLOYMENT}-canary || true
# 全量部署(手动确认)
deploy:full:
stage: deploy-full
needs: [verify:canary]
script:
- |
kubectl -n $NAMESPACE set env deployment/${DEPLOYMENT} \
MODEL_VERSION=$(cat model_config.json | python3 -c "import json,sys;print(json.load(sys.stdin)['version'])")
kubectl -n $NAMESPACE rollout status deployment/${DEPLOYMENT} --timeout=600s
kubectl -n $NAMESPACE delete deployment ${DEPLOYMENT}-canary || true
echo "全量部署完成"
when: manual
# 回滚
rollback:
stage: rollback
script:
- kubectl -n $NAMESPACE rollout undo deployment/${DEPLOYMENT}
- echo "回滚完成"
when: manual
allow_failure: true
灰度部署详解:怎么让只有10%的流量到新模型
核心问题
灰度发布时,怎么控制只有一部分流量走新模型,大部分流量还走旧模型?
第07篇用KServe在真实GPU环境验证过一次灰度:给InferenceService设置canaryTrafficPercent,KServe自动把流量按10比90切到新旧两个Revision,并支持回滚和推广。那是平台已经封装好的能力。这一篇换个角度,看不依赖KServe时,怎么用更基础的K8s原语自己实现灰度,理解背后的流量分配原理。下面给两种方式。
方式一:Canary Deployment(最常用,不需要额外组件)
原理:利用K8s Service的负载均衡:多个Pod共享一个Service,流量按Pod数量比例自然分配。

Service按Pod数量分流,10个旧版本Pod加1个新版本Pod,新版本约拿9%流量
生产环境现状:
Deployment: vllm-service(10个Pod,跑v1模型)
Service: vllm-svc → 负载均衡到这10个Pod
灰度操作:
新增 Deployment: vllm-service-canary(1个Pod,跑v3模型)
让这个canary Pod也被同一个Service选中
结果:
Service后面有11个Pod(10个v1 + 1个v3)
流量自然分配:v3拿到约 1/11 ≈ 9% 的流量
关键配置:
yaml
# 生产Deployment(已有,10副本)
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-service
spec:
replicas: 10
selector:
matchLabels:
app: vllm
template:
metadata:
labels:
app: vllm # ← Service通过这个label选Pod
version: v1 # ← 区分版本(用于监控区分)
---
# Canary Deployment(CI自动创建,1副本)
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-service-canary
spec:
replicas: 1
selector:
matchLabels:
app: vllm
version: v3
template:
metadata:
labels:
app: vllm # ← 同样的label,也会被Service选中
version: v3
---
# Service(不需要改,自动选中所有 app=vllm 的Pod)
apiVersion: v1
kind: Service
metadata:
name: vllm-svc
spec:
selector:
app: vllm # ← 选中v1的10个Pod + v3的1个Pod
ports:
- port: 8000
流量比例: 10个旧Pod + 1个新Pod = 新模型拿到约9%流量。
方式二:Istio精确流量控制(需要Service Mesh)
如果集群装了Istio,可以精确控制任意百分比:
yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: vllm-vs
spec:
hosts:
- vllm-svc
http:
- route:
- destination:
host: vllm-svc
subset: stable
weight: 90 # 90%走旧模型
- destination:
host: vllm-svc
subset: canary
weight: 10 # 10%走新模型
两种方式对比
| 维度 | Canary Deployment | Istio |
|---|---|---|
| 精确度 | 按Pod数比例(粗略) | 任意百分比(精确) |
| 依赖 | 只需要K8s | 需要安装Istio |
| 复杂度 | 低(CI里kubectl apply就行) | 高 |
| 适合 | 大多数场景 | 需要精确控制时 |
建议: 没有Istio就用Canary Deployment,够用了。
灰度期间怎么观察
部署canary后观察5-10分钟,对比新旧模型的指标:
| 指标 | v1(stable) | v3(canary) | 判定 |
|---|---|---|---|
| P99延迟 | 2.1s | 1.8s | 更好 |
| 错误率 | 0.1% | 0.2% | 在可接受范围 |
| GPU显存 | 12GB | 6GB | 更省 |
用Prometheus区分新旧版本的指标(通过version label):
vLLM暴露的是成功请求计数 vllm:request_success_total。给新旧版本的Pod打上不同的 version label 后,可以分别观察两版的请求速率;失败率建议结合网关或应用层的 HTTP 状态码统计。具体指标名以所用 vLLM 版本的 /metrics 输出为准。
promql
# canary(v3)的成功请求速率
sum(rate(vllm:request_success_total{version="v3"}[5m]))
# stable(v1)的成功请求速率
sum(rate(vllm:request_success_total{version="v1"}[5m]))
灰度失败怎么回滚
bash
# 直接删掉canary Deployment
kubectl delete deployment vllm-service-canary
canary Pod没了 → Service只剩v1的Pod → 流量自动100%回到旧模型。不需要改Service配置。
灰度成功怎么全量
bash
# 1. 更新生产Deployment的模型版本(触发滚动更新)
kubectl set env deployment/vllm-service MODEL_VERSION=v3
# 2. 等待所有旧Pod滚动更新成v3
kubectl rollout status deployment/vllm-service
# 3. 删掉canary(生产Deployment已经全部是v3了)
kubectl delete deployment vllm-service-canary
灰度实践中的几个关键点
- 比例精度:Service按Pod数量分流是粗粒度的,10个旧Pod加1个canary约等于9%,多数场景够用。需要精确到任意百分比时用Istio的weight,或按第07篇KServe的canaryTrafficPercent控制。
- 调整比例:想走5%到20%再到100%的阶梯,可以调canary的replicas数量,或调Istio/KServe的权重。
- 对存量的影响:canary是新增的Deployment,不改动原有Pod,灰度期间旧模型不受影响。
- 用户感知:如果新旧模型回答风格差异明显,被分到canary的少量用户可能察觉,需要在验证阶段一并评估输出质量,而不只看延迟和错误率。
- 观察时长:看基本指标5到10分钟即可,核心服务建议观察30分钟到1小时,覆盖一个较完整的流量波峰。
质量门禁设计
| 级别 | 检查项 | 不通过的后果 |
|---|---|---|
| P0 | 模型能加载 | 阻断,不上线 |
| P0 | 请求不报错 | 阻断 |
| P1 | P99延迟 < SLO | 阻断 |
| P1 | 吞吐量 > 基线80% | 阻断 |
| P2 | 显存 < 90% | 告警但不阻断 |
实践边界
本篇是模型部署Pipeline的设计方案,整理自MLflow、GitLab CI和K8s的常规用法,不新增云资源,成本为0。需要说明验证情况:
- 其中的灰度、回滚能力,第07篇已用KServe在真实GPU环境验证过(canaryTrafficPercent按10比90切分、回滚、推广到100%)。
- 本篇的GitLab CI配置、临时测试实例和质量门禁,是可落地的设计模板,尚未在完整CI环境端到端跑通,落地时需结合具体的MLflow地址、对象存储和GPU配额调整参数。
- vLLM的指标名以所用版本的 /metrics 输出为准,不同版本可能有差异。
不把设计模板写成已上线的生产流水线,是为了让读者清楚哪些是已验证的、哪些是待落地的。
小结
- 模型CI/CD多了质量门禁:延迟和吞吐量必须达标
- 四阶段流程:验证→测试→灰度→全量
- 灰度+自动回滚:异常时自动切回旧版本
- 底层仍是熟悉的工具链:GitLab CI加K8s滚动更新,核心增量是模型产物管理和上线前的性能门禁
下一篇预告
AI Infra实战12:GPU成本优化实践