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

四个关键决策:

  1. 批处理:把多个请求合成一个 batch,提高 GPU 利用率,但增加排队延迟;
  2. 模型副本:副本数决定并发上限,GPU 显存决定能否多模型/多版本共存;
  3. 特征获取:同步取特征会把特征延迟加进端到端;要批量、并行、缓存;
  4. 降级:模型超时/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,也不能无限排队。降级要有开关与阈值,恢复后自动切回,并在看板上区分「正常结果/降级结果」。

关联