文件導航

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

  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。
  • 驗證只在載入時發生。 之後沒有任何東西會重新檢查檔案,不管它是被攻擊者替換的,還是被程序 自己替換的。