From e303985cc94c1ccd9a750de52f1519db644edebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=AE=AA=E4=B8=9C?= Date: Wed, 30 Sep 2026 11:06:05 +0800 Subject: [PATCH] Add Qwen2.5 tokenizer compatibility engine and runner workflow --- .dockerignore | 4 + .gitattributes | 5 + .gitea/workflows/docker-build-push.yml | 98 +++++++++++++++ .gitea/workflows/task_info.env | 3 + .gitignore | 3 + Dockerfile | 12 ++ README.md | 55 +++++++++ engine_compat.py | 163 +++++++++++++++++++++++++ tests/test_engine_compat.py | 81 ++++++++++++ vllm_wrapper.sh | 25 ++++ 10 files changed, 449 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitea/workflows/docker-build-push.yml create mode 100644 .gitea/workflows/task_info.env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 engine_compat.py create mode 100644 tests/test_engine_compat.py create mode 100644 vllm_wrapper.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..153d6e7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +__pycache__ +*.pyc +tests diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1d3a572 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.sh text eol=lf +*.py text eol=lf +*.yml text eol=lf +*.env text eol=lf +Dockerfile text eol=lf diff --git a/.gitea/workflows/docker-build-push.yml b/.gitea/workflows/docker-build-push.yml new file mode 100644 index 0000000..48bd35d --- /dev/null +++ b/.gitea/workflows/docker-build-push.yml @@ -0,0 +1,98 @@ +name: Build and verify Qwen2.5 compatibility engines + +on: + push: + tags: + - "v*" + +jobs: + docker: + runs-on: amd64-ubuntu-24.04 + steps: + - name: Clone tagged source + run: | + git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" . + git checkout "${{ gitea.ref_name }}" + + - name: Run compatibility tests + run: python3 -m unittest discover -s tests -q + + - name: Load image metadata + run: | + set -eu + set -a + . .gitea/workflows/task_info.env + set +a + test -n "$FRAMEWORK" + test -n "$TASK_TYPE" + test -n "$GPU_TYPES" + echo "FRAMEWORK=$FRAMEWORK" >> "$GITEA_ENV" + echo "TASK_TYPE=$TASK_TYPE" >> "$GITEA_ENV" + echo "GPU_TYPES=$GPU_TYPES" >> "$GITEA_ENV" + + - name: Validate GPU and task metadata + run: | + set -eu + test -n "${FIXED_TOKEN:-}" || { echo "FIXED_TOKEN is missing"; exit 1; } + for gpu in $GPU_TYPES; do + response="$(curl --silent --show-error --fail-with-body --location --get \ + 'https://modelhub.org.cn/adminApi/image-verify/validate' \ + --header "Xc-Token: $FIXED_TOKEN" \ + --data-urlencode "gpuType=$gpu" \ + --data-urlencode "taskType=$TASK_TYPE")" + VALIDATE_RESPONSE="$response" python3 -c 'import json,os,sys; body=json.loads(os.environ["VALIDATE_RESPONSE"]); sys.exit(0 if body.get("code")==0 and body.get("data") is True else 1)' + echo "validated gpuType=$gpu taskType=$TASK_TYPE" + done + + - name: Log in to Docker registry + run: | + set -eu + test -n "${DOCKER_REGISTRY:-}" + test -n "${DOCKER_USERNAME:-}" + test -n "${DOCKER_PASSWORD:-}" + printf '%s' "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" \ + -u "$DOCKER_USERNAME" --password-stdin + + - name: Build and push target images + run: | + set -eu + image_name="$(printf '%s' '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]' | tr '_' '-')" + image_base="$DOCKER_REGISTRY/$DOCKER_USERNAME/$image_name" + a100_image="$image_base:${{ gitea.ref_name }}-nvidia-a100" + cambricon_image="$image_base:${{ gitea.ref_name }}-cambricon-mlu370" + + docker build \ + --build-arg BASE_IMAGE=harbor.4pd.io/dooke/vllm/vllm/vllm-openai:v0.11.0 \ + -t "$a100_image" . + docker build \ + --build-arg BASE_IMAGE=harbor.4pd.io/hardcore-tech/cambricon-mlu370-pytorch:v25.01-20260228 \ + -t "$cambricon_image" . + + for image in "$a100_image" "$cambricon_image"; do + success=0 + for attempt in 1 2 3; do + if docker push "$image"; then success=1; break; fi + echo "docker push attempt $attempt failed for $image" + sleep 30 + done + test "$success" -eq 1 + done + echo "A100_IMAGE=$a100_image" >> "$GITEA_ENV" + echo "CAMBRICON_IMAGE=$cambricon_image" >> "$GITEA_ENV" + + - name: Notify ModelHub image verification + run: | + set -eu + for gpu in $GPU_TYPES; do + case "$gpu" in + Nvidia-A100) image="$A100_IMAGE" ;; + Cambricon_mlu-370-x4|Cambricon_mlu-370-x8) image="$CAMBRICON_IMAGE" ;; + *) echo "unknown GPU target: $gpu"; exit 1 ;; + esac + payload="$(GPU_TYPE="$gpu" IMAGE="$image" REPO_URL='${{ gitea.server_url }}/${{ gitea.repository }}' TAG='${{ gitea.ref_name }}' ACTOR='${{ gitea.actor }}' python3 -c 'import json, os; print(json.dumps({"framework": os.environ["FRAMEWORK"], "gpuType": os.environ["GPU_TYPE"], "imageUrl": os.environ["IMAGE"], "taskType": os.environ["TASK_TYPE"], "createBy": os.environ["ACTOR"], "repoUrl": os.environ["REPO_URL"], "tag": os.environ["TAG"]}))')" + curl --silent --show-error --fail-with-body --location \ + --request POST 'https://modelhub.org.cn/adminApi/image-verify' \ + --header "Xc-Token: $FIXED_TOKEN" \ + --header 'Content-Type: application/json' \ + --data-raw "$payload" + done diff --git a/.gitea/workflows/task_info.env b/.gitea/workflows/task_info.env new file mode 100644 index 0000000..e6f7f2f --- /dev/null +++ b/.gitea/workflows/task_info.env @@ -0,0 +1,3 @@ +FRAMEWORK=vllm_qwen25_compat +TASK_TYPE=text-generation +GPU_TYPES="Nvidia-A100 Cambricon_mlu-370-x4 Cambricon_mlu-370-x8" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a94b6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ab57b2c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +ARG BASE_IMAGE=harbor.4pd.io/dooke/vllm/vllm/vllm-openai:v0.11.0 +FROM ${BASE_IMAGE} + +COPY engine_compat.py /opt/modelhub/engine_compat.py +COPY vllm_wrapper.sh /opt/modelhub/vllm_wrapper.sh + +RUN set -eu; \ + python3 -m py_compile /opt/modelhub/engine_compat.py; \ + vllm_bin="$(command -v vllm)"; \ + mv "$vllm_bin" "$(dirname "$vllm_bin")/vllm_real"; \ + cp /opt/modelhub/vllm_wrapper.sh "$vllm_bin"; \ + chmod 0755 "$vllm_bin" /opt/modelhub/engine_compat.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe27113 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Qwen2.5 启动兼容引擎 + +这个独立镜像项目沿用 [ModelHub A100 示例仓库](https://dev.modelhub.org.cn/zhengzhongwei/vLLM-Nvidia-A100-opt) +的方式:标签触发 `.gitea/workflows/docker-build-push.yml`,runner 检查元数据、构建并推送镜像, +最后调用 ModelHub 的 `image-verify` 接口注册验证。 + +当前为 A100 参考端和寒武纪 MLU370 x4/x8 构建两个镜像,三个 GPU 共用 +`vllm_qwen25_compat` 框架名。A100 基础镜像来自示例仓库;寒武纪基础镜像来自 +平台现有 MLU370 验证任务使用的镜像。是否允许一个仓库为多个 GPU 注册同一框架, +需要以 runner 的 `validate` 和镜像验证结果为准。 + +## 修复范围 + +- 将列表型 `extra_special_tokens` 合并到 `additional_special_tokens`,解决 + Transformers 调用 `.keys()` 时的崩溃。 +- 对明确写成 `TokenizersBackend` 且带 `tokenizer.json` 的仓库,改用 + `PreTrainedTokenizerFast`。 +- 对 `model_type=qwen2`、`eos_token=<|im_end|>` 且缺少聊天模板的模型,补充 + 简单的 Qwen ChatML 模板,避免 `/v1/chat/completions` 返回 400。 + +启动时在 `/tmp` 建立新模型视图,只重写 `tokenizer_config.json`。权重、模型代码和 +其他文件以符号链接指回只读 `/model`;源模型不变。没有命中上述问题时直接调用原始 +`vllm`。修复记录写在新视图的 `.modelhub-compat-report.json`,同时输出到容器日志。 + +这不能修复缺失权重、尺寸不匹配、Ascend 上持续出现的 0 分输出或平台评测超时。 +尤其是分数为 0 的参考端与目标端必须分别排查;只换一个端的镜像不能保证过审。 + +## 本地验证 + +```bash +python -m unittest discover -s tests -v +python -m py_compile engine_compat.py +``` + +有对应镜像访问权限的 Docker 主机上再运行: + +```bash +docker build --build-arg BASE_IMAGE=harbor.4pd.io/dooke/vllm/vllm/vllm-openai:v0.11.0 \ + -t qwen25-compat:a100 . +docker build --build-arg BASE_IMAGE=harbor.4pd.io/hardcore-tech/cambricon-mlu370-pytorch:v25.01-20260228 \ + -t qwen25-compat:mlu370 . +``` + +## 用 runner 发布 + +1. 在 `dev.modelhub.org.cn` 创建独立仓库,把本目录作为仓库根目录推送。 +2. 为该仓库的 runner 配置 `DOCKER_REGISTRY`、`DOCKER_USERNAME`、 + `DOCKER_PASSWORD`、`FIXED_TOKEN`。不要把凭证写进仓库。 +3. 推送 `v*` 标签。工作流依次跑单元测试、验证 GPU/任务元数据、构建与推送镜像、 + 为 A100 和 MLU370 x4/x8 各发起一次镜像验证。 +4. 在“我的驱动列表”确认三个镜像验证通过,并检查新框架出现在对应 GPU 的 + `frameworks` 接口中,再用少量已知缺陷模型做对照验证。 + +只有平台确认新框架可选且 A100 参考端实际使用新镜像后,才应让 `xc-agent` 把 +列表型 `extra_special_tokens` 的模型路由到此框架。当前智能体继续预检并跳过它们。 diff --git a/engine_compat.py b/engine_compat.py new file mode 100644 index 0000000..d35fd12 --- /dev/null +++ b/engine_compat.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Prepare an isolated model view for known Qwen2.5 tokenizer defects. + +The source model is never edited. Large model files are linked into /tmp. +Only tokenizer_config.json is rewritten when a supported repair is needed. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path +from typing import Any + + +QWEN_CHAT_TEMPLATE = ( + "{% for message in messages %}" + "<|im_start|>{{ message['role'] }}\n" + "{{ message['content'] }}<|im_end|>\n" + "{% endfor %}" + "{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" +) + + +class CompatibilityError(RuntimeError): + pass + + +def _json_object(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise CompatibilityError(f"invalid JSON object: {path.name}") from exc + if not isinstance(value, dict): + raise CompatibilityError(f"invalid JSON object: {path.name}") + return value + + +def _key(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def repair_tokenizer_config( + model_dir: Path, tokenizer: dict[str, Any] +) -> tuple[dict[str, Any], list[str]]: + patched = dict(tokenizer) + repairs: list[str] = [] + + extra = patched.get("extra_special_tokens") + if isinstance(extra, list): + if any( + not ( + isinstance(item, str) + or isinstance(item, dict) and isinstance(item.get("content"), str) + ) + for item in extra + ): + raise CompatibilityError("unsupported extra_special_tokens list") + additional = patched.get("additional_special_tokens", []) + if not isinstance(additional, list): + raise CompatibilityError("additional_special_tokens must be a list") + merged = list(additional) + seen = {_key(item) for item in merged} + for item in extra: + if _key(item) not in seen: + merged.append(item) + seen.add(_key(item)) + patched["additional_special_tokens"] = merged + del patched["extra_special_tokens"] + repairs.append("extra_special_tokens list -> additional_special_tokens") + elif extra is not None and not isinstance(extra, dict): + raise CompatibilityError("extra_special_tokens must be a mapping or list") + + # Only replace the known class mismatch from existing ModelHub failures. + # Arbitrary custom tokenizer classes may require their own Python code. + if patched.get("tokenizer_class") == "TokenizersBackend": + if not (model_dir / "tokenizer.json").is_file(): + raise CompatibilityError("TokenizersBackend requires tokenizer.json") + patched["tokenizer_class"] = "PreTrainedTokenizerFast" + repairs.append("TokenizersBackend -> PreTrainedTokenizerFast") + + if not patched.get("chat_template"): + config_path = model_dir / "config.json" + if config_path.is_file(): + model_config = _json_object(config_path) + if ( + model_config.get("model_type") == "qwen2" + and patched.get("eos_token") == "<|im_end|>" + ): + patched["chat_template"] = QWEN_CHAT_TEMPLATE + repairs.append("added Qwen2 ChatML chat_template") + + return patched, repairs + + +def prepare_model(model_dir: str | Path, *, work_root: str | Path | None = None) -> str | None: + source = Path(model_dir).resolve() + if not source.is_dir(): + raise CompatibilityError(f"model directory is missing: {source}") + config_path = source / "tokenizer_config.json" + if not config_path.is_file(): + return None + + patched, repairs = repair_tokenizer_config(source, _json_object(config_path)) + if not repairs: + return None + + view = Path(tempfile.mkdtemp(prefix="modelhub-compat-", dir=work_root)) + try: + for item in source.iterdir(): + target = view / item.name + if item.name == "tokenizer_config.json": + target.write_text( + json.dumps(patched, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + else: + # The evaluation container is Linux; symlinks also work when + # /model is a read-only mount on a different filesystem. + try: + target.symlink_to(item.resolve(), target_is_directory=item.is_dir()) + except OSError as exc: + if not item.is_file(): + raise CompatibilityError(f"cannot link model item: {item.name}") from exc + try: + os.link(item, target) + except OSError as link_exc: + raise CompatibilityError( + f"cannot link model item: {item.name}" + ) from link_exc + (view / ".modelhub-compat-report.json").write_text( + json.dumps({"source": str(source), "repairs": repairs}, ensure_ascii=False) + + "\n", + encoding="utf-8", + ) + except Exception: + for item in view.iterdir(): + item.unlink() + view.rmdir() + raise + + print(json.dumps({"model_view": str(view), "repairs": repairs}, ensure_ascii=False), file=sys.stderr) + return str(view) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: engine_compat.py MODEL_DIR", file=sys.stderr) + return 2 + try: + view = prepare_model(sys.argv[1]) + except CompatibilityError as exc: + print(f"model compatibility error: {exc}", file=sys.stderr) + return 2 + if view: + print(view) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_engine_compat.py b/tests/test_engine_compat.py new file mode 100644 index 0000000..f68d0d9 --- /dev/null +++ b/tests/test_engine_compat.py @@ -0,0 +1,81 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from engine_compat import QWEN_CHAT_TEMPLATE, CompatibilityError, prepare_model + + +class EngineCompatTests(unittest.TestCase): + def _model(self, root: Path, tokenizer: dict) -> Path: + model = root / "model" + model.mkdir() + (model / "config.json").write_text( + json.dumps({"model_type": "qwen2", "architectures": ["Qwen2ForCausalLM"]}), + encoding="utf-8", + ) + (model / "tokenizer_config.json").write_text( + json.dumps(tokenizer), encoding="utf-8" + ) + (model / "model.safetensors").write_bytes(b"sample-weights") + return model + + def test_repairs_list_and_missing_qwen_chat_template(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self._model( + root, + { + "eos_token": "<|im_end|>", + "extra_special_tokens": ["<|im_start|>", "<|im_end|>"], + "additional_special_tokens": ["<|im_start|>"], + }, + ) + view = Path(prepare_model(source, work_root=root)) + fixed = json.loads((view / "tokenizer_config.json").read_text("utf-8")) + original = json.loads((source / "tokenizer_config.json").read_text("utf-8")) + self.assertIn("extra_special_tokens", original) + self.assertNotIn("extra_special_tokens", fixed) + self.assertEqual( + fixed["additional_special_tokens"], ["<|im_start|>", "<|im_end|>"] + ) + self.assertEqual(fixed["chat_template"], QWEN_CHAT_TEMPLATE) + self.assertEqual((view / "model.safetensors").read_bytes(), b"sample-weights") + self.assertTrue((view / ".modelhub-compat-report.json").is_file()) + + def test_no_change_does_not_create_view(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self._model( + root, + {"eos_token": "<|im_end|>", "chat_template": "custom template"}, + ) + self.assertIsNone(prepare_model(source, work_root=root)) + self.assertEqual([p.name for p in root.iterdir()], ["model"]) + + def test_known_tokenizer_class_is_replaced_only_with_tokenizer_json(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self._model( + root, + {"tokenizer_class": "TokenizersBackend", "chat_template": "custom"}, + ) + with self.assertRaisesRegex(CompatibilityError, "tokenizer.json"): + prepare_model(source, work_root=root) + (source / "tokenizer.json").write_text("{}", encoding="utf-8") + view = Path(prepare_model(source, work_root=root)) + fixed = json.loads((view / "tokenizer_config.json").read_text("utf-8")) + self.assertEqual(fixed["tokenizer_class"], "PreTrainedTokenizerFast") + + def test_unknown_model_family_does_not_get_qwen_template(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self._model(root, {"eos_token": "<|im_end|>"}) + (source / "config.json").write_text( + json.dumps({"model_type": "llama"}), encoding="utf-8" + ) + self.assertIsNone(prepare_model(source, work_root=root)) + + +if __name__ == "__main__": + unittest.main() diff --git a/vllm_wrapper.sh b/vllm_wrapper.sh new file mode 100644 index 0000000..96b71b3 --- /dev/null +++ b/vllm_wrapper.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +vllm_real="$(dirname "$(readlink -f "$0")")/vllm_real" + +if [[ "${1:-}" != "serve" || -z "${2:-}" ]]; then + exec "$vllm_real" "$@" +fi + +model_dir="$2" +shift 2 +prepared="$(python3 /opt/modelhub/engine_compat.py "$model_dir")" +if [[ -z "$prepared" ]]; then + exec "$vllm_real" serve "$model_dir" "$@" +fi + +args=("$@") +for ((i=0; i<${#args[@]}; i++)); do + if [[ "${args[i]}" == "--tokenizer" && $((i+1)) -lt ${#args[@]} && "${args[i+1]}" == "$model_dir" ]]; then + args[i+1]="$prepared" + elif [[ "${args[i]}" == "--tokenizer=$model_dir" ]]; then + args[i]="--tokenizer=$prepared" + fi +done +exec "$vllm_real" serve "$prepared" "${args[@]}"