先定义要学什么,再启动训练
14.1 · 训练任务与可复现环境
训练不是把数据扔进 GPU 后等待损失下降,而是把任务合同、数据快照、模型配置、随机性、评估和资源记录成一个可以重跑的实验。本节从训练目标开始,建立“为什么训练、用什么训练、怎样证明训练有效”的工程基础。
14.1.1 任务合同:先说明要解决什么问题
训练任务合同是项目的第一份技术文档,它把一句模糊的需求翻译成可以执行的输入、输出和验收条件。合同至少要说明:输入是什么、输出是什么、谁会使用输出、允许多大的错误、哪些错误不可接受,以及在什么资源预算内完成。对分类任务,输出可以是标签和置信度;对生成任务,输出还要定义格式、引用要求和拒答边界。
不要一开始就用“准确率提高”作为唯一目标。指标必须和业务损失对应:漏掉一个高风险请求的代价,可能远大于把普通请求错分一次;回答看似流畅,也可能因为没有证据而不能使用。把错误按严重程度分层,并为每一层写出可观察的判定方法,才能让训练目标和真实使用保持一致。
合同还要记录基线。基线可以是规则系统、现有模型、人工流程或一个最小可行的提示词方案。没有基线,就无法判断新训练是否真的带来收益。对资源也要设上限,例如 GPU 小时、单请求延迟、显存、模型大小和每月调用成本;当收益超过预算时,团队应回到合同重新讨论,而不是无限加大模型。
在合同里写明“不做什么”同样重要。暂不支持的语言、不能处理的敏感数据、没有足够证据的领域问题、必须转人工的场景,都应列为边界。边界不是限制学习,而是让后续评估可以诚实地回答“模型在哪些地方可靠”。
| 层次 | 要写清楚 | 示例 |
|---|---|---|
| 任务 | 输入、输出、角色、流程 | 工单文本 → 意图标签 + 置信度 |
| 质量 | 主指标、切片、红线、基线 | macro-F1 ≥ 0.86;高风险召回 ≥ 0.95 |
| 资源 | 训练预算、服务预算、成本上限 | 训练 ≤ 8 GPU·h;p95 ≤ 450 ms |
| 责任 | 审批人、日志保留、拒答与转人工 | 低置信度转人工,保留 30 天审计记录 |
“训练一个懂业务的模型”无法直接验收。可以把它拆成一组可测目标:在冻结测试集上达到指标、在三个关键切片上不低于下限、输出符合 JSON schema、遇到无证据问题主动拒答,并在规定延迟内返回。目标变具体后,才知道应该补数据、改损失还是换部署策略。
把“做一个客服大模型”改写为一页任务合同,至少写出输入/输出、三种错误及代价、基线、两个质量门和一个不支持范围。
参考结论:把输入、约束、证据和失败回退写清楚,并用一次可运行的小实验验证。
完整实现路径:把任务、切分、指标、预算、边界和版本写进训练 manifest,启动前执行 schema 与泄漏审计。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
import hashlib, json
def split_audit(rows):
train = {r["user_id"] for r in rows if r["split"] == "train"}
valid = {r["user_id"] for r in rows if r["split"] == "valid"}
test = {r["user_id"] for r in rows if r["split"] == "test"}
overlap = (train & valid) | (train & test) | (valid & test)
if overlap: raise ValueError(f"entity leakage: {sorted(overlap)[:5]}")
payload = json.dumps(rows, ensure_ascii=False, sort_keys=True).encode()
return {"rows": len(rows), "sha256": hashlib.sha256(payload).hexdigest()}
print(split_audit(rows))验收标准:manifest 可解析、实体无交集、基线和质量门均可计算。
若结果不对:先核对数据快照和切分哈希,再查配置覆盖和 seed。
14.1.2 切分与防泄漏:让评估真正代表未来输入
数据切分决定了你对模型能力的估计。随机按行切分适合样本相互独立的场景,但用户、订单、对话、文章、设备或时间序列通常不是独立的。同一用户的近似文本出现在训练集和测试集,会让分数虚高;把未来才会出现的字段或标签派生信息带入训练,则会产生更隐蔽的泄漏。切分策略要先于训练脚本写进合同。
先找出可能代表同一实体的分组键,例如 user_id、conversation_id、document_id、patient_id 或 device_id,再决定按组切分。时间敏感的任务要用过去训练、未来验证和更远未来测试,避免模型偷看未来分布。若数据来自多个来源,最好保留来源切片,检查某个来源是否只出现在一个 split,从而发现来源与标签之间的偶合。
防泄漏还包括重复与近重复。可以对规范化文本做哈希,对长文档按段落或窗口生成指纹,再在切分前去重。去重不能盲目删除所有相似文本:同一模板的不同实例可能是合理样本,关键是把判断规则记录下来,并报告去重前后的数量、标签分布和来源变化。
切分完成后要做审计,而不是只看样本数。审计应检查实体交集、时间边界、标签比例、长度分布、语言分布、来源比例和派生字段。把审计结果保存为 JSON,与数据快照绑定;下一次数据更新时重新运行,差异超过阈值就停止流水线。
| 风险 | 为什么会虚高 | 修复 |
|---|---|---|
| 同一用户跨 split | 模型记住用户表达习惯 | 按用户或会话分组 |
| 未来字段进入特征 | 训练阶段获得线上不可用信息 | 在特征生成前冻结时间点 |
| 模板重复跨 split | 测试样本几乎见过原文 | 规范化后去重或按模板分组 |
| 标签派生字段 | 输入直接包含答案线索 | 删除字段并审计来源 |
| 调参反复看测试集 | 测试集被间接用于选择 | 冻结测试集,只用验证集调参 |
from collections import Counter
from datetime import datetime
def audit_splits(rows):
groups = {}
for row in rows:
group = row["conversation_id"]
groups.setdefault(group, set()).add(row["split"])
leakage = {
key: sorted(values)
for key, values in groups.items()
if len(values) > 1
}
labels = Counter((row["split"], row["label"]) for row in rows)
dates = [datetime.fromisoformat(row["created_at"]) for row in rows]
return {
"group_leakage": leakage,
"label_counts": {f"{split}:{label}": count
for (split, label), count in labels.items()},
"min_time": min(dates).isoformat(),
"max_time": max(dates).isoformat(),
}
report = audit_splits(records)
if report["group_leakage"]:
raise ValueError("发现跨 split 的会话")
print(report)种子只决定一次随机过程的顺序,不能改变同一实体已经被放到不同 split 的事实。先定义分组、时间和去重规则,再设置种子;如果换种子后分数波动很大,也要检查样本量、稀有标签和切分是否稳定。
为一个包含用户、文本、标签和时间的表设计切分规则。写出至少三条审计断言,并说明哪一种情况必须阻止训练。
参考结论:把输入、约束、证据和失败回退写清楚,并用一次可运行的小实验验证。
完整实现路径:把任务、切分、指标、预算、边界和版本写进训练 manifest,启动前执行 schema 与泄漏审计。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
import hashlib, json
def split_audit(rows):
train = {r["user_id"] for r in rows if r["split"] == "train"}
valid = {r["user_id"] for r in rows if r["split"] == "valid"}
test = {r["user_id"] for r in rows if r["split"] == "test"}
overlap = (train & valid) | (train & test) | (valid & test)
if overlap: raise ValueError(f"entity leakage: {sorted(overlap)[:5]}")
payload = json.dumps(rows, ensure_ascii=False, sort_keys=True).encode()
return {"rows": len(rows), "sha256": hashlib.sha256(payload).hexdigest()}
print(split_audit(rows))验收标准:manifest 可解析、实体无交集、基线和质量门均可计算。
若结果不对:先核对数据快照和切分哈希,再查配置覆盖和 seed。
14.1.3 配置、种子与实验记录
可复现不是把随机种子写成 42 就结束。需要固定基础模型版本、数据快照、分词器、代码提交、依赖、硬件、精度、批量、学习率、梯度累积、训练步数、评估脚本和环境变量。某些 GPU 算子仍可能非确定,因此记录确定性设置和硬件差异。
配置应集中在可读文件中,并在运行开始时复制到实验目录。命令行覆盖参数时,把最终解析后的配置也保存一份,避免只看到启动命令却不知道默认值。配置文件最好区分任务、资源、优化器、日志、保存和评估,减少一个布尔开关控制过多行为。
随机性需要多次运行才能估计稳定性。一个版本只跑一次可能碰巧得到较好的验证分数。资源有限时可以先跑不同种子的小实验,报告均值、标准差和最坏切片;如果结果对种子特别敏感,优先调查数据量、学习率和初始化。
实验日志要记录过程和结论。过程包括每个 step 的损失、学习率、吞吐、显存、梯度范数和验证指标;结论包括是否保留、回滚还是改变假设。只保存最终 checkpoint 会丢掉训练中途的异常,难以判断模型何时开始退化。
实验目录应采用不可变快照。数据和代码通过哈希引用,日志追加写入,生成的图表和评估 JSON 不覆盖旧文件。需要修正时创建新实验 ID,并在 README 说明与旧实验的关系。
配置验证要尽早失败。学习率不能为负,梯度累积必须是正整数,验证频率不能为零,保存路径不能指向训练数据,混合精度选项要与硬件兼容。
可复现还包括失败。记录 OOM、网络中断、数据解析失败、检查点损坏和手动终止,并说明恢复方式。失败日志能帮助下一次训练设计更稳的保存间隔、重试策略和资源估算。
本小节把实验从一次性命令变成可审计对象:别人能知道你用什么、改了什么、结果如何、为什么继续或停止。
| 类别 | 必填字段 | 用途 |
|---|---|---|
| 身份 | experiment_id、parent_id、created_at | 串联实验 lineage |
| 代码 | commit、依赖锁文件、脚本哈希 | 复现逻辑 |
| 数据 | snapshot、切分、许可 | 复现输入 |
| 训练 | seed、batch、lr、steps、precision | 解释优化 |
| 资源 | GPU、显存、耗时、成本 | 比较效率 |
| 结果 | 指标、切片、错误、决策 | 支持发布 |
四条链都带版本号;缺一条,实验就只能被部分复现。
from dataclasses import asdict, dataclass
from datetime import datetime
import json
from pathlib import Path
@dataclass(frozen=True)
class Manifest:
experiment_id: str
base_model: str
data_snapshot: str
code_commit: str
seed: int
created_at: str
def save(self, path: Path) -> None:
path.write_text(
json.dumps(asdict(self), ensure_ascii=False, indent=2),
encoding="utf-8",
)
manifest = Manifest(
"exp-014", "base-v3", "data-v7", "abc123", 7,
datetime.now().isoformat(timespec="seconds"),
)
manifest.save(Path("manifest.json"))
print("saved", manifest.experiment_id)本节完成验收
完成本页后,应能从一个任务合同写出训练目标,选择合理切分并建立可复现实验清单。验收时让同伴用同一份 manifest 重建配置,检查训练集/评估集无交集,并解释一次失败实验为什么不应被删除。
为一个文本分类任务写训练合同与目标字段。
参考结论:包含输入、标签、拒答、错误代价和评估指标。
完整实现路径:把任务、切分、指标、预算、边界和版本写进训练 manifest,启动前执行 schema 与泄漏审计。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
import hashlib, json
def split_audit(rows):
train = {r["user_id"] for r in rows if r["split"] == "train"}
valid = {r["user_id"] for r in rows if r["split"] == "valid"}
test = {r["user_id"] for r in rows if r["split"] == "test"}
overlap = (train & valid) | (train & test) | (valid & test)
if overlap: raise ValueError(f"entity leakage: {sorted(overlap)[:5]}")
payload = json.dumps(rows, ensure_ascii=False, sort_keys=True).encode()
return {"rows": len(rows), "sha256": hashlib.sha256(payload).hexdigest()}
print(split_audit(rows))验收标准:manifest 可解析、实体无交集、基线和质量门均可计算。
若结果不对:先核对数据快照和切分哈希,再查配置覆盖和 seed。
实现按项目分组的切分审计。
参考结论:同一项目不跨 split;未来字段会被列出。
完整实现路径:把任务、切分、指标、预算、边界和版本写进训练 manifest,启动前执行 schema 与泄漏审计。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
import hashlib, json
def split_audit(rows):
train = {r["user_id"] for r in rows if r["split"] == "train"}
valid = {r["user_id"] for r in rows if r["split"] == "valid"}
test = {r["user_id"] for r in rows if r["split"] == "test"}
overlap = (train & valid) | (train & test) | (valid & test)
if overlap: raise ValueError(f"entity leakage: {sorted(overlap)[:5]}")
payload = json.dumps(rows, ensure_ascii=False, sort_keys=True).encode()
return {"rows": len(rows), "sha256": hashlib.sha256(payload).hexdigest()}
print(split_audit(rows))验收标准:manifest 可解析、实体无交集、基线和质量门均可计算。
若结果不对:先核对数据快照和切分哈希,再查配置覆盖和 seed。
建立一次训练 manifest 并模拟两次不同 seed。
参考结论:最终配置、代码、数据、资源和结果都能关联。
完整实现路径:把任务、切分、指标、预算、边界和版本写进训练 manifest,启动前执行 schema 与泄漏审计。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 5:先复制运行,再替换为自己的数据
import hashlib, json
def split_audit(rows):
train = {r["user_id"] for r in rows if r["split"] == "train"}
valid = {r["user_id"] for r in rows if r["split"] == "valid"}
test = {r["user_id"] for r in rows if r["split"] == "test"}
overlap = (train & valid) | (train & test) | (valid & test)
if overlap: raise ValueError(f"entity leakage: {sorted(overlap)[:5]}")
payload = json.dumps(rows, ensure_ascii=False, sort_keys=True).encode()
return {"rows": len(rows), "sha256": hashlib.sha256(payload).hexdigest()}
print(split_audit(rows))验收标准:manifest 可解析、实体无交集、基线和质量门均可计算。
若结果不对:先核对数据快照和切分哈希,再查配置覆盖和 seed。
把任务写成函数 先把任务写成 f(输入,状态) → 输出,而不是先挑模型。输入要说明格式、上下文长度和缺失字段,输出要说明标签、文本或工具调用的结构。
当需求变化时,函数签名帮助判断是修改数据、提示、模型还是服务。签名不清晰,训练过程会不断吸收例外,最后无法说明模型究竟学会了什么。
区分训练目标与使用目标 交叉熵、偏好损失或回归误差是优化器看到的目标;准确率、F1、事实性和转人工率是用户看到的目标。两者可能相关,却不会自动相等。
合同应写出优化目标如何支持验收指标,并允许多个指标共同决定发布。损失下降但高风险召回下降时,质量门应拒绝该版本,而不是只看训练曲线。
输入边界要可执行 边界包括最短和最长长度、允许的语言、字段编码、时间范围以及是否允许附件、表格或多轮上下文。每条边界都应有正例和拒绝例。
边界样例不仅用于文档,也要进入自动测试。服务遇到越界输入时应返回稳定错误码或转人工,不能随机截断后继续生成看似正常的答案。
输出 schema 是契约的一部分 结构化输出要定义字段类型、枚举、必填项、数值范围和未知情况。生成模型即使内容正确,也可能因为多了说明文字而无法被下游解析。
把 schema 校验放在模型之后、业务逻辑之前;校验失败时保留原始响应摘要并触发重试或降级。不要让每个调用方各自写一套解析器。
把错误按代价分层 普通错分、漏掉关键意图、泄露敏感信息和错误执行工具的代价不同。用严重度、发生概率和可逆性分层,比简单计算平均分更接近真实风险。
每个严重度都要有处理动作:确认、拒答、转人工、撤销操作或立即回滚。训练数据和评估报告都保留严重度,判断模型是否只是把错误换了一种形式。
基线必须能运行 基线可以是规则、旧模型、人工流程或最小提示方案,但必须用同一份冻结评估集和同一套后处理运行。只引用历史报告,无法排除环境和数据变化。
基线脚本应和候选脚本共享输入读取与评估函数,只替换模型组件。这样比较公平,也能在候选退化时迅速回到已知可用的版本。
数据快照要有身份 数据目录的名字不是版本。用快照 ID、行数、字节数、规范化规则和哈希描述一次输入,并记录生成时间与许可。
如果数据在训练过程中被覆盖,实验结果就失去来源。将快照设为只读,更新时创建新 ID,并在 manifest 中记录父快照和变化摘要。
切分规则先写后跑 在任务合同中先决定按用户、会话、文档、时间还是来源切分,然后再运行脚本。切分后才讨论规则,容易为了好看的分数反复调整测试集。
审计报告要显示实体交集、时间边界、标签比例和重复率。任一阻断项失败就停止训练,警告项要求负责人签字说明理由。
配置要能被机器读取 把模型、数据、优化器、设备、日志、保存和评估放到结构化配置里,启动时打印解析后的最终配置。命令行覆盖值也必须写回实验目录。
每个字段要有单位、默认值和允许范围。对同一含义只保留一个来源,避免环境变量、命令行和代码默认值互相覆盖。
随机性要被记录 记录 Python、NumPy、框架和数据采样器的种子,同时记录确定性开关、GPU 型号和驱动。即使完全确定性不可得,也要知道差异可能来自哪里。
重要结论用多个种子或报告波动范围。单次偶然好结果不能直接晋升为基线,尤其是小数据、稀有标签和长文本任务。
预算是设计约束 预算不仅是 GPU 小时,还包括数据清洗、人工评审、存储、网络和上线后的请求成本。提前分配探索、验证和发布预算,防止最后没有资源做评估。
当指标提升需要显著增加成本时,把成本曲线和质量曲线一起呈现。工程决策可以选择较小模型,但必须把权衡写进合同和模型卡。
验收门要有停止理由 每个门禁写出通过阈值、统计口径、样本量和失败时的下一步。阈值不应在看到结果后临时修改,否则验收没有意义。
生成任务可以把自动指标、人工抽样和红线样例组合成规则。任何红线失败都应阻断发布,即使总体分数很高。
错误地图连接下一轮工作 把失败归为数据、切分、模型、工具、接口、资源和评估七类,并为每类指定负责人和复现命令。这样“继续优化”会变成可排序的修复任务。
错误地图保留样本 ID、版本和严重度,不直接存含敏感信息的原文。修复后重新运行同一批错误样本,验证问题是否真的消失。
人工接管要提前设计 转人工不是模型失败后的临时补丁,而是合同中的一种输出。定义触发阈值、需要展示的证据、人工可执行的动作和回传字段。
把转人工率、人工处理时长和回流标签纳入评估。模型降低自动错误却推给人工大量请求时,总成本和体验仍需综合判断。
审计字段要贯穿全链路 request_id、experiment_id、data_version、model_version 和 schema_version 应在数据、训练、评估和服务日志中保持一致。
日志字段应有数据字典和保留期限。需要删除个人信息时,保留不可逆哈希与统计摘要,既支持排查又减少隐私暴露。
变更控制要尊重回滚 任何改变数据、损失、阈值、API 或硬件的操作都创建新版本,并记录变更原因、预期影响和回滚点。不要把多个不可逆改变塞进一次发布。
发布前做小流量或影子实验,发布后观察预设窗口。若任一红线触发,按合同回到上一稳定版本,再把新证据带回下一轮实验。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| 任务合同 | 签名、输入/输出、边界 | 回到需求澄清 |
| 数据与切分 | 快照、分组、时间、泄漏报告 | 停止训练并修复数据 |
| 质量与资源 | 基线、阈值、p95、成本 | 不发布,保留候选 |
| 责任与回滚 | 审批、转人工、稳定版本 | 降级或回滚 |
| 变更与审计 | 版本、哈希、日志字段 | 补齐证据后重跑 |
读完这一页后,能否让另一个人只看合同和 manifest 启动一次小实验?如果不能,缺的通常不是更多模型知识,而是输入边界、验收口径或失败动作没有写清楚。
验收样例 为每条质量门准备正常、边界和失败样例,样例 ID 固定并写入报告。
样例不仅覆盖平均情况,还覆盖高代价错误;发布后可以快速回归。
基线快照 保存基线的代码、数据、依赖和运行参数,避免只记录一个历史分数。
候选退化时可在同一环境重跑基线,确认差异来自模型而不是环境。
责任矩阵 合同里标出数据、训练、评估、部署和审批负责人,避免出现“大家都以为别人检查过”。
每个门禁有一个最终负责者和一个替补,交付时能找到明确签字。
资源预算 把探索、验证和发布分开计预算,给人工评审、存储和重试预留余量。
实际成本超过预算时触发评审,而不是悄悄减少评估样本。
配置来源 记录配置来自文件、命令行还是环境变量,并保存解析后的最终快照。
重跑时只需一条命令即可得到相同配置;覆盖关系不靠记忆。
版本关联 代码提交、数据快照、模型权重、评估报告和镜像摘要互相引用。
任一产物都能反向找到实验和合同,不出现孤立文件。
风险分级 将可接受的低风险错误与必须阻断的红线分开,避免平均指标掩盖严重问题。
红线样例失败直接停止,低风险问题进入限制说明和后续任务。
变更审批 数据清洗、标签映射、阈值和 API 的改变都要写原因、预期影响与回滚点。
审查者能判断改变是否超过原合同,必要时重新冻结基准。
接管流程 低置信度、工具失败或越界请求的转人工路径写出输入、证据和回传字段。
人工结果能回流为评估信号,但未经审核不直接进入训练。
签字与归档 最终验收由负责人确认合同、证据、限制、监控和回滚都存在。
归档不是删除文件,而是把版本、链接、时间和审批保留在索引中。
| 补充检查 | 看什么 | 通过条件 |
|---|---|---|
| 目标 | 输入/输出、边界、指标 | 可执行且可测 |
| 证据 | 基线、数据、实验、评估 | 可复现可追溯 |
| 责任 | 负责人、人工、审批 | 有人签字 |
| 风险 | 红线、隐私、回滚 | 有动作有期限 |
如果同伴只拿到合同和 manifest,仍需要你口头解释路径或阈值,说明文档缺少可执行证据,应先补齐而不是开始长跑。
为什么要有合同 训练本身只会最小化指定的损失,不能替你决定什么错误值得避免。合同把用户、风险、预算和证据写在训练之前,防止项目结束后才发现指标与使用目标不一致。
分类任务怎样写输出 除标签外还要说明置信度、未知类、多个标签的顺序和转人工触发条件。下游系统依赖这些字段,不能只说“返回一个类别”。
生成任务怎样写边界 写明允许的事实来源、引用格式、最大长度、拒答场景和工具权限。流畅的幻觉如果没有被合同定义为失败,评估就无法阻断它。
基线为什么必须重跑 历史分数可能来自不同数据、依赖或后处理。用当前冻结基准重跑基线,才能把候选改动和环境变化区分开。
预算怎样影响选择 训练小时、人工评审、存储和线上请求成本共同决定方案。预算使团队在更大模型、更长上下文和更复杂工具之间做可解释取舍。
红线与普通错误 普通错分可以记录并迭代,敏感信息泄露、越权执行或高风险漏检必须直接阻断。红线应有样例、阈值和响应动作。
为什么要写不支持 不支持范围让调用方知道何时转人工,也防止团队用少量成功样例过度宣传。限制本身是模型卡和发布合同的一部分。
谁来签字 训练者不一定是风险负责人。数据、评估、部署和业务责任人分别确认自己负责的证据,最终发布由明确角色审批。
合同如何随项目变化 需求、数据或接口改变时创建新版本,保留父版本、变化原因和回滚点。覆盖旧合同会让历史实验失去解释。
合同完成的标志 另一个人只看合同、manifest 和 README,就能知道怎样运行最小实验、怎样判断通过、失败时回到哪里。
案例:客服意图分类 先写输入是脱敏工单文本,输出是标签、置信度和转人工原因,再列出高风险漏检的代价。基线用当前规则系统,候选模型必须在同一冻结集上比较。
合同中的否定边界 如果模型不能读取附件、不能执行退款、不能处理未授权个人信息,就把这些列为不支持。边界样例进入冒烟和评估,不能只停留在文字说明。
指标冲突的处理 macro-F1 提高但高风险召回下降时,不能用平均分掩盖红线。合同应规定优先级和审批角色,让冲突成为可执行的拒绝条件。
预算变化的处理 训练预算从四小时增加到二十小时,意味着实验计划改变。记录增加的 GPU、人工和存储成本,并重新确认收益是否值得,而不是悄悄延长作业。
基线环境 基线脚本使用同一 tokenizer、后处理和评估函数,只替换模型。这样才不会把后处理差异误认为模型能力,也方便候选失败时一键回退。
数据更新 新增一批数据时创建新快照,写明来源、行数、许可、去重和切分变化。旧快照仍可用于重建历史实验,避免结果被覆盖。
红线样例 红线样例要少而关键,例如越权执行、敏感信息回显和关键意图漏检。样例 ID 稳定,发布前后都自动运行并在报告中单列。
责任接力 数据负责人确认许可和切分,训练负责人确认实验,评估负责人确认门禁,部署负责人确认回滚。每一项有证据和签字,避免最后才发现无人负责。
合同变更 若输出从单标签变为多标签,接口、数据、指标和基线都会改变,应升级合同版本并重新冻结评估,而不是在旧版本上追加字段。
冷启动证明 最终让同伴只看合同和 manifest,完成最小训练、评估和服务冒烟。对方仍需口头解释的步骤,都应回写为命令、默认值或故障说明。
推演一:从需求到字段 产品同学说“希望模型理解客户意图”,工程师不能直接创建一个 text 字段和一个 label。先问清楚意图集合、未知意图、输入渠道、语言、上下文范围、输出使用者,以及预测错误会触发什么动作。把答案写成字段表,再讨论模型。
推演二:选择指标 如果少数高风险意图的漏检代价很高,accuracy 可能掩盖问题。合同可以同时规定 macro-F1、关键类召回、置信度阈值、转人工率和 p95,并说明每个指标的分母、样本数和允许波动。
推演三:定义基线 基线不是一句“旧模型 82 分”,而是一条可运行命令。固定当前数据快照、tokenizer、后处理和环境,重新生成基线报告,保存输出样例与失败 ID,候选版本才能在同一坐标系里比较。
推演四:画责任链 数据负责人保证来源、许可、脱敏和切分;训练负责人保证配置、checkpoint 和异常;评估负责人保证基准、rubric 和门禁;部署负责人保证健康、容量和回滚。职责写清楚,事故时才能快速保护用户。
推演五:预算约束 探索阶段可以用小模型和小数据验证假设,验证阶段才投入完整基准与人工评审,发布阶段预留灰度和回滚成本。预算表若只写 GPU 小时而不写人工、存储和重试,最后的成本判断会失真。
推演六:处理变更 把单标签改成多标签、把离线文件改成实时流、把输出改成可执行工具调用,都会改变合同。为变更创建新版本,列出受影响字段、指标、测试和回滚点,不能只在代码里悄悄加一个分支。
推演七:红线演练 准备越权、敏感信息回显、未知输入和关键意图漏检四类红线样例。发布前自动运行;一条红线失败就停止,即使总体分数漂亮。红线的价值在于把“感觉危险”变成可重复的阻断动作。
推演八:冷启动问答 让同伴只拿到合同、manifest 和公开样例,回答输入边界、启动命令、通过阈值、转人工条件和回滚版本。对方答不出来的地方,就是文档缺口,不应靠口头传承掩盖。
让每一批数据都可解释、可复用
14.2 · 数据管线与训练样本
训练稳定性往往先败在数据管线:长度、编码、批处理、采样、缓存和隐私没有被定义。本节从样本 schema 到高效加载,再到质量门和数据版本,说明如何让 GPU 等待更少、模型看到的数据更可靠。
14.2.1 Dataset schema:让每条样本都能被解释
数据集 schema 是训练系统与数据生产者之间的契约。最小 schema 不能只有 text 和 label,还应包含样本唯一 ID、来源、时间、语言、版本、质量状态和必要的分组键。字段名要稳定,含义要写进文档;如果同一个字段在不同来源代表不同含义,宁可拆成两个字段,也不要靠注释猜测。
把原始数据、规范化数据和训练样本区分开。原始数据保留来源和审计需要,规范化层负责编码、去除明显损坏和统一单位,训练层才加入分词、截断、标签映射和切分信息。分层可以让你重新生成训练数据,而不必修改不可恢复的原始文件。每层都应有行数、哈希和版本记录。
标签要有明确的取值集合、缺失策略和冲突处理。对于多标签任务,说明标签顺序是否有意义;对于偏好数据,说明 chosen、rejected 是否来自同一上下文;对于生成数据,说明参考答案是否允许多个等价表述。标注员意见不一致时,保留原始意见和仲裁结果,避免只留下一个看似确定的标签。
schema 校验应在数据进入训练前运行,并把错误分为阻断和警告。缺少 ID、非法标签、编码失败、时间格式错误、超长比例异常应阻断;少量可解释的空字段可以警告并统计。校验报告要列出样本位置或 ID,让修复者能回到原始记录,而不是只看到一个总数。
| 字段 | 类型/约束 | 用途 |
|---|---|---|
| id | 不可重复字符串 | 追踪、去重和错误回溯 |
| text / messages | UTF-8 文本或消息数组 | 模型输入 |
| label / target | 枚举、数值或结构化对象 | 监督信号 |
| source、created_at | 来源与 ISO 时间 | 切片和时序切分 |
| group_id | 可选分组键 | 防止实体泄漏 |
| quality_status | accepted / review / rejected | 质量门和人工复核 |
| schema_version | 语义化版本 | 兼容与迁移 |
REQUIRED = {"id", "text", "label", "schema_version"}
def validate(row):
missing = REQUIRED - row.keys()
errors = []
if missing:
errors.append(f"缺少字段: {sorted(missing)}")
if not isinstance(row.get("id"), str) or not row["id"].strip():
errors.append("id 必须是非空字符串")
if not isinstance(row.get("text"), str) or not row["text"].strip():
errors.append("text 必须是非空文本")
if row.get("quality_status") not in {"accepted", "review", "rejected"}:
errors.append("quality_status 不在允许集合")
return errors
errors = [(row.get("id"), validate(row)) for row in rows]
errors = [(row_id, detail) for row_id, detail in errors if detail]
print(f"错误样本数: {len(errors)}")如果下游代码默认读取 source、created_at 或 group_id,就不能只在口头上说它们可选。应在 schema 中标记 required、nullable 和 default,并为每个默认值说明含义。schema 版本变化时,先提供迁移脚本,再切换训练任务。
为一个多轮问答数据集写 JSON schema 草案,包含消息角色、时间、来源、质量状态和版本字段,并列出三条阻断级校验。
参考结论:把输入、约束、证据和失败回退写清楚,并用一次可运行的小实验验证。
完整实现路径:构建读取→校验→规范化→tokenize→batch 的分层管线,并统计有效 token 与 padding。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
def padding_report(batches):
used = capacity = 0
for lengths in batches:
maximum = max(lengths)
used += sum(lengths); capacity += maximum * len(lengths)
ratio = 1 - used / capacity
return {"effective_tokens": used, "capacity": capacity,
"padding_ratio": round(ratio, 4)}
plain = padding_report([[5, 48, 9, 45], [6, 50, 8, 44]])
bucketed = padding_report([[5, 6, 8, 9], [44, 45, 48, 50]])
assert bucketed["padding_ratio"] < plain["padding_ratio"]验收标准:坏样本进入隔离区,质量报告可复算,padding 指标受监控。
若结果不对:先单条解码,再单 batch,最后恢复并行和缓存。
14.2.2 批处理与吞吐:让 GPU 等待数据而不是等待磁盘
模型训练速度不仅由 GPU 算力决定。数据读取、解码、分词、padding、批间传输和保存检查点都可能成为瓶颈。先测量每个阶段的时间,再决定是否增加 worker、缓存 token、改用流式读取或调整 batch。盲目增加并发可能把内存耗尽,甚至让训练更慢。
批处理要同时考虑样本数和 token 数。固定样本数的 batch 在长度差异很大时会产生大量 padding;按 token 预算组 batch 能提高有效计算比例,但需要对最长样本、梯度累积和显存峰值设上限。无论采用哪种策略,都应在日志里记录真实 token 数、有效 token 数、padding 比例和每秒 token。
变长序列通常需要 attention mask 和 loss mask。padding 位置不能参与注意力,也不能贡献损失;因果语言模型的标签还要向右错一位。数据 collator 应集中处理这些规则,并用极小的手工 batch 检查形状和 mask,避免错误悄悄进入长时间训练。
吞吐优化需要可回退。先实现一个清晰、可验证的单线程版本,再逐步加入多进程加载、预取、缓存和混合精度。每次改动记录前后吞吐、显存、CPU 利用率和结果差异;如果速度提高但指标改变,要优先调查 padding、采样顺序和随机性,而不是直接接受。
| 维度 | 含义 | 诊断动作 |
|---|---|---|
| load_ms | 从存储取一批的时间 | 检查格式、缓存和并发 |
| collate_ms | 组 batch、分词和 padding | 优化 tokenizer 与长度分桶 |
| transfer_ms | CPU 到设备的拷贝 | 使用 pinned memory、预取 |
| tokens/s | 有效 token 处理速度 | 比较 batch、精度和编译 |
| padding_ratio | 无效 padding 占比 | 按长度分桶或 token batch |
| peak_memory | 峰值显存/内存 | 降低 batch、检查泄漏 |
def collate(samples, pad_id=0):
lengths = [len(item["input_ids"]) for item in samples]
width = max(lengths)
input_ids, attention_mask, labels = [], [], []
for item in samples:
ids = item["input_ids"]
pad = [pad_id] * (width - len(ids))
input_ids.append(ids + pad)
attention_mask.append([1] * len(ids) + [0] * len(pad))
labels.append(item["labels"] + [-100] * len(pad))
return {
"input_ids": input_ids,
"attention_mask": attention_mask,
"labels": labels,
"tokens": sum(lengths),
"padding": sum(width - n for n in lengths),
}
batch = collate(samples)
print(batch["tokens"], batch["padding"])如果为了提高 tokens/s 把 batch 调得很大,却导致频繁 OOM、梯度累积改变或验证间隔变长,端到端时间可能更差。比较优化时要看完成同一训练预算所需的总时间、最终指标和成本,而不是只看某一行日志上的瞬时吞吐。
准备长度差异明显的十条样本,计算固定 batch 的 padding_ratio,再按长度排序分桶,比较两种方式的有效 token 比例。
参考结论:把输入、约束、证据和失败回退写清楚,并用一次可运行的小实验验证。
# 对应本节练习 2:先复制运行,再替换为自己的数据
def padding_report(batches):
used = capacity = 0
for lengths in batches:
maximum = max(lengths)
used += sum(lengths); capacity += maximum * len(lengths)
ratio = 1 - used / capacity
return {"effective_tokens": used, "capacity": capacity,
"padding_ratio": round(ratio, 4)}
plain = padding_report([[5, 48, 9, 45], [6, 50, 8, 44]])
bucketed = padding_report([[5, 6, 8, 9], [44, 45, 48, 50]])
assert bucketed["padding_ratio"] < plain["padding_ratio"]验收标准:坏样本进入隔离区,质量报告可复算,padding 指标受监控。
若结果不对:先单条解码,再单 batch,最后恢复并行和缓存。
14.2.3 质量门、去重与隐私
数据质量门应该在训练前、构建缓存后和每次发布前重复执行。字符编码、空值、重复、异常长度、标签比例、来源版本和敏感字段是基础检查;对于科学数据,还要检查单位、范围和实验条件;对于对话数据,还要检查角色顺序和工具参数。
去重既防止训练浪费,也防止评估泄漏。完全重复可以用哈希,近重复可以用 n-gram 指纹或嵌入相似度。阈值选择要在人工抽样上验证,过低会漏掉模板复制,过高会误删真实但相似的记录。
质量问题要有处置类别:修复、隔离、标记或接受限制。修复脚本必须可复现,隔离记录保留原因,标记记录在训练和评估中可过滤,接受限制要写进数据卡。直接删除会让质量报告看起来变好,却失去问题证据。
隐私处理要早于分词和缓存。姓名、电话、地址、账号、患者编号和内部密钥进入 token 缓存后更难清理。脱敏也要考虑组合识别,使用最小字段、分层权限和可撤回索引;日志不要打印完整样本。
数据许可与训练用途要分开判断。能够下载不代表允许训练,允许内部训练不代表允许发布权重或输出。数据台账记录许可、用途、保留期和联系人,构建脚本在未确认时阻止进入发布快照。
标签不平衡要先理解原因。少数类可能是真实低频事件,也可能是漏标;简单过采样会放大重复,简单加权会改变概率校准。报告每个切片的数量和错误代价,再选择采样、权重或人工补标。
数据漂移从构建时就可监控。比较新旧快照的来源、长度、语言、标签和敏感字段分布,重大变化触发重新评估。训练数据变了,模型分数变化不能直接归因于优化器。
本小节让数据质量变成发布门,而不是训练失败后的解释。
| 问题 | 修复 | 隔离 | 上线限制 |
|---|---|---|---|
| 乱码/字段错 | 重解析并保留脚本 | 记录原文与原因 | 禁止进入训练 |
| 重复/近重复 | 规范化和去重 | 保留重复组 | 防止评估泄漏 |
| 敏感信息 | 脱敏/最小化 | 受控存储 | 限制索引和日志 |
| 标签冲突 | 专家裁决 | 保留分歧 | 进入难例集 |
| 许可不明 | 核验或替代 | 隔离来源 | 不发布权重 |
质量门不是一次通过永久有效;每次新快照、脚本或许可变化都要重新计算。
from collections import defaultdict
from hashlib import sha256
def fingerprint(text: str) -> str:
normalized = " ".join(text.lower().split())
return sha256(normalized.encode("utf-8")).hexdigest()
def duplicates(rows: list[dict]) -> dict[str, list[str]]:
groups = defaultdict(list)
for row in rows:
groups[fingerprint(row["text"])].append(row["id"])
return {key: ids for key, ids in groups.items() if len(ids) > 1}
rows = [{"id": "a", "text": "同一 条目"}, {"id": "b", "text": "同一 条目"}]
print(duplicates(rows))本节完成验收
完成本页后,应能设计带 schema 的 Dataset,解释 padding 和有效 token,建立去重、隐私、许可和漂移质量门。验收时让同伴注入乱码、重复、超长和敏感字段,管线要能隔离并生成报告。
为一个指令数据集写 schema 和 5 条校验规则。
参考结论:ID、来源、版本、状态、隐私和长度可验证。
# 对应本节练习 3:先复制运行,再替换为自己的数据
def padding_report(batches):
used = capacity = 0
for lengths in batches:
maximum = max(lengths)
used += sum(lengths); capacity += maximum * len(lengths)
ratio = 1 - used / capacity
return {"effective_tokens": used, "capacity": capacity,
"padding_ratio": round(ratio, 4)}
plain = padding_report([[5, 48, 9, 45], [6, 50, 8, 44]])
bucketed = padding_report([[5, 6, 8, 9], [44, 45, 48, 50]])
assert bucketed["padding_ratio"] < plain["padding_ratio"]验收标准:坏样本进入隔离区,质量报告可复算,padding 指标受监控。
若结果不对:先单条解码,再单 batch,最后恢复并行和缓存。
计算一批样本的有效 token 与 padding 比例。
参考结论:mask 与 loss 忽略位置一致;长样本有处置策略。
# 对应本节练习 4:先复制运行,再替换为自己的数据
def padding_report(batches):
used = capacity = 0
for lengths in batches:
maximum = max(lengths)
used += sum(lengths); capacity += maximum * len(lengths)
ratio = 1 - used / capacity
return {"effective_tokens": used, "capacity": capacity,
"padding_ratio": round(ratio, 4)}
plain = padding_report([[5, 48, 9, 45], [6, 50, 8, 44]])
bucketed = padding_report([[5, 6, 8, 9], [44, 45, 48, 50]])
assert bucketed["padding_ratio"] < plain["padding_ratio"]验收标准:坏样本进入隔离区,质量报告可复算,padding 指标受监控。
若结果不对:先单条解码,再单 batch,最后恢复并行和缓存。
制作一份数据质量快照报告。
参考结论:含重复、空值、标签、许可和隐私统计,并可比较下一版本。
# 对应本节练习 5:先复制运行,再替换为自己的数据
def padding_report(batches):
used = capacity = 0
for lengths in batches:
maximum = max(lengths)
used += sum(lengths); capacity += maximum * len(lengths)
ratio = 1 - used / capacity
return {"effective_tokens": used, "capacity": capacity,
"padding_ratio": round(ratio, 4)}
plain = padding_report([[5, 48, 9, 45], [6, 50, 8, 44]])
bucketed = padding_report([[5, 6, 8, 9], [44, 45, 48, 50]])
assert bucketed["padding_ratio"] < plain["padding_ratio"]验收标准:坏样本进入隔离区,质量报告可复算,padding 指标受监控。
若结果不对:先单条解码,再单 batch,最后恢复并行和缓存。
三层数据模型 把原始、规范化和训练层分开,原始层保留来源,规范化层处理编码和单位,训练层加入 token、标签与 split。每层都生成行数、哈希和 schema 版本。
分层的好处是能重跑处理而不破坏证据。发现清洗规则错误时,回到原始快照重新生成;不要在训练目录里手工改一行数据。
规范化必须可逆或可解释 去空白、统一 Unicode、处理大小写和换行时,记录规则与例外。对于代码、化学式或专有名词,不能套用普通文本清洗。
保留 raw_text 与 normalized_text 的关联 ID,并抽样比较变化。若规范化删除了有意义的符号,质量门应阻断,要求数据负责人确认。
标签映射要版本化 标签名称、整数 ID、层级关系和未知类策略都属于 schema。不要让训练脚本按字典插入顺序隐式生成标签 ID。
把 label_map.json 与模型包一起发布;加载旧模型时读取它自己的映射。新增标签应升级版本并重新评估旧类别,不能仅追加一行就上线。
元数据帮助切片 source、language、created_at、length_bucket、group_id 和 quality_status 让你可以按来源、时间和风险复盘。
元数据也要做隐私审查。能识别个人或组织的字段应脱敏、分桶或只保留不可逆引用,并记录谁能访问原始值。
重复检测分两步 先对规范化文本做精确哈希,再对长文本或近似样本做指纹检索。精确重复可以阻断,近重复要结合来源和上下文判断。
报告去重前后样本数、标签分布和各 split 变化。过度去重会丢掉合法同模板实例,过少去重则会让测试分数虚高。
流式读取的边界 流式数据适合无法一次装入内存的语料,但需要可重复的分片顺序、重试和结束标记。每个 shard 要有稳定 ID 与行数摘要。
随机打乱可以在 shard 内或 shard 间分层完成,但要记录 buffer 大小和 seed。网络读取失败时只重试可恢复错误,并避免重复计数样本。
tokenization 要可测 记录 tokenizer 版本、词表大小、平均长度、分位数和截断比例。长度分布异常往往先于训练损失异常出现。
把特殊 token、空文本、异常 Unicode 和最长输入放进单元测试。tokenizer 更新后,即使模型权重不变,也应视作新数据版本。
长度分桶减少浪费 按 token 长度把样本分桶可以降低 padding,但要防止同一实体被重新分配到不同 split。分桶只改变 batch 组织,不改变切分事实。
比较分桶前后的有效 token 比、吞吐和显存峰值。若稀有类别集中在最长桶,应在报告中单独呈现其质量。
动态 batch 需要上限 按 token 预算组 batch 能提高利用率,但必须同时限制样本数、最长长度和设备显存。遇到超长样本,明确截断、单独处理或拒绝。
动态 batch 改变有效 batch 后,学习率和日志口径都可能改变。把每个 optimizer step 的真实 token 数写入日志,避免只用样本数估算。
mask 是数据的一部分 attention mask 控制哪些位置可见,loss mask 控制哪些位置计入损失;padding、prompt-only 监督和多轮对话都需要不同规则。
先用两三条手工样本打印 mask 与标签,再做批量训练。一个位置多算或少算都可能让损失看似正常,却学到错误目标。
worker 与预取要有证据 增加 DataLoader worker、prefetch 或 pinned memory 前,先测 load、collate 和 transfer 时间。瓶颈在磁盘时增加 GPU 并不能加速。
优化后同时观察 CPU 内存、文件句柄、吞吐和最终指标。worker 引入的随机顺序或异常重试要写入 manifest。
缓存键必须完整 token 缓存包含文本哈希、规范化规则、tokenizer 版本和 max_length;特征缓存还要包含标签映射与切分版本。
缓存失效原因要分类统计。发现命中率异常时,先检查版本键是否遗漏,而不是直接清空缓存让问题暂时消失。
分片与断点 大数据集按 shard 存储时,记录已完成 shard、样本偏移和随机状态。恢复训练不能只恢复模型参数,否则会重复或跳过数据。
断点文件要原子写入并带校验值。先在小数据上模拟中断、重启和重复运行,确认最终样本顺序符合预期。
隐私门与许可门 训练前检查个人信息、版权、用途限制、地域和保存期限。许可是每个来源和转换步骤的可追踪字段。
无法确认许可或无法脱敏的数据进入 quarantine,不要为了凑数量混入训练。模型卡中说明数据来源和已知限制。
质量报告要能定位 报告总量只是开始,还要按来源、标签、长度、语言和时间列出缺失、异常、重复和拒绝样本。每个错误关联稳定 ID。
质量报告与快照绑定并参与门禁。数据更新时自动比较差异,突然增加的空文本、长文本或某类标签应阻断流水线。
失败重试要分层 解析失败、网络超时、权限拒绝和格式错误的处理方式不同。可恢复错误可以重试,确定性坏数据应记录并隔离。
重试次数、退避时间和最终失败清单都进入报告。否则训练日志里的样本数会和真实数据不一致。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| schema | 字段、类型、版本、必填规则 | 拒绝样本并回源修复 |
| 切分 | 实体、时间、来源、重复 | 重建 split 后重跑 |
| 吞吐 | load、collate、transfer、tokens/s | 回到透明基线 |
| 隐私 | 许可、脱敏、保留期限 | 隔离数据并补审批 |
| 恢复 | shard、偏移、seed、校验 | 模拟中断再验证 |
判断管线是否可靠,要同时问三件事:一条样本能否回到来源,一批样本能否稳定变成同样的张量,一次中断能否从正确位置继续。三者缺一,训练结果都可能不可解释。
数据血缘 记录每条训练样本来自哪个来源、转换脚本和快照,抽样时可以回溯。
发现错误标签或泄漏时,能定位到原始记录和处理步骤。
schema 兼容 新增字段优先向后兼容,删除或改语义时升级 schema 并提供迁移。
旧模型和旧脚本在兼容窗口内仍能读取,迁移后再关闭旧路径。
类别分布 按 split、来源和时间查看标签分布,识别长尾、空类和突变。
异常分布触发重采样、加权或数据修复,而不是让模型在不知情下偏置。
样本权重 重复、置信度和人工仲裁可能影响样本权重,规则必须被记录。
训练日志报告权重统计,评估仍使用冻结且不加权的真实分布。
token 统计 保存平均、分位数、最大长度和截断比例,比较 tokenizer 版本的变化。
长度分布变化先经过审查;截断过多时改数据或任务,而不是默默丢上下文。
mask 断言 对 padding、prompt-only、多轮和标签错位分别写断言,打印小 batch。
断言在数据层失败,训练层不接收不确定的张量。
分片恢复 记录 shard、offset、随机状态和已处理数量,重启时不重复或跳过。
模拟中断后最终计数、顺序和指标符合预期,恢复证据归档。
隐私最小化 只保留训练真正需要的字段,原始敏感字段隔离并按期限删除。
访问有权限记录,公开报告只含脱敏摘要和统计。
质量阈值 为缺失、乱码、异常长度、重复和标签冲突设置阻断或警告阈值。
阈值变化要说明原因,质量报告与快照绑定。
随机采样 采样器、buffer、seed 和 worker 状态都影响复现,应加入 manifest。
换环境重跑时差异可解释,不把偶然样本顺序当作模型变化。
| 补充检查 | 看什么 | 通过条件 |
|---|---|---|
| 结构 | schema、类型、版本 | 迁移可执行 |
| 分布 | 标签、长度、来源、时间 | 异常可解释 |
| 张量 | token、mask、padding | 小 batch 通过 |
| 恢复 | shard、offset、seed | 中断可继续 |
如果你无法从一条训练样本回到来源、从一批 token 回到 schema、从一次中断回到正确 offset,数据管线仍不够可靠。
为什么保留原始层 原始数据是审计和重建的依据;如果只保留清洗后的文本,修复规则或解释来源时没有回溯路径。
schema 版本的意义 字段增加、删除或语义改变都会影响下游。版本让旧模型、迁移脚本和新数据在兼容窗口内各自可识别。
标签冲突怎么办 保留不同标注、仲裁结果和原因,而不是静默选择一个。冲突率本身能提示任务定义或标注说明需要改进。
为什么看长度分布 长度决定 token 成本、截断、padding 和显存。分布突变可能意味着数据源变化或规范化规则出错。
按 token 组 batch 样本长度差异大时固定样本数会浪费 padding。token 预算提高有效计算,但必须设置最长长度和显存上限。
mask 为什么要抽样 mask 错一位可能不报错,却把 padding 或 prompt 当成监督。小 batch 打印形状、有效位置和标签,能在训练前发现问题。
worker 为什么会改结果 并发读取可能改变随机顺序、重试次数和异常处理。记录 worker、buffer 和 seed,才能解释重跑差异。
缓存为什么需要失效 tokenizer、清洗规则或标签映射变化后,旧缓存不再代表新数据。完整缓存键和失效统计避免静默混用。
隐私门如何执行 把许可、脱敏、保留期限和访问权限写成字段与检查命令,不能只放在一段口号式说明里。
管线完成的标志 一条样本可回源、一批样本可稳定变成张量、一次中断可从正确位置继续,且报告能够定位每类异常。
案例:多轮消息数据 每个 conversation_id 下的消息保持顺序,system、user、assistant 角色显式保存。切分按会话而不是按消息,防止同一对话泄漏到不同 split。
原始与规范化 原始文本保留不可变引用,规范化文本记录 Unicode、空白和大小写规则。对代码片段、数字和专有名词使用专门测试,避免通用清洗误删信息。
标签迁移 新增标签时提供旧标签到新标签的映射和未知类策略,旧模型继续读取旧映射,新模型读取新版本。报告中同时展示旧类别的指标变化。
长度治理 统计 token 长度的分位数和截断率,按照长度分桶只改变 batch,不改变 split。最长样本单独测试,确保截断不会把答案或安全边界切掉。
mask 演练 用手工三轮对话打印 input_ids、attention_mask 和 loss mask,标出哪些 token 参与损失。演练结果成为 collator 的回归测试。
批处理公平 比较固定样本 batch 与 token batch 时,保持同一有效 token、学习率和评估频率。报告 padding 比例和显存,不能只看 samples/s。
数据重试 可恢复的网络超时可以退避重试,格式错误应隔离并记录 ID。最终失败清单与样本总数写进快照报告,防止训练静默少数据。
许可链 每个来源保存许可、用途、地域和保留期限;转换脚本引用来源 ID。无法确认许可的数据进入隔离区,不通过数量压力进入训练。
质量阈值 缺少 ID、空文本、非法标签和跨 split 实体是阻断项;少量可解释的缺失可以警告。阈值变化需要负责人说明并保留旧报告。
管线复现 在新目录用同一快照运行 schema、tokenizer、collator 和计数脚本,最终哈希应一致。差异必须指向版本、环境或随机采样,而不是靠手工修改。
推演一:来源到样本 一条样本要能从训练张量回到规范化记录,再回到原始来源和许可。为每层保留稳定 ID、转换脚本版本、行数和哈希;发现标签问题时,修复者不必在多个临时目录里猜哪份才是真实输入。
推演二:消息 schema 多轮数据把 system、user、assistant、tool 角色写成结构化数组,保留顺序、时间和会话 ID。不要把所有内容拼成一条字符串后丢失边界,否则切分、mask、审计和工具调用都无法准确处理。
推演三:标签冲突 两个标注员对同一条工单给出不同标签时,保留原始意见、仲裁结果和原因。冲突率按来源和类别统计,可以发现定义含糊或标注指南不足,而不是简单把冲突样本删除。
推演四:长度控制 先统计 token 长度分布,再决定截断、分桶和最大长度。最长样本要单独检查答案是否被切掉,分桶不能跨越实体 split;否则吞吐虽然提升,评估却可能发生泄漏。
推演五:mask 对照 准备一条含 padding、一条 prompt-only、一条多轮工具调用样本,逐位置打印 attention mask 与 loss mask。人工核对后,把断言放进 collator 测试,避免错误 mask 在长训练中悄悄放大。
推演六:缓存版本 token 缓存键至少含文本哈希、规范化规则、tokenizer、max_length 和 schema;特征缓存还要含标签映射与 split。升级任一项就产生新键,命中率和失效原因进入质量报告。
推演七:失败重试 解析错误、网络超时、权限拒绝和坏数据不是同一种失败。可恢复错误按上限退避重试,确定性错误进入隔离清单;最终训练行数与失败 ID一并归档,不能静默少样本。
推演八:隐私门 训练前按字段说明用途、敏感等级、访问角色和删除期限。无法脱敏或无法确认许可的数据进入 quarantine;公开报告只展示统计和不可逆引用,不把原文复制到日志。
把前向、损失、反向和保存串成闭环
14.3 · 单机训练循环
单机训练是理解所有大规模训练的最小模型。掌握训练模式、损失、梯度、优化器、学习率、混合精度和检查点,才能在分布式或云端出现异常时知道哪一层出了问题。
14.3.1 训练循环:理解每一步发生了什么
训练循环可以拆成数据准备、前向计算、损失计算、反向传播、梯度更新和日志记录六个动作。每个动作都有不同的模式:训练时模型启用 dropout,评估时要切换到 eval;反向传播需要梯度,验证通常使用 no_grad;优化器更新后要清理梯度,避免多个 batch 的梯度意外累积。把这些边界写清楚,才能读懂损失曲线。
第一轮实现应追求透明,而不是追求最短代码。明确写出 global_step、epoch、真实样本数和累计 token,日志里同时记录训练损失与验证指标。若使用梯度累积,要区分 micro-batch 和 optimizer step;学习率调度、保存频率和评估频率通常应按 optimizer step 计数。
损失值下降并不自动代表任务变好。分类任务要观察每类指标和混淆矩阵,生成任务要观察有效 token、格式通过率和人工样例。训练循环应定期调用统一的 evaluate 函数,避免训练时用一套后处理、离线评估又用另一套,产生不可比的结果。
异常也属于训练循环的正常输入。发现 NaN、梯度范数爆炸、数据为空或设备断连时,应保存最后可用状态并让任务明确失败;不要用 try/except 把错误吞掉继续跑。一个好的循环能告诉你失败发生在第几个 batch、哪条样本、哪项统计异常。
def train_one_epoch(model, loader, optimizer, scaler, device, accumulate=1):
from torch.amp import autocast
from torch.nn.utils import clip_grad_norm_
if accumulate < 1:
raise ValueError("accumulate 必须是正整数")
total_steps = len(loader)
if total_steps == 0:
return 0.0
model.train()
optimizer.zero_grad(set_to_none=True)
running = 0.0
for step, batch in enumerate(loader, start=1):
batch = {key: value.to(device) for key, value in batch.items()}
window_start = ((step - 1) // accumulate) * accumulate
window_size = min(accumulate, total_steps - window_start)
with autocast(device_type=device.type, enabled=device.type == "cuda"):
output = model(**batch)
raw_loss = output.loss
scaled_loss = raw_loss / window_size
scaler.scale(scaled_loss).backward()
should_step = step % accumulate == 0 or step == total_steps
if should_step:
scaler.unscale_(optimizer)
clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)
running += raw_loss.detach().item()
return running / max(1, len(loader))| 状态 | 何时更新 | 为什么记录 |
|---|---|---|
| epoch | 完整遍历数据后 | 比较不同轮次 |
| micro_step | 每个 batch 后 | 定位异常样本 |
| optimizer_step | 实际更新参数后 | 对应调度与保存 |
| loss_sum / token_count | 每批累计 | 计算加权平均损失 |
| grad_norm | 更新前 | 发现爆炸或消失 |
| eval_metric | 验证后 | 决定是否保留 checkpoint |
梯度累积时,每个 micro-batch 的 loss 通常要除以 accumulate,否则实际梯度会随累积步数放大。最后一个不足整除的 batch 还要单独处理,确保尾部梯度被更新或明确丢弃,并在日志里记录实际 optimizer step。
不用 Trainer 等高级封装,写出包含 train/eval、梯度累积、梯度裁剪、日志和保存的最小循环,并在两个 batch 的玩具数据上打印每一步状态。
参考结论:把输入、约束、证据和失败回退写清楚,并用一次可运行的小实验验证。
完整实现路径:训练循环显式处理累积、裁剪、评估、保存和恢复;每步记录 loss、学习率、梯度范数与吞吐。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
def train_steps(model, loader, optimizer, loss_fn, accumulate=2):
model.train(); optimizer.zero_grad(set_to_none=True)
for step, (x, y) in enumerate(loader, 1):
loss = loss_fn(model(x), y) / accumulate
if not torch.isfinite(loss): raise FloatingPointError(f"loss at step {step}")
loss.backward()
if step % accumulate == 0:
grad = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step(); optimizer.zero_grad(set_to_none=True)
print({"step": step, "loss": float(loss)*accumulate, "grad_norm": float(grad)})验收标准:小数据能过拟合,恢复后步数/优化器/调度器连续。
若结果不对:从首个异常 step 重放,按数据→前向→loss→梯度→更新排查。
14.3.2 优化器与精度:用稳定的更新换取更快实验
优化器决定参数如何根据梯度移动。AdamW 通过一阶、二阶动量和权重衰减缓解不同参数尺度带来的不稳定,SGD 则更简单、便于理解。选择优化器不是背诵名称,而是结合模型规模、数据量、学习率范围和显存预算做实验。无论选择什么,都要记录初始学习率、衰减、betas、epsilon 和调度器。
学习率通常比优化器名字更敏感。预训练或微调常用 warmup,让早期更新从较小步长逐渐增加;随后可以线性衰减、余弦衰减或按验证指标调整。调度器的 step 时机必须明确:有的按每个 batch,有的按每个 optimizer step。错一个单位,曲线就会和预期不同。
混合精度用较低精度执行大部分计算,同时保留必要的高精度累积。autocast 负责选择安全的计算类型,GradScaler 通过放大损失减少下溢;但归一化、损失和指标聚合仍可能需要 float32。遇到 NaN 时,先检查溢出、学习率和输入异常,再决定是否关闭某一算子或降低精度。
梯度裁剪是保险丝,不是修复学习率的替代品。记录裁剪前的梯度范数,观察是否经常超过阈值;如果每一步都被大幅裁剪,说明目标尺度、数据或学习率可能有问题。调参应一次只改变少数变量,并保留小规模快速实验,避免把数小时的训练浪费在明显错误的配置上。
| 项目 | 要确认 | 证据 |
|---|---|---|
| 学习率 | 初值、调度单位、warmup 比例 | 曲线与最终配置 |
| 权重衰减 | 哪些参数排除衰减 | 参数分组代码 |
| 混合精度 | dtype、scaler、算子例外 | 无 NaN 的日志 |
| 梯度 | 裁剪阈值、范数统计 | grad_norm 分布 |
| 累积 | micro-batch 与有效 batch | optimizer_step 计数 |
| 复现 | 种子、确定性、硬件差异 | manifest 与对照运行 |
梯度累积和多卡会改变有效 batch。可以从小规模对照实验开始,观察损失尺度、收敛速度和验证指标,再决定是否线性调整学习率。任何调整都要与 token 预算、warmup 步数和评估频率一起记录。
固定模型和数据,只改变学习率或 warmup 比例,运行三个短实验。画出损失和验证指标,说明哪个配置更稳定,以及你用什么证据判断。
参考结论:把输入、约束、证据和失败回退写清楚,并用一次可运行的小实验验证。
# 对应本节练习 2:先复制运行,再替换为自己的数据
def train_steps(model, loader, optimizer, loss_fn, accumulate=2):
model.train(); optimizer.zero_grad(set_to_none=True)
for step, (x, y) in enumerate(loader, 1):
loss = loss_fn(model(x), y) / accumulate
if not torch.isfinite(loss): raise FloatingPointError(f"loss at step {step}")
loss.backward()
if step % accumulate == 0:
grad = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step(); optimizer.zero_grad(set_to_none=True)
print({"step": step, "loss": float(loss)*accumulate, "grad_norm": float(grad)})验收标准:小数据能过拟合,恢复后步数/优化器/调度器连续。
若结果不对:从首个异常 step 重放,按数据→前向→loss→梯度→更新排查。
14.3.3 检查点、恢复与故障诊断
检查点是训练时间线的快照。至少包含模型权重、优化器、学习率调度器、混合精度缩放器、当前 step、epoch、数据游标、随机状态和配置。只保存模型权重能用于推理,却不能保证从中断处继续得到相近轨迹。
保存策略要考虑频率和故障窗口。每 N 个 step 保存可以减少损失,但会增加 IO;按时间保存适合长任务,按最佳验证指标保存适合最终模型。两者可以并存:最近检查点用于恢复,最佳检查点用于比较。
保存必须是原子操作。先写临时文件,完成后校验大小和哈希,再替换正式路径;分布式训练只有主进程写共享文件。遇到进程中断或磁盘满,系统应保留上一个有效检查点并记录失败。
恢复时先验证兼容性。模型结构、词表、数据 schema、优化器类型和精度变化可能导致无法续训。加载脚本列出缺失和额外键,允许明确的迁移,而不是静默忽略大量参数。
故障诊断要把症状和根因分开。loss 突然升高可能来自数据版本、学习率重置、恢复位置重复或模型权重损坏;GPU OOM 可能来自长度分布、缓存、梯度保留或 batch 改变。
训练中止后不要立刻删除失败目录。保留日志、系统指标、最后输入和错误堆栈,在受控环境中标记敏感内容。失败案例可以帮助调整保存间隔、资源申请和数据质量门。
恢复实验要有新的 experiment_id,并记录 parent_checkpoint。不要覆盖原始实验,否则无法区分原轨迹和修复轨迹。恢复后先跑一小段验证,确认损失和指标没有异常,再放大。
本小节完成后,你应能在模拟中断后恢复训练,并说明为什么恢复包和推理包是两类不同交付物。
| 内容 | 训练恢复 | 推理发布 | 缺失后果 |
|---|---|---|---|
| model weights | 必须 | 必须 | 无法预测 |
| optimizer/scheduler | 必须 | 不需要 | 续训轨迹改变 |
| scaler | 混合精度需要 | 不需要 | 可能溢出 |
| step/data cursor | 建议 | 不需要 | 重复或跳过数据 |
| config/versions | 必须 | 必须 | 无法解释结果 |
| random state | 严格复现需要 | 不需要 | 结果有偏差 |
恢复先验证再扩大;不要让一次损坏的检查点直接覆盖唯一备份。
from dataclasses import asdict, dataclass
from pathlib import Path
import json
@dataclass(frozen=True)
class Checkpoint:
step: int
model_path: str
optimizer_path: str
config_hash: str
data_cursor: int
def write_manifest(item: Checkpoint, path: Path) -> None:
temp = path.with_suffix(".tmp")
temp.write_text(json.dumps(asdict(item), indent=2), encoding="utf-8")
temp.replace(path)
item = Checkpoint(1200, "model.pt", "optim.pt", "cfg-a1", 38400)
write_manifest(item, Path("checkpoint.json"))
print("checkpoint", item.step)本节完成验收
完成本页后,应能运行一个可解释的单机训练循环,比较优化器/学习率/精度,保存完整检查点并从中断恢复。验收时注入 NaN、改变 batch、损坏 manifest,系统要给出明确诊断而不是静默继续。
让一个小模型过拟合几十条样本。
参考结论:训练、评估模式切换正确;损失和预测能回放。
# 对应本节练习 3:先复制运行,再替换为自己的数据
def train_steps(model, loader, optimizer, loss_fn, accumulate=2):
model.train(); optimizer.zero_grad(set_to_none=True)
for step, (x, y) in enumerate(loader, 1):
loss = loss_fn(model(x), y) / accumulate
if not torch.isfinite(loss): raise FloatingPointError(f"loss at step {step}")
loss.backward()
if step % accumulate == 0:
grad = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step(); optimizer.zero_grad(set_to_none=True)
print({"step": step, "loss": float(loss)*accumulate, "grad_norm": float(grad)})验收标准:小数据能过拟合,恢复后步数/优化器/调度器连续。
若结果不对:从首个异常 step 重放,按数据→前向→loss→梯度→更新排查。
比较两种学习率和是否使用梯度累积。
参考结论:记录有效 batch、实际 lr、吞吐和指标。
# 对应本节练习 4:先复制运行,再替换为自己的数据
def train_steps(model, loader, optimizer, loss_fn, accumulate=2):
model.train(); optimizer.zero_grad(set_to_none=True)
for step, (x, y) in enumerate(loader, 1):
loss = loss_fn(model(x), y) / accumulate
if not torch.isfinite(loss): raise FloatingPointError(f"loss at step {step}")
loss.backward()
if step % accumulate == 0:
grad = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step(); optimizer.zero_grad(set_to_none=True)
print({"step": step, "loss": float(loss)*accumulate, "grad_norm": float(grad)})验收标准:小数据能过拟合,恢复后步数/优化器/调度器连续。
若结果不对:从首个异常 step 重放,按数据→前向→loss→梯度→更新排查。
模拟保存后中断并恢复。
参考结论:恢复包包含优化器、调度器、step、数据游标和配置。
# 对应本节练习 5:先复制运行,再替换为自己的数据
def train_steps(model, loader, optimizer, loss_fn, accumulate=2):
model.train(); optimizer.zero_grad(set_to_none=True)
for step, (x, y) in enumerate(loader, 1):
loss = loss_fn(model(x), y) / accumulate
if not torch.isfinite(loss): raise FloatingPointError(f"loss at step {step}")
loss.backward()
if step % accumulate == 0:
grad = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step(); optimizer.zero_grad(set_to_none=True)
print({"step": step, "loss": float(loss)*accumulate, "grad_norm": float(grad)})验收标准:小数据能过拟合,恢复后步数/优化器/调度器连续。
若结果不对:从首个异常 step 重放,按数据→前向→loss→梯度→更新排查。
模式切换要显式 训练阶段使用 model.train(),验证阶段使用 model.eval();dropout、batch normalization 和缓存行为会因此改变。把模式切换写在函数边界,而不是依赖调用者记忆。
在固定样本上分别运行 train 与 eval,确认输出差异符合预期。验证结束后恢复训练模式,避免后续 batch 意外沿用 eval 状态。
前向输出要有契约 模型输出可能包含 loss、logits、hidden states 或辅助损失。训练循环只取需要的字段,并在形状、dtype 和 device 上做断言。
形状错误应在第一个小 batch 失败,而不是经过几千步才在评估时发现。对分类、因果语言模型和序列标注分别写最小前向测试。
损失的分母要明确 平均损失可以按样本、token 或有效 token 计算。padding 与被屏蔽的标签不能进入分母,否则不同长度的 batch 不可比较。
日志里同时记录 loss_sum 和有效计数,必要时自己计算加权平均。不要把 batch 的 loss 简单平均后当作整个 epoch 的真实损失。
梯度累积改变计数 micro-batch 是一次前向/反向,optimizer step 才是真正更新参数。学习率调度、保存和评估通常以 optimizer step 计数。
尾部不足整除的 micro-batch 要明确处理。打印两种 step,检查 global_step、scheduler.step 和 checkpoint 名称是否一致。
zero_grad 的时机 更新后清理梯度可以释放引用,set_to_none 通常更省内存。若在错误位置清理,会丢掉累积梯度或把上一轮梯度带入下一轮。
在两步 toy batch 上打印某个参数的梯度,确认它在累积期间增加、更新后归零。把这一测试保留为回归用例。
评估要隔离副作用 验证通常使用 no_grad 或 inference_mode,避免构建反向图;评估函数不能修改模型权重、采样器或全局计数。
如果评估需要生成结果,把输出写到独立目录并记录解码参数。重复评估同一 checkpoint 应得到一致统计或明确的随机范围。
优化器状态同样重要 恢复训练不仅加载参数,还要恢复动量、权重衰减分组、学习率调度器和混合精度 scaler。只加载模型会让继续训练的轨迹发生跳变。
保存前后打印参数摘要、学习率和 optimizer step。做一次中断、恢复与连续运行的对照,检查差异是否在可解释范围。
学习率调度要对齐 warmup、余弦或线性调度的步数基于样本、token、batch 还是 optimizer step,必须在配置中写明。
画出前几十步的学习率曲线,再开始长跑。曲线起点、峰值和结束点不符合预期时,先修调度器计数而不是调模型。
混合精度要有观察点 autocast、GradScaler、参数 dtype 和损失 dtype 互相配合。记录 scaler 的缩放变化、溢出次数和关键算子的精度。
遇到 NaN,先保存出错 batch、梯度范数和学习率,逐层排查输入、损失与算子。直接关闭异常而不保留证据,会失去定位线索。
梯度裁剪不是万能药 裁剪可以限制偶发的梯度爆炸,但不能长期掩盖错误标签、过大学习率或损失尺度问题。记录裁剪前后的范数与触发比例。
若几乎每步都裁剪,先做小规模学习率和数据检查。阈值变化应有实验依据,不能只为了让曲线变得平滑。
日志要能回答为什么 每个日志点至少包含 epoch、micro_step、optimizer_step、loss、lr、grad_norm、tokens、吞吐和显存。对异常点保留样本 ID 或 batch 摘要。
日志字段和实验 manifest 对齐,避免训练日志有 step 而评估报告只有时间。结构化 JSON 方便后续画图、比较和告警。
保存策略要兼顾恢复与推理 中间 checkpoint 重点是可恢复,最佳 checkpoint 重点是指标,最终模型重点是可部署。三者不要混为一个覆盖文件。
保存使用临时文件加原子替换,并写校验值。定期测试能否在新进程中加载,而不是等到发布才发现权重损坏。
早停要尊重目标 早停根据验证指标和耐心窗口停止训练,但指标噪声、切片退化和成本也应进入决策。不要只按训练损失最低选择 checkpoint。
记录每次候选的通过/失败原因。遇到整体提升但红线切片下降,宁可保留较旧 checkpoint 并调整数据或目标。
小实验优先于长跑 先用极小数据过拟合,验证循环和标签;再用短预算比较学习率、精度和累积;最后才启动长训练。
每个阶段都有明确通过条件。小实验失败时尽早停止,比训练数小时后才发现 mask 或设备错位更节省资源。
故障是可学习信号 OOM、设备断连、数据解析失败和手动终止都应记录上下文与恢复动作。失败运行不等于垃圾,应保存在实验索引中。
复盘失败样本时,判断是代码缺陷、配置过激还是资源不足,并把修复加入下一次门禁。这样训练系统会随故障不断变稳。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| 前向/损失 | 形状、mask、分母、dtype | 回到 toy batch |
| 优化更新 | grad、lr、step、clip | 修正计数或尺度 |
| 评估 | eval、no_grad、切片 | 隔离副作用 |
| 保存恢复 | 参数、优化器、scheduler、scaler | 做中断恢复对照 |
| 异常 | NaN、OOM、断连与上下文 | 保留失败并复现 |
真正掌握训练循环的标志,是你能从一条异常日志还原“哪个 batch、哪种模式、哪次更新、哪份数据”导致问题,而不是只知道重新运行命令。
初始化 参数初始化、预训练权重和冻结层会影响收敛,配置应记录哪些层可训练。
小数据过拟合测试先确认梯度确实流到预期参数。
损失缩放 分类、生成、序列标注的 loss reduction 不同,累积时要保持有效样本或 token 的尺度。
用手算的 toy batch 对照框架输出,确认分母和 mask。
调度曲线 warmup、峰值、衰减和 step 单位画成曲线,比只看配置字段更容易发现错位。
曲线符合合同后才启动长跑,保存图和最终配置。
冻结与解冻 微调时冻结基座、训练适配器或逐层解冻都改变参数量和优化器状态。
日志列出可训练参数数量,恢复时保持同样的冻结策略。
过拟合检查 极小数据应能过拟合到接近零训练损失,否则可能是标签、mask 或循环错误。
过拟合失败先修正确性,不要靠增加学习率或模型规模掩盖。
评估隔离 评估不应更新权重、scheduler、采样器或随机状态;生成输出写到独立目录。
连续两次评估同一 checkpoint 的结果可解释且版本一致。
梯度诊断 记录范数、最大值、稀疏性和 NaN 位置,可以区分消失、爆炸和输入异常。
异常 batch 被保存并可单独重跑,诊断信息不被清理逻辑吞掉。
恢复对照 连续运行与中途恢复的 global_step、lr、optimizer 和指标应接近。
差异超出允许范围时,检查状态是否漏存而不是继续训练。
实验排序 先测正确性,再测稳定性,再测速度;顺序反过来会让错误被昂贵实验放大。
每一层有短命令和通过标准,失败尽早停止。
结论写法 训练报告同时写配置、曲线、切片、成本、失败和下一步,不只放最终分数。
接手者能够复现最小实验并知道为何选择该 checkpoint。
| 补充检查 | 看什么 | 通过条件 |
|---|---|---|
| 正确性 | toy、过拟合、mask、loss | 小实验通过 |
| 稳定性 | NaN、grad、恢复、seed | 异常可定位 |
| 效率 | step、吞吐、显存 | 基线可比较 |
| 结论 | 指标、限制、下一步 | 可审查 |
当训练结果异常时,先回到最小数据和最透明循环;如果无法解释一轮更新,换更大模型或更多卡只会扩大问题。
为什么先过拟合小数据 如果循环、标签或 mask 正确,极小数据应能被模型记住。过拟合失败先修正确性,不要用更大模型掩盖。
损失为什么要看分母 不同 batch 的有效样本和 token 不同,错误分母会让曲线不可比较,也会影响梯度累积的真实尺度。
optimizer step 与 micro step 只有 optimizer step 改变参数,保存、调度和评估通常围绕它计数。把两种计数打印出来能避免间隔错位。
为什么保存 scaler 混合精度的缩放状态影响下一步是否溢出。只保存权重会让恢复后的训练轨迹与连续运行不同。
梯度裁剪如何解释 偶发裁剪可防止爆炸;持续大幅裁剪说明学习率、数据或损失尺度可能错误。记录触发比例而不是只记录裁剪后的值。
评估为何不用训练模式 dropout 和 batch normalization 在训练与评估行为不同;不切换模式会把随机性带入指标。
异常日志要包含什么 step、样本或 batch 摘要、loss、lr、grad_norm、显存和输入长度足以帮助复现,又可避免把完整敏感文本写入日志。
恢复实验怎么做 把连续运行和中断后恢复的参数、学习率、step、指标放在同一表,确认差异来自可解释的随机性。
调参怎样节省成本 先做短实验筛掉明显错误,再为少量候选安排长跑;每次只改变少数变量,保留失败结果。
训练完成的标志 循环、评估、保存、恢复和异常路径都有小测试,最终报告说明为什么选择某个 checkpoint,而不是只展示最低损失。
案例:二分类微调 先让模型在二十条玩具样本上过拟合,再加入验证集和梯度累积。这样可以把标签、loss、循环和评估的错误隔离在最小范围。
状态打印 每个日志点打印 epoch、micro_step、optimizer_step、有效 token、loss 和 lr。看到损失异常时,可以知道发生在数据、更新还是调度。
参数分组 bias、归一化参数和权重衰减常有不同设置,分组逻辑需要写入配置并在启动时打印可训练参数数量。
学习率试验 固定其他变量做三个短实验,画出 warmup、峰值和衰减曲线。曲线比一个最终数字更容易揭示调度器调用次数错位。
精度切换 FP32、FP16 和 BF16 的溢出行为与硬件有关。对照记录 scaler、NaN 次数和关键切片,不要只报告速度提升。
梯度检查 在两个 batch 上打印某个参数的梯度,确认累积期间增加、更新后清理。持续被裁剪说明要调查学习率、数据或损失尺度。
验证隔离 验证时切换 eval 并关闭梯度,评估函数不改全局 step 或 sampler。相同 checkpoint 连续评估应得到一致的统计口径。
恢复对照 连续运行与中断恢复都保存 optimizer、scheduler、scaler、sampler 和 global_step。把两条曲线叠加,确认差异在可解释范围内。
保存分层 中间 checkpoint 用于恢复,最佳 checkpoint 用于选择,最终模型用于部署。三者分别命名、校验并测试加载,不互相覆盖。
训练结论 报告不仅写最低损失,还写验证切片、成本、异常、恢复和下一步。这样别人知道为什么选这个版本,也知道何时不该继续训练。
推演一:先做 toy batch 用极小数据让模型过拟合,确认标签、mask、loss 和 optimizer 真正工作。若连二十条样本都记不住,说明问题在循环或数据,不应通过扩大模型和训练时间来掩盖。
推演二:追踪两个 step 同时打印 micro_step 与 optimizer_step,记录有效 token、loss、lr、grad_norm 和显存。梯度累积时检查 loss 是否按累积步数缩放,尾部 batch 是否执行了明确的更新。
推演三:比较模式 同一批输入分别在 train 与 eval 模式运行,观察 dropout 等随机层的行为;验证使用 no_grad 或 inference_mode,不修改权重、scheduler、sampler 和全局计数。
推演四:画学习率 把 warmup、峰值和衰减画成曲线,标出调度器每次 step 的时机。很多“模型学不会”的问题其实是 scheduler 按 batch 调用,而配置按 optimizer step 设计。
推演五:诊断 NaN 一旦出现 NaN,保存出错 batch、输入长度、loss、梯度范数、学习率和 scaler 状态。先排查数据、精度和学习率,再决定降低精度或回退,不能直接过滤异常日志。
推演六:做恢复对照 连续运行和中断恢复都保存模型、optimizer、scheduler、scaler、sampler 与 global_step。叠加两条曲线,若差异突然出现,优先查漏存的状态而不是把它归为随机波动。
推演七:选择 checkpoint 中间 checkpoint 用于恢复,最佳 checkpoint 按验证门选择,最终模型按部署格式打包。三者分目录、分版本、带校验,避免最后一次保存覆盖了真正的最佳结果。
推演八:写失败结论 训练报告记录 OOM、断连、坏数据、过拟合失败和回滚,并说明下一步动作。保留失败实验能防止团队重复试错,也让预算花在已验证的假设上。
把一份训练拆到多张卡仍保持可解释
14.4 · 分布式与大规模训练
当模型或数据超过单机资源,就需要数据并行、参数/梯度/优化器切分或模型并行。分布式训练带来吞吐,也带来通信、同步、故障和成本问题。本节从 DDP 的最小原理到更大规模策略,建立安全扩展路径。
14.4.1 DDP 与梯度同步:先理解 rank,再谈多卡加速
数据并行的核心是每个进程持有一份模型副本,读取不同的数据分片,反向传播后通过 all-reduce 汇总梯度。rank 表示进程编号,world_size 表示进程总数,local_rank 通常表示进程在本机使用的设备。只有把这些概念和日志对应起来,才能解释“为什么四张卡只跑了三份数据”或“为什么某个进程先退出”。
启动分布式任务时,每个进程都要正确初始化 process group,并绑定自己的设备。DataLoader 需要 DistributedSampler,且每个 epoch 调用 set_epoch,让不同轮次的打乱顺序一致而不重复。只允许 rank 0 写日志、保存 checkpoint 或创建目录,其他进程应等待必要的 barrier,避免并发写坏文件。
梯度同步发生在反向传播期间,因此梯度累积时可以暂时关闭同步,等到最后一个 micro-batch 再 all-reduce,以减少通信。有效 batch 约等于每卡 micro-batch × 累积步数 × world_size;日志应该同时记录每卡 batch 和全局 batch,避免比较实验时把尺度弄错。
分布式错误常常表现为“全部卡都卡住”。一张卡数据加载失败、某个分支没有参与反向、不同进程执行了不同数量的 batch,都可能让集体通信等待。调试时先用两卡小数据复现,打开超时和 rank 日志,确认每个进程的输入形状、step 数和退出路径。
import os
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
def setup():
dist.init_process_group("nccl")
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
return local_rank, dist.get_rank(), dist.get_world_size()
local_rank, rank, world_size = setup()
model = build_model().to(local_rank)
model = DDP(model, device_ids=[local_rank])
sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank)
loader = DataLoader(dataset, sampler=sampler, batch_size=2)
for epoch in range(epochs):
sampler.set_epoch(epoch)
for batch in loader:
loss = model(**move_to(batch, local_rank)).loss
loss.backward()
optimizer.step()
optimizer.zero_grad(set_to_none=True)
if rank == 0:
save_checkpoint(model.module, "checkpoint.pt")
dist.destroy_process_group()| 日志字段 | 正常关系 | 异常线索 |
|---|---|---|
| rank / world_size | rank 在 0…world_size-1 | 重复或缺失说明启动错误 |
| local_rank / device | 每进程绑定唯一设备 | 多个进程抢同一张卡 |
| batches | 各 rank 数量相同 | sampler 或 drop_last 不一致 |
| global_step | 所有 rank 同步增长 | 某分支跳过反向 |
| all_reduce 时间 | 随通信规模可解释 | 突然变长或超时 |
| 保存者 | 通常只有 rank 0 | 并发写文件损坏 |
barrier 只能等待进程到达同一个位置,不能修复前面已经不同步的 batch 数或错误的 collective 顺序。先保证每个 rank 执行相同的通信序列,再在少量关键节点使用 barrier,并设置超时和失败日志。
在小数据集上启动两个进程,打印 rank、world_size、local_rank、每轮 batch 数和保存者。故意让一个 rank 少读一个 batch,观察并解释挂起现象。
参考结论:把输入、约束、证据和失败回退写清楚,并用一次可运行的小实验验证。
完整实现路径:先验证单进程,再检查每个 rank 的数据、通信点、有效 batch 和 checkpoint 所有权。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
def distributed_accounting(rows):
seen = {}
for row in rows:
key = (row["epoch"], row["sample_id"])
seen.setdefault(key, []).append(row["rank"])
duplicate = {str(k): v for k, v in seen.items() if len(v) != 1}
expected_ranks = sorted({r["rank"] for r in rows})
heartbeats = {r["rank"]: r["heartbeat"] for r in rows}
return {"world_size": len(expected_ranks), "duplicates": duplicate,
"last_heartbeat": heartbeats}
report = distributed_accounting(log_rows)
assert not report["duplicates"]验收标准:全局样本不重复不遗漏,参数同步,故障注入能退出并恢复。
若结果不对:保存各 rank 最后心跳和 collective 名称,区分数据慢与通信挂起。
14.4.2 参数切分:从 DDP 走向 FSDP 与 ZeRO
当单张卡放不下一份完整模型时,只复制模型已经不够。参数切分方法把参数、梯度或优化器状态分散到多个设备,需要在计算前后按需聚合。ZeRO 常按阶段切分优化器状态、梯度和参数;FSDP 则以模块为单位管理全分片参数。它们节省显存,但会增加通信、配置和检查点复杂度。
选择切分策略要先算显存账。混合精度训练仍可能同时保存参数、梯度、优化器状态和临时激活;优化器状态往往比参数本身更占空间。通过减少每卡状态、使用 activation checkpointing 或调整模块包围范围,可以让模型放入设备,但每项措施都要测量额外通信和计算时间。
分片的性能取决于拓扑。相邻 GPU 之间的带宽、跨节点网络、CPU 内存和存储都会影响 all-gather、reduce-scatter 与 checkpoint。把模块切得过细会频繁通信,把模块切得过粗又可能出现峰值显存。实际配置应通过 profiler 和小规模负载寻找平衡,而不是照搬别人的层数。
检查点必须明确保存格式。全量权重便于推理,分片权重便于恢复训练;转换过程要记录版本、参数 dtype 和校验值。恢复时验证 optimizer、scheduler、scaler、数据 sampler 和 global_step 是否同时恢复,否则“从 checkpoint 继续”可能只是加载了模型参数,训练轨迹却已经改变。
| 策略 | 主要节省 | 新增复杂度 | 适合场景 |
|---|---|---|---|
| DDP | 计算分摊,不节省完整模型副本 | 低,通信较直观 | 模型能放入单卡 |
| ZeRO-1/2 | 优化器状态或梯度 | 中,需要分片通信 | 模型接近显存上限 |
| ZeRO-3 / FSDP | 参数、梯度、状态 | 高,需规划模块与保存 | 模型无法单卡容纳 |
| 激活检查点 | 中间激活 | 增加重算时间 | 长序列或深模型 |
| 量化/低秩 | 权重或适配器空间 | 精度与兼容性验证 | 微调或推理资源受限 |
分片后要同时检查峰值显存、tokens/s、通信时间、验证结果和可恢复性。尤其要做一次从中途 checkpoint 继续训练的演练,并用小模型比较分片与非分片的结果是否一致。
选择一个 Transformer 模块,画出参数在 forward 前 all-gather、计算后释放、backward 再聚合的时序,并标出显存峰值和通信点。
参考结论:把输入、约束、证据和失败回退写清楚,并用一次可运行的小实验验证。
完整实现路径:先验证单进程,再检查每个 rank 的数据、通信点、有效 batch 和 checkpoint 所有权。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
def distributed_accounting(rows):
seen = {}
for row in rows:
key = (row["epoch"], row["sample_id"])
seen.setdefault(key, []).append(row["rank"])
duplicate = {str(k): v for k, v in seen.items() if len(v) != 1}
expected_ranks = sorted({r["rank"] for r in rows})
heartbeats = {r["rank"]: r["heartbeat"] for r in rows}
return {"world_size": len(expected_ranks), "duplicates": duplicate,
"last_heartbeat": heartbeats}
report = distributed_accounting(log_rows)
assert not report["duplicates"]验收标准:全局样本不重复不遗漏,参数同步,故障注入能退出并恢复。
若结果不对:保存各 rank 最后心跳和 collective 名称,区分数据慢与通信挂起。
14.4.3 失败、同步与规模验收
分布式失败大致分为初始化、通信、数据、设备和应用逻辑。初始化失败看环境变量、端口和 rank;通信失败看网络、超时和进程是否同时进入 collective;数据失败看分片和文件;设备失败看 OOM 或 GPU 健康;应用失败看控制流和张量形状。
超时设置太短会把慢但正常的节点当成失败,太长又会让真正死锁等待很久。记录 collective 名称、rank、开始时间和最后一次进展,调试时用小规模复现。不要用无限 timeout 掩盖同步错误。
所有 rank 的指标要能聚合。loss 可以求平均,计数和样本数要相加,准确率不能简单平均各 rank 的百分比而忽略样本量。评估时使用全局 confusion matrix 或收集预测,保证分片不改变指标定义。
随机性在多进程中有两层:全局 seed 和每个 rank 的偏移。数据 sampler、增强和 dropout 需要清晰的 seed 规则。严格复现与高吞吐有时冲突,应把目标写进实验合同。
规模验收不能只看速度。增加卡数后要比较每步 loss、最终指标、有效 token、通信比例、显存、能耗和失败率。若 8 卡只快 4 倍且质量改变,要调查 batch、学习率和数据顺序。
故障注入是必要练习。主动终止一个 rank、让一批数据读取超时、填满检查点磁盘,观察系统能否安全退出、保存现场并恢复。没有故障演练,集群成功运行不代表可运营。
大规模训练完成后要产出可交付 artifact:合并权重或分片索引、配置、tokenizer、评估报告、资源账单和已知限制。不要只把集群日志当成果。
本小节的验收标准是:扩容后的质量、成本和恢复能力都能被数据说明。
| 类别 | 指标 | 看什么 |
|---|---|---|
| 吞吐 | global tokens/s、step time | 扩容是否有效 |
| 通信 | all-reduce 时间占比 | 网络/同步瓶颈 |
| 质量 | loss、关键切片、通用回归 | 结果是否稳定 |
| 资源 | 显存、GPU 利用率、能耗 | 是否浪费 |
| 可靠 | 重启成功率、恢复时间 | 能否运营 |
| 成本 | GPU 小时、存储、失败重跑 | 是否值得 |
故障处理要保留现场并把事件转成下一轮测试,而不是简单重启后删除日志。
from dataclasses import dataclass
@dataclass(frozen=True)
class LocalMetric:
correct: int
total: int
loss_sum: float
def aggregate(items: list[LocalMetric]) -> dict[str, float]:
correct = sum(item.correct for item in items)
total = sum(item.total for item in items)
loss_sum = sum(item.loss_sum for item in items)
return {
"accuracy": correct / total if total else 0.0,
"mean_loss": loss_sum / total if total else 0.0,
"samples": float(total),
}
print(aggregate([LocalMetric(8, 10, 3.2), LocalMetric(9, 10, 2.8)]))本节完成验收
完成本页后,应能解释 DDP 的数据分片与梯度同步,做内存预算,比较并行策略并设计故障恢复。验收时用小规模多进程统计全局指标,注入一个 rank 退出,检查是否有超时、现场和恢复记录。
画出 4 个 rank 的 batch、梯度和参数流。
参考结论:说明 sampler、同步点和 rank 0 保存职责。
完整实现路径:先验证单进程,再检查每个 rank 的数据、通信点、有效 batch 和 checkpoint 所有权。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
def distributed_accounting(rows):
seen = {}
for row in rows:
key = (row["epoch"], row["sample_id"])
seen.setdefault(key, []).append(row["rank"])
duplicate = {str(k): v for k, v in seen.items() if len(v) != 1}
expected_ranks = sorted({r["rank"] for r in rows})
heartbeats = {r["rank"]: r["heartbeat"] for r in rows}
return {"world_size": len(expected_ranks), "duplicates": duplicate,
"last_heartbeat": heartbeats}
report = distributed_accounting(log_rows)
assert not report["duplicates"]验收标准:全局样本不重复不遗漏,参数同步,故障注入能退出并恢复。
若结果不对:保存各 rank 最后心跳和 collective 名称,区分数据慢与通信挂起。
做一份单卡、2 卡、4 卡的扩展表。
参考结论:包含质量、吞吐、通信、显存、成本和失败率。
完整实现路径:先验证单进程,再检查每个 rank 的数据、通信点、有效 batch 和 checkpoint 所有权。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
def distributed_accounting(rows):
seen = {}
for row in rows:
key = (row["epoch"], row["sample_id"])
seen.setdefault(key, []).append(row["rank"])
duplicate = {str(k): v for k, v in seen.items() if len(v) != 1}
expected_ranks = sorted({r["rank"] for r in rows})
heartbeats = {r["rank"]: r["heartbeat"] for r in rows}
return {"world_size": len(expected_ranks), "duplicates": duplicate,
"last_heartbeat": heartbeats}
report = distributed_accounting(log_rows)
assert not report["duplicates"]验收标准:全局样本不重复不遗漏,参数同步,故障注入能退出并恢复。
若结果不对:保存各 rank 最后心跳和 collective 名称,区分数据慢与通信挂起。
设计一次分布式故障演练。
参考结论:能安全退出、保留 checkpoint 并从新 experiment_id 恢复。
完整实现路径:先验证单进程,再检查每个 rank 的数据、通信点、有效 batch 和 checkpoint 所有权。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 5:先复制运行,再替换为自己的数据
def distributed_accounting(rows):
seen = {}
for row in rows:
key = (row["epoch"], row["sample_id"])
seen.setdefault(key, []).append(row["rank"])
duplicate = {str(k): v for k, v in seen.items() if len(v) != 1}
expected_ranks = sorted({r["rank"] for r in rows})
heartbeats = {r["rank"]: r["heartbeat"] for r in rows}
return {"world_size": len(expected_ranks), "duplicates": duplicate,
"last_heartbeat": heartbeats}
report = distributed_accounting(log_rows)
assert not report["duplicates"]验收标准:全局样本不重复不遗漏,参数同步,故障注入能退出并恢复。
若结果不对:保存各 rank 最后心跳和 collective 名称,区分数据慢与通信挂起。
rank 与 world size rank 是进程编号,world_size 是总进程数,local_rank 是本机设备索引。把三者打印到启动日志,才能知道数据和 GPU 是否按预期分布。
启动脚本、环境变量和调度器必须保持一致。缺 rank、重复 local_rank 或 world_size 不同,优先修环境,不要在训练代码里猜测。
process group 的生命周期 初始化通信组、绑定设备、训练、保存和销毁是完整生命周期。初始化失败要快速退出,销毁要放在清理路径,避免下一次作业继承残留进程。
为初始化设置超时并记录 backend、master 地址摘要和设备。不要把密钥或完整网络配置写入公开日志。
sampler 防止重复 DistributedSampler 让每个 rank 读取不同索引,并在每个 epoch 调用 set_epoch。没有 set_epoch,打乱顺序可能每轮相同;没有 sampler,所有卡会重复读取整份数据。
记录每个 rank 的首尾样本 ID 和 batch 数,在小数据上核对无交集。样本数不足时明确 drop_last 或 padding 的策略。
all-reduce 的含义 梯度同步通常通过 all-reduce 求和或平均。不同框架对 loss 和梯度的缩放约定可能不同,必须确认有效 batch 与学习率的关系。
用两个进程在同一个 toy 模型上比较单卡和 DDP 的梯度。若结果不一致,先检查 loss reduction、累积和 sampler。
只让 rank 0 写文件 日志、checkpoint、评估输出和目录创建通常由 rank 0 完成,其他 rank 参与计算并等待必要同步。
保存路径中加入版本和校验值。并发写一个 JSON 或覆盖同一权重,会造成损坏且难以回滚。
同步点要有理由 barrier 只能等待,不会修复 collective 顺序或 batch 数不一致。同步点过多会降低吞吐,过少则可能在读写阶段产生竞态。
在代码注释中说明每个 barrier 的前置条件和后置条件,并通过异常路径测试。
梯度累积与 no_sync 累积期间可暂时关闭梯度同步,最后一步再同步,减少通信。要保证每个 rank 在同一个 micro-step 结束同步。
打印同步步和局部步,检查尾部 batch 是否触发更新。关闭同步不能用于跳过必须共享的状态更新。
全局 batch 的口径 每卡 batch、micro-batch、累积步数和 world_size 共同决定全局有效 batch。实验表中同时保存这些字段,才能比较单卡与多卡。
改变卡数后重新检查 warmup、学习率、评估频率和 token 预算,不要只把进程数改大。
网络拓扑会改变性能 同机 NVLink、PCIe 和跨节点网络的带宽不同,all-gather、reduce-scatter 和 checkpoint 都会受影响。
用 profiler 分开计算、通信和数据等待时间。把节点、GPU 型号和互联方式写入 manifest。
FSDP 的模块边界 FSDP 按模块管理参数聚合与释放,包围范围影响峰值显存和通信频率。太细会频繁通信,太粗会出现内存峰值。
先用小模型测量不同 wrap 策略,再放大规模。保存配置与模块规则,避免只记录一个“fsdp=true”。
ZeRO 的阶段 ZeRO-1 主要切分优化器状态,ZeRO-2 再切分梯度,ZeRO-3 连参数也切分。节省显存的同时增加通信和保存复杂度。
选择阶段要结合模型、序列长度、硬件和恢复要求。用相同 token 预算比较速度、显存和结果,而不是只看能否启动。
分片 checkpoint 分片权重方便恢复训练,但推理常需要转换成完整或特定格式。转换要记录 source checkpoint、dtype、版本和校验值。
在发布前从分片 checkpoint 恢复一次,再加载转换后的模型做固定样例。两条路径结果不应出现未解释差异。
故障与超时 单个 rank 的 OOM、数据异常或进程退出会让其他 rank 在 collective 等待。设置超时、捕获 rank 日志并保留作业 ID。
恢复策略可以是重启同一 checkpoint、缩小 batch 或降级节点。不要无限重试同一坏数据或配置。
弹性与重新分片 节点数量变化会改变 sampler、全局 batch 和通信拓扑。弹性恢复必须记录已经处理的数据状态,不能只加载模型参数。
先在小数据上模拟少一张卡或换节点,验证样本不重复、step 可解释、指标不突变。
资源隔离 通信、数据加载和训练会竞争 CPU、网络和存储。多作业共用带宽时,单个作业可能出现长尾。
为作业设置资源配额和优先级,监控网络、文件句柄和 CPU 等待。性能异常要区分代码问题和平台争用。
规模上限的判断 扩容不是目标,只有在质量或完成时间收益超过通信与成本时才值得。记录强扩展与弱扩展曲线。
当增加 GPU 后 tokens/s 几乎不变,先看通信占比和数据管线,再决定优化或停止扩容。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| 启动 | rank、world_size、设备、backend | 终止并修环境 |
| 数据 | sampler、set_epoch、batch 数 | 重建分片 |
| 通信 | collective 顺序、超时、耗时 | 缩小规模复现 |
| 保存 | rank 0、分片格式、校验 | 恢复演练 |
| 扩容 | 通信占比、tokens/s、成本 | 停止无效扩容 |
分布式训练不是卡越多越好,而是每个进程都做正确的工作,并且失败时能够定位和恢复。先用两卡证明通信与数据分片正确,再用 profiler 判断规模化是否值得。
启动环境 确认 backend、rank、world_size、local_rank 和设备映射,保存启动摘要。
任何重复 rank 或缺失设备都在训练前失败。
数据分片 记录每个 rank 的样本范围、batch 数和 drop_last 策略,防止重复或遗漏。
小数据核对各 rank 的 ID 集合无交集且并集完整。
collective 顺序 所有进程必须执行相同的 all-reduce、broadcast 和 barrier 顺序。
遇到挂起先比较 rank 日志,不要盲加 barrier。
全局损失 确认 loss 是求和还是平均,梯度是否按 world_size 缩放。
单卡与两卡 toy 对照的更新方向一致。
通信隐藏 用 no_sync 或重叠通信与计算减少等待,但要保证最后一步同步。
报告通信占比和同步步,不以更低通信换取错误梯度。
分片边界 FSDP/ZeRO 的模块包围、参数释放和临时聚合影响峰值。
小模型测试不同边界,保存规则而不是一个布尔开关。
检查点格式 恢复训练的分片状态与推理所需的完整权重是两类制品。
分别校验加载、转换和中断恢复,记录 dtype 与摘要。
故障重启 超时、OOM、数据坏样本和节点丢失的处理方式不同。
重启从可用 checkpoint 开始,坏数据被隔离,不无限重试。
拓扑差异 同机与跨节点带宽不同,规模化曲线应按节点和互联方式记录。
扩容前先测通信占比,确认收益超过网络和成本。
平台配额 多作业争用 CPU、网络和存储时,单作业性能会波动。
把平台资源、优先级和作业 ID 加入 manifest,异常时区分代码与争用。
| 补充检查 | 看什么 | 通过条件 |
|---|---|---|
| 启动 | rank、设备、backend | 前置失败 |
| 数据 | sampler、ID、batch | 无重复遗漏 |
| 通信 | 顺序、缩放、耗时 | 两卡对照 |
| 保存 | 分片、转换、恢复 | 演练通过 |
能在两卡小数据上解释每个 rank 读了什么、何时同步、谁保存以及如何恢复,再考虑扩大规模;顺序反过来会让调试成本指数增加。
为什么需要 rank 日志 多卡作业中的同一条错误可能在不同进程表现不同。rank、local_rank 和 world_size 让日志能定位设备与进程。
sampler 与 set_epoch 每个 rank 应读取不同索引,每轮改变打乱顺序。漏掉 set_epoch 会让训练顺序重复,漏掉 sampler 会重复计算数据。
all-reduce 不是复制梯度 它按约定求和或平均梯度,loss reduction 和有效 batch 会影响尺度。单卡与两卡 toy 对照是最简单的验证。
为什么 rank 0 保存 多个进程同时写同一文件会互相覆盖或损坏。其他进程只需同步并等待保存完成。
barrier 何时有用 它适合保护明确的阶段边界,不适合修复 collective 顺序、batch 数不一致或坏数据。
通信与计算重叠 梯度累积时可减少同步次数,或让通信与计算重叠;但最后的同步必须发生,不能为了速度跳过。
全局 batch 怎样计算 每卡 batch、累积步数和 world_size 一起决定全局有效 batch。扩容后要重新检查学习率和 warmup。
分片策略怎样选 先估算参数、梯度、优化器和激活的峰值,再比较 DDP、ZeRO、FSDP 与检查点开销。
故障怎样恢复 不同错误对应不同动作:数据坏样本隔离,OOM 降低资源,节点失败重启;无限重试只会浪费资源。
分布式完成的标志 两卡小规模能解释数据、通信、保存和恢复,再用 profiler 证明扩容带来真实的吞吐或时间收益。
案例:两卡训练 先固定一份小数据,把 rank、local_rank、world_size、sampler 和每轮 batch 打印出来。单卡与两卡的 loss、梯度方向和有效 token 应能解释,而不是只看总耗时。
启动失败 master 地址、端口、设备映射或 backend 错误应在初始化阶段明确失败。日志保留作业 ID、rank 和异常摘要,避免其他进程只看到“等待通信”。
数据不均 不同 rank 的 batch 数不一致会造成 collective 挂起。对 drop_last、最后一批和样本不足的处理写入配置,并在小数据上模拟。
通信顺序 所有进程必须以相同顺序调用 all-reduce、broadcast 和 barrier。某个分支提前 return 时,其他进程会一直等待;调试先比较 rank 时间线。
全局学习率 扩容会改变有效 batch,warmup 和学习率可能需要重新实验。manifest 同时记录每卡 batch、累积步数、world_size 和全局 token。
no_sync 的边界 梯度累积可以暂时不通信,但最后一个 micro-batch 必须同步。日志写出同步步与局部步,尾部不足整除的 batch 不能悄悄丢失。
分片选择 模型放不下时再考虑 ZeRO 或 FSDP,先用显存账本判断节省来自参数、梯度还是优化器。分片配置应包含模块边界和 checkpoint 格式。
保存与加载 rank 0 写全量或索引,其他 rank 等待;恢复时同时加载优化器、scheduler、scaler 和 sampler。只加载权重不能证明训练轨迹恢复。
故障路径 数据坏样本、OOM、节点丢失和网络超时分别采用隔离、缩小资源、重启或重新调度。无限重试同一错误会放大成本。
规模结论 增加 GPU 后 tokens/s 若几乎不变,先查看通信和数据等待占比。扩容只有在完成时间或成本明显改善时才有意义。
先测量瓶颈,再选择加速手段
14.5 · 性能与成本优化
训练和推理的性能优化都必须以测量为起点。显存、吞吐、延迟、IO、通信和能耗彼此牵制,本节建立 profiler、内存预算、编译/量化、缓存和路由的判断方法,避免用未经验证的技巧换来不可解释的质量退化。
14.5.1 显存与吞吐:先画资源账本
性能优化的第一步是把资源拆成可解释的账本。训练显存通常由参数、梯度、优化器状态、激活、临时 workspace 和通信缓冲组成;推理显存还受 batch、序列长度、KV cache 和并发请求影响。只看“显存占用 80%”无法知道下一次长输入会不会 OOM,必须记录峰值、触发位置和输入形状。
吞吐也要定义清楚。样本数/秒适合长度相近的分类任务,token/秒更适合语言模型;服务则更关心首 token 延迟、生成 token/秒和 p50/p95/p99。比较两个方案时,要用同一批输入、同一质量门和同一测量窗口,并报告预热阶段与稳定阶段,避免把编译或缓存时间混在一起。
建立小型基准套件很有价值:短输入、中等输入、最长允许输入、空输入、并发一和目标并发各跑一遍。每个场景记录 CPU、GPU、显存、吞吐、延迟和错误率。基准不是越复杂越好,而是能够稳定复现瓶颈,并在优化后告诉你是否真的改善了端到端体验。
资源账本还要包含成本。把 GPU 小时、存储、网络、日志和人工评审折算到一次训练或一百万次请求,才能比较“更大模型”和“更好缓存”的真实收益。成本不是事后财务统计,而是模型合同中的约束;当指标提升只来自预算大幅增加时,应明确记录这个权衡。
| 指标 | 训练侧 | 推理侧 |
|---|---|---|
| 延迟 | step time、数据等待 | 首 token、完整响应、p95 |
| 吞吐 | samples/s、tokens/s | tokens/s、requests/s |
| 资源 | 峰值显存、CPU、网络 | 显存、KV cache、并发 |
| 质量 | 验证指标、收敛步数 | 固定集质量、拒答率 |
| 成本 | GPU·h、存储、重试 | 每请求成本、峰值容量 |
| 稳定性 | OOM、NaN、恢复时间 | 超时、错误率、尾延迟 |
线上用户遇到的是一次具体请求,不是平均请求。至少同时看 p50 与 p95,长输入和高并发要单独成组;显存同样要看峰值而不是平均值。任何优化都要保留原始基线和测量脚本,确保别人能复算。
为一个生成服务设计五种输入场景,写出测量命令、预热次数、并发数、指标和通过阈值,并说明为什么不能只报告平均延迟。
参考结论:先测量,再做单变量改变;同时记录质量、延迟、资源和回退证据。
完整实现路径:性能实验固定输入和输出长度,分开预热与测量,报告 p50/p95、吞吐、显存和成本。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
from statistics import mean
def percentile(values, q):
values = sorted(values)
index = round((len(values) - 1) * q)
return values[index]
def benchmark(latencies_ms, requests, seconds, peak_mb):
return {"p50_ms": percentile(latencies_ms, .50),
"p95_ms": percentile(latencies_ms, .95),
"throughput_rps": requests / seconds,
"peak_memory_mb": peak_mb, "samples": len(latencies_ms)}
report = benchmark(measured_ms, requests=200, seconds=8.2, peak_mb=4120)
assert report["samples"] >= 30验收标准:只改变一个变量,结果含原始记录、方差和质量回归。
若结果不对:先用 profiler 找瓶颈,再优化;不要凭 GPU 利用率猜原因。
14.5.2 编译、量化与缓存:优化后仍要守住结果
编译把动态执行路径转换成更适合硬件的图,可能减少 Python 调度和内核启动开销;量化则用更少的位数表示权重或激活,降低显存和带宽压力。两者都不是“打开开关就一定更快”:动态 shape、算子不支持、首次编译、量化误差和回退到 eager 都可能抵消收益。
优化要从可回退的单变量实验开始。先固定数据、模型、硬件和并发,只改变编译模式;记录冷启动和热态延迟、吞吐、峰值显存、错误率和质量。再单独尝试权重量化或 KV cache 量化,比较关键切片和格式通过率。实验报告要保留原始输出样例,防止只看一个总分。
缓存必须设计键。token 缓存至少要包含 tokenizer 版本、规范化规则和文本哈希;模型输出缓存还要包含模型版本、采样参数、系统提示和工具版本。没有完整键,升级后会读到旧结果,问题既难发现又难回滚。缓存命中率和失效原因也要进入监控。
编译和量化可能改变数值边界。对 logits、长度截断、停止条件和安全分类器做边界测试,检查极端输入、空输入和最长输入。若质量下降只发生在某个切片,应记录为已知权衡;若出现不可解释的退化,优先回到基线而不是继续叠加优化。
from dataclasses import dataclass
from time import perf_counter
@dataclass
class Result:
name: str
p95_ms: float
tokens_per_second: float
peak_memory_mb: float
quality: float
def benchmark(name, runner, cases):
latencies, tokens = [], 0
started = perf_counter()
for case in cases:
t0 = perf_counter()
output = runner(case)
latencies.append((perf_counter() - t0) * 1000)
tokens += len(output.split())
elapsed = perf_counter() - started
latencies.sort()
p95 = latencies[max(0, int(len(latencies) * .95) - 1)]
return Result(name, p95, tokens / max(elapsed, 1e-9), peak_memory(), quality(cases))
for variant, runner in variants:
print(benchmark(variant, runner, benchmark_cases))| 对照维度 | 必须相同 | 允许变化 |
|---|---|---|
| 输入 | 固定基准与边界样例 | 无 |
| 环境 | 同一硬件、驱动、依赖 | 无 |
| 质量 | 同一评分与阈值 | 无 |
| 优化 | 同一模型版本 | 编译、量化或缓存策略 |
| 测量 | 同一预热、窗口、并发 | 优化后的配置 |
每个优化版本都应能一键切回未优化基线,并在 manifest 中记录开关。不要把编译缓存或量化权重覆盖原始制品;新制品使用新版本号和校验值,出现质量回归时才能迅速定位。
用同一组输入比较 eager 与编译(或 FP16 与量化)版本,至少报告 p95、吞吐、峰值显存和质量差异,并写出回退条件。
参考结论:先测量,再做单变量改变;同时记录质量、延迟、资源和回退证据。
完整实现路径:性能实验固定输入和输出长度,分开预热与测量,报告 p50/p95、吞吐、显存和成本。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
from statistics import mean
def percentile(values, q):
values = sorted(values)
index = round((len(values) - 1) * q)
return values[index]
def benchmark(latencies_ms, requests, seconds, peak_mb):
return {"p50_ms": percentile(latencies_ms, .50),
"p95_ms": percentile(latencies_ms, .95),
"throughput_rps": requests / seconds,
"peak_memory_mb": peak_mb, "samples": len(latencies_ms)}
report = benchmark(measured_ms, requests=200, seconds=8.2, peak_mb=4120)
assert report["samples"] >= 30验收标准:只改变一个变量,结果含原始记录、方差和质量回归。
若结果不对:先用 profiler 找瓶颈,再优化;不要凭 GPU 利用率猜原因。
14.5.3 成本预算与优化实验
成本不是最后看账单,而是训练计划的一部分。估算 GPU 小时、存储、网络、数据处理、失败重跑和人工审核;对推理服务还要估算峰值容量、闲置、缓存和多区域冗余。把单次请求、每千 token 和每个成功任务的成本分开。
规模扩大可能降低墙钟时间,却提高同步和失败成本。比较单卡、少量多卡和大集群的质量—时间—成本曲线,找到满足截止时间和预算的点。一个训练任务如果需要反复重跑,标称价格没有意义。
优化实验应该使用固定小基准。每次改变一个变量,预热后重复多次,报告中位数和 p95;同时跑关键质量回归和安全样本。性能提升未达到预设阈值,或质量变化超过红线,就不合并。
资源利用率低可能来自排队、数据、通信或模型太小。不要仅凭 GPU 利用率判断浪费。记录调度等待、实际运行、失败重试和空闲,才能决定缩小模型、合并任务或更换机器。
训练数据和检查点的存储生命周期需要分级。开发快照短期保留,评估快照和发布包长期保留,敏感原始数据按许可删除。冷热存储、压缩和去重可以降成本,但要保留可恢复性。
预算也包含机会成本。工程师等待集群、专家等待评估、服务等待人工确认,都影响项目交付。让自动化检查尽早失败,把专家时间留给高风险和有争议样本,通常比继续加机器更有效。
成本优化要有回滚。新量化、批处理或路由上线后出现长尾或错误,能够快速切回旧配置;不能为了省一点钱锁死唯一路径。
本小节的结果是一份带阈值的优化决策,而不是一串孤立的技巧。
| 成本项 | 测量 | 可优化方向 | 不可牺牲 |
|---|---|---|---|
| 训练 GPU | GPU 小时、失败重跑 | 规模/精度/检查点 | 关键质量 |
| 数据 | 解析 CPU、存储、带宽 | 缓存/压缩/流式 | 来源和许可 |
| 推理 | tokens、并发、闲置 | 路由/批处理/量化 | 风险边界 |
| 人工 | 标注、复核、事件 | 错误地图/自动校验 | 专家责任 |
| 机会 | 排队、恢复、发布周期 | 预检/灰度 | 可回滚 |
只有同时通过性能和质量门,优化才进入下一版配置。
from dataclasses import dataclass
@dataclass(frozen=True)
class Cost:
gpu_hours: float
gpu_rate: float
storage_gb: float
storage_rate: float
retries: int
def total(cost: Cost) -> float:
compute = cost.gpu_hours * cost.gpu_rate
storage = cost.storage_gb * cost.storage_rate
return compute + storage + cost.retries * compute
item = Cost(12.5, 2.0, 80.0, 0.03, 1)
print(round(total(item), 2))本节完成验收
完成本页后,应能用 profiler 和成本拆解找到瓶颈,比较编译、量化、缓存、批处理和路由,并把质量回归与性能阈值写进决策。验收时用同一基准测冷/热启动、p50/p95、显存、成本和关键质量。
为一次训练或推理任务制作端到端计时表。
参考结论:包含数据、计算、通信、保存、p50/p95 和有效 token。
完整实现路径:性能实验固定输入和输出长度,分开预热与测量,报告 p50/p95、吞吐、显存和成本。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
from statistics import mean
def percentile(values, q):
values = sorted(values)
index = round((len(values) - 1) * q)
return values[index]
def benchmark(latencies_ms, requests, seconds, peak_mb):
return {"p50_ms": percentile(latencies_ms, .50),
"p95_ms": percentile(latencies_ms, .95),
"throughput_rps": requests / seconds,
"peak_memory_mb": peak_mb, "samples": len(latencies_ms)}
report = benchmark(measured_ms, requests=200, seconds=8.2, peak_mb=4120)
assert report["samples"] >= 30验收标准:只改变一个变量,结果含原始记录、方差和质量回归。
若结果不对:先用 profiler 找瓶颈,再优化;不要凭 GPU 利用率猜原因。
设计一个带版本和权限的缓存键。
参考结论:模型、提示、资料、用户范围都参与;敏感请求不共享。
完整实现路径:性能实验固定输入和输出长度,分开预热与测量,报告 p50/p95、吞吐、显存和成本。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
from statistics import mean
def percentile(values, q):
values = sorted(values)
index = round((len(values) - 1) * q)
return values[index]
def benchmark(latencies_ms, requests, seconds, peak_mb):
return {"p50_ms": percentile(latencies_ms, .50),
"p95_ms": percentile(latencies_ms, .95),
"throughput_rps": requests / seconds,
"peak_memory_mb": peak_mb, "samples": len(latencies_ms)}
report = benchmark(measured_ms, requests=200, seconds=8.2, peak_mb=4120)
assert report["samples"] >= 30验收标准:只改变一个变量,结果含原始记录、方差和质量回归。
若结果不对:先用 profiler 找瓶颈,再优化;不要凭 GPU 利用率猜原因。
做一次单变量性能优化实验。
参考结论:有基线、预热、重复、质量回归和采用/回滚阈值。
完整实现路径:性能实验固定输入和输出长度,分开预热与测量,报告 p50/p95、吞吐、显存和成本。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 5:先复制运行,再替换为自己的数据
from statistics import mean
def percentile(values, q):
values = sorted(values)
index = round((len(values) - 1) * q)
return values[index]
def benchmark(latencies_ms, requests, seconds, peak_mb):
return {"p50_ms": percentile(latencies_ms, .50),
"p95_ms": percentile(latencies_ms, .95),
"throughput_rps": requests / seconds,
"peak_memory_mb": peak_mb, "samples": len(latencies_ms)}
report = benchmark(measured_ms, requests=200, seconds=8.2, peak_mb=4120)
assert report["samples"] >= 30验收标准:只改变一个变量,结果含原始记录、方差和质量回归。
若结果不对:先用 profiler 找瓶颈,再优化;不要凭 GPU 利用率猜原因。
先拆显存账 参数、梯度、优化器状态、激活、临时 workspace、通信缓冲和 KV cache 需要分别估算。峰值常出现在某个层或某个最长输入,而不是平均时刻。
对每项记录 dtype、shape、生命周期和触发位置。出现 OOM 时先知道哪项占满,再选择降 batch、分片、检查点或截断。
定义吞吐口径 分类任务可报告 samples/s,语言模型更应报告有效 tokens/s;服务要区分首 token、生成速度、p50 和 p95。
测量脚本固定预热、输入集和并发。只比较同一质量门下的端到端时间,避免把局部内核速度当作用户收益。
建立边界样例 短输入、平均输入、最长输入、空输入、异常字符和目标并发共同构成小型基准。每种场景都记录成功与失败。
边界样例要进入回归,而不是压测后丢弃。优化后若最长输入变慢或拒绝率上升,必须在报告中说明。
把成本换算成单位 训练成本用 GPU·h、存储、网络和人工评审计算;推理成本按每请求或每百万 token 计算。
成本与质量一起画曲线。选择更大的模型或更高并发时,写清楚增加的预算和获得的收益。
数据等待是瓶颈 GPU 利用率低不一定是模型慢,可能是读取、解码、分词、padding 或 CPU 到设备传输在等待。
把 step 拆成 load、collate、transfer、forward、backward 和 save。每次只优化占比最高的阶段。
编译的冷启动 编译可能在第一次请求或第一次 shape 上花费额外时间,动态 shape 还可能触发多次编译。
报告冷启动和热态两组数据,并限制编译缓存大小。生产探针应等待模型真正可服务,而不是进程刚启动。
量化的误差面 权重、激活、KV cache 的量化影响不同,误差常集中在长上下文、稀有语言、结构化输出或安全边界。
用关键切片和边界样例评估,不要只看总分。量化制品与原始权重分目录保存,随时可回退。
缓存键要包含版本 token、embedding 和完整响应的缓存需要分别考虑文本哈希、tokenizer、模型、提示、解码参数和工具版本。
缓存命中率下降时,先查键和失效原因。升级版本不能继续读取旧模型的输出,除非合同明确允许。
批处理有等待成本 动态 batching 把相近请求合并可以提高利用率,却会让先到的请求等待。最大批大小和最大等待窗口要通过压测确定。
按优先级和最大 token 设配额。高优先级与健康请求保持独立资源,避免被长请求拖垮。
显存泄漏要复现 循环里保存 GPU 张量、未关闭引用、日志携带完整输出都可能让显存逐步增长。
做固定长度的长时间 soak test,记录每 N 个请求的峰值。发现增长后用最小请求复现,再查引用生命周期。
编译与算子回退 不支持的算子、动态控制流或异常 shape 可能回退到 eager,导致单个切片突然变慢。
记录编译图命中和回退次数。质量或延迟异常时,先确认是否同一执行路径。
配置组合会互相作用 batch、序列长度、精度、checkpoint、并发和分片不是独立旋钮,改一项可能改变其他项的峰值。
用实验矩阵记录完整配置,而不是只写“开启优化”。选择少量高价值组合,避免无穷穷举。
能耗也是成本 GPU 功耗、空转时间、数据中心资源和冷却都与训练和服务规模有关。即使暂不计费,也应作为容量规划依据。
在相同质量门下比较单位 token 能耗或 GPU·h。报告节省来自算法、批处理还是硬件,方便迁移。
停止优化的条件 当 p95、成本或稳定性已满足合同后,继续优化可能增加复杂度而没有实质收益。
把“达到目标后冻结配置”写入发布流程。新的优化应另开实验,不在稳定版本上随意改开关。
性能回归门 每次代码、依赖、驱动或硬件更新都可能改变性能。固定基准可以快速发现回归。
设置相对基线的允许波动,并保留原始测量。超出阈值先暂停发布,不要用重新测量覆盖旧结果。
从局部到端到端 内核、tokenizer、队列和网络各自的改善,只有合在同一请求路径上才算用户收益。
最后用真实请求比例做一次端到端验证,报告质量、延迟、吞吐、错误和成本五项。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| 资源 | 显存、CPU、网络、功耗峰值 | 降低峰值或回退 |
| 速度 | 冷启动、热态、p95、tokens/s | 回到基线 |
| 质量 | 固定集、关键切片、红线 | 禁止发布 |
| 缓存 | 版本键、命中率、失效原因 | 清理并修键 |
| 成本 | GPU·h、每请求、重试 | 重新评估方案 |
任何“更快”的结论都要同时回答:快了多少、在哪种输入下、质量是否保持、成本是否下降、失败后能否回退。缺一项,就只是局部优化而不是工程收益。
显存预算表 先列参数、梯度、优化器、激活、临时张量和通信缓冲,按 dtype 与 shape 估算,再用 profiler 验证峰值。
预算表记录触发阶段;如果实际峰值高于估算,优先查临时张量和最长输入,而不是简单把 batch 减半。
有效 token 对语言模型,padding 不产生有效学习信号。记录有效 token、总 token 和 padding 比例,吞吐按有效 token 计算更诚实。
长度分桶和 token batch 的收益要在相同质量和全局 batch 下比较,避免只追求日志上的数字。
预热与稳态 编译、缓存、内存分配和连接建立都属于冷启动,稳态才代表重复请求的服务成本。
报告预热次数、冷启动时间、热态 p50/p95,并把就绪探针放在真正可用之后。
阶段占比 把一次 step 或请求拆为数据、传输、计算、通信、后处理和写日志,先找占比最高的阶段。
优化低占比阶段通常不会改变端到端时间;每次改动都保存前后阶段占比。
动态 shape 不同长度会导致 padding、编译图和 KV cache 大小变化。压测只用固定长度会掩盖真实尾延迟。
建立长度分桶和最长边界,记录每个桶的 p95、显存和错误率。
编译缓存 编译缓存的键应包含模型、输入 shape、dtype、驱动和编译选项,缓存目录要有容量上限。
命中率和编译失败原因进入监控;更新任一关键版本时创建新缓存命名空间。
量化校准 量化需要代表性校准数据,校准集的长度、语言和领域分布会影响误差。
校准集不应泄漏测试集;报告校准版本和关键切片的质量变化。
混合精度对照 FP16、BF16 和 FP32 的溢出与速度不同,硬件支持也不同。不能只在一张卡上得出普遍结论。
固定输入、驱动和 kernel,比较 NaN、p95、吞吐、峰值显存和质量。
缓存一致性 token 缓存、检索缓存和响应缓存有不同失效条件,单独设计键和保留期限。
模型或提示升级时,旧响应默认不可复用;必要时做迁移而不是静默混用。
批大小拐点 增大 batch 可能先提高吞吐,超过显存或内存带宽后反而下降,并拉长排队。
绘制 batch 与 tokens/s、p95、显存的曲线,选择拐点附近而不是最大值。
并发与队列 并发请求共享 KV cache、kernel 和网络;排队时间可能比计算时间更影响用户。
分别报告 queue_ms 和 compute_ms,设置最大队列和过载响应。
CPU 侧瓶颈 分词、JSON 编解码、压缩、日志和线程锁都可能让 GPU 空闲。
用采样 profiler 定位 CPU 热点,先减少重复转换,再考虑增加线程。
存储与 checkpoint 频繁保存会占用网络与 IO,保存太少又降低恢复点密度。
按训练预算和故障概率选择间隔,使用异步或原子写入并记录实际耗时。
网络通信 跨节点网络带宽和延迟决定 all-gather、reduce-scatter 和服务调用成本。
把通信时间与计算时间拆开,网络争用时记录节点和作业,避免误判为模型变慢。
能耗与利用率 GPU 利用率高不代表每个 token 都有价值;padding、重试和空转都会消耗资源。
计算单位 token 能耗和单位有效请求成本,优化目标应与质量门同时满足。
异常路径成本 超时、重试、回退和错误日志也消耗 GPU、网络和存储,不能只计算成功请求。
按成功、拒绝、重试和回滚分别计费,找出异常流量放大的原因。
性能回归 代码、依赖、驱动和硬件更新都可能改变 kernel 选择和内存行为。
在 CI 或发布前运行固定微基准与端到端基准,超出允许波动就暂停。
复杂度预算 编译、量化、缓存和自定义 kernel 会增加维护成本,收益不足时不值得保留。
为每项优化写拥有者、回退命令和重新评估日期,避免无人维护的开关长期存在。
| 测量项 | 对照要求 | 解释方式 |
|---|---|---|
| 基线 | 输入、环境、质量、测量窗口 | 固定并保存原始结果 |
| 变量 | 只改一个主要优化项 | 记录配置和回退 |
| 资源 | 峰值显存、CPU、网络、功耗 | 定位阶段占比 |
| 用户体验 | p50/p95、队列、错误、取消 | 按长度和并发切片 |
| 维护 | 复杂度、拥有者、有效期 | 定期复审或移除 |
当你说“量化后更快”时,能否补充冷启动、热态 p95、有效 tokens/s、关键切片质量、成本和回退方式?如果不能,结论还停留在局部实验。
案例:一次性能对照 固定模型、输入、硬件和质量门,先测基线,再只启用一种编译、量化或缓存。报告冷启动、热态 p95、有效 tokens/s、显存和质量。
峰值显存 最长输入、动态 batch、KV cache 和临时 workspace 可能一起触发峰值。压测要包含边界长度,记录触发阶段,而不是只记录平均占用。
阶段剖析 将 step 拆成读取、collate、传输、前向、反向、通信和保存。优化占比最高的阶段,改低占比项通常不会改变端到端时间。
有效吞吐 padding token 不产生同等计算价值,语言模型按有效 token 报告更诚实。长度分桶改变 batch 组织后,要重新检查全局 batch 和学习率。
编译缓存 首次编译时间与稳态速度分开;缓存键包含模型、shape、dtype、驱动和选项。版本升级后使用新命名空间,不复用未知旧图。
量化校准 校准集代表真实长度、语言和领域,不能偷看测试集。量化后对结构化输出、安全边界和长上下文做切片评估。
批量拐点 增加 batch 先提高吞吐,超过内存带宽或显存后会下降,还可能增加队列等待。绘制 batch—吞吐—p95 曲线,选择拐点而非最大值。
缓存一致性 token、检索和响应缓存的失效条件不同。键包含模型、tokenizer、提示、解码和工具版本,命中率与失效原因进入监控。
异常成本 超时、重试、回滚和日志写入也消耗资源。按成功、拒绝、重试和降级分组计算单位成本,避免只统计成功请求。
性能回归 代码、依赖、驱动和硬件都可能改变 kernel。发布前运行固定微基准和端到端基准,超出波动范围先暂停,而不是覆盖基线。
维护复杂度 自定义 kernel、编译选项和缓存会增加维护面。为每项优化写拥有者、回退命令和复审日期,收益不足时及时移除。
显存账本示例 一个训练 step 同时持有参数、梯度、优化器状态和激活;混合精度只改变其中一部分的字节数。把每项的 shape、dtype 和生命周期列出来,才能知道应降低序列长度、启用激活检查点,还是采用参数切分。
吞吐的真实分母 如果 batch 中一半是 padding,samples/s 看起来很高,真正产生学习信号的 tokens/s 却很低。报告有效 token、padding 比和每个 optimizer step 的总 token,比较才不会被长度分布误导。
Profiler 的阅读 先看时间线里 GPU 是否空闲,再看 CPU、数据加载、通信和 kernel。找到占比最高的阶段后只改一项,保存前后截图或统计;没有时间线的优化往往只是感觉。
编译实验 编译模式可能在首次 shape 上花费秒到分钟,动态输入还会产生多个图。把冷启动、图命中、回退次数和稳态 p95 分开记录,生产探针必须等待预热完成。
量化实验 权重量化减少存储,激活或 KV cache 量化还会改变运行时误差。用代表性校准集、最长输入和结构化输出做切片,出现退化时保留未量化权重作为回退。
缓存一致性 缓存键若遗漏 tokenizer、模型、提示或工具版本,升级后会返回旧结果,问题难以追踪。键、命中率、失效原因和保留期限都写入监控,并在版本变化时主动失效。
并发拐点 并发增加到某个点后,KV cache 和队列等待会让 p95 急升。压测多种长度和取消比例,选择质量稳定且尾延迟可控的点,而不是追求最大 QPS。
优化停止 当质量红线、p95、成本和稳定性都满足合同,继续叠加编译、量化或自定义 kernel 可能只增加维护面。给每项优化写拥有者和复审日期,收益不足就回退。
端到端成本 完成同一质量门时,比较整个训练或请求的总耗时、GPU 小时、存储、重试和人工评审。局部 kernel 加速若被数据等待和回滚成本抵消,就不算真实收益。
基线留存 每次编译、量化、缓存或驱动更新都保留原始基线和版本摘要。优化失败时可以直接回到已知可用的执行路径,避免现场重新寻找默认配置。
边界输入 最长长度、空输入、异常字符和高并发共同决定峰值。优化报告要单列这些样例,而不是只展示平均长度下的漂亮曲线。
维护责任 给每项性能开关指定拥有者、回退命令和复审日期。没有人维护的自定义 kernel 或缓存最终会成为发布风险,而不是免费加速。
把优化结论写成可复验的句子。例如“在同一硬件、输入分布和质量门下,编译版本将热态 p95 从 420 ms 降到 350 ms,峰值显存增加 8%,冷启动增加 12 秒,关键切片指标变化在允许范围内,并且可用一个开关回到基线”。这种句子同时包含条件、收益、代价和回退,比“性能提升”更适合交付。
把 checkpoint 变成可调用、可监控的服务
14.6 · 推理服务与部署
部署不是把模型文件复制到服务器,而是把权重、分词器、配置、API、队列、健康检查、权限和回滚组合成稳定服务。本节从服务契约开始,逐步讲清启动、批处理、流式输出、容量和安全。
14.6.1 模型包与 API:把权重变成稳定契约
可部署模型不是一个单独的权重文件,而是一组彼此匹配的制品:权重、配置、tokenizer、预处理、后处理、依赖、模型卡、版本和校验值。打包时要明确加载入口,并在启动阶段检查文件存在、维度一致、dtype 支持和 schema 版本。启动成功只代表能加载,不代表请求契约正确,因此还要运行固定冒烟样例。
API 契约要定义请求字段、默认值、最大长度、超时、错误码和响应格式。生成服务还要说明 temperature、top_p、max_tokens、stop 等参数的范围,以及是否支持流式输出。错误应分为客户端可修复、服务暂时不可用和模型拒答,客户端才能选择重试、提示用户或转人工。
版本管理要同时覆盖接口和模型。模型升级但 API 不变时,可以保持兼容;字段删除或语义改变时,必须升级 schema 版本并提供迁移窗口。日志里记录模型版本和请求 schema,遇到质量变化才能按版本切片,而不是把所有流量混在一起。
| 层次 | 包含内容 | 发布前检查 |
|---|---|---|
| 权重 | 参数文件、dtype、校验值 | 可加载、维度匹配 |
| 预处理 | tokenizer、规范化、截断 | 边界输入与版本 |
| 推理 | 模型类、生成/分类逻辑 | 固定样例输出 |
| 接口 | schema、错误码、超时 | 契约测试 |
| 运行 | 依赖、镜像、设备要求 | 冷启动与健康检查 |
| 治理 | 模型卡、限制、联系方式 | 责任和回滚记录 |
from pathlib import Path
import hashlib
import json
def sha256(path):
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def validate_package(root):
root = Path(root)
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
required = ["weights.safetensors", "tokenizer.json", "config.json"]
missing = [name for name in required if not (root / name).exists()]
if missing:
raise FileNotFoundError(missing)
actual = sha256(root / "weights.safetensors")
if actual != manifest["weights_sha256"]:
raise ValueError("权重校验值不匹配")
return manifest
print(validate_package("model_package"))权重、tokenizer 和配置应使用不可变版本目录或制品摘要。覆盖同名文件会让旧日志无法对应真实内容,也会让回滚变成猜测。部署前把摘要写入 manifest 和启动日志。
为一个小模型建立目录结构,列出必须文件,计算权重校验值,并让缺文件、错摘要和版本不匹配分别产生清晰错误。
参考结论:把契约、指标、资源和回退证据写清楚,并用一次可运行的小实验验证。
完整实现路径:服务包包含权重哈希、配置、预处理和 schema;startup、ready、live 分工,异步请求具备超时取消。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(); READY = False
class Request(BaseModel):
text: str = Field(min_length=1, max_length=2000)
class Response(BaseModel):
label: str; score: float; model_version: str
@app.get("/ready")
def ready():
if not READY: raise HTTPException(503, "model not ready")
return {"status": "ready"}
@app.post("/predict", response_model=Response)
def predict(req: Request):
if not READY: raise HTTPException(503, "model not ready")
return Response(label="ok", score=.91, model_version="demo-v1")验收标准:gold request 通过,缺文件/坏 schema/未就绪分别返回明确错误。
若结果不对:先看 HTTP 状态和 request_id,再查队列、模型 ready 状态与资源。
14.6.2 队列与流式:在高并发下保持可控
服务收到请求后,不一定能立即把任务交给 GPU。队列负责吸收短暂峰值、合并相似请求并限制并发;背压负责在容量不足时尽早拒绝或降级,而不是无限排队让所有请求超时。设计队列时要区分等待时间和计算时间,并记录队列深度、排队 p95、超时数量和取消数量。
流式输出改善用户感知,但不等于总耗时一定更短。服务要定义首 token 超时、分片格式、连接断开后的清理和最大生成长度。客户端取消时要释放生成任务和 KV cache,避免“用户已经离开,GPU 仍在生成”。流式协议升级时,也要保持非流式客户端的兼容路径。
批处理和动态 batching 能提高 GPU 利用率,却会增加等待。可以设置最大批大小、最大等待窗口和长度分桶,并用压测找到延迟与吞吐的平衡。高优先级请求、健康检查和管理接口要有独立配额,避免普通长请求耗尽全部容量。
| 参数 | 控制什么 | 异常处理 |
|---|---|---|
| max_queue | 最多等待多少请求 | 超过则快速拒绝或降级 |
| max_wait_ms | 动态批等待上限 | 到时立即发车 |
| max_tokens | 单请求计算上限 | 截断或要求分页 |
| concurrency | 同时执行数 | 根据显存和 p95 调整 |
| cancel_timeout | 取消后的清理时间 | 强制释放资源并报警 |
只有幂等、明确属于暂时性错误的请求才适合自动重试,并且要有指数退避、最大次数和总超时。模型拒答、参数错误和超限不应重试;重试次数、来源和最终结果必须记录,方便区分真实流量与客户端放大流量。
从请求进入到响应完成,标出鉴权、排队、批处理、推理、流式发送、取消和超时位置,并为每个位置写一个指标。
参考结论:把契约、指标、资源和回退证据写清楚,并用一次可运行的小实验验证。
# 对应本节练习 2:先复制运行,再替换为自己的数据
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(); READY = False
class Request(BaseModel):
text: str = Field(min_length=1, max_length=2000)
class Response(BaseModel):
label: str; score: float; model_version: str
@app.get("/ready")
def ready():
if not READY: raise HTTPException(503, "model not ready")
return {"status": "ready"}
@app.post("/predict", response_model=Response)
def predict(req: Request):
if not READY: raise HTTPException(503, "model not ready")
return Response(label="ok", score=.91, model_version="demo-v1")验收标准:gold request 通过,缺文件/坏 schema/未就绪分别返回明确错误。
若结果不对:先看 HTTP 状态和 request_id,再查队列、模型 ready 状态与资源。
14.6.3 健康检查、容量与安全
服务健康检查至少区分启动、存活和就绪。启动检查回答模型是否完成加载,存活检查回答进程是否还能工作,就绪检查回答当前是否可以接收流量。三者混用会导致模型加载慢时被误重启,或依赖故障时继续接收请求。
Kubernetes 的 startup、liveness 和 readiness probes 就是这种分工的工程化体现:startup 通过后才开始其他检查,readiness 失败可以停止分发流量,liveness 失败才考虑重启。探针本身要轻量,不能每次调用大模型或昂贵数据库。
容量规划用压测而非猜测。输入长度、输出长度、并发、批处理窗口、模型精度和工具比例都影响结果。逐步提高负载,记录 p50/p95、错误、显存、队列和恢复;找到服务开始退化的拐点并留安全余量。
自动扩缩容需要稳定信号。CPU 可能不代表 GPU 队列,GPU 利用率可能在长尾期间很高但无法说明等待。结合队列 token、请求等待、并发和错误率设置扩缩容,冷启动时间也要纳入容量。
安全边界包括认证、授权、速率、输入限制、输出过滤、密钥和网络。模型服务不应默认能访问所有数据或工具;每个请求携带角色和 scope,网关与工具端双重校验。
日志和指标要脱敏。记录请求 ID、版本、长度、状态、延迟和错误类型,必要时保存受控的输入摘要和证据 ID,不把完整患者或商业秘密写入普通日志。
服务发布要有灰度和回滚。先让影子流量或少量低风险用户使用,比较质量、成本和长尾;发现异常时可以切回旧包、旧索引或人工模式。健康检查通过不等于业务质量通过。
本小节完成后,应能写出启动/存活/就绪探针和一份安全容量计划。
| 探针 | 回答 | 失败动作 | 不要做 |
|---|---|---|---|
| startup | 加载完成了吗? | 等待或重启 | 把慢加载当死锁 |
| liveness | 进程还能进展吗? | 重启实例 | 高负载就重启 |
| readiness | 现在能接流量吗? | 移出服务端点 | 继续接新请求 |
| 业务质量 | 输出是否合格? | 降级/回滚 | 用 200 状态掩盖 |
基础健康与业务质量是两条不同的线,必须同时通过。
from dataclasses import dataclass
from typing import Literal
State = Literal["starting", "ready", "draining", "failed"]
@dataclass
class Health:
model_loaded: bool = False
accepting: bool = False
draining: bool = False
def state(self) -> State:
if self.draining:
return "draining"
if not self.model_loaded:
return "starting"
if self.accepting:
return "ready"
return "failed"
health = Health(model_loaded=True, accepting=True)
print(health.state())本节完成验收
完成本页后,应能为推理包设计版本化 API,解释动态批处理和流式状态,区分 startup/liveness/readiness,并做容量、安全、灰度和回滚计划。验收时让服务经历模型加载慢、队列超限、客户端断连和权限不足四种情况。
设计一个结构化推理响应。
参考结论:状态、版本、证据、延迟、trace_id 和错误码完整。
# 对应本节练习 3:先复制运行,再替换为自己的数据
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(); READY = False
class Request(BaseModel):
text: str = Field(min_length=1, max_length=2000)
class Response(BaseModel):
label: str; score: float; model_version: str
@app.get("/ready")
def ready():
if not READY: raise HTTPException(503, "model not ready")
return {"status": "ready"}
@app.post("/predict", response_model=Response)
def predict(req: Request):
if not READY: raise HTTPException(503, "model not ready")
return Response(label="ok", score=.91, model_version="demo-v1")验收标准:gold request 通过,缺文件/坏 schema/未就绪分别返回明确错误。
若结果不对:先看 HTTP 状态和 request_id,再查队列、模型 ready 状态与资源。
计算一组请求的队列等待与 p95。
参考结论:长请求和取消请求不被平均值隐藏。
# 对应本节练习 4:先复制运行,再替换为自己的数据
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(); READY = False
class Request(BaseModel):
text: str = Field(min_length=1, max_length=2000)
class Response(BaseModel):
label: str; score: float; model_version: str
@app.get("/ready")
def ready():
if not READY: raise HTTPException(503, "model not ready")
return {"status": "ready"}
@app.post("/predict", response_model=Response)
def predict(req: Request):
if not READY: raise HTTPException(503, "model not ready")
return Response(label="ok", score=.91, model_version="demo-v1")验收标准:gold request 通过,缺文件/坏 schema/未就绪分别返回明确错误。
若结果不对:先看 HTTP 状态和 request_id,再查队列、模型 ready 状态与资源。
写出三种健康探针的条件。
参考结论:探针轻量;就绪失败会摘流量,存活失败才重启。
# 对应本节练习 5:先复制运行,再替换为自己的数据
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(); READY = False
class Request(BaseModel):
text: str = Field(min_length=1, max_length=2000)
class Response(BaseModel):
label: str; score: float; model_version: str
@app.get("/ready")
def ready():
if not READY: raise HTTPException(503, "model not ready")
return {"status": "ready"}
@app.post("/predict", response_model=Response)
def predict(req: Request):
if not READY: raise HTTPException(503, "model not ready")
return Response(label="ok", score=.91, model_version="demo-v1")验收标准:gold request 通过,缺文件/坏 schema/未就绪分别返回明确错误。
若结果不对:先看 HTTP 状态和 request_id,再查队列、模型 ready 状态与资源。
模型包是组合制品 权重、配置、tokenizer、预处理、后处理、依赖和模型卡必须相互匹配。单独复制权重不能保证可运行。
制品目录使用不可变版本和摘要,启动时逐项校验。缺文件、维度不符或摘要错误要阻断服务。
API 先写 schema 请求字段、默认值、最大长度、超时、错误码、响应格式和流式协议都要明确。生成参数也应设范围。
把 schema 测试放进 CI 和启动冒烟。下游不应依赖模型输出中的偶然空格或说明文字。
模型版本与接口版本 模型升级可以保持旧接口兼容;字段删除、语义改变或错误码改变应升级 schema。
日志同时记录 model_version 和 schema_version,质量变化才能按版本切片。
加载方式要稳定 明确 CPU/GPU、dtype、设备映射和 tokenizer 初始化顺序。加载阶段不应读取用户请求或隐式下载未知文件。
将依赖和权重来源锁定。网络不可用时,启动应给出清晰错误,而不是半初始化地接收流量。
健康与就绪分开 存活探针只判断进程是否还在;就绪探针还要确认模型已加载、队列可用、依赖连接正常。
未就绪的实例不接流量。重启循环时保留启动失败原因并限制重试。
队列吸收峰值 队列可以缓冲短峰,但长度和等待时间必须有上限。无限排队只会把错误从 503 变成超时。
超过上限时快速拒绝、降级或转人工,并返回可行动的错误码。
动态 batching 要有窗口 按长度或 token 合并请求可提高利用率,最大等待窗口决定用户感知的延迟。
压测多种流量和长度分布,分别报告排队与计算时间。
流式取消要清理 客户端断开后,生成任务、连接和 KV cache 都要释放。否则用户看不到结果,GPU 仍被占用。
为取消写专门的测试,观察资源是否在短时间内回收。
重试只用于暂时错误 超时、连接重置和临时过载可能重试;参数错误、拒答和权限错误不应重试。
指数退避、最大次数和总预算一起设置,记录 retry_count 防止重试风暴。
并发要受显存约束 并发提高不一定提高吞吐,长上下文和 KV cache 可能迅速耗尽显存。
根据峰值显存和 p95 设配额,超出后排队或降级,而不是继续接收。
鉴权与限流 区分用户、服务和管理接口的权限,按租户设置速率和 token 配额。
密钥不写日志;拒绝原因要可区分,便于客户端修复。
观测字段贯穿请求 request_id、trace_id、model_version、schema_version、queue_ms 和 latency_ms 让一条请求可追踪。
输入内容默认脱敏,只在经过授权的调试路径保留短期样本。
蓝绿与灰度 蓝绿适合快速切换,灰度适合小比例观察新版本。两者都需要稳定版、候选版和路由规则。
灰度前设观测窗口与红线,触发时自动摘流并恢复稳定版。
容量计划来自真实流量 根据峰值 QPS、长度分布、并发、GPU 利用率和扩容时间估算容量。平均流量会掩盖突发。
定期压测和容量演练,验证扩容、降级和回滚是否在预算内完成。
数据与权重安全 模型包、日志、缓存和临时文件都有泄露面。最小权限、加密和访问审计应覆盖整个生命周期。
发现疑似泄露先隔离、撤销凭据并保留证据,不要直接删除现场。
退役要有迁移窗口 关闭旧模型前,确认调用方已迁移、历史结果可读取、回滚窗口已过。
归档 manifest、镜像摘要、指标与事件记录,保留可审计索引。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| 制品 | 权重、配置、tokenizer、摘要 | 阻断启动 |
| 接口 | schema、错误码、超时、流式 | 契约测试失败即回退 |
| 容量 | 并发、队列、显存、p95 | 限流或扩容 |
| 健康 | 存活、就绪、依赖、启动 | 摘流并调查 |
| 治理 | 鉴权、日志、灰度、退役 | 暂停发布 |
一个服务只有在模型包、接口、容量、健康和回滚都能被脚本验证时才算可部署。手工在服务器上改一行配置得到的“能跑”,不属于可重复交付。
输入校验 服务入口先检查 content-type、必填字段、长度、编码和参数范围,拒绝错误请求而不是把异常交给模型。
校验错误返回稳定的 4xx 与字段提示;日志只记录字段名、长度和 request_id,避免把敏感原文写入错误日志。
超时层级 网关、队列、检索、模型生成和下游工具应有独立超时,总超时小于各层简单相加的预算。
每次超时标出发生层级和已消耗时间。客户端重试只针对明确的暂时性错误,避免所有层同时重试。
并发预算 模型显存、KV cache、队列和 CPU 线程共同决定可承受并发,单看 QPS 会忽略长输入的峰值。
用长度分布和目标峰值压测,给每个租户或优先级设置 token 配额,并在超限前返回降级提示。
流式协议 流式响应需要定义事件类型、序号、结束标记、错误格式和断开后的资源清理。
同时保留非流式兼容接口,用相同的核心推理函数生成结果,避免两条路径的质量口径不同。
取消传播 用户取消、网关超时或下游断开时,取消信号要传到队列、批处理和模型生成,不能只关闭 HTTP 连接。
记录取消原因和资源回收时长;如果无法立即取消,限制后台任务数量并报警。
模型预热 首个请求常包含权重加载、编译、缓存和分配开销,不能用它代表稳定态延迟。
就绪探针在预热请求和依赖检查通过后才放行;把冷启动时间单独记录用于扩容预算。
滚动升级 新实例逐步加入,旧实例继续服务,路由器根据就绪状态分配流量。
升级期间同时观察版本比例、错误率和 p95;任一异常可以摘除候选而不影响稳定实例。
灰度键 灰度可以按租户、地区、请求标签或随机比例分流,键的选择会影响样本代表性。
固定同一用户的路由,避免一次会话在不同版本间跳转;报告灰度样本的切片分布。
回滚缓存 候选版本的缓存、tokenizer 和响应格式可能与稳定版不兼容,切回时不能直接复用所有缓存。
缓存键包含模型和 schema 版本;回滚后清理不兼容项,保留可审计的命中与失效统计。
依赖降级 检索、工具、鉴权或日志系统不可用时,模型服务应有明确的只读、无工具或转人工模式。
降级不应默默改变承诺;响应中标记能力受限,监控记录降级比例和持续时间。
权限边界 推理、读取模型包、查看原文日志、触发回滚和修改路由应分属不同角色。
采用最小权限和临时授权,关键动作需要审批并记录操作者、时间与原因。
错误响应 错误码要让客户端区分参数错误、限流、暂时不可用、拒答和内部失败。
为每个错误码写处理建议,避免客户端对所有错误都重试或把拒答当成服务故障。
可观测关联 request_id、trace_id、model_version、schema_version、queue_ms 与 generation_ms 组成一次请求的最小关联集。
在网关、队列、模型和工具日志中保持字段名一致,排查时从告警跳到单次请求。
数据保留 输入、输出、评分和工具参数的保留期限应按用途和风险区分,调试样本不能无限期保存。
默认脱敏和短期保留,延长保存需有审批;删除后仍可保留不可逆统计用于容量分析。
成本隔离 不同租户、模型和路由的 token、GPU 时间、缓存命中和重试应能单独计量。
给高成本功能设配额和预算告警,避免一个租户的长输出掩盖整体成本异常。
压测模型 压测流量要包含真实长度、并发、取消、错误和峰值,而不是只发送固定短句。
保存压测脚本、输入分布、硬件和版本;每次优化复用同一脚本以支持公平比较。
上线回退 发布前写清楚路由开关、稳定镜像、数据迁移状态和验证命令,回滚不依赖临时手工改配置。
演练从候选到稳定的完整路径,测量摘流、启动、健康检查和质量确认的总时间。
退役迁移 旧模型下线前确认调用方已迁移、历史结果可读取、指标面板仍可查询。
归档模型包、manifest、评估和事件记录,保留规定期限的兼容端点或明确的迁移错误。
| 决策 | 观察证据 | 建议动作 |
|---|---|---|
| 接口 | schema、错误码、超时 | 契约测试与兼容窗口 |
| 容量 | 长度、并发、队列、显存 | 压测后设配额 |
| 运行 | 预热、滚动、灰度、回滚 | 演练并记录耗时 |
| 治理 | 权限、脱敏、保留、成本 | 审计与预算告警 |
| 迁移 | 调用方、历史结果、归档 | 延长兼容或下线 |
如果服务只在作者电脑上启动、只能处理一个固定样例、遇到超时只能重试,那么它还不是可运营的部署。请把每个异常写成客户端可理解的响应和运维可执行的动作。
案例:分类 API 请求进入后先做 schema、长度、鉴权和配额检查,模型只接收合法结构。响应统一包含 label、score、model_version 和 request_id。
错误分层 参数错误、限流、暂时不可用、拒答和内部失败使用不同错误码。客户端据此修复、退避、提示或转人工,不能对所有错误重试。
预热与就绪 权重加载、编译、tokenizer、依赖连接和固定冒烟全部完成后才进入 ready。存活探针只判断进程,不应放行未就绪实例。
队列上限 队列吸收短峰但必须有最大长度和等待时间。超过上限快速拒绝或降级,比无限排队再超时更可控。
动态批处理 按 token 或长度合并请求时,记录等待与计算时间。固定同一用户的路由,避免一次会话在两个版本之间跳转。
流式取消 客户端断开要释放生成任务和 KV cache。测试取消、网关超时和下游断连,确认资源在可接受窗口回收。
容量曲线 用真实长度和目标并发压测,记录显存、p95、吞吐和错误。平均 QPS 无法代表长输入峰值,容量要按最坏切片估算。
灰度路由 影子流量先观察输出,不影响用户;小比例灰度再按租户或随机比例放大。每一阶段预先写红线和观察窗口。
安全与权限 读取模型包、看原文日志、发布、回滚和改路由需要不同角色。密钥不写日志,所有关键动作记录操作者和原因。
回滚验证 切回稳定版后检查健康、错误率、p95、关键样例、数据写入和缓存兼容。回滚成功不等于根因已修复,候选证据继续保留。
退役迁移 统计旧接口调用量、历史结果可读性和缓存兼容,确认调用方迁移后再释放旧资源。归档 manifest、镜像和事件记录。
启动顺序 先读取不可变 manifest,再校验权重、配置、tokenizer 和依赖,最后预热固定请求。任何一步失败都不应把实例标为 ready,启动日志要给出可行动的原因。
请求契约 请求 schema、错误码、超时、最大 token、停止条件和流式事件格式统一版本。模型输出先经校验与后处理,再交给业务,避免每个客户端自行猜格式。
队列背压 队列是有限资源。达到长度或等待上限时快速返回限流/降级,让客户端采取退避、转人工或减少输出,而不是把所有请求拖到超时。
流式清理 连接断开、用户取消和网关超时都要传播到生成任务,释放 KV cache、队列槽位和临时文件。为取消写测试,观察资源是否在预算内回收。
灰度路由 按租户或稳定哈希保持会话粘性,候选与稳定版路由比例可审计。影子流量先评估不影响用户,灰度阶段才允许真实结果。
容量预算 结合输入长度、峰值并发、显存和扩容时间估算容量;平均 QPS 不足以保护最长输入。压测结果写入发布合同,超出上限自动降级。
安全边界 推理权限、日志访问、模型包读取和回滚权限分离,密钥不写日志。安全事件先隔离并撤销凭据,再分析,不直接清除现场。
退役迁移 旧接口保持兼容窗口,统计调用量、历史结果读取和缓存命中,确认调用方已迁移后再释放旧实例。归档 manifest、镜像摘要和事件记录。
契约先于路由 模型服务先固定请求与响应 schema,再决定 HTTP、队列或流式实现。请求中的版本、租户、超时和最大 token 需要明确默认值。
这样客户端可以在模型替换时保持兼容,错误也能被分类处理,而不是把所有失败都归为 500。
加载与就绪 实例启动时依次校验 manifest、权重摘要、tokenizer、配置和依赖,并运行固定冒烟。存活探针与就绪探针不能混用。
未完成预热的实例不接流量;如果加载失败,日志应给出缺失文件或不匹配字段,不让它在半初始化状态服务。
队列的上限 排队可以吸收短峰,但必须有最大长度和等待窗口。超过上限时快速拒绝、降级或转人工,比无限排队更诚实。
把 queue_ms 与 compute_ms 分开记录,客户端据此决定退避或减少输出;重试总预算不能超过请求总超时。
动态 batching 按 token 或长度合并请求可以提高 GPU 利用率,但会让先到请求等待。批大小、等待窗口和最长长度要通过真实流量压测确定。
固定同一用户的路由键,避免会话在版本间跳动;灰度报告应显示候选和稳定版的长度、租户分布。
流式与取消 流式协议定义事件序号、结束标记和错误格式,客户端断开时取消信号要传播到生成任务。
测试取消后 KV cache、队列槽位和临时文件是否回收;不能只关闭连接却让 GPU 在后台继续生成。
容量边界 并发、显存、KV cache、CPU 线程和下游工具共同决定容量,平均 QPS 不能代表最长输入的峰值。
用边界长度和目标并发测 p95、错误率和峰值显存,达到上限前就触发限流或降级。
错误码行为 参数错误、权限、限流、暂时不可用、拒答和内部失败应有不同错误码和客户端建议。
客户端只对幂等的暂时性错误退避重试;拒答和参数错误若重复提交,只会扩大负载。
灰度与回滚 候选先走影子或小比例流量,观察窗口覆盖高峰和低峰。稳定版始终保留可用路由。
回滚后检查健康、p95、关键样例、缓存兼容和数据写入;候选日志保留,便于修复后重新评估。
安全与成本 模型包、日志、缓存和管理接口都需要最小权限、脱敏和审计;长输出、重试和高并发也会放大成本。
按模型、租户、路由和错误类型计量 token 与 GPU 时间,预算告警应能触发限额或审批。
退役与迁移 旧接口、历史结果和缓存要有兼容窗口,统计调用方迁移进度后再释放旧资源。
归档 manifest、镜像摘要、评估、事件和审批,不直接删除现场;退役本身也应有回滚期限。
用证据判断版本,而不是用感觉选模型
14.7 · 评估与实验管理
训练结束不代表模型完成。评估要连接任务合同、冻结基准、切片、专家判断、成本和线上反馈。本节建立可比较的实验管理方法,让每一次版本选择都有样本、有指标、有风险和有回归。
14.7.1 基准与专家:把“好不好”拆成可重复判断
评估基准不是一张随时修改的题单,而是一份被冻结、可追溯、能代表目标用户的样本集合。建立基准时要标出来源、时间、语言、长度、风险等级和任务切片,给每条样本一个稳定 ID。测试答案、评分脚本和版本都应受权限控制,避免模型开发过程反复看到测试内容后产生过拟合。
自动指标适合做快速回归,例如准确率、macro-F1、召回率、格式通过率、引用命中率、校准误差和拒答率。但自动指标只能观察它被定义的部分,生成任务还需要人工评审事实性、相关性、完整性、风格和安全。人工评审要提供 rubric、示例和冲突仲裁规则,并报告评审者之间的一致性。
评估报告不要只给一个总分。总分可能掩盖小语种、长上下文、少数标签或高风险样本的退化。按切片呈现样本数、指标、置信区间和失败示例,才能决定是接受整体提升,还是因为某个红线切片而拒绝发布。报告还要和成本、延迟一起读,便宜但慢的模型未必适合线上。
| 部分 | 内容 | 作用 |
|---|---|---|
| 基准 | 版本、来源、样本数、切片 | 知道测了什么 |
| 自动指标 | 定义、分母、置信区间 | 快速比较 |
| 人工评审 | rubric、盲评、仲裁 | 观察难以自动化的质量 |
| 错误分析 | 代表性失败与严重度 | 指导下一轮数据或模型 |
| 资源 | 延迟、吞吐、成本 | 判断能否部署 |
| 结论 | 通过、限制、回退条件 | 形成发布决策 |
from collections import defaultdict
def group_by(rows, key):
groups = defaultdict(list)
for row in rows:
groups[row.get(key, "unknown")].append(row)
return groups
def report(predictions):
result = {}
for slice_name, rows in group_by(predictions, "slice").items():
correct = sum(row["prediction"] == row["label"] for row in rows)
result[slice_name] = {
"n": len(rows),
"accuracy": correct / max(1, len(rows)),
"errors": [row["id"] for row in rows
if row["prediction"] != row["label"]][:20],
}
return result
predictions = [
{"id": "s-1", "slice": "standard", "prediction": 1, "label": 1},
{"id": "s-2", "slice": "standard", "prediction": 0, "label": 1},
{"id": "l-1", "slice": "long", "prediction": 0, "label": 0},
{"id": "u-1", "prediction": 1, "label": 0},
]
print(report(predictions))专家评审有主观性和成本,应该用来回答自动指标无法回答的问题。先写 rubric 和边界样例,再抽样;记录评审者、版本和分歧,不要把一个人的直觉直接当作真值。
为一个问答模型分别设计自动指标、人工 rubric 和红线样本,说明每层发现什么问题,以及三层结论冲突时如何决策。
参考结论:冻结输入和评分规则,分离质量、成本、延迟和安全证据,再决定是否发布。
完整实现路径:评估拆为自动指标、人工 rubric、风险切片和回归差分,版本表保存完整证据。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
def evaluate(rows, rubric_version):
required = {"id", "expected", "prediction", "risk", "human_score"}
for row in rows:
missing = required - row.keys()
if missing: raise ValueError(f"{row.get('id')}: {sorted(missing)}")
exact = sum(r["expected"] == r["prediction"] for r in rows) / len(rows)
high_risk = [r for r in rows if r["risk"] == "high"]
high_pass = sum(r["human_score"] >= 4 for r in high_risk) / max(1, len(high_risk))
return {"rubric_version": rubric_version, "exact": exact,
"high_risk_pass": high_pass, "n": len(rows)}验收标准:总体与红线切片同时过门,评审冲突有裁决规则。
若结果不对:先确认评估集和评分器版本,再查模型,避免把评估漂移误判为模型退化。
14.7.2 回归与消融:知道是哪项改动带来结果
版本比较必须让改动可归因。固定基准、推理参数、硬件和测量脚本,只改变一个主要因素,并在 manifest 中记录父版本。若同时更换数据、提示词、模型和解码策略,即使分数提高,也无法知道收益来自哪里,下一次迁移更难复用。
回归测试关注已知能力不能倒退。为每个关键能力建立少量高价值样本和阈值,例如结构化输出、长文本引用、拒答、高风险分类和多语言。每次提交或模型升级运行快速回归;完整基准可按发布节奏运行,但不能只在最后一次才发现核心能力坏掉。
消融实验有助于验证假设。可以分别移除数据过滤、检索、工具调用、某类增广、某个适配器或某项量化,比较质量与成本变化。消融不一定要穷举,先针对合同里最重要的假设设计对照,并报告样本量和不确定性,避免把偶然波动解释成机制。
结果差异要写成错误地图。把失败按数据问题、提示/接口问题、模型能力、工具依赖、资源限制和评估偏差分类,给每类指定下一步动作。错误地图比“模型还需优化”更有用,因为它把下一轮工作变成可以排队的任务。
| 比较 | 保持不变 | 回答的问题 |
|---|---|---|
| 基线 vs 候选 | 数据、评估、环境 | 候选是否整体提升 |
| 去掉检索 | 模型、提示、基准 | 收益来自知识还是模型 |
| 去掉过滤 | 模型、训练步数 | 数据质量门是否必要 |
| 不同精度 | 权重、输入、服务配置 | 速度换来多少质量 |
| 关键切片 | 同一 slice 与阈值 | 是否触碰发布红线 |
随机种子、抽样和服务抖动都可能造成差异。重要结论至少做重复运行或报告置信区间;如果资源有限,优先对关键切片重复,而不是只重复总分。保留失败的实验同样重要,它们能说明某个假设不成立。
选择数据过滤、检索和量化中的三个因素,设计至少四个对照版本,列出质量、延迟和成本指标,并说明你会怎样从结果中选择下一步。
参考结论:冻结输入和评分规则,分离质量、成本、延迟和安全证据,再决定是否发布。
完整实现路径:评估拆为自动指标、人工 rubric、风险切片和回归差分,版本表保存完整证据。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
def evaluate(rows, rubric_version):
required = {"id", "expected", "prediction", "risk", "human_score"}
for row in rows:
missing = required - row.keys()
if missing: raise ValueError(f"{row.get('id')}: {sorted(missing)}")
exact = sum(r["expected"] == r["prediction"] for r in rows) / len(rows)
high_risk = [r for r in rows if r["risk"] == "high"]
high_pass = sum(r["human_score"] >= 4 for r in high_risk) / max(1, len(high_risk))
return {"rubric_version": rubric_version, "exact": exact,
"high_risk_pass": high_pass, "n": len(rows)}验收标准:总体与红线切片同时过门,评审冲突有裁决规则。
若结果不对:先确认评估集和评分器版本,再查模型,避免把评估漂移误判为模型退化。
14.7.3 人机评估、成本与真实任务
真实任务评估要观察用户如何使用输出,而不是只看模型答题。记录复制、修改、退回、追问、升级和完成时间;同时保留样本和权限,避免把行为数据直接解释成满意度。
专家评审的价值在于发现危害和语境问题。一个答案可能字面正确,却遗漏限制或让人误解;一个答案可能与参考文本不同,却更适合用户。评审量表要允许说明理由,并把争议案例加入规范。
人工修改率是有用但不充分的指标。用户可能懒得修改错误,也可能把正确答案改成个人风格。结合抽样复核、任务成功和严重事件,才能解释修改行为。
成本要按成功任务计算。一次请求的模型 token、检索、工具、人工审核和失败重试都纳入;如果输出被用户退回并重新请求,前一次成本仍然存在。这样才能比较自动化真正节省了什么。
在线实验要保护用户。高风险场景不宜随机给用户未经验证的模型;可以先使用影子流量或低风险任务。A/B 的分桶、时间和用户角色要记录,避免组间分布差异。
反馈入口要具体。让用户标记错误字段、证据、延迟或不适用,而不是只提供一个好/坏按钮。反馈经过人工审阅后才能进入训练或回归集。
评估结果写出适用范围和下一步。没有达到门槛时,可以缩小用户、语言、时间或任务范围,而不是悄悄改指标。诚实限制是工程交付的一部分。
本小节把离线分数连接到真实工作:模型是否减少了成本,是否保留了人的判断,是否降低而非隐藏风险。
| 维度 | 指标 | 采集方式 | 注意 |
|---|---|---|---|
| 质量 | 完成率、修改率、严重错误 | 任务日志/抽样 | 沉默不等于满意 |
| 效率 | 完成时间、追问次数 | 端到端计时 | 含人工与工具 |
| 成本 | token、GPU、审核 | 账单/日志 | 按成功任务 |
| 安全 | 越权、敏感、升级 | 事件系统 | 红线优先 |
| 公平 | 分群差异、覆盖 | 切片评估 | 报告样本量 |
线上证据不是替代离线,而是把离线没有覆盖的真实行为带回来。
from dataclasses import dataclass
@dataclass(frozen=True)
class Task:
model_tokens: int
retrieval_cost: float
review_minutes: float
review_rate: float
def total_cost(task: Task, token_rate: float, minute_rate: float) -> float:
return (
task.model_tokens * token_rate
+ task.retrieval_cost
+ task.review_minutes * minute_rate * task.review_rate
)
task = Task(900, 0.002, 2.5, 0.4)
print(round(total_cost(task, 0.00001, 0.8), 4))本节完成验收
完成本页后,应能建立冻结基准、切片、专家量表、回归门和真实任务成本模型。验收时比较两个版本,既报告质量和安全,也报告 p95、人工修改和每个成功任务成本。
设计 12 条冻结评估样本。
参考结论:正常、难例、拒答、越权、长输入和来源版本齐全。
完整实现路径:评估拆为自动指标、人工 rubric、风险切片和回归差分,版本表保存完整证据。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
def evaluate(rows, rubric_version):
required = {"id", "expected", "prediction", "risk", "human_score"}
for row in rows:
missing = required - row.keys()
if missing: raise ValueError(f"{row.get('id')}: {sorted(missing)}")
exact = sum(r["expected"] == r["prediction"] for r in rows) / len(rows)
high_risk = [r for r in rows if r["risk"] == "high"]
high_pass = sum(r["human_score"] >= 4 for r in high_risk) / max(1, len(high_risk))
return {"rubric_version": rubric_version, "exact": exact,
"high_risk_pass": high_pass, "n": len(rows)}验收标准:总体与红线切片同时过门,评审冲突有裁决规则。
若结果不对:先确认评估集和评分器版本,再查模型,避免把评估漂移误判为模型退化。
写一个版本差异和消融表。
参考结论:每项改动有假设、指标、成本和回滚。
完整实现路径:评估拆为自动指标、人工 rubric、风险切片和回归差分,版本表保存完整证据。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
def evaluate(rows, rubric_version):
required = {"id", "expected", "prediction", "risk", "human_score"}
for row in rows:
missing = required - row.keys()
if missing: raise ValueError(f"{row.get('id')}: {sorted(missing)}")
exact = sum(r["expected"] == r["prediction"] for r in rows) / len(rows)
high_risk = [r for r in rows if r["risk"] == "high"]
high_pass = sum(r["human_score"] >= 4 for r in high_risk) / max(1, len(high_risk))
return {"rubric_version": rubric_version, "exact": exact,
"high_risk_pass": high_pass, "n": len(rows)}验收标准:总体与红线切片同时过门,评审冲突有裁决规则。
若结果不对:先确认评估集和评分器版本,再查模型,避免把评估漂移误判为模型退化。
设计影子流量评估。
参考结论:不影响用户决策;能收集真实延迟、错误和人工反馈。
完整实现路径:评估拆为自动指标、人工 rubric、风险切片和回归差分,版本表保存完整证据。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 5:先复制运行,再替换为自己的数据
def evaluate(rows, rubric_version):
required = {"id", "expected", "prediction", "risk", "human_score"}
for row in rows:
missing = required - row.keys()
if missing: raise ValueError(f"{row.get('id')}: {sorted(missing)}")
exact = sum(r["expected"] == r["prediction"] for r in rows) / len(rows)
high_risk = [r for r in rows if r["risk"] == "high"]
high_pass = sum(r["human_score"] >= 4 for r in high_risk) / max(1, len(high_risk))
return {"rubric_version": rubric_version, "exact": exact,
"high_risk_pass": high_pass, "n": len(rows)}验收标准:总体与红线切片同时过门,评审冲突有裁决规则。
若结果不对:先确认评估集和评分器版本,再查模型,避免把评估漂移误判为模型退化。
冻结基准版本 基准要有稳定 ID、来源、时间、切片和答案版本。开发过程中不能为了提高分数随意替换测试样本。
更新基准时创建新版本,并报告新增、删除和标签变化。旧版本仍保留,用来比较长期回归。
自动指标先定义分母 准确率、macro-F1、召回、格式通过率和引用命中率的分母不同,空集和拒答也需要规定。
把计算函数和示例结果放入测试。指标脚本版本与评估报告绑定,防止口径悄悄变化。
置信区间表达不确定性 小样本的分数可能波动很大;可以用 bootstrap 或重复抽样给出区间,并报告切片样本数。
两个版本区间高度重叠时,不要夸大差异。扩大样本、增加种子或补充人工评审。
专家 rubric 要有例子 评分维度、等级定义、正反例和不可接受行为应写给评审者。没有例子,每个人的“好”都不同。
先做少量校准,统计一致性并仲裁分歧。修改 rubric 要升级版本,不要直接覆盖旧结论。
盲评减少偏见 评审者尽量不知道模型版本,输出顺序随机,避免因为模型名称或先后顺序产生偏好。
保留评审任务 ID、版本和分歧,不把个人姓名与原文混在公开报告中。
切片揭示平均值盲点 按语言、长度、来源、风险、时间和用户群切片,观察各自样本数与指标。
切片样本过少时标记不确定,不要只挑最好看的切片。红线切片可以直接阻断发布。
回归集守住已知能力 为结构化输出、长上下文、拒答、引用和高风险任务各保留少量高价值样本。
每次提交运行快速回归,任何核心能力跌破阈值都阻断合并或发布。
父版本让改动可归因 候选模型要记录父版本、数据变化、提示、解码、工具和环境。一次改变多个因素,结论会变得模糊。
对重要改动做单变量或小型因子实验,失败候选也进入实验索引。
消融验证机制 移除检索、过滤、增广、量化或某个工具,观察质量、延迟和成本变化。
消融结果只说明当前基准下的贡献,不能直接推广到所有任务。报告样本和限制。
错误地图指向修复 把失败分为数据、模型、接口、工具、资源和评估偏差,记录样本 ID 与严重度。
每类错误分配负责人、复现命令和截止时间。修复后重跑同一批失败样本。
线上反馈要谨慎采样 用户点赞、点踩、转人工和修改结果都是信号,但可能受用户群、提示和展示方式影响。
先定义采样和隐私规则,再把反馈加入离线评估。不要把未经审查的线上文本直接当训练标签。
质量与成本一起决策 更高分数可能来自更长输出、更慢检索或更高模型成本。评估报告应并列质量、延迟、吞吐、错误和单位成本。
根据合同选择可接受前沿,而不是盲目追求最高单项分数。
数据污染需要追查 公开基准可能出现在训练语料中,导致分数不能代表泛化。检查来源、时间和近似匹配,至少披露风险。
发现污染时分离受影响结果,增加新基准或时间外测试,不要假装分数仍可直接比较。
随机实验要可复现 固定 seed、解码参数、硬件和服务版本。生成评估要记录温度、top_p、max_tokens 与停止规则。
若必须随机采样,报告多个运行的均值、范围和样例,而不是只展示一次最好输出。
发布结论要可读 报告开头给出通过/限制/拒绝,随后列证据、未知风险和下一步。不要让读者从几十张图里猜结论。
限制条件和不支持范围与模型版本一起发布,供调用方做正确决策。
评估结果可被回滚 指标阈值、红线和回滚版本都应在发布合同中写出。评估只是建议,最终发布仍需责任人审批。
灰度期间持续抽样,线上证据触发红线时,优先回到稳定版并保留候选实验。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| 基准 | 版本、来源、切片、答案 | 冻结并审计 |
| 指标 | 定义、分母、区间、样本数 | 修正口径后重跑 |
| 人工 | rubric、盲评、一致性 | 校准或仲裁 |
| 回归 | 核心能力与红线 | 阻断发布 |
| 决策 | 质量、成本、延迟、回滚 | 审批或退回 |
读完评估报告,应该能回答:候选版本提升了什么、在哪些切片退化、证据有多大不确定性、成本是否值得、出现线上风险如何回到哪个版本。
先定义评估问题 评估不是“看看模型好不好”,而是验证合同中的某个主张:是否更准确、是否更安全、是否更快或是否降低人工成本。
每个问题对应输入、指标、样本量和决策门。没有明确问题的分数不应直接用于发布。
建立版本化基准 冻结样本、标签、答案、切片和评分脚本,所有更新创建新版本并记录差异。
长期比较使用旧基准,新能力使用新增基准;两者不要混成一个不可解释的总分。
指标口径一致 明确分母、空集、拒答、截断和多答案等边界。相同名称的指标在不同脚本中可能有不同实现。
把计算函数、示例输入和预期输出纳入测试,报告版本与依赖。
人工评分校准 在正式评审前用共享样例统一 rubric,解释各等级差异和红线。
统计一致性、记录分歧和仲裁;修改 rubric 后重新评估受影响样本。
盲评与随机化 隐藏模型名称、随机输出顺序、避免前一条答案影响后一条判断,减少先验偏见。
随机种子和评审任务 ID 进入报告,重跑时可还原抽样。
切片不只看最好 语言、长度、来源、时间、风险和用户群可能产生不同质量。切片样本数太少时,结果应标记不确定。
红线切片可以阻断发布;整体平均分不能抵消严重切片的风险。
回归集小而关键 为结构化输出、引用、拒答、长上下文和关键业务各保留少量高价值样本。
每次代码或模型变化都先跑快速回归,再决定是否投入完整评估。
父版本记录因果 候选实验记录父版本以及数据、提示、解码、工具和环境变化,避免一次改动太多。
复杂改动用单变量、小型因子或逐步实验拆解;失败候选也保存以免重复。
消融验证假设 移除检索、过滤、增广、工具或量化,观察质量、延迟和成本的变化。
消融只在当前任务和数据上说明贡献,结论需带样本、版本和限制。
置信区间保护判断 小样本差异可能只是抽样波动,使用 bootstrap、重复种子或多个评审任务估计范围。
区间重叠时谨慎使用“显著提升”;优先补充关键切片证据。
污染与记忆 公开测试题可能出现在训练语料中,近似匹配会让分数高估泛化。
披露污染风险,使用时间外或新编基准验证,不能把受污染分数当作真实能力。
线上反馈有偏差 点赞、修改、转人工和投诉受用户群、界面和流程影响,不等同于随机真值。
固定采样、脱敏和审核流程,先把反馈变成可解释的评估切片。
质量与成本前沿 更高分可能需要更长输出、更贵模型或更多人工。把质量、延迟、吞吐和单位成本放到同一表中。
选择合同允许的前沿点,并说明放弃其他点的原因。
解码参数也是变量 temperature、top_p、max_tokens、stop 和随机种子会改变生成结果,评估必须记录它们。
生产参数和离线参数不一致时,重新评估或明确结论只适用于离线配置。
失败样例要可复现 保存样本 ID、版本、输入摘要、输出摘要、评分和随机性,避免只截图一个结果。
修复后重跑同一失败集,并确认问题没有转移到另一个切片。
报告先给决策 开头写通过、限制或拒绝,随后给证据、未知风险、成本和下一步。
限制条件与模型卡一起发布,调用方知道什么时候必须转人工或拒答。
评估触发回滚 合同里预先写出红线、观察窗口和回滚版本,线上抽样触发时按约定行动。
评估不是一次性的通行证,灰度期间的真实证据同样能改变结论。
维护评估资产 基准、脚本、rubric、报告和错误地图都需要负责人、版本和归档策略。
过期评估不能继续作为发布依据;定期复审样本是否仍代表真实任务。
| 证据 | 应冻结 | 结论边界 |
|---|---|---|
| 基准 | 版本、切片、答案、污染风险 | 冻结或换新 |
| 指标 | 定义、分母、区间、脚本 | 修口径重跑 |
| 人工 | rubric、盲评、一致性、仲裁 | 校准评审 |
| 实验 | 父版本、变量、消融、失败 | 补对照 |
| 发布 | 红线、窗口、回滚、责任 | 审批或退回 |
如果两个模型总分接近,你是否知道哪一个在关键切片更稳、成本更低、证据更可信?如果不知道,需要补充切片、人工 rubric 或对照实验,而不是继续争论总分。
案例:候选模型选择 冻结基准、解码参数和硬件,比较基线与候选的总分、关键切片、p95、成本和失败样例。只看总分无法回答是否值得发布。
切片报告 按语言、长度、来源、时间、风险和用户群显示样本数、指标和不确定性。样本很少的切片不能用一个百分比做强结论。
自动与人工 自动指标负责快速回归,人工 rubric 负责事实性、完整性和安全。两者冲突时,先查评分口径与红线,再由责任人按合同裁决。
盲评校准 先用共享样例解释等级和不可接受行为,再随机输出顺序并隐藏模型名。记录一致性、分歧和仲裁,不能把单一专家直觉当真值。
回归集 为结构化输出、引用、拒答、长上下文和高风险任务准备小而关键的样本,每次改动都先运行。
消融 移除检索、数据过滤、增广、工具或量化,观察质量、延迟和成本。消融只能说明当前基准下的贡献,结论需要带限制。
污染 检查测试样本是否可能出现在训练语料或缓存中,发现风险时使用时间外或新编基准。受污染分数要单独披露。
线上反馈 点赞、修改、转人工和投诉都是有偏信号,先做抽样、脱敏和复核,再转成离线切片,不要直接当标签。
实验可归因 候选记录父版本和所有主要变量,优先做单变量对照。失败实验也保留,避免团队重复走同一条路。
发布结论 报告开头写通过、限制或拒绝,随后给证据、未知风险、成本和回滚。让调用方知道结论适用于什么范围。
持续评估 灰度期仍要抽样和监测;线上真实证据可以推翻离线结论。评估资产有负责人、版本和复审日期,过期就不能继续作为门禁。
基准的代表性 基准不仅要有足够样本,还要覆盖真实来源、长度、语言、时间和风险。若线上分布发生变化,旧基准仍可做回归,但要增加新切片验证当前使用。
总分与切片 总体指标用于快速比较,切片指标用于发现不平等和红线。报告每个切片的样本数、区间和失败示例,防止少数高风险样本被平均掉。
人工 rubric 把事实性、相关性、完整性、风格和安全拆成可观察描述,给出正反例和等级锚点。评审者先校准再盲评,分歧保留并仲裁。
随机性 生成评估记录随机种子、温度、top_p、max_tokens 和停止规则。重要结论用多个运行或报告范围,不把一次漂亮输出当作稳定能力。
污染 测试题可能出现在训练语料、提示模板或缓存中。发现近似匹配时披露风险,使用时间外、新编或受控基准验证泛化。
回归 回归集只保留关键能力和已知失败,运行快且每次都能比较。新增回归样本说明来源与问题,避免无目的增长导致维护困难。
消融 每次消融只改变一个主要因素,并同时报告质量、延迟和成本。消融结果支持当前假设,不自动推广到其他数据或模型。
反馈 用户点赞、修改、转人工和投诉受界面与人群影响,先脱敏、抽样、审核,再转成离线切片或待标注任务。
结果表达 报告开头给出通过、限制或拒绝,随后列证据、未知风险、成本和回滚。把限制写进模型卡,让调用方知道如何使用。
持续更新 评估资产有负责人、版本和复审日期,基准、rubric、脚本和错误地图都要归档。线上灰度证据可以推翻离线结论,触发重新评估。
问题先于分数 每项评估先写要验证的主张:质量、成本、延迟或安全。否则同一总分会被不同角色用来支持互相矛盾的结论。
主张对应基准、指标、样本量和门禁,报告开头给出通过、限制或拒绝,再展示细节。
基准的生命周期 冻结基准用于长期回归,新增基准用于新能力;两者都保留来源、答案、切片和版本。
基准更新产生差异报告,不覆盖旧版本;旧版本可以发现“新模型修好能力却破坏旧能力”。
专家评审流程 先用共享样例校准 rubric,再进行盲评和随机化。评分等级要有可观察描述,红线行为单独列出。
报告评审者一致性、分歧和仲裁,不能用一个人的直觉代替可重复证据。
切片的取舍 语言、长度、来源、时间、风险和用户群都可能影响结果,但切片样本过少时区间很宽。
报告样本数和不确定性,红线切片优先于平均分;不要为了好看只选择表现最好的切片。
回归的节奏 提交时运行小而关键的回归集,发布前运行完整冻结基准,灰度期继续抽样。
不同节奏的脚本共享指标实现和版本字段,避免快速回归与完整评估口径不一致。
消融和归因 候选同时改变数据、提示、工具和解码时无法归因。先做单变量对照,再用小型因子实验探索交互。
失败候选也保存父版本、变量和结果,下一轮可以直接排除已经证伪的假设。
污染与泄漏 公开题、缓存结果或人工调参都可能让测试集被间接看到。发现近似匹配时要披露,不能继续把高分当泛化证据。
新增时间外或受控基准,分离受污染结果;评估报告写明测试集的访问权限。
线上反馈 点赞、点踩、转人工和用户修改是有偏信号,不能直接等同于标签。先脱敏、抽样、审核,再形成离线切片。
反馈来源、采样窗口和用户群进入报告,质量变化与界面或流程变化分开分析。
质量与资源 更高分可能来自更长输出、更慢检索或更贵模型。报告并列质量、p95、吞吐、错误、人工时长和单位成本。
选择合同允许的前沿点并说明取舍,避免把单项最高分误当成最优版本。
结论可回滚 发布结论写出红线、观察窗口、审批人和回滚版本,线上证据可以推翻离线结论。
评估资产有负责人和复审日期,过期的基准、rubric 或脚本不能继续作为门禁。
评估任务卡 每个评估任务卡写清输入版本、输出规则、自动指标、人工 rubric、红线和通过阈值。卡片有版本后,跨模型比较才不会因口径变化失真。
失败样例库 把失败按数据、模型、工具、接口和资源分类,保存样本 ID、版本、严重度和复现命令。修复后重跑原样例,确认问题没有转移。
不确定性 报告置信区间、种子波动、评审一致性和样本量,区分“已证实提升”和“暂时看起来更好”。资源有限时优先重复关键红线切片。
决策记录 写出为何接受或拒绝候选、放弃了哪些替代方案、成本和限制是什么。下一位维护者才能理解选择,而不是重新猜测历史。
线上闭环 灰度期间的真实反馈进入待审核队列,确认偏差和隐私后再加入评估。离线高分不能阻止线上红线触发回滚,线上证据也应更新下一版基准。
把比较写成证据链。先冻结基准和解码,再按切片查看质量,最后把延迟、成本、人工工作量和回滚条件放在同一张决策表中。这样即使两个版本的总分接近,也能根据风险和预算选择更合适的版本,而不是凭一次示例或个人偏好下结论。
让失败推动下一轮。每个失败样例都关联数据、模型、接口、工具或资源类别,并保留可复现命令。修复后重跑原样例,再确认没有把错误转移到另一个切片;评估真正的产物不是一个分数,而是一张能指导下一次实验的错误地图。
上线之后仍然要知道系统发生了什么
14.8 · 监控、安全与发布治理
模型上线只是运行阶段的开始。监控要覆盖质量、资源、数据分布、安全事件和用户行为,发布要有灰度、红线、回滚和复盘。本节把工程可观测性与 AI 风险治理放在同一条运营链上。
14.8.1 指标与追踪:让线上问题能够定位
监控要覆盖四个层面:服务是否健康、资源是否足够、输入输出质量是否变化、系统是否触碰安全与合规边界。健康指标包括可用率、错误率、超时和队列;资源指标包括 CPU、GPU、显存、吞吐和成本;质量指标可以是用户反馈、格式通过率、转人工率、拒答率和抽样评审分数。
日志、指标和追踪各有职责。指标适合看趋势和告警,日志保存单次请求的上下文,追踪连接网关、队列、检索、模型和工具调用。三者应共享 request_id、model_version、schema_version 和 trace_id。记录输入时要脱敏或只保留哈希、长度和风险标签,不能为了调试把个人信息无限期写进日志。
告警必须对应动作。队列深度升高可能需要限流或扩容,错误率升高可能需要回滚,质量抽样退化可能需要暂停灰度,敏感数据命中则应触发安全流程。告警阈值要考虑基线、持续时间和分片,避免一次短暂抖动造成疲劳告警;每条告警都要有负责人和 runbook。
| 信号 | 示例 | 触发动作 |
|---|---|---|
| 健康 | 5xx、超时、探针失败 | 重启、摘流或回滚 |
| 资源 | 显存、队列、p95、成本 | 限流、扩容或降级 |
| 质量 | 抽样分数、格式率、转人工 | 暂停灰度、调查数据 |
| 安全 | 越权、敏感信息、异常工具调用 | 阻断、隔离、审计 |
import hashlib
import json
from datetime import datetime, timezone
def safe_request_log(request, response, model_version):
text = request.get("text", "")
return {
"time": datetime.now(timezone.utc).isoformat(),
"request_id": request["request_id"],
"model_version": model_version,
"input_hash": hashlib.sha256(text.encode()).hexdigest(),
"input_chars": len(text),
"status": response.get("status", "unknown"),
"latency_ms": response.get("latency_ms"),
"safety_flags": response.get("safety_flags", []),
}
print(json.dumps(safe_request_log(req, resp, "model-1.3"), ensure_ascii=False))指标要有目的和保留期限。记录个人数据、完整提示词或工具参数前先判断是否必要,设计脱敏、访问控制和自动删除。没有负责人和 runbook 的告警只是噪声,最终会被团队忽略。
至少列出八个指标,分别归入健康、资源、质量、安全,并为其中三项写告警阈值、持续时间和收到告警后的第一步动作。
参考结论:把事件事实、影响范围、保护动作和验证证据分开记录。
完整实现路径:监控覆盖健康、资源、质量和安全;告警包含阈值、窗口、责任人、止损和恢复验证。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
def decide(metrics):
if metrics["security_incidents"] > 0: return "rollback"
if metrics["error_rate"] > .02: return "pause"
if metrics["p95_ms"] > 450: return "pause"
if metrics["quality_score"] < .86: return "rollback"
return "continue"
metrics = {"security_incidents": 0, "error_rate": .008,
"p95_ms": 390, "quality_score": .89}
assert decide(metrics) == "continue"验收标准:灰度可暂停、回滚可演练、事故可用 trace 完整重放。
若结果不对:先止损并冻结证据,再比较版本/流量/依赖变化,最后修复。
14.8.2 事件响应:把异常变成可复盘的学习材料
模型事件可能来自代码、数据、基础设施、供应链或使用方式。响应的第一步是保护用户和证据:确认影响范围,停止继续扩大,保留相关版本、日志和配置,再决定降级、限流或回滚。不要一上来删除异常日志或直接重训,因为那会让根因消失。
事件等级应和影响挂钩。服务完全不可用、敏感信息泄露、高风险错误增加、成本异常和单个低优先级切片退化的处理时限不同。每一级都要有通知对象、决策权限和更新频率。事后复盘关注系统改进,而不是寻找个人过错:哪些门禁没拦住,哪些监控没覆盖,哪些文档让人误判。
runbook 要能在压力下使用。它应包含确认命令、观察面板、回滚步骤、降级开关、联系人和恢复后的验证;命令使用版本化路径,不依赖作者本地别名。完成演练后,记录实际耗时和卡点,把演练结果变成下一次发布的门禁。
| 部分 | 要回答 | 产物 |
|---|---|---|
| 影响 | 哪些用户、时间、功能受影响 | 时间线与范围 |
| 检测 | 谁在何时通过什么信号发现 | 告警与日志链接 |
| 缓解 | 采取了什么降级或回滚 | 命令与审批 |
| 根因 | 代码、数据、资源还是流程 | 证据链 |
| 修复 | 短期补丁与长期改进 | 任务清单与负责人 |
| 验证 | 如何证明风险已下降 | 回归、演练、复盘 |
复盘要把每个判断连接到时间戳、日志、配置和版本。区分已证实事实、合理推断和仍待验证的假设;如果证据不足,就把补充观测列为行动项。
假设新模型上线后 p95 翻倍且错误率上升,写出前十分钟的确认、保护、回滚和验证步骤,并列出不能做的事情。
参考结论:把事件事实、影响范围、保护动作和验证证据分开记录。
完整实现路径:监控覆盖健康、资源、质量和安全;告警包含阈值、窗口、责任人、止损和恢复验证。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
def decide(metrics):
if metrics["security_incidents"] > 0: return "rollback"
if metrics["error_rate"] > .02: return "pause"
if metrics["p95_ms"] > 450: return "pause"
if metrics["quality_score"] < .86: return "rollback"
return "continue"
metrics = {"security_incidents": 0, "error_rate": .008,
"p95_ms": 390, "quality_score": .89}
assert decide(metrics) == "continue"验收标准:灰度可暂停、回滚可演练、事故可用 trace 完整重放。
若结果不对:先止损并冻结证据,再比较版本/流量/依赖变化,最后修复。
14.8.3 灰度、回滚与治理文档
发布候选要有版本组合:模型、适配器、索引、提示、工具、服务镜像和配置。只给一个模型版本无法解释线上差异。发布记录还要包含目标用户、流量比例、开始结束时间和批准人。
灰度可以按用户、任务、区域或百分比进行,但高风险请求不应未经同意随机实验。影子模式只运行模型不影响决策,低风险灰度观察质量和容量,达到门槛后再扩大。
回滚分层进行。先关闭适配器或新提示,切换旧索引或工具,再切换基础模型;每一步都做健康和业务质量检查。回滚后要告诉使用者系统当前能力和限制,不能让旧版本悄悄运行。
治理文档包括模型卡、数据说明书、任务合同、评估报告、发布记录和事件复盘。文档互相引用版本,发现限制变更时同步更新。文档不是宣传页,要如实写已知失效、未覆盖群体和不适用场景。
NIST AI RMF 把风险管理分为治理、映射、测量和管理等工作,工程团队可以把它映射到角色、任务合同、评估和事件流程。框架不能替代具体法规和机构政策,但能帮助建立持续治理。
回滚演练要定期进行。模拟新版本质量下降、索引过期、工具不可用和敏感事件,测量检测时间、决策时间、恢复时间和用户通知。演练结果进入错误地图和下一次发布门。
停用也要有流程。删除缓存、撤销密钥、停用路由、归档日志、处理数据撤回,并保留必要的审计证明。一个不再使用的模型仍可能通过旧客户端被调用。
本小节的目标是让发布成为有条件的治理决策,而不是部署脚本的最后一步。
| 门 | 必须检查 | 不通过动作 |
|---|---|---|
| 技术 | 包校验、启动、健康、容量 | 不接流量 |
| 质量 | 基准、切片、回归 | 冻结/修复 |
| 安全 | 越权、敏感、权限 | 隔离/通知 |
| 运营 | 监控、owner、runbook | 不发布 |
| 业务 | 任务成功、人工负担、成本 | 缩小范围 |
| 治理 | 文档、批准、回滚 | 退回评审 |
灰度不是自动晋级;每一阶段都要有证据和停止条件。
from dataclasses import asdict, dataclass
from datetime import datetime
import json
@dataclass(frozen=True)
class Release:
release_id: str
model: str
index: str
traffic_percent: int
approved_by: str
at: str
def serialize(release: Release) -> str:
return json.dumps(asdict(release), ensure_ascii=False, sort_keys=True)
release = Release("rel-14", "model-v4", "index-v3", 10,
"reviewer-01", datetime.now().isoformat())
print(serialize(release))本节完成验收
完成本页后,应能设计指标/日志/追踪、数据漂移、事件 runbook、灰度门槛和分层回滚,并把模型卡与发布记录关联。验收时模拟一次敏感事件和一次质量退化,要求在规定时间内隔离、回滚、通知并完成回归。
设计一次请求的 trace 字段。
参考结论:含模型、提示、索引、工具、状态、延迟和权限,且脱敏。
完整实现路径:监控覆盖健康、资源、质量和安全;告警包含阈值、窗口、责任人、止损和恢复验证。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
def decide(metrics):
if metrics["security_incidents"] > 0: return "rollback"
if metrics["error_rate"] > .02: return "pause"
if metrics["p95_ms"] > 450: return "pause"
if metrics["quality_score"] < .86: return "rollback"
return "continue"
metrics = {"security_incidents": 0, "error_rate": .008,
"p95_ms": 390, "quality_score": .89}
assert decide(metrics) == "continue"验收标准:灰度可暂停、回滚可演练、事故可用 trace 完整重放。
若结果不对:先止损并冻结证据,再比较版本/流量/依赖变化,最后修复。
写一个高风险事件 runbook。
参考结论:有分级、owner、SLA、隔离、通知和回归。
完整实现路径:监控覆盖健康、资源、质量和安全;告警包含阈值、窗口、责任人、止损和恢复验证。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
def decide(metrics):
if metrics["security_incidents"] > 0: return "rollback"
if metrics["error_rate"] > .02: return "pause"
if metrics["p95_ms"] > 450: return "pause"
if metrics["quality_score"] < .86: return "rollback"
return "continue"
metrics = {"security_incidents": 0, "error_rate": .008,
"p95_ms": 390, "quality_score": .89}
assert decide(metrics) == "continue"验收标准:灰度可暂停、回滚可演练、事故可用 trace 完整重放。
若结果不对:先止损并冻结证据,再比较版本/流量/依赖变化,最后修复。
安排一次灰度回滚演练。
参考结论:可关闭适配器/索引/模型;恢复后有能力说明。
完整实现路径:监控覆盖健康、资源、质量和安全;告警包含阈值、窗口、责任人、止损和恢复验证。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 5:先复制运行,再替换为自己的数据
def decide(metrics):
if metrics["security_incidents"] > 0: return "rollback"
if metrics["error_rate"] > .02: return "pause"
if metrics["p95_ms"] > 450: return "pause"
if metrics["quality_score"] < .86: return "rollback"
return "continue"
metrics = {"security_incidents": 0, "error_rate": .008,
"p95_ms": 390, "quality_score": .89}
assert decide(metrics) == "continue"验收标准:灰度可暂停、回滚可演练、事故可用 trace 完整重放。
若结果不对:先止损并冻结证据,再比较版本/流量/依赖变化,最后修复。
四层信号要互相连接 健康、资源、质量和安全信号分别看不同问题,不能用一个总体可用率替代。每条信号关联 request_id、model_version 和时间窗口。
仪表板给趋势,日志给单次上下文,追踪给跨组件路径。三者字段一致,才能从告警跳到具体请求。
指标标签要克制 按模型、租户、路由和版本切分很有用,但把完整用户文本或无限 ID 当标签会导致高基数和隐私风险。
先定义有限集合和保留期限。需要排查的样本用受控存储,不把敏感内容塞进监控系统。
延迟拆分而非只看总数 排队、预处理、检索、推理、后处理和网络各自贡献延迟。p50、p95、p99 要按阶段和输入长度观察。
一旦尾延迟上升,先定位占比最高的阶段。不要用增加超时掩盖容量不足。
质量监控采用抽样 线上通常没有即时真值,可以用格式通过率、转人工、用户修改和定期人工抽样做代理。
抽样规则、评审者和时间窗固定,报告偏差。重大风险样本提高抽样率并触发专门审查。
漂移需要基线 输入语言、长度、来源和主题变化会改变模型表现。记录上线初期分布作为基线,定期与当前窗口比较。
漂移不是自动等于退化,但应触发调查、重评估或补数据。
安全事件要有红线 越权工具调用、敏感信息输出、提示注入和异常成本都应有明确阈值和阻断动作。
事件发生时先限流、隔离和保留证据,再分析根因。不要把违规样本直接公开在普通日志里。
告警必须带 runbook 每条告警写阈值、持续时间、负责人、确认命令、降级和恢复验证。没有动作的告警最终会被忽略。
演练告警路径并记录实际耗时。变更服务后同步更新 runbook。
事件等级决定响应 按影响用户数、持续时间、可逆性和安全风险分级,决定通知、更新频率和审批权限。
等级可在调查中升级,但不要为了减少通知而低估严重度。
先保护用户再定位 异常时先摘除候选流量、关闭危险工具或启用只读模式,防止影响扩大。
保护动作必须可回退并记录时间。稳定后再收集完整证据,避免修复掩盖现场。
回滚要验证而非盲切 回滚后检查健康、错误率、p95、关键质量样本和数据写入,确认旧版真的恢复。
保留候选版本的日志和 manifest,以便修复后重新灰度,而不是永久删除。
灰度要有观察窗口 按租户、比例或场景逐步放量,观察至少覆盖峰值和低峰。每阶段提前写出通过阈值。
任一红线触发就自动摘流。不要为了完成日程而跳过观察窗口。
蓝绿切换保留旧环境 旧版继续可用,路由开关负责切换,便于快速回滚。数据格式变化要先向后兼容。
切换前后记录路由比例和版本。确认迁移完成后再释放旧资源。
权限与审计 发布、回滚、查看日志和访问模型包需要不同权限,关键动作保存操作者、时间和原因。
定期审查权限与密钥,离职或项目结束及时撤销。
模型卡表达限制 模型卡记录用途、数据、已知风险、拒答范围、评估版本和联系方式,帮助调用方做适当决策。
模型卡随版本更新,不要用一份旧说明覆盖所有候选。
供应链与依赖 镜像、Python 包、CUDA、模型权重和 tokenizer 都可能带来安全与兼容风险。锁定摘要并扫描来源。
发现高风险依赖先隔离版本,验证替代方案,再安排升级;不要线上临时 pip install。
演练让回滚变真实 定期模拟 p95 飙升、模型加载失败、数据漂移和敏感输出,测量发现到恢复的时间。
演练结果成为门禁和培训材料。没有演练过的回滚,只是一条未经验证的命令。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| 可观测 | 指标、日志、追踪、字段字典 | 补齐信号 |
| 安全 | 红线、权限、脱敏、审计 | 隔离并升级事件 |
| 事件 | 等级、runbook、时间线 | 演练后修复 |
| 发布 | 灰度、窗口、通过阈值 | 摘流或回滚 |
| 退役 | 迁移、归档、旧版窗口 | 延长兼容期 |
治理不是上线前的一次审批,而是持续循环:观察信号、识别影响、保护用户、保留证据、修复系统、复测并更新合同。每一步都要有可执行的负责人和时间点。
健康与可用率 存活探针只说明进程未退出,就绪探针还要检查模型加载、依赖、队列和设备。两者不能混成一个健康数字。
实例未就绪时摘流并保留启动错误;重启循环要有退避和报警。
资源监控 CPU、GPU、显存、网络、队列、并发、缓存和存储共同决定容量,单独看 GPU 利用率会误判。
把资源指标按模型版本、租户和输入长度切片,超阈值时限流、扩容或降级。
质量代理 线上真值通常延迟到达,可以用格式通过率、转人工、用户修改和抽样评审做代理。
代理指标需要基线和采样规则,不能把一次投诉直接解释为整体退化。
安全信号 提示注入、越权工具调用、敏感信息、异常输出长度和成本尖峰都应纳入安全监控。
安全红线触发时先阻断或隔离,按事件等级通知负责人,保留必要证据。
日志脱敏 request_id 和版本字段有助排查,完整原文、密钥和个人信息可能增加风险。
默认哈希、长度和风险标签;受控调试样本短期保存,访问必须审计。
追踪链路 网关、排队、检索、模型、工具和后处理共享 trace_id,才能解释一条响应为何变慢或出错。
追踪采样率按风险和成本设计,保留高风险事件而非无限保留全部请求。
阈值与窗口 瞬时抖动不等于事件,持续时间、相对基线和切片共同决定告警。
每条告警写阈值、窗口、负责人和 runbook,避免疲劳告警。
事件分级 影响用户数、持续时间、可逆性和安全风险决定响应等级。
等级可以升级,不能为了少通知而低估;写出更新频率和审批权限。
保护动作 异常时先摘候选流量、关闭危险工具、只读或转人工,防止影响扩大。
保护动作可回退,记录时间和操作者;稳定后再收集完整证据。
根因分析 区分代码、数据、模型、依赖、容量和流程因素,避免把所有事故都归为“模型问题”。
时间线连接到版本、配置和日志;事实、推断和待验证假设分开写。
回滚验证 回滚后不仅看服务 200,还要检查关键质量样本、p95、数据写入和安全策略。
验证通过才宣布恢复;候选制品和事件证据继续保留。
灰度观察 按租户、比例或场景逐步放量,观察窗口覆盖峰值和低峰,并预设红线。
红线触发自动摘流,不能为了日程跳过窗口或手工忽略告警。
权限管理 发布、回滚、查看原文日志、读模型包和改路由需要不同角色。
采用最小权限、临时授权和双人审批,定期撤销过期凭据。
模型卡 用途、数据、限制、评估版本、拒答范围和联系方式帮助调用方正确使用。
每次模型或数据变化更新模型卡,旧版本说明保留归档。
供应链 镜像、依赖、驱动、权重和 tokenizer 都可能带来安全或兼容风险。
锁定摘要、扫描来源,升级先在预发布验证,禁止线上临时安装未知包。
演练与恢复时间 没有演练的回滚只是文字。模拟 p95 飙升、加载失败、数据漂移和敏感输出,测量发现到恢复。
把演练耗时和卡点写回门禁、runbook 与培训材料,持续缩短恢复时间。
数据保留 不同用途需要不同保留期限,调试样本、指标和事件记录不应无限期保存。
自动删除原文,保留合规的统计摘要和不可逆索引。
退役审计 旧版本下线要有迁移证明、历史结果可读性和归档清单。
保留兼容窗口与索引,确认没有调用方后再释放资源。
| 治理层 | 必须有 | 触发动作 |
|---|---|---|
| 可观测 | 指标、日志、追踪、脱敏 | 定位并告警 |
| 保护 | 限流、摘流、隔离、降级 | 阻止影响扩大 |
| 响应 | 分级、runbook、时间线 | 按权限处置 |
| 恢复 | 回滚、健康、质量、数据 | 验证后宣布 |
| 学习 | 复盘、演练、模型卡、归档 | 更新门禁 |
当线上 p95 翻倍时,你能否在几分钟内知道影响版本、队列状态、候选流量比例和回滚命令?若不能,先补可观测字段和 runbook,再谈更复杂的模型治理。
案例:p95 突然升高 先按模型版本、路由、长度和时间确认影响范围,再拆分队列、推理、检索和网络延迟。不要先把超时调大。
保护顺序 摘除候选流量、关闭危险工具、启用只读或转人工,先阻止影响扩大,再收集完整证据。所有动作记录时间和操作者。
告警可执行 阈值、持续时间、负责人、确认命令、降级和恢复验证写在同一条 runbook 中。没有动作的告警只是噪声。
日志最小化 request_id、版本、长度、哈希和风险标签足以关联多数问题;完整原文只在受控调试路径短期保存。
事件分级 按用户影响、持续时间、可逆性和安全风险分级,确定通知对象和更新频率。调查中允许升级等级。
根因时间线 将告警、发布、配置、依赖、数据变化和回滚连接到时间戳。区分已证实事实、推断和待验证假设。
灰度窗口 候选按比例、租户或场景逐步放量,窗口覆盖峰值和低峰。红线触发自动摘流,不为日程跳过观察。
回滚确认 旧版恢复后检查健康、错误率、p95、关键样例、安全策略和数据写入,不能只看 HTTP 200。
权限审计 发布、回滚、查看原文日志和修改路由分角色授权,关键动作双人审批或临时授权。
模型卡更新 用途、数据、限制、评估版本、拒答范围和联系方式随模型版本更新,旧说明保留归档。
演练恢复 定期模拟加载失败、数据漂移、敏感输出和成本尖峰,测量发现到恢复并把卡点修进 runbook。
退役 迁移证明、历史结果、监控面板和事件记录归档后再下线旧版,不能删除所有现场。
告警到动作 每条告警都写阈值、持续时间、负责人、确认命令、保护动作和恢复验证。没有动作的指标会产生疲劳,最终没人响应。
健康定义 存活只说明进程存在,就绪还要确认模型、依赖、队列和设备可用。未就绪实例不接流量,启动失败原因保留在版本日志。
质量代理 格式通过率、转人工、用户修改和抽样评审可作为延迟真值的代理,但都可能有偏。固定采样规则并报告基线与局限。
日志最小化 request_id、版本、长度、哈希和风险标签通常足够关联问题,完整输入只在受控调试中短期保存。访问、导出和删除都应可审计。
追踪阶段 拆分网关、排队、检索、推理、工具和后处理延迟,p95 上升时先定位占比最高的阶段。一个总延迟数字无法指导修复。
事件等级 按影响用户、持续时间、可逆性和安全风险分级,决定通知范围与更新频率。调查过程中允许升级等级,不能为了少通知而低估。
保护用户 异常首先摘除候选、关闭危险工具、限流或转人工,防止影响扩大。保护命令应可回退并记录时间、操作者与原因。
回滚验证 回滚后检查健康、错误率、p95、关键质量样例、安全策略和数据写入;HTTP 200 只是接口层,不代表系统恢复。
灰度窗口 候选按比例、租户或场景逐步放量,窗口覆盖峰值与低峰。红线触发自动摘流,观察中不能用手工忽略告警。
治理资产 模型卡、runbook、权限清单、供应链摘要和事件复盘都是发布资产,随版本更新并保留旧记录。治理是持续循环,不是一次审批。
监控四层 健康看可用率与错误,资源看显存与队列,质量看抽样与转人工,安全看越权与敏感输出。四层互相补充,不能用一个总分替代。
每层指标都绑定模型、版本、时间和租户切片,告警才有定位价值;高基数的完整原文不要放进指标标签。
阶段延迟 总 p95 只是结果,排队、预处理、检索、推理、后处理和网络才是可修复的原因。
trace_id 贯穿各阶段,告警跳到单次请求后能看出主要耗时,而不是盲目增加超时。
抽样质量 线上没有即时真值时,用格式通过率、用户修改、转人工和定期人工评审做代理。
固定采样规则和基线,报告偏差;高风险样本提高采样率并进入专门审查。
日志边界 request_id、model_version、schema_version、长度、哈希和风险标签通常足够排查。完整输入只在受控调试场景短期保存。
访问、导出和删除都要审计;日志保留期限按用途和风险制定,不为“以后可能有用”无限保存。
告警动作 阈值、持续时间、负责人、确认命令、保护动作和恢复验证写入 runbook。
没有动作的告警会被忽略;每次告警演练都记录真实耗时和卡点,随后更新文档。
事件分级 用户影响、持续时间、可逆性和安全风险决定等级,等级决定通知范围和更新频率。
调查中允许升级;先保护用户和证据,再分析根因,避免修复过程抹掉现场。
灰度规则 候选按稳定键或比例逐步放量,观察窗口覆盖峰值和低峰,并预设红线。
触发红线就摘流或回滚,不能为了完成日程跳过窗口;稳定版路由始终保持可用。
回滚检查 回滚后检查探针、错误率、p95、关键质量样例、安全策略和数据写入,HTTP 200 不足以证明恢复。
验证通过才宣布事件结束,候选的 manifest、日志和评估继续保留供修复。
权限与供应链 发布、回滚、查看原文日志、读取模型包和改路由分角色授权;镜像、依赖、驱动和权重锁定摘要。
升级先在预发布验证,线上禁止临时安装未知包;关键权限和密钥定期复审。
演练与退役 模拟加载失败、数据漂移、敏感输出、成本尖峰和旧版退役,测量发现到恢复。
演练结果变成门禁、培训和迁移清单;旧版归档后再释放资源,保留规定期限的审计索引。
事件时间线 把发布、配置、告警、用户影响、保护动作和恢复验证放在同一时间线,明确哪些是事实、哪些是推断。时间线比口头复述更适合复盘和审计。
告警去重 同一根因可能产生多条告警。用 request_id、版本、阶段和时间窗口聚合,减少噪声,同时保留高风险事件的独立通知。
恢复演练 模拟候选加载失败、p95 飙升、敏感输出和数据漂移,测量从发现到摘流、回滚、健康与质量确认的总时间。演练结果成为发布门禁。
权限审查 定期检查谁能读原文日志、下载模型包、改路由和触发回滚。临时授权到期自动撤销,关键动作保存审批和原因。
治理更新 模型、数据、依赖或接口变化后,同步更新模型卡、runbook、权限清单和保留策略。治理文件落后于代码时,线上决策会引用错误边界。
退役证明 下线旧版前保存迁移完成、历史结果可读、指标面板可查和归档索引的证据。没有退役证明,未来无法解释旧结果或恢复旧服务。
治理要能在压力下执行。指标、告警和模型卡只有连接到负责人、runbook、保护动作和回滚命令才有价值。发布前用故障演练验证发现到恢复的时间,发布后按同一套字段追踪版本、租户、请求和影响范围,避免真正出事时临时寻找信息。
把证据留到退役。候选模型、旧版服务、事件时间线、审批和评估报告都应有版本化归档。下线不是删除,而是确认调用方迁移、历史结果可读、兼容窗口结束并保留规定期限的审计索引;这样未来仍能解释旧结果并恢复必要服务。
把一次事故变成系统改进。复盘不能只写“加强监控”,而要落到新增指标、阈值、负责人、演练和验证时间。例如把候选模型的版本比例加入仪表板,为敏感输出增加阻断告警,补一条回滚冒烟,并在下一次灰度前验证恢复时间确实缩短。
报错指纹与排错顺序
| 症状 | 先看什么 | 第一处置 |
|---|---|---|
CUDA out of memory | 峰值显存、序列长度、micro-batch、残留进程 | 保存失败 batch,减小输入或 micro-batch;不要先清空全部证据 |
| 分布式进程挂起 | 各 rank 最后心跳、最后 collective、batch 数 | 停止扩容;用最小 world size 和相同数据重放 |
| HTTP 422 | 响应体中的字段路径与 API schema | 修请求或版本化 schema,不要在模型里吞掉校验错误 |
| readiness 503 | 权重加载、gold request、依赖连接 | 保持实例不接流量;不要把 ready 伪装成 live |
# 可复现的请求校验失败(FastAPI / Pydantic)
# POST /predict {"text": ""}
# -> 422,错误路径应指向 body.text
def debug_order(request_id):
return [
f"freeze request and logs: {request_id}",
"check release/config/model hashes",
"check queue, readiness and resource metrics",
"replay one gold request on the previous version",
"rollback first when a red-line gate is crossed",
]统一流程:止损 → 冻结证据 → 定位层次 → 最小重放 → 修复测试 → 灰度验证
训练与服务排错都不要从“重启试试”开始。重启可能暂时消除症状,却同时清掉显存、队列和进程状态证据。先记录版本、请求/批次 ID、最后成功点和资源快照;达到安全红线时先回滚,再离线分析。
把训练结果变成可交付、可回滚、可维护的系统
前面的章节分别讨论了训练目标、数据管线、单机与分布式训练、性能、推理服务和评估治理。综合交付不是把这些主题简单相加,而是把它们串成一条别人能够复现、审查和接手的证据链:给定同一份代码、数据版本与配置,团队可以得到同一类结果;当结果不符合预期时,能定位是哪一层出了问题;当线上需要切换模型时,能在不丢失服务的情况下回滚。
14.9.1 从合同和 manifest 开始组织一次交付
一个可交付的模型项目应该先回答“交付什么、凭什么算完成、失败后怎么办”,再讨论训练脚本的细节。建议把这些答案写入版本库中的 manifest(清单)和 README,而不是依赖某个人的记忆。manifest 不只是记录模型名称,它还要固定任务定义、数据快照、代码提交、随机种子、运行环境、评估门槛、服务接口和责任人。这样,训练任务、评估任务和部署任务读取的是同一份事实,避免出现“训练用了一套参数,部署又凭手工改成另一套”的漂移。
合同里的指标必须能被验证。例如“回答更好”不是验收条件;“在冻结的中文问答集上,准确率不低于基线,拒答率不超过阈值,p95 延迟不超过预算,并且高风险样本经过人工复核”才是可执行的合同。对于生成任务,还要写清楚评分方式、抽样数量、评审者、允许的已知缺陷和不可接受的红线。合同也应该声明不做什么:不支持的语言、不保证的事实性范围、不能输入的敏感数据,都要明示。边界写得越早,后续争论越少。
| 字段 | 回答的问题 | 常见证据 |
|---|---|---|
| project_id / owner | 谁负责、属于哪个系统? | 代码仓库、责任人、变更记录 |
| data_version | 训练和评估使用哪份数据? | 快照 ID、哈希、许可说明 |
| model / commit | 具体模型和代码是哪一版? | 权重地址、Git 提交、依赖锁定文件 |
| acceptance | 达到什么指标才算通过? | 自动评测报告、人工抽检表 |
| runtime | 怎样启动和调用? | 镜像摘要、API schema、启动日志 |
| rollback | 异常时回到哪里? | 上一稳定版本、回滚演练记录 |
from dataclasses import dataclass, asdict
from pathlib import Path
import json
import platform
import sys
@dataclass(frozen=True)
class ProjectManifest:
project_id: str
data_version: str
code_commit: str
model_uri: str
acceptance: dict
runtime: dict
def save(self, path: str) -> None:
payload = asdict(self)
payload["python"] = sys.version.split()[0]
payload["platform"] = platform.platform()
Path(path).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
project = ProjectManifest(
project_id="support-intent-v1",
data_version="tickets-2026-08-01",
code_commit="abc1234",
model_uri="registry://support-intent:1.3.0",
acceptance={"macro_f1": 0.86, "p95_ms": 450},
runtime={"endpoint": "/v1/classify", "timeout_ms": 800},
)
project.save("artifacts/manifest.json")
print(project.project_id)如果数据、代码或模型发生变化,就必须生成新的 manifest 版本,而不是覆盖旧文件。可以把 manifest 看作一次实验或一次发布的身份证:它允许你回答“这个结果从哪里来”,也允许别人判断“我能不能复现”。路径、密钥和个人信息不应直接写入清单;秘密放在密钥管理系统里,清单只保存引用名。
为一个文本分类或问答任务写一份 JSON manifest,至少包含任务目标、数据版本、基线指标、通过阈值、延迟预算、模型版本、运行命令和回滚版本。逐项说明每个字段由谁产生、在哪个步骤校验。
14.9.2 把训练、评估和部署串成验收流水线
综合交付的关键不是“每个脚本都能运行”,而是阶段之间有明确的输入输出。数据检查通过后才允许启动训练;训练产物必须经过完整性校验,才能进入评估;评估报告满足门槛后才生成可部署的模型包;服务启动时再次读取 manifest 并检查模型、配置和接口是否匹配。每一关都应该留下日志和产物,失败时停止流水线,而不是继续往下发布一个未知状态的模型。
可以把流水线看作状态机:draft(草稿)→ trained(已训练)→ evaluated(已评估)→ staged(待发布)→ canary(灰度)→ active(线上)→ retired(退役)。状态转换必须由证据触发。例如,没有评估报告就不能从 trained 进入 evaluated;没有健康检查和回滚演练就不能从 staged 进入 canary。状态机让团队讨论“当前处于哪一态、下一步缺什么证据”,而不是争论某个人口头上说“应该可以了”。
| 阶段 | 必须读取 | 必须产生 | 失败时 |
|---|---|---|---|
| 数据门 | schema、许可、切分规则 | 质量报告、版本摘要 | 阻止训练并回到数据修复 |
| 训练门 | manifest、配置、数据快照 | 权重、日志、checkpoint | 保留失败运行供定位 |
| 评估门 | 冻结基准、候选权重 | 指标、切片、人工复核 | 禁止打包或要求重训 |
| 预发布门 | 模型包、API schema | 镜像摘要、冒烟测试 | 不进入灰度 |
| 灰度门 | 线上影子流量、监控规则 | 对比报告、审批记录 | 自动降级到稳定版 |
| 退役门 | 替代版本、迁移计划 | 下线记录、归档索引 | 保留服务并重新评审 |
from dataclasses import dataclass
from time import perf_counter
import json
@dataclass
class TestResult:
name: str
passed: bool
detail: str
def run_smoke_tests(client):
tests = []
started = perf_counter()
response = client.predict("请把我的订单改到明天送达")
tests.append(TestResult(
"schema",
isinstance(response, dict) and "label" in response,
"返回值必须含 label",
))
tests.append(TestResult(
"safe_fallback",
response.get("label") in {"delivery_change", "needs_human"},
"未知意图应进入允许的集合",
))
elapsed_ms = (perf_counter() - started) * 1000
tests.append(TestResult(
"latency",
elapsed_ms <= 800,
f"首次请求 {elapsed_ms:.1f} ms",
))
return tests
def summarize(tests):
return {
"passed": all(item.passed for item in tests),
"checks": [item.__dict__ for item in tests],
}
print(json.dumps(summarize(run_smoke_tests(client)), ensure_ascii=False))冒烟测试只回答服务是否启动、接口是否符合约定、最基本的安全与延迟约束是否成立。它不能替代冻结基准、切片指标和专家复核。完整评估应在部署前运行,冒烟测试则应在每次启动、升级和回滚时快速运行;两者的时间预算和覆盖范围不同,不能混成一个“通过/失败”数字。
把自己的项目画成六个状态,给每个箭头写一条可执行门禁。至少包含一次失败回退:例如评估不通过回到训练配置,或灰度异常回到上一稳定版本。然后说明每一关要保存哪些日志和产物。
14.9.3 用 README、冷启动和迁移证明项目真的可接手
项目完成的标志不是作者在自己的电脑上运行成功,而是一个没有参与过项目的人,按照 README 从干净环境开始,能在合理时间内得到可解释的结果。冷启动验收应从空目录或新环境开始:安装依赖、准备最小公开样例、生成 manifest、执行数据检查、运行小规模训练或加载固定权重、启动服务、调用示例请求、查看评估报告。每一步都应有预期输出和故障排查入口。
README 还要写清楚“哪些东西不能提交”和“哪些东西必须归档”。密钥、个人数据、未授权的原始数据和临时缓存不能进入代码库;模型权重、数据快照、日志和评估报告要放到有权限控制的制品仓库,并在 README 中给出版本引用。迁移时先做双写或影子流量,再逐步切换;确认新版本的指标、成本和错误率稳定后,才关闭旧版本。退役也要记录,而不是直接删除服务,让后续审计失去证据。
| 检查项 | 可接受证据 | 常见失败 |
|---|---|---|
| 环境 | 锁定依赖、Python/驱动版本、启动命令 | 只能在作者电脑运行 |
| 数据 | 最小样例、下载说明、许可与脱敏规则 | README 暗示读本地私有路径 |
| 模型 | 权重版本、校验值、加载方式 | 文件名相同但内容已被覆盖 |
| 服务 | 请求/响应示例、错误码、健康检查 | 只写“启动 API”,没有契约 |
| 评估 | 一条命令生成报告,说明阈值 | 只展示最好的一次实验 |
| 迁移 | 灰度比例、观察窗口、回滚命令 | 切换失败只能人工重启 |
from pathlib import Path
import subprocess
import sys
CHECKS = [
("python_version", [sys.executable, "--version"]),
("manifest", [sys.executable, "scripts/validate_manifest.py"]),
("unit_tests", [sys.executable, "-m", "pytest", "-q"]),
("smoke", [sys.executable, "scripts/smoke_tests.py"]),
]
def run_check(name, command):
result = subprocess.run(
command,
cwd=Path(__file__).parent,
text=True,
capture_output=True,
check=False,
)
print(f"[{name}] exit={result.returncode}")
if result.stdout:
print(result.stdout.strip())
if result.returncode != 0 and result.stderr:
print(result.stderr.strip())
return result.returncode == 0
failed = [name for name, command in CHECKS if not run_check(name, command)]
if failed:
raise SystemExit(f"冷启动失败:{', '.join(failed)}")
print("冷启动验收通过")迁移至少包含兼容性、数据、指标和回滚四个问题。新服务要能处理旧客户端仍在发送的字段;新旧版本的日志字段要能对齐;灰度观察窗口要覆盖高峰和低峰;回滚命令要在演练中验证过。涉及数据格式变化时,优先采用向后兼容、双读或版本化 schema,避免一次发布同时改变模型、接口和数据库,导致任何异常都无法定位。
把项目复制到一个新目录,按照 README 执行全部命令并计时。记录第一次失败的地方、缺失的说明和最终生成的证据。最后新增一条回滚命令,并让同伴只看 README 完成演练;如果对方需要口头提示,就把提示写回文档。
项目边界 综合项目从一个可描述的任务开始,不要同时改变模型、数据、服务和业务流程。先选择一个能在本地或小型环境复现的范围。
范围写入合同,列出输入、输出、用户、风险和不支持项。范围越清晰,最终 README 越容易被新成员执行。
manifest 作为主索引 把数据、代码、权重、配置、评估、服务和责任人放在同一份 manifest 的引用里。每个引用都可追溯到制品或提交。
manifest 只增加新版本,不覆盖旧版本。对照两个版本时能看出具体字段变化。
最小数据集先行 先准备一个公开或已脱敏的最小样例,能够完成 schema、训练、评估和服务冒烟。
最小样例不追求高分,而是证明整条链路能跑通。长训练和大数据应在链路稳定后再加入。
训练实验矩阵 为基线、候选和回退配置分配 experiment_id,记录 seed、预算、指标和失败原因。
矩阵中保留失败运行,避免团队重复尝试已经证伪的配置。
评估门禁 冻结测试集、切片、人工 rubric、质量红线、延迟和成本阈值。
门禁脚本返回明确的退出码和报告位置,不能只在终端打印一句“通过”。
模型打包 把权重、tokenizer、配置和校验值组装成不可变目录,启动前做完整性检查。
打包失败阻止部署;不要让服务启动后才发现缺少词表或标签映射。
服务冒烟 用固定请求验证 schema、错误码、拒答、超时和最小安全边界。
冒烟失败时不进入灰度,保留启动日志和模型版本用于定位。
容量与降级 压测目标并发、最长输入和峰值流量,决定队列上限、限流、降级和转人工策略。
容量不足时快速拒绝或降级,不用无限重试把故障放大。
监控与事件 为健康、资源、质量和安全信号配指标、日志、追踪和 runbook。
每个告警都有负责人和第一步动作,演练后记录恢复时间。
灰度与回滚 先影子或小比例灰度,观察预设窗口,再扩大流量。候选与稳定版均保持可用。
触发红线自动摘流并回滚;回滚后重新运行健康、质量和数据写入检查。
README 冷启动 从新目录安装依赖、准备样例、运行门禁、加载模型、启动服务和生成报告。
同伴不能只听口头提示完成;每次卡点都写回 README。
安全与隐私 说明数据许可、脱敏、权限、密钥、日志保留与模型限制。
发现疑似泄露先隔离和撤销凭据,再按事件流程调查。
迁移兼容 旧接口字段、历史结果和调用方迁移计划要先确认。
采用双读、版本化 schema 或影子流量,避免一次切换同时改变所有依赖。
退役与归档 下线旧版前归档 manifest、镜像摘要、评估、事件和审批记录。
保留可审计索引和规定期限的回滚窗口,不能直接删除所有证据。
同伴复核 让没有参与实现的人只看文档运行项目并填写问题清单。
复核问题按阻断、重要、建议分级,阻断项修复后再验收。
成果表达 最终报告按合同、证据、限制和下一步组织,不用代码行数代替质量。
清楚说明哪些结论已证实、哪些仍是假设,方便下一章继续学习。
| 检查对象 | 最低证据 | 失败处理 |
|---|---|---|
| 合同 | 目标、边界、指标、责任 | 回到需求澄清 |
| 数据/训练 | 快照、实验、checkpoint、报告 | 修复门禁并重跑 |
| 服务 | 包、API、冒烟、容量 | 不进入灰度 |
| 治理 | 监控、权限、事件、回滚 | 隔离并演练 |
| 接手 | README、冷启动、迁移、归档 | 补文档再复核 |
把仓库交给一位没有参与实现的同伴,只给 README、公开样例和允许的制品引用;如果对方能完成冷启动、生成评估报告、启动服务并演练回滚,你才真正完成了一次模型工程交付。
一次只解决一个主张 综合项目不需要展示所有技术,而要证明一个明确主张:例如在固定成本下提升意图分类,或把一个小模型稳定部署。
主张写进 README 开头,所有数据、实验和发布证据都围绕它组织,避免项目变成工具堆砌。
目录结构要讲故事 src、scripts、configs、data、runs、artifacts、tests 等目录各司其职,文件名包含版本或用途。
新成员按目录就能知道先读哪个文件、哪个命令生成哪个产物,不必搜索整仓库。
命令要从根目录运行 README 中的命令使用明确的工作目录和相对路径,依赖锁定文件保证环境一致。
在干净目录执行一遍,记录实际输出和预计耗时;路径依赖作者电脑的命令要修正。
样例要可公开 用小型、脱敏、许可清楚的样例覆盖 schema、训练、评估和服务,不依赖私有数据库。
私有数据用下载说明和占位引用代替,不能把秘密或个人信息塞进仓库。
产物索引要完整 manifest 关联数据快照、代码提交、权重、评估报告、镜像和路由版本,文件用摘要而非模糊名称。
接手者可由索引找到真实制品并校验内容,旧版本仍可查询和回滚。
验收命令要有退出码 数据检查、训练 smoke、评估门禁、模型包校验和服务冒烟分别返回成功或失败状态。
CI 或发布系统能据此停止推进,而不是解析一段人类日志猜结果。
训练与服务分离 训练环境、模型包和线上服务有不同依赖与权限,不能让服务容器随意修改训练数据。
只发布必要制品,权重与配置只读;训练失败不会影响稳定服务。
评估报告要可重建 保存基准版本、脚本、解码参数、硬件和输出摘要,报告不只是一张截图。
接手者按命令可重新生成同样的指标,差异能够解释为版本或随机性。
冒烟覆盖失败路径 除了成功请求,还测试空输入、超长、未知标签、超时、拒答和取消。
每个失败都对应错误码、日志字段和客户端动作,避免线上才发现接口边界。
灰度前有稳定版本 候选和稳定版同时可用,路由比例、观察窗口和红线先写入发布记录。
接手者知道怎样放量、暂停和摘流,不需要联系原作者确认按钮位置。
回滚是真命令 回滚命令指向不可变镜像、模型包和配置,执行后有健康与质量验证。
演练一次从候选到稳定的完整流程,记录耗时和权限卡点。
监控与文档相互链接 README 给出仪表板、日志字段和 runbook 入口;runbook 反向链接到模型版本与合同。
发生事件时从一个 request_id 能跳到指标、追踪、评估和变更记录。
迁移采用兼容期 接口字段、历史结果、缓存和调用方逐步迁移,必要时双读或影子流量。
在兼容窗口结束前统计旧调用量,确认降为零再退役,避免静默丢请求。
安全审查不能最后补 权限、脱敏、密钥、许可、供应链和日志保留在项目开始就记录。
发布前做一次安全清单复核,发现红线先隔离,再决定是否继续工程优化。
复盘写给未来的自己 记录哪些假设成立、哪些失败、哪个阈值有效、哪项复杂度不值得维护。
下一轮项目可直接复用模板与测试,避免重新踩相同的坑。
学习成果要能迁移 把训练、评估、服务、治理中的通用命令和数据字典抽成模板,但保留项目特有边界。
迁移到新领域时先替换合同和基准,再复用脚手架,不要复制旧项目的隐含假设。
同伴复核是最终门 交付前让没有参与实现的人只看文档运行,收集他卡住的地方。
阻断项修复后再次冷启动;复核记录和版本一起归档,证明项目真的可接手。
| 交付环节 | 最低证据 | 接手者动作 |
|---|---|---|
| 结构 | 目录、命令、样例、依赖 | 新环境可启动 |
| 证据 | manifest、实验、评估、日志 | 结果可重建 |
| 服务 | 包、API、冒烟、容量 | 候选可灰度 |
| 治理 | 监控、权限、事件、回滚 | 异常可恢复 |
| 接手 | README、迁移、归档、复核 | 不用口头提示 |
把项目交给同伴后,最有价值的不是他称赞代码,而是他能指出文档在哪一步不够清楚。每个卡点都应该变成一条新的命令、说明或自动检查。
交付主张 综合项目先写一个能验证的主张,所有代码、数据、评估和服务证据围绕它组织,避免展示与目标无关的工具。
目录与命令 目录结构和命令路径应让新成员知道从哪里开始、每个命令产出什么,以及失败后看哪份日志。
最小样例 公开或脱敏的小样例跑通 schema、训练、评估和服务,再扩大数据与模型。链路不通时不要投入长训练。
产物索引 manifest 关联快照、提交、权重、评估、镜像和路由,每个引用可校验、不可变。
门禁退出码 数据检查、训练 smoke、评估、包校验和服务冒烟分别有清晰退出码,流水线据此停止。
训练与服务隔离 训练依赖、模型包和线上服务分开权限;服务不修改训练数据,失败实验不影响稳定版本。
失败路径 空输入、超长、拒答、超时、取消和依赖不可用都进入冒烟;每个失败有错误码和客户端动作。
灰度回滚 候选和稳定版同时可用,路由比例、窗口、红线和回滚命令写入发布记录并演练。
监控链接 README 链接到仪表板、日志字段和 runbook;从一个 request_id 可以跳到评估、发布和事件。
迁移与退役 旧接口、历史结果、缓存和调用方逐步迁移,统计旧调用量为零后再释放旧资源并归档。
同伴冷启动 不参与实现的人只看 README 完成演练,所有口头提示都变成文档、命令或自动检查。
下一次迭代 复盘哪些假设成立、哪些失败、哪些优化不值得维护,把通用模板与项目边界分开保存。