チェックポイントの完全性
チェックポイントの完全性
Laya は読み込み時に Hugging Face Hub からモデルの重みをダウンロードします。既定では リポジトリの既定リビジョンが指すものをそのまま使います。これは便利であり、オフラインキャッシュ がすでに持っているものとも一致します。レビュー済みのコミットに固定したい場合や、バイト列が 変わったチェックポイントの読み込みを拒否したい場合は、どちらも利用でき、どちらもオプトインです。
ここにあるものは、要求しない限り Laya の読み込み内容を変えません。したがって既存のデプロイに
これらのオプションを追加しても安全です。どちらもライブラリレベルの機能です。ダイジェストは
環境変数を通じて HTTP サーバーに届き、リビジョンの固定は LAYA_REVISION を通じて届きます
—— サーバーでリビジョンを固定する を参照してください。
関連:デプロイ用の変数は Docker、laya.load と Agent、
Router。
リビジョンを固定する
どのローダーにも revision を渡せます。コミット SHA、ブランチ、タグを受け付け、Hub に
転送されます。
import laya
agent = laya.load("convaiinnovations/laya", revision="<commit sha, branch, or tag>")
print(agent.revision) # what the download resolved to
自分で書いたリテラルより、Laya に同梱されているレビュー済みの SHA を優先してください。 それらはチェックポイントと一緒に更新されるので、この形なら古くなりません。
from laya import PINNED_REVISIONS
agent = laya.load(
"convaiinnovations/laya",
revision=PINNED_REVISIONS["convaiinnovations/laya"],
)
Router も同じ revision を受け付け、revisions を使えばチェックポイントごとに固定できます。
PINNED_REVISIONS のキーは 3 つのスタンドアロンリポジトリなので、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 が 3 つのチェックポイントを 1 つのバンドルリポジトリ
(convaiinnovations/laya で、multilingual/ と typed-decisions/ がそのサブフォルダ)
から読み込むのに対し、laya-multilingual のコミット SHA はバンドルリポジトリに存在しない
からです。standalone_repos がないと、固定は無視されるどころか読み込みそのものが失敗します。
バンドルリポジトリに留まりたい場合は、モデルごとの revisions= ではなく、3 つ共通の
revision= で固定してください。
2 つしか提供しない場合でも 3 つとも固定してください。Router はプリロードしたものに関係なく
知っているすべてのチェックポイントを提供するので、固定していないエントリはルーティング 1 回で
固定なしの読み込みに至ります。
なぜ固定が既定ではないのか
既定で固定すると、古いキャッシュ済みスナップショットからの読み込みが壊れます。これは
オンデバイスやエアギャップ環境のデプロイで重要です。HF_HUB_OFFLINE=1 と、固定より前の
キャッシュの組み合わせは動かなくなります。そのため Laya は、リビジョンを渡さない限り Hub の
既定を保ち、必要になったときのためにレビュー済み SHA を用意しています。
成果物のダイジェストを検証する
固定したリビジョンはどのコミットを取得するかを示します。ダイジェストは、期待するどの
バイト列かを示します。リストしたファイルは、そのいずれかが解析される前、そして重みが
ランタイムに届く前にハッシュ化されます。マップの形は
{path relative to the checkpoint: sha256 hex} で、先に生成してから渡します。
両方必要か
固定したリビジョンだけでも内容は確定します。Hub は git なので、コミットがツリーを決め、 大きなファイルはそれ自身の SHA-256 でアドレス指定されます。固定してダウンロードが成功すれば、 そのコミットが名指しするバイト列が手に入っています。つまりダイジェストはそのチェックを 繰り返すためのものではなく、何を信頼するかが違うのです。
リビジョンは Hub にコミットを問い合わせ、その答えを信じます。ダイジェストはあなたが作り あなたが保管する記録で、読み込みのたびに比較されます。これによって、固定にはない次の 3 つが手に入ります。
- 大半のケース(=固定なし)をカバーします。 固定はオプトインで既定では無効なので、 ほとんどのデプロイは動くブランチを追います。その場合、変化に気づけるのはダイジェストだけです。
- 自分のディスクに対する検査になります。 ダウンロード後、チェックポイントはそのマシン上の 何かが編集しうるキャッシュ内の普通のファイルです。読み込み時にそれを再検証するものは ありません —— ダイジェストを除いては。
- 供給元からの独立をもたらします。 ミラー、プロキシ、あるいは Hub 自身が別のバイト列を 返した場合、ダイジェストだけが、検査対象に自己証明を求めない唯一の統制です。
この独立は、マップの生成が手作業である理由でもあります。フィンガープリントは、それが検査する 対象自身が生成した瞬間に、独立した記録でなくなるからです。
マップを生成する
このページを含め、どこかからダイジェストをコピーするのではなく、自分がレビューした
チェックポイントから生成してください。これを代行するコマンドは意図的にありません。Laya が
いまダウンロードしたコピーから計算したマップは、そのバイト列をハッシュ化し、それを自分自身と
照合することになるからです。この検査が意味を持つのは、そのバイト列こそ欲しいものだと人が
判断したからであり、マップの生成はその判断が記録される工程です。マップはちょうど 1 つの
チェックポイントに属します:バンドルリポジトリは、ルート(英語のチェックポイント)と
multilingual/ とで異なる rl_agent_config.json を持つので、一方から生成したマップは他方では
失敗します。
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 の読み込みは 5 つのファイルを解析し、上記はそのうち 4 つです。(ONNXAgent は
異なるファイル群を読み、加えて onnx と onnx_path というキーを受け付けてグラフ自体を
ダイジェスト化します。)5 つ目の tokenizer/tokenizer_config.json は意図的に外しています。
Laya は検証の後にこれを正規化して書き戻すことがあり、その場合に固定していると次の
読み込みが失敗するからです。この書き戻しは条件付きで、ファイルが tokenizer_class を宣言して
いないか、TokenizersBackend を宣言しているか、extra_special_tokens をリストとして持っている
場合にのみ発火します。したがってチェックポイントによっては決して起きず、そのファイルを固定
しても動くように見えます。外しておくのが可搬な選択であり、その代わり解析されるファイルが
1 つ未検証のままになります。これが守るものと守らないもの
を参照してください。
マップを使う
import json
import laya
with open("digests.json") as f:
agent = laya.load("convaiinnovations/laya", expected_sha256=json.load(f))
キーはチェックポイントディレクトリからの相対パスです。不一致は ValueError を送出し、
記載されているのに存在しないファイルは FileNotFoundError を送出します。記載していない
ファイルはまったく検査されないので、このマップは同時に「何を守っているか」の定義でもあります。
ローカルディレクトリでも Hub からのダウンロードでも同じように機能します。
コードに触れずに
LAYA_SHA256_DIGESTS は同じマップを JSON として保持し、ローダーが明示的な expected_sha256
なしで呼ばれたときに適用されます:
export LAYA_SHA256_DIGESTS="$(cat digests.json)"
laya-serve
これを生成してくれるものはありません。値は自分自身のマップであり、自分がレビューした
チェックポイントのものです。Docker では compose が起動する前に環境に置く必要があり、
上記のように export するか、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 は検査を黙って飛ばすのではなく例外を送出します。
1 つのプロセスが複数のチェックポイントを読み込む場合は、それぞれに名前を付けてください。 この変数は 2 つの形を取り、値の型がどちらであるかを示します:
# 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 自身の読み方であり、同じパスをすべてに適用するため、ルーター上
では 1 つのチェックポイントにしか一致せず、残りは拒否されます。バンドルリポジトリは
チェックポイントごとに別々の 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
入れ子のマップが名指ししないチェックポイントは、エラーではなく意図的に固定なしのままに
なります。また、ルーターが知らないモデル名は、そのチェックポイントを未検証のまま残すのでは
なく例外を送出します。en は english に解決されます。これは
Router(sha256_digests=...) が適用する正規化と同じです。
この 2 つの経路はこの最後の点で異なり、両方を使うとつまずきやすいところです。環境変数の
入れ子マップが省いたチェックポイントは空のマップに固定されるので、フラットなマップがそこへ
漏れ込むことはありません。コード中の Router(sha256_digests=...) から省いたチェックポイントは
エントリ自体が存在しないため、環境変数が言う内容へフォールバックします。どちらを使う場合でも、
固定したいチェックポイントはすべて名指ししてください。
未設定または空の変数は検証なしを意味するので、必要としない環境では省いても安全です。壊れた JSON は検査を黙って飛ばすのではなく例外を送出し、1 つのオブジェクトに 2 つの形を混ぜることは 名前を挙げて拒否されます。
サーバーで不一致が起きるとどう見えるか
どう表面化するかはプリロードによります。素の laya-serve は既定でプリロードする
(LAYA_PRELOAD=1)ので、不一致は起動時に失敗します —— 派手で確実です。このリポジトリの
コンテナは LAYA_PRELOAD=0 を設定しており(compose.http.yaml、上書きは Docker
に記載)、そこでは最初の読み込みがリクエスト時まで起きず、それまで何も検証されません。
その場合の不一致は、そのチェックポイントにルーティングされたチケットに対する 422 です。
laya/serve.py は ValueError を HTTPException(422) に対応付け、ダイジェストの文言を
呼び出し元に返します。記載されているが存在しないファイルは代わりに FileNotFoundError を
送出し、これは汎用の 500 “inference failed” に落ち、理由はコンテナログにしか出ません。
422 を想定した計画を立ててください。ログ、ダッシュボード、アラートルールではクライアント エラーとして並ぶので、運用者が壊れたデプロイを探す既定の場所が、これの現れない唯一の場所に なります。
サーバーでリビジョンを固定する
LAYA_REVISION は、すべてのチェックポイントのダウンロードに適用されるコミット、ブランチ、
タグ、または 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>"}},
)
ダイジェストは常にモデルごとです —— revision にルーター全体の等価物はありません。コミット
SHA はチェックポイント間で共有できますが、ダイジェストはそうできないからです。デプロイ用の
変数は Docker、コンストラクタの全体は Router を参照して
ください。
チェックポイントが更新されたとき
2 つの統制は異なる振る舞いをし、手を借りる必要があるのは片方だけです。
固定したリビジョンはその場にとどめます。 新しいチェックポイントは、固定を変えるまで
固定済みのデプロイには届きません。それが固定の目的です。PINNED_REVISIONS はライブラリと
ともに動くので、より新しいレビュー済みコミットを取ることは SHA を編集することではなく、
Laya をアップグレードすることを意味します。
ダイジェストは意図的に読み込みを止めます。 マップは自分がレビューしたバイト列から
生成したものです。異なるバイト列は、何かが解析される前に ValueError を送出します:
ValueError: laya: SHA-256 mismatch for rl_agent_config.json: expected ae287b56…, got 25061739…
これは機能が働いているのであって、回避すべきバグではありません。順序が重要です:
- バイト列が変わった理由を突き止める —— 意図したリリースか、想定外の何かか。
- 新しいチェックポイントをレビューする。
- レビューしたコピーからマップを再生成する。
- 新しいマップをデプロイする。
手順 3 に飛ばないでください。 いま届いたものに対して生成器を再実行すると検査は通りますが、 何も検証していません —— 新しいバイト列がそこにあるという理由で信頼済みとして記録され、 それこそがこのダイジェストが検出するために存在した状態です。
細かい点が 2 つあります。LAYA_SHA256_DIGESTS で配られる新しいマップはプロセスの再起動が
必要です。動いているサーバーは起動時の環境を保持するからです。また、この手順はリビジョン
固定されていないデプロイにのみ当てはまります。両方の統制を有効にすると、固定を動かすまで
新しいバイト列は届きません。
実際に読み込まれたものを確認する
すべてのエージェントは、由来となったコミットを記録します。ローカルディレクトリの場合は
None です:
agent.revision # Agent and ONNXAgent
router.loaded_revisions # {"english": "55cf4c4e…", …} for each resident agent
agent.revision はダウンロードが解決したスナップショットを報告し、渡したものへフォールバック
するので、ブランチやタグで固定すると SHA ではなくその名前が返ります。このフィールドに SHA が
欲しいなら SHA で固定してください。ローカルディレクトリからの読み込みは None を報告し、
そこでは revision は無視されます。解決すべき Hub のスナップショットがないからです。
サーバーも同じことを報告し、これがデプロイが想定どおりのチェックポイントで動いているかを 確認する最短の方法です:
curl -s localhost:8000/health
# {"status":"ok","loaded":["english"],"revisions":{"english":"55cf4c4e…"},"device":"auto"}
laya-ts
TypeScript パッケージは、固定とダイジェストの部分 —— revision、expectedSha256、
リビジョンの読み戻し —— を反映しています。LAYA_SHA256_DIGESTS に相当するものもサーバーも
ないので、上記 2 つの節は当てはまりません:
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>" },
});
明示的なリビジョンは ~/.cache/laya-ts/ 配下のディスクキャッシュのパスに加わるので、
異なる固定をした成果物が衝突することはありません。ブラウザではリビジョンは代わりに
リクエスト URL で運ばれ、CacheStorage を同じようにキー付けします。createNodeProvider は
読み込む ONNX グラフ用に expectedSha256 を受け付けます。
これが守るものと守らないもの
レビューした内容から変わったチェックポイント —— 上流リポジトリの編集、侵害されたミラー、 壊れたダウンロード、改変されたローカルコピー —— を検出します。
レビューしていないチェックポイントを安全にするものではありません。ダイジェストが言うのは 「バイト列が記録したものと一致する」ことだけで、そのバイト列が信頼できると決めるのは 依然としてあなたです。
依拠する前に知っておく価値のある制限が 3 つあります。
- 記載したファイルしか検査されません。「すべて検証する」モードはなく、記載していない ファイルを拒否する方法もないので、マップから漏れた成果物は未検証のまま読み込まれます。 マップが保証の境界です。
- したがって解析されるファイルが 1 つ、境界の外にあります。
tokenizer/tokenizer_config.jsonは解析されますが、Laya はダイジェスト検査の直後にそれを 正規化して書き戻すことがあるため、固定が最初の読み込みでは成功し次では失敗しえます。 推奨のマップがこれを外しているのはそのためで、つまりそのバイト列は検証されません。書き戻しは ファイルの宣言内容に依存するので、起きるかどうかはチェックポイント次第です。 - 検証は読み込み時にのみ行われます。 その後は、攻撃者に置き換えられてもプロセス自身に 置き換えられても、ファイルを再検査するものはありません。