64.2 训练与推理架构
一句话
训练与推理是两条不同的流水线:训练追求可复现(数据/代码/超参/环境固定),推理追求可预测的延迟与成本(批处理、缓存、容量、降级);把两者混成一条链路,通常两头都不达标。
两条流水线,两种目标
| 训练流水线 | 推理流水线 | |
|---|---|---|
| 输入 | 历史快照 + 特征 + 标签 | 在线请求 + 实时特征 |
| 目标 | 模型效果、可复现 | 延迟、吞吐、成本、稳定 |
| 典型时长 | 分钟–天 | 毫秒–秒 |
| 失败代价 | 重跑一次 | 用户直接感知 |
| 关键指标 | AUC/NDCG、训练时长、成本 | P99、错误率、GPU 利用率、单次成本 |
| 版本 | 数据版本 + 代码 + 超参 + 环境 | 模型版本 + 特征版本 + 配置 |
🔑 训练侧最重要的不是「训练得快」,而是同样的输入能复现同样的模型;推理侧最重要的不是「模型最强」,而是在 SLO 内稳定给出结果。两者的优化目标不同,不能共用一套「上线」流程。
训练流水线:五个可复现要素
| 要素 | 怎么固定 | 缺失的后果 |
|---|---|---|
| 数据快照 | 数据集版本 / 分区时间 / 内容 hash | 重训结果不同,无法归因 |
| 代码版本 | Git commit(含特征代码) | 不知道当时用的哪版逻辑 |
| 超参/配置 | 配置文件 + 实验跟踪(MLflow/W&B) | 调参记录丢失 |
| 环境 | 容器镜像 + 依赖锁 + CUDA 版本 | 「我机器上能跑」 |
| 随机种子 | Python/NumPy/框架种子 + 数据顺序 | 结果不可复现 |
训练产出的不是「一个模型文件」,而是一个可追溯的制品:
model.pt / saved_model
+ metrics.json(离线评估)
+ signature(输入输出 schema)
+ feature_spec(用了哪些特征、什么时间语义)
+ data_snapshot(训练数据版本)
+ git_commit / image_digest
⚠️ 只存
model.pt的团队,三个月后无法回答「这个模型是用哪批数据、哪版特征、什么超参训的」;出问题只能重训,无法回滚到「当时那个模型」。
推理的三种形态
| 形态 | 延迟 | 吞吐 | 适用 | 典型 |
|---|---|---|---|---|
| 在线实时 | 毫秒–百毫秒 | 中 | 推荐、风控、搜索排序 | Triton、KServe、vLLM |
| 批量 | 分钟–小时 | 高 | 离线打分、报表、画像 | Spark / Ray / 批推理 |
| 流式 | 秒–分钟 | 高 | 实时特征、事件打分 | Flink + 模型 UDF |
🔑 同一个模型可以同时有在线与批量两条推理路径,但它们必须用同一份预处理与特征定义;否则离线打分和在线打分会对不上(见 64.1 特征平台)。
在线推理服务的解剖
client ──> gateway ──> model server ──> feature client ──> online store
│
├── dynamic batching
├── GPU/CPU executor
└── response
四个关键决策:
- 批处理:把多个请求合成一个 batch,提高 GPU 利用率,但增加排队延迟;
- 模型副本:副本数决定并发上限,GPU 显存决定能否多模型/多版本共存;
- 特征获取:同步取特征会把特征延迟加进端到端;要批量、并行、缓存;
- 降级:模型超时/GPU 不足时返回规则兜底,而不是 500。
代码:批大小、容量与尾延迟
模型服务单批的耗时近似 service = base + per_item * batch;在给定到达率下,容量是 batch / (window + service)。
package main
import "fmt"
const (
offeredRPS = 2000.0 // 到达率
baseMS = 5.0 // 每批固定开销
perItemMS = 0.1 // 每多一条的边际开销
windowMS = 5.0 // 攒批窗口
)
func main() {
fmt.Println("=== 动态批处理的容量与尾延迟(解析模型,offered=2000 req/s)===")
fmt.Printf("%-6s %-10s %-12s %-10s %-9s %-10s %s\n",
"batch", "service(ms)", "latency(ms)", "capacity/s", "util", "queue(ms)", "p99(ms)")
for _, b := range []int{1, 8, 16, 32, 64} {
service := baseMS + perItemMS*float64(b)
latency := windowMS + service
capacity := float64(b) / (latency / 1000.0)
util := offeredRPS / capacity
queue, p99 := 0.0, 0.0
stable := "unstable"
if util < 1 {
queue = util / (1 - util) * service // M/M/1 平均排队
p99 = latency + 3*queue // 粗估,不是严格分位数
stable = "stable"
}
fmt.Printf("%-6d %-10.1f %-12.1f %-10.0f %-9.2f %-10.1f %-10.1f %s\n",
b, service, latency, capacity, util, queue, p99, stable)
}
fmt.Println("\n=== 结论 ===")
fmt.Println("批大小从 8 加到 32,容量从 741/s 提到 2424/s;但 2000/s 下 batch 8/16 都不稳定,")
fmt.Println("batch 32 的 p99 估算约 129ms;batch 64 才把利用率降到 0.51,p99 回落到约 52ms——")
fmt.Println("代价是显存与首包延迟。")
fmt.Println("生产上要按 SLO 反推 batch/window:p99 优先还是吞吐优先,不能同时最优。")
}
本机实测(macOS,Apple M1,Go 1.27.1):
=== 动态批处理的容量与尾延迟(解析模型,offered=2000 req/s)===
batch service(ms) latency(ms) capacity/s util queue(ms) p99(ms)
1 5.1 10.1 99 20.20 0.0 0.0 unstable
8 5.8 10.8 741 2.70 0.0 0.0 unstable
16 6.6 11.6 1379 1.45 0.0 0.0 unstable
32 8.2 13.2 2424 0.82 38.7 129.2 stable
64 11.4 16.4 3902 0.51 12.0 52.4 stable
=== 结论 ===
批大小从 8 加到 32,容量从 741/s 提到 2424/s;但 2000/s 下 batch 8/16 都不稳定,
batch 32 的 p99 估算约 129ms;batch 64 才把利用率降到 0.51,p99 回落到约 52ms——
代价是显存与首包延迟。
生产上要按 SLO 反推 batch/window:p99 优先还是吞吐优先,不能同时最优。
读数:
| 批大小 | 容量 | 利用率 | p99 估算 | 结论 |
|---|---|---|---|---|
| 1 | 99/s | 20.2 | 无意义 | 队列无界增长,服务被打穿 |
| 8 | 741/s | 2.70 | 无意义 | 仍不稳定,2000/s 扛不住 |
| 16 | 1379/s | 1.45 | 无意义 | 接近但不够,排队持续上涨 |
| 32 | 2424/s | 0.82 | 129ms | 稳定,但尾延迟偏高 |
| 64 | 3902/s | 0.51 | 52ms | 更稳、尾延迟更低,显存代价更大 |
🔑 批处理不是「越大越好」:批越大,单批耗时越长、首包延迟越高、显存占用越大。正确做法是按 SLO 反推:先定 p99 预算,再选 batch/window;批大小超过某个点后,尾延迟的收益会被排队成本吃掉。
容量与冷启动
| 问题 | 表现 | 做法 |
|---|---|---|
| GPU 显存不足 | 只能跑 1 个模型副本 | 量化、模型切分、多模型共享 |
| 冷启动 | 新副本加载模型要几十秒 | 预热、镜像缓存、最小副本数 > 0 |
| 突发流量 | 队列堆积、超时 | 预留容量 + 快速扩容 + 降级 |
| 多版本共存 | 显存翻倍 | 只保留 stable + canary,旧版本及时下线 |
⚠️ 自动扩缩容对模型服务不是「立刻生效」:拉镜像、加载权重、预热 CUDA 都要时间。突发流量下,扩容速度往往赶不上流量上涨;必须保留最小副本与降级路径。
训练/服务偏斜(train-serving skew)
| 来源 | 例子 | 防法 |
|---|---|---|
| 预处理不同 | 离线用 pandas 填均值,在线用 0 | 同一份预处理代码/配置 |
| 特征时间不同 | 离线用 label_time,在线用 now | 同一套时间语义与 as-of 逻辑 |
| 缺失值不同 | 离线 dropna,在线保留 | 同一套缺失策略 |
| 模型输入顺序不同 | 特征列顺序错位 | schema 校验 + 签名 |
| 版本错配 | 新特征 + 旧模型 | 特征版本与模型版本绑定 |
🔑 上线前必须做 shadow 推理:把线上流量复制一份给新模型,只记录不返回,比较新旧模型的分数分布、延迟、错误率;shadow 不伤人,是发现 train-serving skew 的最低成本方式。
生产落地:指名 → 可抄 → 可验 → 可退
指名:模型服务用 NVIDIA Triton(多框架、动态批处理)、KServe(K8s 原生、灰度/自动扩缩)、vLLM(LLM 推理,见 64.7 Transformer 与大模型推理);实验跟踪用 MLflow。
可抄(Triton 配置,未验证:本机未安装 Triton):
# config.pbtxt:动态批处理
dynamic_batching {
preferred_batch_size: [8, 16, 32]
max_queue_delay_microseconds: 5000
}
instance_group [{ count: 2 kind: KIND_GPU }]
可验:本节程序输出容量/利用率/p99 表;生产上用压测工具(k6/Locust)打 2000 QPS,观察 P99、GPU 利用率、队列长度是否与模型一致。可退:模型服务保留 stable/canary 两个版本,异常时切回 stable;批处理参数可热更新;GPU 不足时降级到 CPU 小模型或规则兜底。
反模式
| 反模式 | 后果 |
|---|---|
训练只存 model.pt,不存数据/代码版本 | 无法复现、无法归因 |
| 离线/在线两套预处理 | train-serving skew,线上掉点 |
| 批处理只追求 GPU 利用率 | 首包延迟与 p99 飙升 |
| 自动扩缩容不预热 | 新副本冷启动,请求超时 |
| 所有模型常驻显存 | 显存不足,无法灰度 |
| 只看平均延迟 | 尾延迟爆炸被平均值掩盖 |
| 模型超时直接 500 | 用户侧故障,没有兜底 |
| 训练/推理共用一套环境不锁版本 | 「昨天还能跑」的随机故障 |
自检
Q1 训练流水线与推理流水线的核心目标有什么不同?
▸ 答案:训练追求可复现与效果:固定数据快照、代码、超参、环境、种子,保证同样输入得到同样模型;推理追求可预测的延迟、吞吐、成本与稳定:批处理、副本、缓存、降级、SLO。两者优化目标不同,不能用同一套发布/回滚流程。
Q2 为什么「只保存 model.pt」不够?
▸ 答案:模型文件不包含训练数据版本、特征定义、超参、代码 commit、环境依赖。没有这些,无法复现、无法解释指标变化、无法回滚到「当时那个模型」,也无法定位是数据、特征还是代码导致的效果下降。制品应包含模型 + metrics + signature + feature_spec + data_snapshot + commit/image。
Q3 批大小从 8 加到 32,容量和尾延迟各怎么变?为什么 batch 8 的 p99 是「无意义」?
▸ 答案:容量从 741/s 提到 2424/s,但 2000/s 到达率下 batch 8 的利用率 2.70 > 1,队列无界增长,系统不稳定,稳态 p99 不存在;batch 32 利用率 0.82,p99 估算约 129ms。批处理提升容量,但会把排队延迟加进尾延迟。
Q4 为什么模型服务的自动扩缩容不能只按 CPU/GPU 利用率?
▸ 答案:模型服务是「加载权重 + 预热」后才能服务,扩容有几十秒延迟;按利用率触发时,队列可能已经堆积。要结合队列长度、P99、并发数提前扩容,并保留最小副本、预热池与降级路径。GPU 利用率高不一定是瓶颈,也可能是批处理把 GPU 打满但队列仍在涨。
Q5 train-serving skew 的五个常见来源是什么?
▸ 答案:预处理不同(填值/归一化)、特征时间不同(label_time vs now)、缺失值策略不同(dropna vs 保留)、输入列顺序/schema 不同、模型与特征版本错配。防法是同一份预处理与特征定义、schema 校验、特征/模型版本绑定,以及 shadow 推理对比分布。
Q6 shadow 推理与 canary 的区别是什么?
▸ 答案:shadow 把流量复制给新模型但不返回给用户,只记录预测/延迟/错误,用于验证模型行为与 train-serving skew,不影响业务;canary 让一小部分真实用户走新模型并返回结果,用于验证系统指标与业务指标,有真实风险。通常先 shadow,再 canary,最后 A/B。
Q7 模型服务超时/GPU 不足时应该怎么降级?
▸ 答案:优先返回「可接受但稍差」的结果:规则/热门兜底、小模型、缓存结果、降低候选集;同时记录降级原因与比例。不能直接 500,也不能无限排队。降级要有开关与阈值,恢复后自动切回,并在看板上区分「正常结果/降级结果」。