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 變體:那層間接是為金鑰存在的,而摘要對映不是金鑰。
當一個程序載入不止一個 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。 - 驗證只在載入時發生。 之後沒有任何東西會重新檢查檔案,不管它是被攻擊者替換的,還是被程序 自己替換的。