↓快轉到主要內容
  1. 教學文章/

MLX-LM 離線模型產物:Revision 固定、Manifest 與冷環境驗收

·8 分鐘· loading · loading · ·
MLX-LM Hugging Face Offline Model Artifacts Manifest Apple-Silicon
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
科技觀點 - 本文屬於一個選集。
§ 34: 本文

一. 前言:離線能跑,不代表帶得走
#

拍拍君把模型資料夾複製到另一台 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 不相容。 把這三個問題拆開,離線交付就不必靠舊電腦的快取友情贊助。

延伸閱讀
#

科技觀點 - 本文屬於一個選集。
§ 34: 本文

相關文章

MLX-LM 回歸評測:版本化題庫、逐題差異與 CI Gate
·8 分鐘· loading · loading
MLX-LM LLM Regression-Testing Evaluation Ci Apple-Silicon
MLX-LM 結構化 JSON:Schema、Pydantic 驗證與 Repair/Retry
·7 分鐘· loading · loading
MLX-LM LLM Structured Output JSON Schema Pydantic Outlines Apple-Silicon Quality Assurance
MLX-LM Speculative Decoding:Draft Model、加速條件與品質 Benchmark
·10 分鐘· loading · loading
Mlx MLX-LM LLM Speculative Decoding Draft Model Apple-Silicon Benchmark Local AI
MLX-LM 模型轉換與量化:4/8-bit、Mixed Quant 與品質評測
·9 分鐘· loading · loading
Mlx MLX-LM LLM Quantization Apple-Silicon Model Conversion Local AI
MLX-LM 長文本聊天實戰:Context、KV Cache 與記憶體取捨
·10 分鐘· loading · loading
Mlx MLX-LM LLM Long Context KV Cache Apple-Silicon Local AI
MLX-LM 本機 API Server:OpenAI 相容介面、Prompt Cache 與 Tool Calling
·8 分鐘· loading · loading
Mlx MLX-LM LLM OpenAI API Prompt Cache Tool Calling Apple-Silicon