0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM-jp-4のtrust_remote_codeを固定して監査する

0
Posted at

LLM-jp-4のtrust_remote_codeを固定して監査する

LLM-jp-4 33B を使うなら、重みを落とす前に trust_remote_code=True が何を読むのか固定しておいたほうがいい。ここを main のまま通すと、同じ起動コマンドでも翌週には別の実装を実行しうる。

8月18日に公開された LLM-jp-4 33B は、Dense 型で 64 layers、context length は 65,536。モデルカードの例でも trust_remote_code=True を指定している。モデルカードCookbook を追うと、これは飾りのフラグではない。LLM-jp-4 は独自 tokenizer と Harmony 形式まわりの実装を同梱している。

今日、Hub の tree を取りにいく小さいコマンドを書いていたら、手元の名前解決が先に落ちた。33B を取り始めてから気づく類の問題ではなかったので、ネットワークにつながらなくても通る自己テストを先に置いた。重みのダウンロードは、そのあとでいい。

固定するのは重みではなく、実行経路

trust_remote_code が有効なとき、まず見たいのは次の4種類だ。

対象 見る理由
*.py 独自 tokenizer や model 実装が入る
*.jinja chat template が入力トークン列を決める
config.json auto_map など読み込むクラスを決める
tokenizer_config.json template と tokenizer の設定を持つ

ここで固定したいのは重みではなく、実行経路だ。

下のスクリプトは Hugging Face の tree API からこの4種類だけを抜き出し、ファイルごとの oid と commit SHA を JSON に残す。標準ライブラリだけで動く。main や tag は受け取らず、40桁の commit SHA だけを受け取るようにした。

#!/usr/bin/env python3
import argparse
import json
from urllib.parse import quote
from urllib.request import Request, urlopen

TARGET_NAMES = {"config.json", "generation_config.json", "tokenizer_config.json"}
TARGET_SUFFIXES = (".py", ".jinja")

def is_execution_relevant(path: str) -> bool:
    return path.rsplit("/", 1)[-1] in TARGET_NAMES or path.endswith(TARGET_SUFFIXES)

def build_lock(model: str, revision: str, entries: list[dict]) -> dict:
    files = []
    for entry in entries:
        path = entry.get("path", "")
        if entry.get("type") != "file" or not is_execution_relevant(path):
            continue
        oid = entry.get("oid")
        if not isinstance(oid, str) or not oid:
            raise ValueError(f"oid がないため固定できません: {path}")
        files.append({"path": path, "oid": oid})
    if not files:
        raise ValueError("実行に関係する .py / .jinja / 設定JSON が見つかりません")
    return {
        "model": model,
        "revision": revision,
        "files": sorted(files, key=lambda item: item["path"]),
    }

def fetch_tree(model: str, revision: str) -> list[dict]:
    safe_model = quote(model, safe="/")
    safe_revision = quote(revision, safe="")
    url = (
        f"https://huggingface.co/api/models/{safe_model}"
        f"/tree/{safe_revision}?recursive=true&expand=false"
    )
    request = Request(url, headers={"Accept": "application/json"})
    with urlopen(request, timeout=20) as response:
        payload = json.load(response)
    if not isinstance(payload, list):
        raise ValueError("Hub API の応答が配列ではありません")
    return payload

def self_test() -> None:
    sample = [
        {"type": "file", "path": "config.json", "oid": "config-oid"},
        {"type": "file", "path": "llmjp4_tokenizer.py", "oid": "tokenizer-oid"},
        {"type": "file", "path": "chat_template.jinja", "oid": "template-oid"},
        {"type": "file", "path": "model-00001.safetensors", "oid": "weight-oid"},
    ]
    lock = build_lock("example/model", "a" * 40, sample)
    assert [item["path"] for item in lock["files"]] == [
        "chat_template.jinja", "config.json", "llmjp4_tokenizer.py"
    ]
    print("self-test: 3 files selected")
    print("self-test: OK")

parser = argparse.ArgumentParser()
parser.add_argument("--model")
parser.add_argument("--revision")
parser.add_argument("--output", default="remote_code_lock.json")
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()

if args.self_test:
    self_test()
elif not args.model or not args.revision:
    parser.error("--model と --revision を指定してください")
elif len(args.revision) != 40 or any(c not in "0123456789abcdef" for c in args.revision.lower()):
    parser.error("--revision には40桁のGit commit SHAを指定してください")
else:
    lock = build_lock(args.model, args.revision, fetch_tree(args.model, args.revision))
    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(lock, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print(f"locked {len(lock['files'])} files -> {args.output}")

掲載コードは Python 3.14 で次の出力を確認した。

$ python audit_hf_remote_code.py --self-test
self-test: 3 files selected
self-test: OK

実行前に lock を作る

Hub の「Files and versions」で commit SHA を選び、その値を渡す。main を渡せないので、ブランチ更新をうっかり本番へ持ち込まない。

python audit_hf_remote_code.py \
  --model llm-jp/llm-jp-4-33b-thinking \
  --revision <Files_and_versionsで選んだ40桁のcommit_SHA>
git add remote_code_lock.json

remote_code_lock.json は重みのハッシュ一覧ではない。更新時には旧ファイルと diff を取り、llmjp4_tokenizer.py、template、auto_map のどれが変わったかをレビューする。この差分が空なら、少なくとも remote code の実行経路は前回と同じだと確認できる。

Cookbook は thinkinginstruct で独自 tokenizer、template、token 単位の出力 parsing に注意が必要だとしている。つまり trust_remote_code を外せば済む話ではない。外すなら、同梱コードを自前で取り込み、その版も同じように固定する必要がある。

おわりに

LLM-jp-4 33B の価値は、重みだけでは動かないところにもある。カスタム実装を有効にするなら、許可の一回で終わらせず、どの commit のどのファイルを読んだかをリポジトリに残す。これならモデル更新時のレビューは数十GBの差分ではなく、数個のコードと設定に絞れる。推論サーバーを速く立てる前に、この lock をCIで比較するところから始めるのが実務では効いた。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?