266 lines
10 KiB
Python
266 lines
10 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
preflight.py —— ModelHub XC 引擎启动前兼容修补器(Iluvatar bi-100 · vLLM)
|
|||
|
|
|
|||
|
|
在 vLLM 启动**之前**读取模型目录,把「引擎镜像层」的结构性不兼容修掉。
|
|||
|
|
判据全部来自真实失败日志(见 README.md「证据」一节)。
|
|||
|
|
|
|||
|
|
已合并的两处**社区已上线修复**(来自 EngineX-Sunrise/enginex-S2-vllm-fix-tokenizer,
|
|||
|
|
dev=sunruoxi,2026-05-28 上线,已在曦望 S2 生产使用):
|
|||
|
|
· extra_special_tokens: list -> dict
|
|||
|
|
· tokenizer_class 归一化(TokenizersBackend / TiktokenTokenizer -> 正常类)
|
|||
|
|
加上本引擎针对 Iluvatar bi-100 失败日志新增的三处:
|
|||
|
|
· R2 模型无 chat_template -> 注入默认 Jinja 模板
|
|||
|
|
· R4 architectures 是镜像未注册类名 -> --hf-overrides 别名映射
|
|||
|
|
· R7 镜像缺 ixformer.contrib.vllm.layers -> 纯 PyTorch shim(经 PYTHONPATH)
|
|||
|
|
|
|||
|
|
输出:JSON {"extra_args": [...], "patches": [...], "overlay": "..."}
|
|||
|
|
extra_args 由 entrypoint.sh 追加到 `vllm serve` 之后。
|
|||
|
|
|
|||
|
|
设计红线:
|
|||
|
|
* 绝不写模型目录(平台挂载的 /model 可能只读);只用临时覆盖目录 + 命令行参数
|
|||
|
|
* 失败安全:任何一步异常只记 warning 并继续,最坏按原命令跑
|
|||
|
|
* 干净模型零改动:不加任何参数
|
|||
|
|
"""
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import shutil
|
|||
|
|
import sys
|
|||
|
|
import tempfile
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# R4:已证实的「引擎镜像未注册 -> 已注册」架构别名表。只收有实证的,不猜。
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
ARCH_ALIASES = {
|
|||
|
|
# 信创模盒官方基线 README 明确要求改成这个(Qwen3.6 系列)
|
|||
|
|
"Qwen3_5MoeForConditionalGeneration": "Qwen3_5MoeForCausalLM",
|
|||
|
|
"Qwen3_5ForConditionalGeneration": "Qwen3_5ForCausalLM",
|
|||
|
|
# Gemma3 早期类名(寒武纪 mlu370 引擎实测 KeyError: 'gemma3_text')
|
|||
|
|
"Gemma3ForConditionalGeneration": "Gemma3ForCausalLM",
|
|||
|
|
"Gemma3TextForCausalLM": "Gemma3ForCausalLM",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 社区已上线的「坏 tokenizer_class」清单(来自 fix_tokenizer.py)
|
|||
|
|
BAD_TOKENIZER_CLASSES = ("TokenizersBackend", "TiktokenTokenizer")
|
|||
|
|
|
|||
|
|
# tokenizer 相关文件:覆盖目录只需要这些
|
|||
|
|
TOKENIZER_FILES = (
|
|||
|
|
"tokenizer.json", "tokenizer_config.json", "special_tokens_map.json",
|
|||
|
|
"vocab.json", "merges.txt", "tokenizer.model", "added_tokens.json",
|
|||
|
|
"chat_template.jinja", "preprocessor_config.json",
|
|||
|
|
)
|
|||
|
|
# 权重文件后缀:永远不拷贝/软链进覆盖目录
|
|||
|
|
WEIGHT_SUFFIXES = (".safetensors", ".bin", ".gguf", ".pt", ".pth", ".msgpack",
|
|||
|
|
".h5", ".onnx", ".ckpt", ".npz", ".arrow")
|
|||
|
|
|
|||
|
|
# R2:最小可用 Jinja 聊天模板(仅当模型自身没有时注入)
|
|||
|
|
DEFAULT_CHAT_TEMPLATE = (
|
|||
|
|
"{%- for message in messages -%}\n"
|
|||
|
|
"{{- '' + message['role'] + '\\n' + message['content'] + '\\n' -}}\n"
|
|||
|
|
"{%- endfor -%}\n"
|
|||
|
|
"{%- if add_generation_prompt -%}\n"
|
|||
|
|
"{{- 'assistant\\n' -}}\n"
|
|||
|
|
"{%- endif -%}\n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _log(msg):
|
|||
|
|
sys.stderr.write("[preflight] " + msg + "\n")
|
|||
|
|
sys.stderr.flush()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_json(path):
|
|||
|
|
try:
|
|||
|
|
with open(path, "r", encoding="utf-8") as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
_log("WARN 读不到 %s: %s" % (path, e))
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _dump_json(obj, path):
|
|||
|
|
try:
|
|||
|
|
with open(path, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(obj, f, ensure_ascii=False, indent=1)
|
|||
|
|
return True
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
_log("WARN 写不了 %s: %s" % (path, e))
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_overlay(model_dir, overlay):
|
|||
|
|
"""把 tokenizer 相关文件镜像进 overlay:优先软链,软链不可用则硬拷贝。
|
|||
|
|
权重文件一律不碰。"""
|
|||
|
|
tok_dir = os.path.join(overlay, "tokenizer")
|
|||
|
|
os.makedirs(tok_dir, exist_ok=True)
|
|||
|
|
n_link = n_copy = 0
|
|||
|
|
try:
|
|||
|
|
names = os.listdir(model_dir)
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
_log("WARN listdir 失败 %s: %s" % (model_dir, e))
|
|||
|
|
return tok_dir, 0, 0
|
|||
|
|
# 1) 白名单 tokenizer 文件
|
|||
|
|
for name in TOKENIZER_FILES:
|
|||
|
|
s = os.path.join(model_dir, name)
|
|||
|
|
d = os.path.join(tok_dir, name)
|
|||
|
|
if not os.path.isfile(s) or os.path.exists(d) or os.path.islink(d):
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
os.symlink(s, d)
|
|||
|
|
n_link += 1
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
try:
|
|||
|
|
shutil.copy2(s, d)
|
|||
|
|
n_copy += 1
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
_log("WARN 拷贝失败 %s: %s" % (s, e))
|
|||
|
|
# 2) 其余非权重文件也软链过去(chat_template.* / *.jinja 等),保证 vLLM 找得到
|
|||
|
|
for name in names:
|
|||
|
|
if name in TOKENIZER_FILES or name.lower().endswith(WEIGHT_SUFFIXES):
|
|||
|
|
continue
|
|||
|
|
s = os.path.join(model_dir, name)
|
|||
|
|
d = os.path.join(tok_dir, name)
|
|||
|
|
if os.path.exists(d) or os.path.islink(d):
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
os.symlink(s, d)
|
|||
|
|
n_link += 1
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
return tok_dir, n_link, n_copy
|
|||
|
|
|
|||
|
|
|
|||
|
|
def detect_tokenizer(model_dir):
|
|||
|
|
"""复刻社区 detect_tokenizer.py 的判定:fast / sentencepiece / bpe / unknown"""
|
|||
|
|
files = set()
|
|||
|
|
try:
|
|||
|
|
files = set(os.listdir(model_dir))
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
if "tokenizer.json" in files:
|
|||
|
|
return "fast"
|
|||
|
|
if "tokenizer.model" in files:
|
|||
|
|
return "sentencepiece"
|
|||
|
|
if "vocab.json" in files and "merges.txt" in files:
|
|||
|
|
return "bpe"
|
|||
|
|
return "unknown"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
ap = argparse.ArgumentParser()
|
|||
|
|
ap.add_argument("--model", default=os.environ.get("MODEL_DIR", "/model"))
|
|||
|
|
ap.add_argument("--api", default="", choices=["", "chat", "completion"])
|
|||
|
|
ap.add_argument("--out", default="")
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
model_dir = args.model
|
|||
|
|
patches = []
|
|||
|
|
extra = []
|
|||
|
|
|
|||
|
|
if not os.path.isdir(model_dir):
|
|||
|
|
res = {"extra_args": [], "patches": ["skip: 模型目录不存在 " + model_dir],
|
|||
|
|
"overlay": ""}
|
|||
|
|
_emit(res, args.out)
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
tok_cfg = _load_json(os.path.join(model_dir, "tokenizer_config.json"))
|
|||
|
|
cfg = _load_json(os.path.join(model_dir, "config.json"))
|
|||
|
|
tok_type = detect_tokenizer(model_dir)
|
|||
|
|
|
|||
|
|
need_tok_fix = False
|
|||
|
|
if isinstance(tok_cfg, dict):
|
|||
|
|
# 社区已上线的两条判据
|
|||
|
|
if isinstance(tok_cfg.get("extra_special_tokens"), list):
|
|||
|
|
need_tok_fix = True
|
|||
|
|
if tok_cfg.get("tokenizer_class") in BAD_TOKENIZER_CLASSES:
|
|||
|
|
need_tok_fix = True
|
|||
|
|
|
|||
|
|
overlay = ""
|
|||
|
|
tok_dir = ""
|
|||
|
|
if need_tok_fix:
|
|||
|
|
overlay = tempfile.mkdtemp(prefix="mhxc_compat_")
|
|||
|
|
tok_dir, nlk, ncp = _build_overlay(model_dir, overlay)
|
|||
|
|
cfg_path = os.path.join(tok_dir, "tokenizer_config.json")
|
|||
|
|
fixed = dict(tok_cfg)
|
|||
|
|
# (a) extra_special_tokens list -> dict(与社区上线版同形:token -> token)
|
|||
|
|
if isinstance(fixed.get("extra_special_tokens"), list):
|
|||
|
|
orig = fixed["extra_special_tokens"]
|
|||
|
|
fixed["extra_special_tokens"] = {t: t for t in orig}
|
|||
|
|
patches.append("R3: extra_special_tokens list(%d) -> dict" % len(orig))
|
|||
|
|
# (b) tokenizer_class 归一化
|
|||
|
|
oc = fixed.get("tokenizer_class")
|
|||
|
|
if oc in BAD_TOKENIZER_CLASSES:
|
|||
|
|
if tok_type == "sentencepiece":
|
|||
|
|
fixed["tokenizer_class"] = "LlamaTokenizer"
|
|||
|
|
else:
|
|||
|
|
fixed["tokenizer_class"] = "PreTrainedTokenizerFast"
|
|||
|
|
fixed["from_slow"] = False
|
|||
|
|
fixed.pop("backend", None)
|
|||
|
|
patches.append("R3b: tokenizer_class %s -> %s" % (oc, fixed["tokenizer_class"]))
|
|||
|
|
if _dump_json(fixed, cfg_path):
|
|||
|
|
extra += ["--tokenizer", tok_dir]
|
|||
|
|
patches.append("覆盖 tokenizer 目录 %s(软链 %d / 拷贝 %d,权重未碰)" % (tok_dir, nlk, ncp))
|
|||
|
|
else:
|
|||
|
|
patches.append("R3: 想修但写覆盖目录失败,按原样跑")
|
|||
|
|
|
|||
|
|
# ---------------- R2:模型无 chat_template -> 注入默认模板 ---------------
|
|||
|
|
if args.api != "completion":
|
|||
|
|
has_tpl = isinstance(tok_cfg, dict) and bool(tok_cfg.get("chat_template"))
|
|||
|
|
has_tpl_file = False
|
|||
|
|
try:
|
|||
|
|
has_tpl_file = any(f.startswith("chat_template") for f in os.listdir(model_dir))
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
if not has_tpl and not has_tpl_file:
|
|||
|
|
if not overlay:
|
|||
|
|
overlay = tempfile.mkdtemp(prefix="mhxc_compat_")
|
|||
|
|
tpl = os.path.join(overlay, "default_chat_template.jinja")
|
|||
|
|
try:
|
|||
|
|
with open(tpl, "w", encoding="utf-8") as f:
|
|||
|
|
f.write(DEFAULT_CHAT_TEMPLATE)
|
|||
|
|
extra += ["--chat-template", tpl]
|
|||
|
|
patches.append("R2: 模型无 chat_template,注入默认模板 %s" % tpl)
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
patches.append("R2: 想注入模板但失败 %s" % e)
|
|||
|
|
|
|||
|
|
# ---------------- R4:architectures 未注册 -> --hf-overrides -------------
|
|||
|
|
if isinstance(cfg, dict):
|
|||
|
|
archs = cfg.get("architectures") or []
|
|||
|
|
if isinstance(archs, str):
|
|||
|
|
archs = [archs]
|
|||
|
|
if archs and archs[0] in ARCH_ALIASES:
|
|||
|
|
new = ARCH_ALIASES[archs[0]]
|
|||
|
|
extra += ["--hf-overrides",
|
|||
|
|
json.dumps({"architectures": [new]}, separators=(",", ":"))]
|
|||
|
|
patches.append("R4: architectures %s -> %s" % (archs[0], new))
|
|||
|
|
|
|||
|
|
# ---------------- R7:缺模块 -> PYTHONPATH 注入 shims --------------------
|
|||
|
|
shim_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shims")
|
|||
|
|
if os.path.isdir(shim_dir):
|
|||
|
|
prev = os.environ.get("PYTHONPATH", "")
|
|||
|
|
os.environ["PYTHONPATH"] = shim_dir + (os.pathsep + prev if prev else "")
|
|||
|
|
patches.append("R7: PYTHONPATH += %s" % shim_dir)
|
|||
|
|
|
|||
|
|
_emit({"model": model_dir, "api": args.api, "extra_args": extra,
|
|||
|
|
"patches": patches, "overlay": overlay, "tokenizer_override": tok_dir},
|
|||
|
|
args.out)
|
|||
|
|
_log("patches=%d %s" % (len(patches), " | ".join(patches) if patches else "(无需修补)"))
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _emit(res, out_path):
|
|||
|
|
txt = json.dumps(res, ensure_ascii=False, indent=1)
|
|||
|
|
if out_path:
|
|||
|
|
try:
|
|||
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
|||
|
|
f.write(txt)
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
_log("WARN 写 %s 失败: %s" % (out_path, e))
|
|||
|
|
sys.stdout.write(txt + "\n")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|