一. 前言:離線能跑,不代表帶得走 #
拍拍君把模型資料夾複製到另一台 Mac,關掉網路,準備展示。 結果不是聊天,而是缺檔錯誤。 原來舊電腦上「已經下載」的模型,還靠著別處的快取活著。 本機成功一次,是開發進度;離開原環境仍能載入,才是交付證據。
MLX-LM 入門教你生成文字, 量化篇比較格式與精度取捨。 今天不比速度、不評答案品質,只完成一件事: 把選定模型做成可搬移、可驗證、可在空快取環境載入的產物。 雜湊原理可先看 hashlib,這裡直接把它用在交付流程。
二. 三個身分,不要只記模型名稱 #
| 身分 | 要回答的問題 | 本文紀錄 |
|---|---|---|
| 上游來源 | 下載哪個倉庫的哪次提交? | repo ID、完整 commit SHA |
| 交付內容 | 交出去的每個檔案是什麼? | 相對路徑、大小、SHA-256 |
| 執行環境 | 哪組軟體真的載入成功? | Python、macOS、架構、套件版本 |
main 會移動,tag 也不是不能改;完整 commit SHA 才能指定此次來源。
但固定 revision 不會順便固定 Python、tokenizer 套件或 MLX kernel。
反過來說,環境完全相同,也不能證明模型目錄沒有缺一片權重。
這三層要各自留下紀錄。
三. 建立小型交付實驗 #
以下在 Apple Silicon Mac 執行;準備階段需要網路。 選用約 135M 參數的英文小模型,目的是縮短驗收,不是推薦中文品質。 2026-10-26 的實測環境為 Python 3.12.12、MLX 0.32.4、 MLX-LM 0.32.2、huggingface_hub 2.3.0、Transformers 5.20.2。
mkdir pypy-model-delivery
cd pypy-model-delivery
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python 'mlx-lm==0.32.2' 'huggingface-hub==2.3.0'
這只是本次重現入口,不是所有未來相依套件的完整鎖定。 正式交付應另存經審核的 dependency lock 與對應平台的安裝材料。 uv 離線環境篇負責那一層,今天不重建 wheelhouse。 模型倉庫的授權、原模型來源與再散布條件也須另外審核;不要只看組織名稱。
四. 從固定 Revision 取出實體檔案 #
依 Hub 下載指南,
snapshot_download() 可指定 revision,回傳該快照的本機路徑。
快取 snapshot 可能使用指向 blobs 的符號連結;
只搬 snapshot 目錄並保留連結,目的地可能沒有連結目標。
官方快取說明有目錄結構示意。
存成 prepare.py,在新實驗目錄執行:
import json
import platform
import shutil
from importlib.metadata import distributions
from pathlib import Path
from huggingface_hub import snapshot_download
REPO = "mlx-community/SmolLM2-135M-Instruct-8bit"
REV = "0f0d9b8218915bc34d401e1a340b8c049d300d5e"
root = Path("release")
root.mkdir(exist_ok=False)
snapshot = snapshot_download(repo_id=REPO, revision=REV)
# 讀取快取,但不修改它;將連結目標實體複製到交付區。
shutil.copytree(snapshot, root / "model", symlinks=False)
provenance = {"repo_id": REPO, "revision": REV}
(root / "provenance.json").write_text(json.dumps(provenance, indent=2))
runtime = {
"python": platform.python_version(), "macos": platform.mac_ver()[0],
"architecture": platform.machine(),
"packages": dict(sorted((d.metadata["Name"], d.version)
for d in distributions())),
}
(root / "runtime.json").write_text(json.dumps(runtime, indent=2))
print("materialized:", root.resolve())
.venv/bin/python prepare.py
symlinks=False 會把檔案連結目標複製成實體內容;不要改寫 Hub 原快取。
本例完整下載所選小倉庫,保留 README、詞表與特殊 token 設定,避免猜副檔名漏檔。
大型倉庫可以採經審核的檔案白名單,但不能直接假設「只有 safetensors 就夠」。
輸出目錄已存在時故意停止;重試請用新目錄,別把兩次下載混在同一包。
若下載中斷,殘留的 release 不是完成產物,不能拿去封存。
五. 先驗模型結構,再封存檔案集合 #
我們的交付規格很窄:release/model/ 必須是所選模型的自足目錄。
除了 config,還要求 tokenizer.json、tokenizer_config 與 chat template。
有權重 index 時,weight_map 引用的分片集合必須和實體分片一致。
這能抓出「少複製一片」和「混入另一版分片」,不代表張量形狀已正確。
以下存成 artifact.py:
import hashlib
import json
import re
import sys
from pathlib import Path, PurePosixPath
def read_json(path):
return json.loads(path.read_text(encoding="utf-8"))
def digest(path):
with path.open("rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
def inventory(root):
if root.is_symlink() or not root.is_dir():
raise ValueError("release root must be a real directory")
result = {}
for path in sorted(root.rglob("*")):
if path.is_symlink():
raise ValueError(f"symlink: {path.name}")
if path.is_dir():
continue
if not path.is_file():
raise ValueError("non-regular file")
name = path.relative_to(root).as_posix()
if name != "manifest.json":
result[name] = {"bytes": path.stat().st_size, "sha256": digest(path)}
return result
def structure(root):
model = root / "model"
for name in ("config.json", "tokenizer.json", "tokenizer_config.json"):
value = read_json(model / name)
if not isinstance(value, dict) or not value:
raise ValueError(f"invalid {name}")
config = read_json(model / "tokenizer_config.json")
if not config.get("chat_template") and not (model / "chat_template.jinja").is_file():
raise ValueError("missing chat template")
weights = {p.name for p in model.glob("*.safetensors")}
if not weights or any((model / n).stat().st_size == 0 for n in weights):
raise ValueError("missing or empty weights")
index = model / "model.safetensors.index.json"
if index.exists():
mapping = read_json(index)["weight_map"]
if not isinstance(mapping, dict) or not mapping:
raise ValueError("empty weight_map")
shards = set(mapping.values())
if any(not isinstance(n, str) or PurePosixPath(n).name != n
or "\\" in n or not n.endswith(".safetensors") for n in shards):
raise ValueError("invalid shard path")
if shards != weights:
raise ValueError("shard inventory differs from index")
origin = read_json(root / "provenance.json")
if not isinstance(origin.get("repo_id"), str) or not origin["repo_id"]:
raise ValueError("missing repo ID")
if not re.fullmatch(r"[0-9a-f]{40}", origin.get("revision", "")):
raise ValueError("revision must be a full commit SHA")
runtime = read_json(root / "runtime.json")
if not all(runtime.get(k) for k in ("python", "macos", "architecture", "packages")):
raise ValueError("missing runtime record")
def seal(root):
files = inventory(root)
structure(root)
with (root / "manifest.json").open("x", encoding="utf-8") as target:
json.dump({"schema": 1, "files": files}, target, indent=2, sort_keys=True)
def verify(root):
actual = inventory(root)
manifest = read_json(root / "manifest.json")
if type(manifest.get("schema")) is not int or manifest["schema"] != 1:
raise ValueError("unsupported manifest schema")
if not isinstance(manifest.get("files"), dict) or actual != manifest["files"]:
raise ValueError("missing, extra or modified artifact files")
structure(root)
return manifest
if __name__ == "__main__":
action, directory = sys.argv[1:]
if action not in {"seal", "verify"}:
raise SystemExit("use seal or verify")
(seal if action == "seal" else verify)(Path(directory))
print(f"{action}: OK")
.venv/bin/python artifact.py seal release
.venv/bin/python artifact.py verify release
manifest.json 不計算自己的 hash;封存後不再原地覆寫。
驗證時重新掃描實際檔案集合,所以多出來的檔案同樣會失敗。
也不依照 manifest 的外部路徑開檔:內容直接和掃描結果比對。
所有符號連結都拒絕,避免交付包又偷偷依賴別處。
這是教學用的單模型 admission policy,不是通用 Hub 驗證器。 SentencePiece-only、其他 tokenizer 結構、adapter 或多模態模型需另訂規格。 即使 JSON、分片列表和 hash 都通過,還是可能有不相容的 model_type、 錯誤張量或 tokenizer;下一步必須真的載入。
六. 把產物搬走,再做空快取驗收 #
存成 smoke.py,與 artifact.py 放在一起:
import json
import os
import sys
from pathlib import Path
from artifact import digest, verify
# 必須由啟動命令設定,且在 import MLX / Hub 之前檢查。
assert os.environ.get("HF_HUB_OFFLINE") == "1"
assert os.environ.get("TRANSFORMERS_OFFLINE") == "1"
cache = Path(os.environ["HF_HOME"])
assert not cache.exists() or not any(cache.iterdir()), "HF_HOME is not empty"
attempts = []
def no_python_network(event, args):
if event in {"socket.connect", "socket.getaddrinfo"}:
attempts.append(event)
raise OSError("network forbidden in artifact smoke test")
sys.addaudithook(no_python_network)
from mlx_lm import generate, load
from mlx_lm.sample_utils import make_sampler
root = Path(sys.argv[1]).resolve(strict=True)
verify(root)
model, tokenizer = load(
str(root / "model"), trust_remote_code=False,
tokenizer_config={"local_files_only": True, "trust_remote_code": False})
prompt = tokenizer.apply_chat_template(
[{"role": "user", "content": "Say hello to Pypy."}],
tokenize=False, add_generation_prompt=True)
answer = generate(model, tokenizer, prompt=prompt, max_tokens=16,
sampler=make_sampler(temp=0), verbose=False)
assert answer.strip() and not attempts
print(json.dumps({"loaded": True, "answer": answer,
"python_network_attempts": attempts,
"manifest_sha256": digest(root / "manifest.json")}))
load() 傳入解析後的絕對路徑,不傳 repo ID。
明確禁止 remote code,也不讓 tokenizer 自行尋找線上材料。
不要在失敗後開啟 trust_remote_code=True 或補上網路,然後還稱它離線驗收。
下面另建 Python 環境,從已暖好的 uv 套件快取安裝相同套件, 再把模型放到新的位置;uv 快取與 Hub 模型快取是兩套系統。 在前面同一平台、同一輪安裝後執行:
uv pip freeze --python .venv/bin/python > runtime-requirements.txt
uv venv --python 3.12 cold-venv
uv pip install --offline --no-deps --python cold-venv/bin/python -r runtime-requirements.txt
mkdir cold-room
cp -R release cold-room/release
HF_HOME="$PWD/cold-room/hf-home" HF_HUB_CACHE="$PWD/cold-room/hf-home/hub" HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 cold-venv/bin/python smoke.py cold-room/release
每次驗收都要使用新的空 HF_HOME;重跑前先改用另一個 cold-room 路徑。
freeze 是此次已安裝版本清單,不含 wheel hash,也不能取代完整供應鏈鎖定。
--offline 安裝若失敗,表示相依套件材料沒備齊,請回到依賴交付層修正。
不應移除這個開關把失敗藏起來。
官方環境變數文件
指出 Hub 設定在 import 時讀取,所以必須從啟動命令就設定。
單純 local_files_only 使用舊機器大快取,仍可能掩蓋交付漏檔;空快取才有意義。
Python audit hook 另外拒絕 socket 連線與 DNS 嘗試,連被函式庫吃掉的嘗試也會記錄。
但它不是 OS 防火牆,不能攔住所有 native library 網路行為。
正式 air-gapped 驗收仍應在隔離網段或作業系統層封鎖網路;本文沒有宣稱做過那項隔離。
七. 實測結果:確認載入,不替模型背書 #
本次封存 12 個資料檔,合計 147,878,167 bytes;manifest 本身另計。
搬移後,新 Python 環境與空 Hub 快取成功載入並生成 16-token 上限的短輸出,
loaded 為 true,python_network_attempts 是空陣列。
這代表本次來源、檔案集合與測試 runtime 通過了這條載入路徑。
它不證明答案正確、不保證跨版本逐字一致,也不保證另一台 Mac 的系統相容。
answer.strip() 只是排除空輸出;這不是模型品質門檻。
真正的任務品質請接 回歸評測,不要把 smoke test 改名叫 benchmark。
而 runtime.json 是準備環境紀錄;接收端也應另存自己的實際 runtime 與驗收結果。
八. 負向測試:故意交一包壞的 #
下面存成 test_delivery.py,直接使用剛完成的 release:
import shutil
from pathlib import Path
import pytest
from artifact import verify
@pytest.mark.parametrize("case", ["modified", "missing", "extra"])
def test_bad_delivery(tmp_path, case):
root = tmp_path / "release"
shutil.copytree(Path("release"), root)
weight = root / "model/model.safetensors"
if case == "modified":
weight.chmod(0o600) # 只改測試複本;Hub 檔案可能原本是唯讀。
with weight.open("r+b") as target:
first = target.read(1)
target.seek(0)
target.write(bytes([first[0] ^ 1]))
elif case == "missing":
weight.unlink()
else:
(root / "unexpected.txt").write_text("not in this release")
with pytest.raises(ValueError):
verify(root)
uv pip install --python .venv/bin/python pytest
.venv/bin/python -m pytest -q test_delivery.py
正式測試再加:缺 tokenizer、空權重、index 指向不存在分片、額外分片、
分片路徑越界、符號連結、錯 manifest schema、revision: main 與缺 runtime。
本文另以微型合成 fixture 執行 22 項檔案契約測試;它們不是可載入模型。
加上上述三項真實權重複本測試,分別覆蓋規格邊界與實際交付檔。
注意:刪檔後如果重建 manifest,byte 比對可能一致;結構檢查仍應擋住缺分片。
因此「交付前重新算一遍 hash」不能替代來源與內容審核。
九. 交接時還差哪幾件事? #
- 授權材料:保留模型卡、來源對應與適用條款;缺 license 文字不代表可自由散布。
- 來源驗證:本地生成的 manifest 只證明內容一致;能同時換掉 manifest 和模型的人仍能造假。
- 可信交付:從受信管道取得核准的 manifest 摘要,或使用可驗證簽章;不要讓接收端自己重封裝就算通過。
- 不可變性:封存後改一個檔案就是新版;驗證與載入之間也不能讓其他程序改檔。
- 環境材料:模型 SHA 不會固定 Metal、macOS 或 Python 依賴,換平台要重新驗收。
- 額外元件:adapter、processor、外部詞表與應用程式 prompt 也要明確列入,不能默默借用開發機內容。
這份短工具假設受控、靜止的本機交付目錄,不處理惡意程序競速換檔, 也不是可安全解壓任意不可信壓縮檔的 sandbox。 先安全展開、確認來源,再驗 manifest 與結構,最後載入;順序別倒過來。
結語 #
拍拍君現在交出去的,不再是「某個好像跑過的資料夾」。 而是固定上游提交、實體化檔案、完整清單、環境紀錄和一條可重做的驗收命令。 遇到錯誤時,先問來源變了、檔案缺了,還是 runtime 不相容。 把這三個問題拆開,離線交付就不必靠舊電腦的快取友情贊助。