文档导航

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。顺序很重要:

  1. 查清字节为什么变了 —— 是一次有意的发布,还是你没料到的事。
  2. 复核新的 checkpoint。
  3. 从复核过的副本重新生成映射。
  4. 部署新的映射。

不要直接跳到第 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。
  • 验证只在加载时发生。 之后没有任何东西会重新检查文件,不管它是被攻击者替换的,还是被进程 自己替换的。