Skip to content
Published on

LLM Ops 到底在做什么 —— 可复现性、数据污染、检查点、模型晋升与回滚

分享
Authors

引言 —— 答不上"这模型当初是怎么做出来的"的那一刻

三个月前上线的模型被发现了问题。有人问当时用什么数据训练的、配置是什么、当时的评测分数是多少。如果团队给出的答案是"大概在某个笔记本里",那么这个组织就是没有 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 基准大小
参数bf16140 GB
主权重fp32280 GB
Adam 一阶矩fp32280 GB
Adam 二阶矩fp32280 GB
合计(完整续训状态)980 GB
仅推理权重bf16140 GB

每 43 分钟留下 980GB,一天就是 33TB。20 天就是 660TB。所以需要一套保存策略。实务中通行的做法是分层。

  1. 续训用:只保留最近 2~3 个,其余立即删除。保存完整状态。
  2. 里程碑:按固定的 token 数(例如每 100B token)只保留推理用权重。用于扩展曲线分析和事后复盘。
  3. 发布版:只永久保留真正部署过的版本。必须始终把运行清单和评测报告一起打包。

把评测接入 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 评测里。

注册与晋升 —— 从训练交接到服务的节点

模型注册表不是文件存储,而是证据附加流程。核心就是提前定好:进入下一阶段之前必须附上什么。

阶段必需附件审批
候选运行清单、训练损失曲线、数据清单哈希自动
预发布回归评测报告、分词器与对话模板指纹、许可证来源值班工程师
生产完整评测报告、安全性评估、负载测试结果、回滚计划两名评审人
下线指定替代模型、保留期限负责人

交接实际会断裂的地方,大多数情况下是以下六种。顺序按我遇到的频率排列。

  1. 对话模板不一致。训练时用的模板和服务引擎实际应用的模板不一样。哪怕只差一个空格、一个换行,模型看到的就是它从未训练过的分布。把检查点里 tokenizer_config.json 中模板字符串的哈希值,在训练端和服务端分别取一次并比较。
  2. 新增特殊 token 后嵌入层大小不一致。SFT 过程中新增 token 会让嵌入矩阵变大,如果服务端配置还保留着原来的词表大小,要么加载失败,要么悄悄用错 token。
  3. 缺少结束 token 设置。生成不会停止,会一直跑到最大长度。延迟和成本变成好几倍,用户看到的输出后面还会拖着多余的废话。
  4. LoRA 适配器是否已合并。把没合并的适配器交给服务端,结果只加载了基座模型,微调效果整个消失。评测时带着适配器跑、服务端却没接上适配器,这种组合特别容易出现。
  5. 精度漂移。训练用 bf16、评测用 fp32、服务用 fp8 的情况很常见。必须至少用服务实际会用的精度和量化方式跑一遍评测。
  6. 填充方向与最大位置长度。训练时右填充、生成时左填充是标准做法,一旦颠倒,只有在批量生成时质量才会崩掉,单条测试抓不出来。
# 晋升前的指纹比对 —— 确认训练产物和服务配置看到的是同一个东西
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 的工具清单每年都在变,但责任不会变。这次运行能不能重建?这个分数能不能信?这个模型从哪来能不能说清楚?出问题时能不能在几分钟内回滚?

如果这四个问题都能回答"能",用什么工具都无所谓。哪怕有一个答案是"不能",再多接一个工具也解决不了。系列的最后一篇文章会在公开的训练案例中,看这些原则在真实的大规模训练里是如何崩溃、又是如何被修复的。