From bda57181f81077bc640625c0e893a49bdfb9a1c3 Mon Sep 17 00:00:00 2001 From: user_4BTLNC Date: Sun, 25 Oct 2026 02:37:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ModelHub=20XC=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=EF=BC=88/health=20+=20/stats=20+=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=8F=90=E4=BA=A4=20worker=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 4 + .gitignore | 4 + Dockerfile | 17 ++ README.md | 97 +++++++++ cards.py | 534 +++++++++++++++++++++++++++++++++++++++++++++++ main.py | 364 ++++++++++++++++++++++++++++++++ requirements.txt | 0 7 files changed, 1020 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cards.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e664b9e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.git/ +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57139fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +state.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ffb1fdf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PORT=8080 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8080 + +# 平台通过 /health 做 livenessProbe;SIGTERM 由 main.py 处理,30s 内退出 +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c31fb66 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# 信创模盒适配智能体(ModelHub XC Adapt Agent) + +按《信创模盒适配智能体提交说明》(https://www.modelhub.org.cn/#/docs/6 )实现的策略镜像: +自动从社区模型库挑选候选模型,按卡批量提交国产算力适配验证任务。 + +## 一、平台契约对照 + +| 平台要求 | 本仓库实现 | +|---|---| +| 仓库根目录 Dockerfile | `Dockerfile`,基于 `modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` | +| 暴露运行端口 | `EXPOSE 8080` | +| `GET /health` 返回 200 | `main.py` 内置 HTTP 服务,`/health` → `{"status":"ok"}` | +| 环境变量 `STRATEGY_ID` | 提交任务时填入请求体 `strategyId` | +| 优雅停机(SIGTERM,30s) | 收到信号置位 `STOP`,worker 退出后关闭服务,保存状态 | +| 资源 100m/256Mi ~ 1C/512Mi | 纯标准库、单线程 worker,常驻内存 < 40MB | + +另外平台会注入 `EXTERNAL_SERVICE_TOKEN`(即个人主页的 xcToken),请求头用 `Xc-Token`。 + +## 二、目录结构 + +``` +. +├── Dockerfile # 镜像构建 +├── main.py # 服务入口:/health、/stats + 后台提交 worker +├── cards.py # 11 张目标算力卡的 GPU 标识、默认框架、configParams 模板 +├── requirements.txt # 空文件(仅用 Python 标准库,无需第三方依赖) +└── README.md +``` + +`cards.py` 里的 `configParams` 直接取自平台网页端选择「适配成功率最高」框架后自动生成的 YAML, +与手工在页面提交时下发的配置完全一致,避免因启动命令/端口/显存参数写错导致验证失败。 + +## 三、业务流程 + +1. `POST /api/computility/models/list/page/vo` 拉模型库(22 个种子关键词 × 最多 5 页); +2. 过滤:仅 `文本生成`、已在 ≥`MIN_MACHINES` 张卡适配成功(说明仓库完整)、 + 排除 GGUF / AWQ / GPTQ / 量化(int4/int8/w8a8/w4a16/quantized/fp8/nvfp4)/ LoRA / adapter 等仓库; +3. 逐卡提交 `POST /api/adapt/task/add`,请求头 `Xc-Token`,请求体含 `strategyId`; +4. 返回码处理: + - `0` 成功,计入进度并落盘; + - `60027` 该卡已适配成功 → 跳过该组合; + - `60028` 该组合正在验证中 → 跳过该组合; + - `50000` 上游仓库拉取失败(模型已删除等)→ 跳过; + - `401/403/60014` 凭证或策略问题 → 记录错误并退避,不空转; +5. 每个模型最多提交 `QUOTA_PER_MODEL` 次(默认 2),避免同一模型刷满所有卡。 + +## 四、环境变量 + +| 变量 | 默认 | 说明 | +|---|---|---| +| `STRATEGY_ID` | 平台注入 | 策略 ID,必填 | +| `XC_TOKEN` / `EXTERNAL_SERVICE_TOKEN` | 平台注入 | xcToken,请求头 `Xc-Token` | +| `TARGET_TASKS` | 250 | 本策略要提交成功的任务数 | +| `QUOTA_PER_MODEL` | 2 | 同一模型最多提交几次 | +| `MIN_MACHINES` | 2 | 模型至少已在几张卡适配成功才入选 | +| `INTERVAL_SEC` | 1.5 | 两次提交间隔 | +| `MAX_ATTEMPTS` | 4000 | 总尝试上限,防止无限循环 | +| `MODELHUB_API_BASE` | https://www.modelhub.org.cn | 接口域名 | +| `DRY_RUN` | 空 | 置 1 时不真正提交,用于本地自测 | +| `STATE_FILE` | /tmp/modelhub_agent_state.json | 进度落盘,重启可续跑 | + +## 五、本地自测(不联网提交) + +```bash +pip install -r requirements.txt +DRY_RUN=1 STRATEGY_ID=demo TARGET_TASKS=5 python main.py +curl http://127.0.0.1:8080/health # {"status":"ok"} +curl http://127.0.0.1:8080/stats # 进度、错误码分布、最近一次事件 +``` + +## 六、上线步骤 + +1. **建仓库**:在 https://dev.modelhub.org.cn 新建仓库(例如 `xc-adapt-agent`),把本目录推上去: + + ```bash + git init && git add . && git commit -m "feat: modelhub xc adapt agent" + git remote add origin https://dev.modelhub.org.cn/<你的用户名>/xc-adapt-agent.git + git push -u origin main + ``` + +2. **生成 xcToken**:官网个人主页 → 设置里生成/刷新 xcToken(该 token 由平台注入到容器)。 + +3. **注册智能体**:官网 → 个人主页 →「我的适配智能体」→「新增智能体」, + 填写名称、仓库地址、标签,提交后列表出现该智能体。 + +4. **运行**:在列表点「运行」启动,点「停止」中止。运行后访问 `/stats` 查看提交进度: + + ```json + {"submitted": [...], "attempts": 63, "codes": {"0": 20, "60028": 41}, "progress": "20/250"} + ``` + +## 七、注意事项 + +- 平台对「重复/低质提交」有监控,`TARGET_TASKS` 不建议一次性设得很大,可分日多轮运行; +- 提交成功的任务仍需平台排队验证,`/stats` 只统计**提交成功**,最终是否适配成功以任务列表为准; +- 若 `/stats` 出现 `60014`,说明 `Xc-Token` 或 `strategyId` 不匹配,检查 token 是否刷新、智能体是否已上线; +- 容器被 RESTART 后 worker 会读取 `STATE_FILE` 续跑,不会重复提交已成功的组合。 diff --git a/cards.py b/cards.py new file mode 100644 index 0000000..d78e106 --- /dev/null +++ b/cards.py @@ -0,0 +1,534 @@ +"""目标算力卡配置:卡名、平台内部 GPU 标识、默认框架与配置参数模板。 + +configParams 取自平台网页端选择「适配成功率最高」框架后自动生成的 YAML, +保证与手工提交时下发到验证机的配置完全一致。 +""" + +CARDS = [ + { + "gpu": 'Biren_166m', + "label": '壁砺166M', + "framework": 'vllm_fix_tokenizer', + "configParams": ( + "framework: vllm_fix_tokenizer\n" + "nv_framework: vllm_fix_tokenizer\n" + "lang: en\n" + "max_model_len: 4096\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --dtype\n" + " - float16\n" + " - --gpu-memory-utilization\n" + " - '0.95'\n" + " - -tp\n" + " - '1'\n" + " - --enforce-eager\n" + " - --trust-remote-code" + ), + }, + { + "gpu": 'Iluvatar_mrv-100', + "label": '智铠100', + "framework": 'vllm', + "configParams": ( + "framework: vllm\n" + "nv_framework: vllm\n" + "max_model_len: 4096\n" + "lang: en\n" + "temperature: 0.4\n" + "repetition_penalty: 1.1\n" + "top_p: 0.9\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '20644'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --gpu-memory-utilization\n" + " - '0.9'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'" + ), + }, + { + "gpu": 'Iluvatar_bi-150', + "label": '天垓150', + "framework": 'vllm', + "configParams": ( + "framework: vllm\n" + "nv_framework: vllm\n" + "api: completion\n" + "max_model_len: 4096\n" + "max_tokens: 1024\n" + "temperature: 0.7\n" + "repetition_penalty: 1.2\n" + "top_p: 0.9\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --gpu-memory-utilization\n" + " - '0.9'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'" + ), + }, + { + "gpu": 'Cambricon_mlu-370-x8', + "label": 'MLU370-X8', + "framework": 'vllm-mlu', + "configParams": ( + "framework: vllm-mlu\n" + "nv_framework: vllm_fix_tokenizer\n" + "api: completion\n" + "lang: en\n" + "temperature: 0.4\n" + "repetition_penalty: 1.1\n" + "top_p: 0.9\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " env:\n" + " - name: MAX_MODEL_LEN\n" + " value: 4096\n" + " - name: VLLM_ALLOW_LONG_MAX_MODEL_LEN\n" + " value: '1'\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --trust-remote-code\n" + " - --dtype\n" + " - float16\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --dtype\n" + " - float16\n" + " - --gpu-memory-utilization\n" + " - '0.95'\n" + " - -tp\n" + " - '1'\n" + " - --enforce-eager\n" + " - --trust-remote-code" + ), + }, + { + "gpu": 'Sunrise_pt-200-x1', + "label": '曦望 S2', + "framework": 'vllm_fix_tokenizer', + "configParams": ( + "framework: vllm_fix_tokenizer\n" + "nv_framework: vllm_fix_tokenizer\n" + "api: completion\n" + "max_tokens: 1024\n" + "temperature: 0.7\n" + "repetition_penalty: 1.2\n" + "top_p: 0.9\n" + "lang: zh\n" + "max_model_len: 4096\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --gpu-memory-utilization\n" + " - '0.9'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --dtype\n" + " - float16\n" + " - --gpu-memory-utilization\n" + " - '0.95'\n" + " - -tp\n" + " - '1'\n" + " - --enforce-eager\n" + " - --trust-remote-code" + ), + }, + { + "gpu": 'Kunlunxin_p-800', + "label": 'P800', + "framework": 'vllm_fix_tokenizer', + "configParams": ( + "framework: vllm_fix_tokenizer\n" + "nv_framework: vllm_fix_tokenizer\n" + "lang: en\n" + "max_model_len: 4096\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --dtype\n" + " - float16\n" + " - --gpu-memory-utilization\n" + " - '0.95'\n" + " - -tp\n" + " - '1'\n" + " - --enforce-eager\n" + " - --trust-remote-code" + ), + }, + { + "gpu": 'MetaX_c-500', + "label": '曦云C500', + "framework": 'vllm_tokenizer_patch', + "configParams": ( + "nv_framework: vllm_fix_tokenizer\n" + "framework: vllm_tokenizer_patch\n" + "storage: gpfs\n" + "max_tokens: 1024\n" + "max_model_len: 4096\n" + "api: completion\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command: ['/opt/conda/bin/vllm', 'serve', '/model', '--port', '8000', '--served-model-name', 'llm', '--max-model-len', '4096', '--gpu-memory-utilization', '0.9', '--enforce-eager', '--trust-remote-code' ,'-tp', '1' ]\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --dtype\n" + " - float16\n" + " - --gpu-memory-utilization\n" + " - '0.95'\n" + " - -tp\n" + " - '1'\n" + " - --enforce-eager\n" + " - --trust-remote-code" + ), + }, + { + "gpu": 'hygon_k100-ai', + "label": 'K100AI', + "framework": 'vllm', + "configParams": ( + "framework: vllm\n" + "nv_framework: vllm\n" + "max_model_len: 4096\n" + "api: completion\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '20644'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'" + ), + }, + { + "gpu": 'Vastai_va16', + "label": 'VGX VA16', + "framework": 'vllm_fix_tokenizer', + "configParams": ( + "framework: vllm_fix_tokenizer\n" + "nv_framework: vllm_fix_tokenizer\n" + "lang: en\n" + "max_model_len: 4096\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --dtype\n" + " - float16\n" + " - --gpu-memory-utilization\n" + " - '0.95'\n" + " - -tp\n" + " - '1'\n" + " - --enforce-eager\n" + " - --trust-remote-code" + ), + }, + { + "gpu": 'Iluvatar_bi-100', + "label": '天垓100', + "framework": 'vllm', + "configParams": ( + "framework: vllm\n" + "nv_framework: vllm\n" + "lang: en\n" + "max_model_len: 4096\n" + "sut_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --gpu-memory-utilization\n" + " - '0.9'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '80'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --enforce-eager\n" + " - --trust-remote-code\n" + " - -tp\n" + " - '1'" + ), + }, + { + "gpu": 'Cambricon_mlu-370-x4', + "label": 'MLU370-X4', + "framework": 'vllm-mlu', + "configParams": ( + "framework: vllm-mlu\n" + "nv_framework: vllm_fix_tokenizer\n" + "sut_config:\n" + " values:\n" + " gpu_num: 1\n" + " env:\n" + " - name: MAX_MODEL_LEN\n" + " value: 4096\n" + " command:\n" + " - vllm\n" + " - serve\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --trust-remote-code\n" + " - --dtype\n" + " - float16\n" + "ref_config:\n" + " gpu_num: 1\n" + " values:\n" + " command:\n" + " - /opt/entrypoint.sh\n" + " - /model\n" + " - --port\n" + " - '8000'\n" + " - --served-model-name\n" + " - llm\n" + " - --max-model-len\n" + " - '4096'\n" + " - --dtype\n" + " - float16\n" + " - --gpu-memory-utilization\n" + " - '0.95'\n" + " - -tp\n" + " - '1'\n" + " - --enforce-eager\n" + " - --trust-remote-code" + ), + }, +] + +CARD_INDEX = {c["gpu"]: c for c in CARDS} diff --git a/main.py b/main.py new file mode 100644 index 0000000..aeb0e60 --- /dev/null +++ b/main.py @@ -0,0 +1,364 @@ +"""信创模盒(ModelHub XC)适配智能体。 + +平台契约(见 https://modelhub.org.cn/#/docs/6): + - 仓库根目录必须有 Dockerfile,且 EXPOSE 一个端口用于健康检查 + - 必须提供 GET /health,返回 200 表示存活 + - 通过环境变量 STRATEGY_ID 获取自身策略 ID + - 必须正确处理 SIGTERM,30s 内完成退出 + - 资源:requests 100m/256Mi,limits 1C/512Mi + +业务逻辑: + 1. 拉取社区模型库,筛出「文本生成 + 仓库完整 + 未在该卡适配过」的模型; + 2. 按卡依次调用 /api/adapt/task/add 提交验证任务; + 3. 命中 60027(该卡已验证成功)/ 60028(该卡验证中)就换下一个组合; + 4. 直到提交满 TARGET_TASKS 条或模型池耗尽。 +""" + +from __future__ import annotations + +import json +import os +import signal +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from cards import CARDS + +# ---------------------------------------------------------------- 配置 + +API_BASE = os.getenv("MODELHUB_API_BASE", "https://www.modelhub.org.cn").rstrip("/") +STRATEGY_ID = os.getenv("STRATEGY_ID", "") +XC_TOKEN = os.getenv("XC_TOKEN") or os.getenv("EXTERNAL_SERVICE_TOKEN") or "" +TARGET_TASKS = int(os.getenv("TARGET_TASKS", "250")) +QUOTA_PER_MODEL = int(os.getenv("QUOTA_PER_MODEL", "2")) +INTERVAL_SEC = float(os.getenv("INTERVAL_SEC", "1.5")) +MAX_ATTEMPTS = int(os.getenv("MAX_ATTEMPTS", "4000")) +DRY_RUN = os.getenv("DRY_RUN", "").strip().lower() in ("1", "true", "yes", "on") +STATE_FILE = os.getenv("STATE_FILE", "/tmp/modelhub_agent_state.json") +POOL_FILE = os.getenv("POOL_FILE", "/tmp/modelhub_agent_pool.json") +PAGE_SIZE = int(os.getenv("PAGE_SIZE", "50")) + +SEED_QUERIES = [q for q in os.getenv( + "SEED_QUERIES", + "Qwen2.5-0.5B,Qwen2.5-1.5B,Qwen2.5-3B,Qwen2.5-7B,Qwen3-0.6B,Qwen3-1.7B,Qwen3-4B,Qwen3-8B," + "Llama-3.2,Llama-3.1,InternLM2.5,MiniCPM,Phi-3,Phi-4,gemma-2,Mistral-7B,Yi-1.5,GLM-4," + "ChatGLM3,DeepSeek-R1-Distill,Qwen2-1.5B,Qwen2-7B", +).split(",") if q.strip()] + +# 明显不能直接跑文本生成验证的仓库(统一转小写后匹配) +BAD_TOKENS = ("gensyn", "gguf", "awq", "gptq", "fp8", "fp4", "nvfp4", "adapter", "lora", + "int4", "int8", "w4a16", "w8a8", "w8a16", "quantized", "bnb", "exl2", "hqq", + "mlx", "onnx", "openvino", "tflite") +MIN_MACHINES = int(os.getenv("MIN_MACHINES", "2")) # 至少在一张卡上适配成功过,说明仓库完整 + +# ---------------------------------------------------------------- 全局状态 + +STOP = threading.Event() +LOCK = threading.Lock() +STATE = { + "started_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "submitted": [], # [{model, card, taskId, at}] + "attempts": 0, + "codes": {}, # code -> count + "last_event": None, + "finished": False, + "error": None, +} + + +def log(msg: str) -> None: + print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True) + + +def _save_state() -> None: + try: + tmp = STATE_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(STATE, f, ensure_ascii=False, indent=1) + os.replace(tmp, STATE_FILE) + except Exception as exc: # 状态落盘失败不影响主流程 + log(f"save state failed: {exc}") + + +def _load_state() -> None: + try: + with open(STATE_FILE, "r", encoding="utf-8") as f: + saved = json.load(f) + STATE["submitted"] = saved.get("submitted", []) + log(f"resume: {len(STATE['submitted'])} submitted") + except FileNotFoundError: + pass + except Exception as exc: + log(f"load state failed: {exc}") + + +# ---------------------------------------------------------------- HTTP + +def _request(method: str, path: str, body: dict | None = None, timeout: int = 30) -> tuple[int, str]: + url = API_BASE + path + data = None + headers = {"Accept": "application/json", "User-Agent": "modelhub-xc-agent/1.0"} + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + if XC_TOKEN: + headers["Xc-Token"] = XC_TOKEN + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", "replace") + except Exception as exc: + return 0, str(exc) + + +def _get_json(path: str, timeout: int = 30) -> dict: + status, text = _request("GET", path, timeout=timeout) + try: + return json.loads(text) + except Exception: + return {"code": -1, "message": f"HTTP {status}: {text[:200]}"} + + +def _post_json(path: str, body: dict, timeout: int = 60) -> dict: + status, text = _request("POST", path, body=body, timeout=timeout) + try: + return json.loads(text) + except Exception: + return {"code": -1, "message": f"HTTP {status}: {text[:200]}"} + + +# ---------------------------------------------------------------- 模型池 + +def build_pool() -> list[dict]: + """拉模型库,返回 [{id, machines}],按「已适配卡数」升序(可选卡更多)。""" + seen: dict[str, dict] = {} + for query in SEED_QUERIES: + for page in range(1, 6): + payload = {"pageNum": page, "pageSize": PAGE_SIZE, "searchText": query} + res = _post_json("/api/computility/models/list/page/vo", payload, timeout=40) + records = (res.get("data") or {}).get("records") or [] + if not records: + break + for item in records: + model_id = item.get("modelId") or "" + machines = [m.get("machineName") for m in (item.get("machines") or [])] + level = (item.get("taskLevelsInfo") or {}).get("taskLevelChineseName") + if not model_id or not machines or level != "文本生成": + continue + lower = model_id.lower() + if any(tok in lower for tok in BAD_TOKENS): + continue + if len(machines) < MIN_MACHINES: + continue + if model_id in seen: + continue + seen[model_id] = {"id": model_id, "machines": machines} + if len(records) < PAGE_SIZE: + break + if STOP.is_set(): + break + pool = sorted(seen.values(), key=lambda x: len(x["machines"])) + log(f"pool built: {len(pool)} models") + try: + with open(POOL_FILE, "w", encoding="utf-8") as f: + json.dump(pool, f) + except Exception: + pass + return pool + + +def load_pool() -> list[dict]: + try: + with open(POOL_FILE, "r", encoding="utf-8") as f: + pool = json.load(f) + if pool: + log(f"pool loaded from cache: {len(pool)}") + return pool + except Exception: + pass + return build_pool() + + +# ---------------------------------------------------------------- 提交 + +def submit(model: str, card: dict) -> dict: + body = { + "modelAddress": f"https://huggingface.co/{model}", + "taskType": "text-generation", + "targetGpu": card["gpu"], + "framework": card["framework"], + "strategyId": STRATEGY_ID, + "configParams": card["configParams"], + } + if DRY_RUN: + return {"code": 0, "data": {"id": "dry-run"}, "dryRun": True} + return _post_json("/api/adapt/task/add", body) + + +def worker() -> None: + _load_state() + if not STRATEGY_ID: + STATE["error"] = "STRATEGY_ID 未注入,智能体无法提交(平台运行时自动注入)" + log(STATE["error"]) + return + if not XC_TOKEN and not DRY_RUN: + STATE["error"] = "XC_TOKEN/EXTERNAL_SERVICE_TOKEN 未注入,请在个人主页生成 xcToken 并交给平台" + log(STATE["error"]) + return + + pool = load_pool() + submitted_pairs = {(s["model"], s["card"]) for s in STATE["submitted"]} + per_model: dict[str, int] = {} + for s in STATE["submitted"]: + per_model[s["model"]] = per_model.get(s["model"], 0) + 1 + + while not STOP.is_set() and len(STATE["submitted"]) < TARGET_TASKS: + progress = False + for card in CARDS: + if STOP.is_set() or len(STATE["submitted"]) >= TARGET_TASKS: + break + for cand in pool: + if STOP.is_set() or len(STATE["submitted"]) >= TARGET_TASKS: + break + if STATE["attempts"] >= MAX_ATTEMPTS: + log("reach MAX_ATTEMPTS, stop") + return + if card["gpu"] in cand["machines"]: + continue + if per_model.get(cand["id"], 0) >= QUOTA_PER_MODEL: + continue + pair = (cand["id"], card["gpu"]) + if pair in submitted_pairs: + continue + + res = submit(cand["id"], card) + code = res.get("code") + STATE["attempts"] += 1 + STATE["codes"][str(code)] = STATE["codes"].get(str(code), 0) + 1 + STATE["last_event"] = { + "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "model": cand["id"], "gpu": card["gpu"], "code": code, + "message": (res.get("message") or "")[:160], + } + progress = True + + if code == 0: + task_id = (res.get("data") or {}).get("id") + with LOCK: + STATE["submitted"].append({ + "model": cand["id"], "card": card["gpu"], + "taskId": task_id, + "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }) + per_model[cand["id"]] = per_model.get(cand["id"], 0) + 1 + submitted_pairs.add(pair) + log(f"OK {len(STATE['submitted'])}/{TARGET_TASKS} | {card['label']} | {cand['id']} | {task_id}") + _save_state() + elif code == 60027: # 该卡已经验证成功过 + submitted_pairs.add(pair) + elif code == 60028: # 该组合正在验证中 + submitted_pairs.add(pair) + elif code == 50000: # 拉不到上游模型信息 + submitted_pairs.add(pair) + elif code in (401, 403, 60014): + log(f"fatal {code}: {res.get('message')}") + STATE["error"] = f"{code}: {res.get('message')}" + _save_state() + STOP.wait(120) + elif code in (-1, 0) and res.get("message", "").startswith("HTTP"): + log(f"network issue: {res.get('message')}") + STOP.wait(10) + time.sleep(INTERVAL_SEC) + if not progress: + log("no progress this round; rebuild pool") + pool = build_pool() + if not pool: + log("empty pool, exit") + break + time.sleep(30) + + STATE["finished"] = True + _save_state() + log(f"worker done: submitted={len(STATE['submitted'])} attempts={STATE['attempts']}") + + +# ---------------------------------------------------------------- HTTP 服务 + +class Handler(BaseHTTPRequestHandler): + server_version = "modelhub-xc-agent/1.0" + + def do_GET(self) -> None: # noqa: N802 + path = urllib.parse.urlparse(self.path).path + if path == "/health": + self._send({"status": "ok"}) + return + if path == "/stats": + with LOCK: + payload = json.loads(json.dumps(STATE, ensure_ascii=False)) + payload["target"] = TARGET_TASKS + payload["progress"] = f"{len(payload['submitted'])}/{TARGET_TASKS}" + payload["strategyId"] = STRATEGY_ID or None + payload["dryRun"] = DRY_RUN + self._send(payload) + return + if path == "/": + self._send({ + "name": "modelhub-xc-adapt-agent", + "status": "stopped" if STOP.is_set() else "running", + "strategyId": STRATEGY_ID or None, + "target": TARGET_TASKS, + "submitted": len(STATE["submitted"]), + "attempts": STATE["attempts"], + "dryRun": DRY_RUN, + }) + return + self._send({"error": "not found"}, status=404) + + def log_message(self, fmt: str, *args) -> None: + print(f"{self.address_string()} - {fmt % args}", flush=True) + + def _send(self, body: dict, status: int = 200) -> None: + payload = json.dumps(body, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def _handle_signal(signum: int, _frame) -> None: + log(f"received signal {signum}, shutting down") + STOP.set() + + +def main() -> None: + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + thread = threading.Thread(target=worker, name="submit-worker", daemon=True) + thread.start() + + server = ThreadingHTTPServer(("0.0.0.0", int(os.getenv("PORT", "8080"))), Handler) + server.timeout = 1 + log(f"agent listening on 0.0.0.0:{os.getenv('PORT', '8080')} (dryRun={DRY_RUN}, target={TARGET_TASKS})") + + while not STOP.is_set(): + server.handle_request() + + thread.join(timeout=25) + server.server_close() + _save_state() + log("bye") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29