checkpoint 完整性
checkpoint 完整性
Laya 在加载时从 Hugging Face Hub 下载模型权重。默认它取仓库默认 revision 指向的东西,这很方便, 也是离线缓存已经持有的内容。如果你更愿意钉住一个复核过的 commit,或者拒绝加载字节已经变了的 checkpoint,两者都可用,而且都是可选的。
在你主动要求之前,这里没有任何东西会改变 Laya 加载什么,所以把这些选项加到已有的部署上是安全的。
两者都在库这一层:摘要通过一个环境变量抵达 HTTP 服务器,revision 钉住通过 LAYA_REVISION ——
见在服务器里钉住 revision。
相关:Docker 讲部署变量,laya.load 与 Agent,以及
Router。
钉住一个 revision
给任何加载器传 revision。它接受一个 commit SHA、一个分支或一个 tag,并转发给 Hub。
import laya
agent = laya.load("convaiinnovations/laya", revision="<commit sha, branch, or tag>")
print(agent.revision) # what the download resolved to
优先用 Laya 自带的、复核过的 SHA,而不是自己写一个字面量:它们随 checkpoint 一起更新,所以这种 写法不会过期。
from laya import PINNED_REVISIONS
agent = laya.load(
"convaiinnovations/laya",
revision=PINNED_REVISIONS["convaiinnovations/laya"],
)
Router 接受同样的 revision,并用 revisions 分别钉住每个 checkpoint。PINNED_REVISIONS
的键就是那三个独立仓库,所以要钉住一个 Router 就得带 standalone_repos=True:
router = laya.Router(standalone_repos=True, revisions={
"english": PINNED_REVISIONS["convaiinnovations/laya"],
"multilingual": PINNED_REVISIONS["convaiinnovations/laya-multilingual"],
"typed-decisions": PINNED_REVISIONS["convaiinnovations/laya-typed-decisions"],
})
这很重要,因为默认的 Router 从同一个捆绑仓库加载三个 checkpoint(convaiinnovations/laya,
其中 multilingual/ 和 typed-decisions/ 是子目录),而来自 laya-multilingual 的 commit
SHA 在捆绑仓库里并不存在。没有 standalone_repos,这个钉住不只是被忽略 —— 加载会失败。如果你
更愿意留在捆绑仓库上,就用一个 revision= 给三个 checkpoint 一起钉住,而不是逐个模型用
revisions=。
即便你只服务两个,也要把三个都钉住。一个 Router 会提供它知道的每一个 checkpoint,不管你预加载
了什么,所以一条没钉住的条目离「未钉住地加载」只差一次路由决策。
为什么钉住不是默认行为
默认就钉住会破坏从较旧的缓存快照加载,这对端上部署和气隙部署很重要:HF_HUB_OFFLINE=1 配上一个
早于该钉住的缓存就会失效。所以除非你传入一个 revision,Laya 保持 Hub 的默认行为,同时把复核过的
SHA 开放出来,供你想用时使用。
校验产物摘要
钉住的 revision 说的是取哪个 commit。摘要说的是你预期哪些字节。你列出的每个文件都会在任何一个
被解析之前、在权重到达运行时之前被哈希。这个映射是 {path relative to the checkpoint: sha256 hex}
—— 先生成它,再传入。
两者都需要吗?
钉住的 revision 已经固定了内容:Hub 就是 git,所以 commit 决定了整棵树,大文件由它们各自的 SHA-256 定位。如果你钉住了而下载成功,你拿到的就是那个 commit 命名的字节。所以摘要不是用来重复 那项检查的 —— 它的不同在于它信任什么。
一个 revision 向 Hub 要一个 commit,然后相信这个回答。一个摘要是你做、你留的记录,每次加载 都会比对。它带来三样钉住给不了的东西:
- 覆盖常见情形,也就是没钉住的情形。 钉住是可选的,默认关闭,所以多数部署跟随一条移动的分支。 这时摘要是唯一能察觉变化的东西。
- 对你自己的磁盘做检查。 下载之后,checkpoint 就是缓存里的普通文件,机器上任何东西都能改 它们。加载时没有任何东西会重新校验它们 —— 除了摘要。
- 独立于来源。 如果一个镜像、代理或 Hub 本身提供了不同的字节,摘要是唯一一个不要求被测对象 自己为自己背书的控制手段。
这种独立性也是生成映射必须手动完成的原因:一旦被检查的东西替你把指纹生成出来,指纹就不再是一份 独立的记录。
生成映射
从一个你复核过的 checkpoint 生成它,而不是从任何地方复制摘要,包括这一页。故意没有命令替你生成
它:从 Laya 刚刚下载的那份算出的映射,会拿那些字节去哈希,然后再拿它们自己校验自己。这项检查之所以
有价值,只是因为有人决定了这些字节就是他们想要的,所以生成映射正是那个决定被记录下来的步骤。
一份映射只属于一个 checkpoint:捆绑仓库根目录里的 rl_agent_config.json(english
checkpoint)和 multilingual/ 里的不一样,所以从其中一个生成的映射会对着另一个失败。
import hashlib, json, os
CHECKPOINT = "/path/to/checkpoint" # the directory a load actually reads
FILES = [
"rl_agent_config.json",
"tokenizer/tokenizer.json",
"encoder/config.json",
"model.safetensors",
]
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
digests = {rel: sha256(os.path.join(CHECKPOINT, rel)) for rel in FILES}
with open("digests.json", "w") as f:
json.dump(digests, f, indent=2)
一次 torch Agent 加载会解析五个文件,上面四个就是其中之四。(ONNXAgent 读的是另一组,并且
额外接受 onnx 和 onnx_path 这两个键来给图本身做摘要。)第五个是 tokenizer/tokenizer_config.json,
故意排除在外:Laya 可能在校验之后把它规范化并写回,那样的话钉住它会让下一次加载失败。这次
重写是有条件的 —— 只有当文件没有声明 tokenizer_class、或声明了 TokenizersBackend、或把
extra_special_tokens 带成一个列表时才触发 —— 所以在有些 checkpoint 上它根本不会发生,钉住
这个文件看起来也能用。把它排除在外是可移植的选择,代价是有一个被解析的文件未经验证。见
它保护什么、不保护什么。
使用映射
import json
import laya
with open("digests.json") as f:
agent = laya.load("convaiinnovations/laya", expected_sha256=json.load(f))
键是相对于 checkpoint 目录的路径。不匹配会抛 ValueError,列出的文件缺失会抛
FileNotFoundError。你没列出的文件完全不检查,所以这份映射同时也定义了你在保护什么。它在本地
目录和 Hub 下载上都有效。
不改代码
LAYA_SHA256_DIGESTS 以 JSON 保存同一份映射,在任何加载器被调用、且没有显式给出
expected_sha256 时生效:
export LAYA_SHA256_DIGESTS="$(cat digests.json)"
laya-serve
没有任何东西替你生成它:这个值是你自己的映射,来自一个你复核过的 checkpoint。在 Docker 下,它
必须在 compose 启动之前就在环境里,要么像上面那样导出,要么写在 compose 会读的 .env 文件里
—— 服务会把 ${LAYA_SHA256_DIGESTS:-} 透传下去,所以变量没设就静默地意味着不校验:
echo "LAYA_SHA256_DIGESTS=$(cat digests.json)" >> .env
docker compose -f compose.yaml -f compose.http.yaml up laya-serve
这个变量没有 _FILE 变体:那层间接是为密钥存在的,而摘要映射不是密钥。
变量未设或为空意味着不校验,所以不需要它的环境把它留空是安全的。JSON 格式错误会抛错,而不是 静默跳过检查。
当一个进程加载不止一个 checkpoint 时,给每个 checkpoint 起名。 这个变量有两种形状,值类型 说明是哪一种:
# one set of files, checked on every checkpoint the process loads
LAYA_SHA256_DIGESTS='{"rl_agent_config.json": "<sha256>"}'
# a map per checkpoint, which is what a router serving several needs
LAYA_SHA256_DIGESTS='{"english": {"rl_agent_config.json": "<sha256>"},
"multilingual": {"rl_agent_config.json": "<sha256>"}}'
扁平形式是 verify_digests 自己的读法,把同样的路径应用到所有东西上,所以在一个 router 上它只能
匹配其中一个 checkpoint,其余的都会拒绝。捆绑仓库给每个 checkpoint 都带一份单独的
model.safetensors 和 rl_agent_config.json,所以要给它们起名:
flat map generated from the english checkpoint
load english ok
load multilingual ValueError: laya: SHA-256 mismatch for rl_agent_config.json
嵌套映射没有命名的 checkpoint 会被故意留为未钉住,而不是报错;而 router 不认识的一个模型名会抛错,
而不是让那个 checkpoint 处于未校验状态。en 会解析成 english,和 Router(sha256_digests=...)
应用的同一个规范化。
两条路在最后这一点上不同,如果你两个都用,很容易被绊到。环境变量的嵌套映射省略掉的 checkpoint
会被钉到一个空映射,所以扁平映射无法渗进它。代码里从 Router(sha256_digests=...) 省略掉的
checkpoint 则完全没有条目,所以它仍会回退到环境变量说的东西。你打算钉住的每个 checkpoint,都要
在你所用的那一个里命名。
变量未设或为空意味着不校验,所以不需要它的环境把它留空是安全的。JSON 格式错误会抛错,而不是静默 跳过检查,而在同一个对象里混用两种形状会按名被拒绝。
服务器里不匹配长什么样
它以什么形式浮现取决于预加载。裸的 laya-serve 默认预加载(LAYA_PRELOAD=1),所以不匹配会在
启动时失败 —— 响亮而确定。本仓库里的容器设了 LAYA_PRELOAD=0(compose.http.yaml,
Docker 记录了这项覆盖),所以在那里首次加载发生在某个请求上,在那之前什么也不校验。
不匹配随后会在任何路由到该 checkpoint 的工单上表现为一个 422:laya/serve.py 把 ValueError
映射成 HTTPException(422),并把摘要文本返回给调用方。列出了但缺失的文件则会抛
FileNotFoundError,它会落到一个笼统的 500 “inference failed”,原因只在容器日志里。
要考虑到 422。它在日志、仪表盘和告警规则里被归类为客户端错误,所以运维找坏部署时默认去的地方, 恰好是它不会出现的地方。
在服务器里钉住 revision
LAYA_REVISION 保存一个应用到每次 checkpoint 下载的 commit、分支或 tag,或者单词 reviewed,
后者会在 PINNED_REVISIONS 里查每个仓库并使用它自己的 SHA:
LAYA_REVISION=reviewed laya-serve
对一个表里没有条目的仓库用 reviewed 会抛错,而不是未钉住地加载它 —— 一个悄悄解析不到任何
东西的钉住,正是这个控制手段要防的失败。显式的 revision= 参数仍然优先于这个变量,未设或空白
意味着「没有要求」,所以一份 HF_HUB_OFFLINE=1 的缓存会照旧加载。
在代码里,Router 逐模型接受两者:
router = laya.Router(
revisions={"english": PINNED_REVISIONS["convaiinnovations/laya"]},
sha256_digests={"english": {"rl_agent_config.json": "<sha256>"}},
)
摘要始终逐模型 —— 没有全 router 范围的 revision 等价物,因为一个 commit SHA 可以跨 checkpoint
共享,而一个摘要不能。Docker 讲部署变量,Router 讲完整的
构造函数。
当一个 checkpoint 被更新
这两个控制手段行为不同,而且只有其中一个需要你做什么。
钉住的 revision 把你留在原处。 在你改变钉住之前,新的 checkpoint 到不了一个钉住的部署 ——
这正是钉住的意义。PINNED_REVISIONS 随库移动,所以采用一个更新的复核 commit 意味着升级 Laya,
而不是改一个 SHA。
摘要会拦住加载,而且是故意的。 你的映射是从你复核过的字节生成的。字节不同会在任何东西被解析
之前抛 ValueError:
ValueError: laya: SHA-256 mismatch for rl_agent_config.json: expected ae287b56…, got 25061739…
那是功能在正常工作,不是要绕开的 bug。顺序很重要:
- 查清字节为什么变了 —— 是一次有意的发布,还是你没料到的事。
- 复核新的 checkpoint。
- 从复核过的副本重新生成映射。
- 部署新的映射。
不要直接跳到第 3 步。 拿刚到的任何东西重跑生成器,会让检查通过却什么也没校验 —— 它把新字节 记录成可信,只因为它们在场,而这恰恰是摘要本要检测的状态。
两个细节。通过 LAYA_SHA256_DIGESTS 送来的新映射需要重启进程,因为运行中的服务器会保留它启动
时的环境。而且这个次序只适用于没有钉住 revision 的部署:两个控制都开着时,在你移动钉住之前,
新字节永远不会到达。
确认实际加载了什么
每个 agent 都会记录它来自哪个 commit,本地目录则是 None:
agent.revision # Agent and ONNXAgent
router.loaded_revisions # {"english": "55cf4c4e…", …} for each resident agent
agent.revision 报告下载解析到的快照,取不到时回退到你传入的东西,所以按分支或 tag 钉住会把这个
名字回声出来而不是一个 SHA —— 想让它是一个 SHA,就按 SHA 钉住。从本地目录加载会报告 None,
而且那里会忽略 revision,因为没有 Hub 快照可解析。
服务器报告同样的东西,这是确认一个部署跑着的就是你以为的那个 checkpoint 的最快方式:
curl -s localhost:8000/health
# {"status":"ok","loaded":["english"],"revisions":{"english":"55cf4c4e…"},"device":"auto"}
laya-ts
TypeScript 包复刻了钉住和摘要这两部分 —— revision、expectedSha256,以及把 revision 读回来。
它没有 LAYA_SHA256_DIGESTS 的等价物,也没有服务器,所以上面两节不适用于它:
import { loadNodeBundle, PINNED_REVISIONS } from "laya-ts";
const bundle = await loadNodeBundle("convaiinnovations/laya", {
revision: PINNED_REVISIONS["convaiinnovations/laya"],
expectedSha256: { "rl_agent_config.json": "<sha256 of that file>" },
});
一个显式的 revision 会加入 ~/.cache/laya-ts/ 下的磁盘缓存路径,所以不同钉住的产物永不冲突。
在浏览器里,revision 改为随请求 URL 传递,它以同样的方式给 CacheStorage 做键。createNodeProvider
为它加载的 ONNX 图接受 expectedSha256。
它保护什么、不保护什么
它检测一个内容相对你复核时已经变了的 checkpoint —— 上游仓库改动、被入侵的镜像、损坏的下载,或 被修改过的本地副本。
它不会让一个未经复核的 checkpoint 变安全。摘要只说明字节与你记录的一致;断定那些字节能信,仍然 是你的决定。
在依赖它之前值得知道的三个限制:
- 只检查列出的文件。 没有「校验一切」模式,也没有办法拒绝一个你没列出的文件,所以映射里缺失 的产物会未经验证地加载。映射就是这项保证的边界。
- 因此有一个被解析的文件在它之外。
tokenizer/tokenizer_config.json会被解析,但 Laya 可能 在摘要检查之后立刻把它规范化并写回,所以钉住它可能第一次加载成功、下一次失败。推荐的映射出于 这个原因把它排除在外,这意味着它的字节未被验证。这次重写取决于文件声明了什么,所以它会不会 发生取决于 checkpoint。 - 验证只在加载时发生。 之后没有任何东西会重新检查文件,不管它是被攻击者替换的,还是被进程 自己替换的。