From fa0f8ea38b94931ebce55be868342e7a3e398e77 Mon Sep 17 00:00:00 2001 From: wanglin <2281216234@qq.com> Date: Sat, 3 Oct 2026 20:03:36 +0800 Subject: [PATCH] enginex-mrv100-compat: preflight.py --- preflight.py | 265 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 preflight.py diff --git a/preflight.py b/preflight.py new file mode 100644 index 0000000..d5a4c43 --- /dev/null +++ b/preflight.py @@ -0,0 +1,265 @@ +#!/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())