- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 —— 答不上"这模型当初是怎么做出来的"的那一刻
- 可复现性的最小单位 —— 运行清单
- 数据管道与污染管理
- 检查点管理 —— 间隔与保存是两个不同的问题
- 把评测接入 CI
- 注册与晋升 —— 从训练交接到服务的节点
- 部署之后 —— 回归检测与回滚
- 工具地图 —— 按类别划分
- 结语 —— 无法复现的结果不是结果
引言 —— 答不上"这模型当初是怎么做出来的"的那一刻
三个月前上线的模型被发现了问题。有人问当时用什么数据训练的、配置是什么、当时的评测分数是多少。如果团队给出的答案是"大概在某个笔记本里",那么这个组织就是没有 LLM Ops。
把 LLM Ops 当成一份工具清单来学,并不能阻止这种情况。就算接入了实验跟踪工具,如果没有记录数据快照,也无法复现;就算把评测自动化了,如果评测集被污染,整个分数都是无效的。
所以这篇文章不按工具,而是按责任来组织。每一节都围绕"会出什么问题"和"要记录什么才能防止它"来展开。工具只在最后按类别附上,不涉及厂商价格——价格很快就会过时。
可复现性的最小单位 —— 运行清单
首先要坦率地说明一点。逐比特的可复现在大多数情况下是不可能的。GPU 数量一变,归约顺序就会变,浮点结果跟着变;内核自动调优在每次运行时可能选到不同的算法;哪怕只是驱动更新一次,结果也会跟着晃动。把所有确定性开关都打开可以复现,但速度会大幅下降,预训练用不了。
所以现实的目标不是"同样的比特",而是能从同样的坐标重新出发。这个坐标要留在运行清单里。
# run_manifest.py —— 在训练开始时由 rank 0 调用一次
import hashlib
import importlib.metadata as md
import json
import os
import platform
import subprocess
import time
def _sh(cmd: str) -> str:
try:
return subprocess.check_output(cmd, shell=True, text=True,
stderr=subprocess.DEVNULL).strip()
except Exception:
return "unknown"
def build_manifest(config: dict, data_snapshot_id: str) -> dict:
cfg_bytes = json.dumps(config, sort_keys=True).encode()
return {
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"code": {
"commit": _sh("git rev-parse HEAD"),
"dirty": _sh("git status --porcelain") != "",
"remote": _sh("git config --get remote.origin.url"),
},
"config": {
"sha256": hashlib.sha256(cfg_bytes).hexdigest()[:16],
"values": config,
},
"data": {
"snapshot_id": data_snapshot_id, # 不可变快照标识符
"manifest_sha256": _sh(f"sha256sum data/manifests/{data_snapshot_id}.jsonl | cut -d' ' -f1"),
},
"packages": {
p: md.version(p)
for p in ("torch", "transformers", "trl", "peft", "accelerate",
"deepspeed", "datasets")
if _safe_version(p)
},
"hardware": {
"gpu": _sh("nvidia-smi --query-gpu=name --format=csv,noheader | head -1"),
"gpu_count": int(os.environ.get("WORLD_SIZE", "1")),
"driver": _sh("nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1"),
"python": platform.python_version(),
},
"scheduler": {
"slurm_job_id": os.environ.get("SLURM_JOB_ID"),
"nodelist": os.environ.get("SLURM_JOB_NODELIST"),
},
"seeds": {"global": config.get("seed"), "data_order": config.get("data_seed")},
}
def _safe_version(p: str) -> bool:
try:
md.version(p)
return True
except md.PackageNotFoundError:
return False
关于随机种子再补充一点。只有一个全局种子是不够的。需要单独设置一个数据顺序种子,这样才能在固定模型初始化的前提下,只改变数据顺序来做实验。而且数据加载器的每个 worker 都会各自派生种子,所以改变 worker 数量就会改变数据顺序。worker 数量是实验变量,不是性能调节旋钮。
数据管道与污染管理
训练数据必须是不可变快照,而不是"文件夹"。如果只记录路径,而这个路径下的内容以后又变了,运行清单就变成了谎言。实务中常见的做法大致是这样。
- 原始数据一次性写入对象存储,绝不覆盖。
- 训练实际引用的是一份清单,里面记录文件列表、每个文件的哈希值,以及样本数量。
- 清单本身带版本号,运行清单里记录的是这份清单的哈希值。
数据污染要在这个基础上管理。如果评测集里的题目或答案混进了训练语料,分数会上升,但实际能力不会变。最基本的防御有两种。
# 粗略扫描评测集与训练语料之间的 n-gram 重叠。
# 不是精确的工具,只是抓"明显事故"的第一道过滤器。
from datasketch import MinHash, MinHashLSH # pip install datasketch
def shingles(text: str, n: int = 13) -> set:
toks = text.split()
return {" ".join(toks[i:i + n]) for i in range(max(1, len(toks) - n + 1))}
def build_index(eval_docs, threshold: float = 0.8):
lsh = MinHashLSH(threshold=threshold, num_perm=128)
for i, doc in enumerate(eval_docs):
m = MinHash(num_perm=128)
for s in shingles(doc):
m.update(s.encode())
lsh.insert(f"eval-{i}", m)
return lsh
def scan(train_docs, lsh) -> list:
hits = []
for j, doc in enumerate(train_docs):
m = MinHash(num_perm=128)
for s in shingles(doc):
m.update(s.encode())
found = lsh.query(m)
if found:
hits.append((j, found))
return hits
第二道防线更简单,也更有效:专门留出一份从未进入训练流水线的保留集。把它放在单独的存储里,训练集群完全访问不到。污染扫描可能会漏掉一些东西,但流水线一开始就看不到的数据,不可能被污染。
开源模型这边有一个正面解决这个问题的案例值得参考。Allen AI 的 Olmo 3 把检查点、数据集和依赖全部公开,让第三方可以直接审计是否存在污染。内部项目做不到同样的公开程度,但至少可以把"是否可审计"定为设计标准。
检查点管理 —— 间隔与保存是两个不同的问题
不要把这两个问题混在一起。"多久存一次"关乎故障应对,"保留多久"关乎审计与成本。
间隔是从故障间隔反推出来的。Young 和 Daly 的一阶近似认为,最优间隔等于保存时间与平均故障间隔之积的两倍再开平方根。
import math
def optimal_interval_min(save_minutes: float, mtbf_hours: float) -> float:
"""Young/Daly 一阶近似: T = sqrt(2 * delta * M)"""
return math.sqrt(2 * save_minutes * mtbf_hours * 60)
def wasted_fraction(interval_min: float, save_minutes: float, mtbf_hours: float) -> float:
"""保存耗时 + 故障导致的平均重新计算时间所占的比例"""
mtbf_min = mtbf_hours * 60
return save_minutes / interval_min + (interval_min / 2) / mtbf_min
for gpus, mtbf in [(64, 120.0), (512, 15.0), (16384, 3.1)]:
t = optimal_interval_min(save_minutes=5, mtbf_hours=mtbf)
print(f"GPU {gpus:6} MTBF {mtbf:6.1f}h 最优间隔 {t:6.1f}分钟 "
f"浪费比例 {wasted_fraction(t, 5, mtbf):.1%}")
# GPU 64 MTBF 120.0h 最优间隔 268.3分钟 浪费比例 3.7%
# GPU 512 MTBF 15.0h 最优间隔 94.9分钟 浪费比例 10.5%
# GPU 16384 MTBF 3.1h 最优间隔 43.1分钟 浪费比例 23.2%
最后一行的 MTBF 3.1 小时不是随便给的数字。它是 Llama 3 405B 训练中 54 天内发生 419 次意外中断这一报告数据换算成时间后的结果。在这个规模下,即使严格遵守最优间隔,总时间里仍有 20% 以上会消耗在保存和重新计算上。规模一旦扩大,检查点就不再是附带工作,而会变成主要的成本项。
保留周期是另一个问题。保存 70B 模型的完整训练状态,大致是这个量级。
| 项目 | 精度 | 70B 基准大小 |
|---|---|---|
| 参数 | bf16 | 140 GB |
| 主权重 | fp32 | 280 GB |
| Adam 一阶矩 | fp32 | 280 GB |
| Adam 二阶矩 | fp32 | 280 GB |
| 合计(完整续训状态) | 980 GB | |
| 仅推理权重 | bf16 | 140 GB |
每 43 分钟留下 980GB,一天就是 33TB。20 天就是 660TB。所以需要一套保存策略。实务中通行的做法是分层。
- 续训用:只保留最近 2~3 个,其余立即删除。保存完整状态。
- 里程碑:按固定的 token 数(例如每 100B token)只保留推理用权重。用于扩展曲线分析和事后复盘。
- 发布版:只永久保留真正部署过的版本。必须始终把运行清单和评测报告一起打包。
把评测接入 CI
评测如果靠人手动跑,一定会漏掉。可是每次提交都跑一遍完整评测套件也不现实。所以要分层。
| 层级 | 时机 | 内容 | 门槛 |
|---|---|---|---|
| 冒烟 | 每次提交 | 格式合规、分词往返、对话模板渲染、生成 100 个样本 | 失败则阻止合并 |
| 回归 | 每晚 | 固定的黄金集、确定性解码、与最近 3 个版本对比 | 超出阈值则告警 |
| 完整 | 晋升前 | 公开基准 + 领域集 + 安全性 | 不附报告不能晋升 |
冒烟层抓住的不是质量,而是事故。对话模板坏了、分词器加了特殊 token 但没有同步、结束 token 变了,都属于这一类。这类事故是以异常的形式暴露出来的,而不是评测分数,所以能又快又准地被抓住。
# 回归层示例。把评测工具和任务版本一起锁定。
pip install "lm-eval==0.4.12"
lm_eval --model hf \
--model_args pretrained=./out/sft-8b,dtype=bfloat16 \
--tasks arc_challenge,hellaswag,gsm8k \
--batch_size 16 \
--seed 1234 \
--output_path reports/$(date +%F)-sft-8b.json \
--log_samples
打开 --log_samples 把生成结果本身保留下来很重要。只留分数的话,以后没法回答"为什么分数掉了"。评测工具的版本和任务定义也要一起记录——同一个基准,harness 版本一变,分数也会跟着变。我确认过的 lm-evaluation-harness 版本是 0.4.12,发布日期是 2026 年 5 月 11 日。
评测设计本身另外整理在不靠感觉做的 LLM 评测里。
注册与晋升 —— 从训练交接到服务的节点
模型注册表不是文件存储,而是证据附加流程。核心就是提前定好:进入下一阶段之前必须附上什么。
| 阶段 | 必需附件 | 审批 |
|---|---|---|
| 候选 | 运行清单、训练损失曲线、数据清单哈希 | 自动 |
| 预发布 | 回归评测报告、分词器与对话模板指纹、许可证来源 | 值班工程师 |
| 生产 | 完整评测报告、安全性评估、负载测试结果、回滚计划 | 两名评审人 |
| 下线 | 指定替代模型、保留期限 | 负责人 |
交接实际会断裂的地方,大多数情况下是以下六种。顺序按我遇到的频率排列。
- 对话模板不一致。训练时用的模板和服务引擎实际应用的模板不一样。哪怕只差一个空格、一个换行,模型看到的就是它从未训练过的分布。把检查点里
tokenizer_config.json中模板字符串的哈希值,在训练端和服务端分别取一次并比较。 - 新增特殊 token 后嵌入层大小不一致。SFT 过程中新增 token 会让嵌入矩阵变大,如果服务端配置还保留着原来的词表大小,要么加载失败,要么悄悄用错 token。
- 缺少结束 token 设置。生成不会停止,会一直跑到最大长度。延迟和成本变成好几倍,用户看到的输出后面还会拖着多余的废话。
- LoRA 适配器是否已合并。把没合并的适配器交给服务端,结果只加载了基座模型,微调效果整个消失。评测时带着适配器跑、服务端却没接上适配器,这种组合特别容易出现。
- 精度漂移。训练用 bf16、评测用 fp32、服务用 fp8 的情况很常见。必须至少用服务实际会用的精度和量化方式跑一遍评测。
- 填充方向与最大位置长度。训练时右填充、生成时左填充是标准做法,一旦颠倒,只有在批量生成时质量才会崩掉,单条测试抓不出来。
# 晋升前的指纹比对 —— 确认训练产物和服务配置看到的是同一个东西
import hashlib
import json
from transformers import AutoTokenizer, AutoConfig
def fingerprint(path: str) -> dict:
tok = AutoTokenizer.from_pretrained(path)
cfg = AutoConfig.from_pretrained(path)
tmpl = tok.chat_template or ""
return {
"chat_template_sha": hashlib.sha256(tmpl.encode()).hexdigest()[:16],
"vocab_size_tokenizer": len(tok),
"vocab_size_config": cfg.vocab_size,
"eos_token_id": tok.eos_token_id,
"pad_token_id": tok.pad_token_id,
"max_position_embeddings": getattr(cfg, "max_position_embeddings", None),
"torch_dtype": str(getattr(cfg, "torch_dtype", None)),
}
a, b = fingerprint("./out/sft-8b"), fingerprint("./serving/model")
diff = {k: (a[k], b[k]) for k in a if a[k] != b[k]}
print(json.dumps(diff, indent=2, ensure_ascii=False) if diff else "一致")
assert a["vocab_size_tokenizer"] == a["vocab_size_config"], "词表大小不一致"
部署之后 —— 回归检测与回滚
部署之后会变的不只是模型。服务引擎版本、提示词、检索索引、上层应用代码,各自都会变。所以在质量下降时能定位到底是什么变了,才是回归检测的本质。
把下面三件事都接上,大多数情况都能抓到。
- 黄金集回放。在刚部署完之后,以及每天固定的时间,用确定性配置跑一遍同样的几百条输入,把结果存下来。在文本层面比较和之前结果的差异。比起分数,差异列表更有用。
- 输出分布监控。回复长度分布、拒答比例、结构化输出的 schema 校验失败率、没有结束 token 就跑到最大长度结束的比例。这四项都不是质量指标,但作为事故指标几乎是完美的。
- 按请求追踪。把提示词、模型版本、提示词版本、检索文档标识符、延迟、token 数绑定到同一条 trace 里。标准化这部分由 OpenTelemetry 的 GenAI 语义约定负责,但截至 2026 年 7 月,我看到的说法是这份规约仍处于开发阶段、尚未稳定。我没能在官方规范文档里亲自确认这个状态标记,所以在采用之前,请自行去规约仓库确认稳定性等级。实务上的含义很简单:属性名可能会变,把埋点代码放在一层薄薄的适配器后面。
回滚应该是配置变更,而不是重新构建。把上一版本的权重、分词器、提示词、服务引擎版本存成一个不可变的包,部署就只是把指针切换到指向这个包。如果是基于适配器的部署,回滚会特别便宜——基座模型不动,只替换适配器就行。
回滚中经常被漏掉的一点:提示词要和模型一起回滚。为配合新模型改了提示词,结果只回滚了模型,这种组合就从来没被评测过。
工具地图 —— 按类别划分
这里不推荐具体产品,只写类别和选型标准。版本号是 2026 年 8 月 2 日确认的数值。
| 类别 | 候选 | 选型标准 |
|---|---|---|
| 实验跟踪 | MLflow(3.15.0, 2026-07-31)、Weights and Biases、ClearML、Aim | 能否在离线/内网环境自行托管?制品存储是否直接使用对象存储? |
| 数据版本管理 | DVC、LakeFS、对象存储 + 自研清单 | 在几十 TB 规模下是靠引用而不是复制来工作吗? |
| 评测 | lm-evaluation-harness(0.4.12, 2026-05-11)、LightEval、自研领域套件 | 任务定义是否带版本号?是否保留样本级别的日志? |
| 追踪与可观测性 | Langfuse(4.14.2, 2026-07-30)、自研 OpenTelemetry 流水线 | 能否把 span 接入已经在用的可观测性技术栈? |
| 模型注册表 | MLflow Model Registry、Hugging Face Hub(含私有)、对象存储 + 元数据数据库 | 能否在晋升阶段强制走审批流程? |
| 服务 | vLLM(0.26.0, 2026-07-25)、SGLang(0.5.16, 2026-07-25)、KServe(v0.19.0, 2026-06-14) | 能否原样读取训练产出的格式?能否锁定版本? |
选型标准可以归结成一句话:工具消失了,记录还在不在。就算实验跟踪服务器挂了,只要运行清单的 JSON 还留在对象存储里,就能恢复。反过来,如果所有元数据都只存在于某个 SaaS 里,那么合同到期的那天,组织的训练历史也就跟着结束了。
结语 —— 无法复现的结果不是结果
LLM Ops 的工具清单每年都在变,但责任不会变。这次运行能不能重建?这个分数能不能信?这个模型从哪来能不能说清楚?出问题时能不能在几分钟内回滚?
如果这四个问题都能回答"能",用什么工具都无所谓。哪怕有一个答案是"不能",再多接一个工具也解决不了。系列的最后一篇文章会在公开的训练案例中,看这些原则在真实的大规模训练里是如何崩溃、又是如何被修复的。