Unified Gemma 4 family image for Gemma-4-31B and GEV-26B-Decide
All checks were successful
Docker Build and Push / docker (push) Successful in 1m11s

- detect_model.py picks TP/port/served name/eager/util from config.json
- validate TP against the model config (dense global KV heads, MoE k % 32)
- one apply_patch.py covers both variants, idempotent
- Dockerfile stays generic: no ENV/WORKDIR/EXPOSE/ENTRYPOINT
- run.sh/verify.sh follow the llama4-bi150 layout
This commit is contained in:
2026-10-27 14:19:51 +08:00
commit 05f1c3932f
12 changed files with 1652 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.gitea
README.md
run.sh
verify.sh
docker-compose.yml
*.log

View File

@@ -0,0 +1,132 @@
name: Docker Build and Push
on:
push:
tags:
- "v*"
jobs:
docker:
runs-on: amd64-ubuntu-24.04
steps:
- name: Clone repository
run: |
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
git checkout "${{ gitea.ref_name }}"
- name: Set image metadata
run: |
IMAGE_NAME="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
IMAGE="${DOCKER_REGISTRY}/${DOCKER_USERNAME}/${IMAGE_NAME}:${{ gitea.ref_name }}"
echo "IMAGE_NAME=${IMAGE_NAME}" >> "$GITEA_ENV"
echo "IMAGE=${IMAGE}" >> "$GITEA_ENV"
- name: Load and Validate Task Info
run: |
set -a
. .gitea/workflows/task_info.env
set +a
for name in FRAMEWORK GPU_TYPE TASK_TYPE; do
eval "value=\${${name}:-}"
if [ "$name" = "FRAMEWORK" ] && [ -z "$value" ]; then
echo "${name} is empty in .gitea/workflows/task_info.env"
exit 1
fi
echo "${name}=${value}" >> "$GITEA_ENV"
done
- name: Validate Image Verify Metadata
run: |
if [ -z "${FIXED_TOKEN:-}" ]; then
echo "FIXED_TOKEN is not configured on runner"
exit 1
fi
if ! response="$(curl --silent --show-error --location --get 'https://modelhub.org.cn/adminApi/image-verify/validate' \
--header "Xc-Token: ${FIXED_TOKEN}" \
--data-urlencode "gpuType=${GPU_TYPE:-}" \
--data-urlencode "taskType=${TASK_TYPE:-}")"; then
echo "failed to call image verify validate API"
exit 1
fi
VALIDATE_RESPONSE="$response" python3 - <<'PY'
import json
import os
import sys
raw = os.environ.get("VALIDATE_RESPONSE", "")
try:
body = json.loads(raw)
except json.JSONDecodeError:
print("image verify validate API returned invalid JSON")
print(raw)
sys.exit(1)
if body.get("code") == 0 and body.get("data") is True:
print("image verify metadata validation passed")
sys.exit(0)
message = body.get("message") or "unknown error"
print(f"image verify metadata validation failed: {message}")
print(raw)
sys.exit(1)
PY
- name: Login to Docker Registry
run: |
echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" \
-u "$DOCKER_USERNAME" \
--password-stdin
- name: Build Docker Image
run: |
docker build -t "$IMAGE" .
- name: Push Docker Image
run: |
for attempt in 1 2 3; do
echo "Starting docker push attempt ${attempt}/3 for ${IMAGE}"
docker push "$IMAGE" &
PUSH_PID=$!
while kill -0 "$PUSH_PID" 2>/dev/null; do
echo "docker push is still running at $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
sleep 60
done
if wait "$PUSH_PID"; then
echo "docker push completed successfully"
exit 0
fi
echo "docker push failed on attempt ${attempt}/3"
sleep 30
done
echo "docker push failed after 3 attempts"
exit 1
- name: Notify Image Verify
run: |
if [ -z "${FIXED_TOKEN:-}" ]; then
echo "FIXED_TOKEN is not configured on runner"
exit 1
fi
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 "{
\"framework\": \"${FRAMEWORK}\",
\"gpuType\": \"${GPU_TYPE}\",
\"imageUrl\": \"${IMAGE}\",
\"taskType\": \"${TASK_TYPE}\",
\"createBy\": \"${{ gitea.actor }}\",
\"repoUrl\": \"${{ gitea.server_url }}/${{ gitea.repository }}\",
\"tag\": \"${{ github.ref_name }}\"
}"

View File

@@ -0,0 +1,3 @@
FRAMEWORK=vllm_gemma_4
GPU_TYPE=Iluvatar_bi-150
TASK_TYPE=text-generation

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.log
__pycache__/
*.pyc

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Gemma 4 family on Iluvatar BI-V150
#
# 一个镜像同时支持:
# - Gemma-4-31B-it (dense,TP4,4 卡,8095,eager 关)
# - GEV-26B-Decide (MoE,TP2,2 卡,8094,eager 开)
#
# 两个变体共用同一个基础镜像和同一组补丁,差别只在 TP / 端口 / served name /
# eager / 显存 / 并发数,全部由入口脚本读 config.json 后决定。
#
# 镜像只包含运行时依赖和补丁,不设置 ENV / WORKDIR / EXPOSE / ENTRYPOINT / CMD;
# 模型路径、端口、TP、GPU、挂载等全部由 run.sh / docker-compose.yml 在启动时传入。
#
# 构建:
# docker build -t gemma4-bi150:2026-10-27 .
#
# 运行(由 run.sh 传入 entrypoint 和全部参数):
# ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
# ./run.sh /mnt/disk0/models/autotrust/GEV-26B-Decide
#
# 补丁是针对下面这个精确基础镜像生成的;换基础镜像需要重新生成 diff。
# registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v7
# (vLLM 0.17.0+corex.20260612070235)
FROM registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v7
ARG IMAGE_VERSION=2026-10-27
LABEL org.opencontainers.image.title="gemma4-bi150" \
org.opencontainers.image.description="Gemma 4 31B-it / GEV-26B-Decide on Iluvatar BI-V150 with vLLM 0.17.0+corex" \
org.opencontainers.image.version="${IMAGE_VERSION}"
COPY apply_patch.py /opt/gemma4/apply_patch.py
COPY detect_model.py /opt/gemma4/detect_model.py
COPY entrypoint.sh /usr/local/bin/gemma4-entrypoint.sh
RUN chmod +x /usr/local/bin/gemma4-entrypoint.sh \
&& chmod +x /opt/gemma4/detect_model.py \
&& python3 /opt/gemma4/apply_patch.py
# 故意不设置 ENV / WORKDIR / EXPOSE / ENTRYPOINT / CMD:
# 镜像保持通用,启动方式由 run.sh 或 docker-compose.yml 决定。

391
README.md Normal file
View File

@@ -0,0 +1,391 @@
# gemma4-bi150 — Gemma 4 家族统一部署(Iluvatar BI-V150)
一个镜像同时支持两个 Gemma 4 家族模型,启动时读模型 `config.json` 自动选择运行参数:
| 模型 | 类型 | 权重 | TP | 卡数 | 端口 | served name | eager | 显存 | seqs |
|---|---|---:|---:|---:|---:|---|---:|---:|---:|
| `gemma-4-31B-it` | dense,60 层 | 58.25 GiB | **4** | 4 | **8095** | `gemma-4-31B-it` | 0 | 0.90 | 4 |
| `GEV-26B-Decide` | MoE,30 层 / 128 experts | 48.07 GiB | **2** | 2 | **8094** | `GEV-26B-Decide` | 1 | 0.95 | 2 |
> 本仓库由 `memory/gemma4-31b-deploy/` 和 `memory/gev-26b-decide-deploy/` 合并而来,
> 原目录保留未删。合并依据见文末「为什么可以合并」。
---
## 1. 目录结构
```text
gemma4-bi150-deploy/
├── Dockerfile # 只放基础镜像 + 补丁 + 脚本,不写 ENV/EXPOSE/ENTRYPOINT
├── entrypoint.sh # 容器入口:打补丁 → detect → exec vllm
├── detect_model.py # 从 config.json 判断 dense / MoE,并校验 TP 可行性
├── apply_patch.py # 幂等应用两个 CoreX vLLM 补丁
├── run.sh # 构建 + 启动 + 健康检查
├── verify.sh # health / models / chat / 可选推理 / 可选图片
├── docker-compose.yml
├── .dockerignore
├── .gitignore
├── .gitea/workflows/
│ ├── docker-build-push.yml
│ └── task_info.env
└── README.md
```
---
## 2. 快速开始
```bash
cd gemma4-bi150-deploy
# Gemma-4-31B(4 卡,默认用 15,14,13,12)
./run.sh /mnt/disk0/models/google/gemma-4-31B-it
# GEV-26B-Decide(2 卡,默认用 14,15)
./run.sh /mnt/disk0/models/autotrust/GEV-26B-Decide
# 验证
./verify.sh --model-dir /mnt/disk0/models/google/gemma-4-31B-it
./verify.sh --model-dir /mnt/disk0/models/autotrust/GEV-26B-Decide --with-image
```
模型类型不需要手写参数,`run.sh` 会调用 `detect_model.py` 读 `config.json` 判断。
### 2.1 不传模型目录
```bash
./run.sh
```
按顺序尝试:
```text
/mnt/disk0/models/google/gemma-4-31B-it
/mnt/disk0/models/autotrust/GEV-26B-Decide
```
### 2.2 Compose
```bash
MODEL_HOST_DIR=/mnt/disk0/models/google/gemma-4-31B-it docker compose up -d --build
```
Compose 里 TP / PORT / SERVED_NAME / MAX_NUM_SEQS / GPU_MEM_UTIL / ENFORCE_EAGER
都默认留空,由容器内 `entrypoint.sh` 检测后填充。
---
## 3. 自动识别逻辑
### 3.1 判断变体
```python
arch = config["architectures"][0] # 必须以 Gemma4 开头
text = config.get("text_config", config)
experts = num_experts / num_local_experts / n_routed_experts
```
| 条件 | 变体 | 运行参数 |
|---|---|---|
| `experts > 1` | GEV-26B-Decide(MoE) | TP2 / 8094 / eager1 / 0.95 / 2 seqs |
| `experts` 缺失或 `== 1` | Gemma-4-31B(dense) | TP4 / 8095 / eager0 / 0.90 / 4 seqs |
| 无 experts 且目录名含 `gev` | GEV | 同上 |
| 无 experts 且目录名含 `gemma` | Gemma-4-31B | 同上 |
| 其它 / 架构不是 Gemma4 | 直接报错 | 不启动 |
`num_experts=1` 按 dense 处理,因为部分 dense 配置用 1 表示"没有专家"。
### 3.2 顺便校验 TP 可行性(这是合并后的额外收益)
两个变体都有硬性 TP 上限,`detect_model.py` 会拿 `config.json` 反查:
```text
dense: num_global_key_value_heads % TP == 0
Gemma-4-31B 的 4 个 global KV head,TP8 会切成 0.5 → 拒绝启动
MoE: (moe_intermediate_size / TP) % 32 == 0
CoreX ixfops.moe_w16a16_group_gemm 要求 k % 32 == 0
GEV 704/2=352 ✅,704/4=176 ❌
```
所以下面这些都会被提前拦住,而不是等 vLLM 跑到一半才崩:
```bash
TP=8 ./run.sh .../gemma-4-31B-it # num_global_key_value_heads=4 % 8 != 0
TP=4 ./run.sh .../GEV-26B-Decide # 704/4=176,k % 32 != 0
```
确实需要强跑时用 `GEMMA4_SKIP_TP_CHECK=1` 降级为警告。
### 3.3 检测输出
```text
$ python3 detect_model.py /mnt/disk0/models/autotrust/GEV-26B-Decide
DETECTED_MODEL_KIND=gev-26b-decide
DETECTED_TP=2
DETECTED_PORT=8094
DETECTED_SERVED_NAME=GEV-26B-Decide
DETECTED_GPU_COUNT=2
DETECTED_MAX_NUM_SEQS=2
DETECTED_GPU_MEM_UTIL=0.95
DETECTED_ENFORCE_EAGER=1
DETECTED_DESCRIPTION='AutoTrust GEV-26B-Decide MoE (BF16, TP2, 2 GPUs)'
```
```bash
python3 detect_model.py <dir> --json # verify.sh 用
python3 detect_model.py <dir> --tp 4 # 按 TP4 校验可行性
```
---
## 4. 覆盖启动参数
显式环境变量优先于检测结果。
```bash
# 换卡
GPUS=12,13,14,15 ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
# 换端口和模型名
PORT=9001 SERVED_NAME=my-gemma4 ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
# 加 vLLM 参数
EXTRA_ARGS="--max-model-len 16384" ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
# 加 docker 参数
EXTRA_DOCKER_ARGS="-v /mnt/disk1:/data:ro" ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
# 用已有镜像(CI 产物)不重新 build
BUILD=0 IMAGE=harbor.4pd.io/modelhubxc/xiejiahao/gemma4-bi150:v0.1.0 \
./run.sh /mnt/disk0/models/google/gemma-4-31B-it
# 只打印命令
DRY_RUN=1 ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
```
常用环境变量:
```text
IMAGE NAME MODEL_HOST_DIR MODEL_MOUNT PORT SERVED_NAME TP GPUS
MAX_MODEL_LEN MAX_NUM_SEQS GPU_MEM_UTIL MAX_IMAGES ENFORCE_EAGER
AUTO_DETECT HEALTH_TIMEOUT EXTRA_ARGS EXTRA_DOCKER_ARGS BUILD DRY_RUN
GEMMA4_SKIP_TP_CHECK
```
`AUTO_DETECT=0` 可关闭容器内检测,全部手动给值。
---
## 5. 为什么 Dockerfile 里没有 ENV / EXPOSE / ENTRYPOINT
镜像必须能同时服务 dense 和 MoE 两个变体,而这些参数在两个变体之间不同:
| 参数 | Gemma-4-31B | GEV-26B-Decide |
|---|---:|---:|
| TP | 4 | 2 |
| 端口 | 8095 | 8094 |
| eager | 0 | 1 |
| 显存 | 0.90 | 0.95 |
| seqs | 4 | 2 |
写进镜像就只能二选一,或者做两个镜像,违背"一个家族一个镜像"的目标。
所以启动方式统一由 `run.sh` / `docker-compose.yml` 传入:
```bash
docker run ... \
--entrypoint /usr/local/bin/gemma4-entrypoint.sh \
--workdir /opt/gemma4 \
--expose 8095 \
-e MODEL_PATH=/models/gemma4 \
-e AUTO_DETECT=1 \
<image>
```
---
## 6. 补丁
`apply_patch.py` 应用两个补丁,构建镜像时和容器启动时都会幂等执行一次。
| 补丁 | 目标文件 | 不打的后果 |
|---|---|---|
| `MultiModalDataDict` 导入路径 | `vllm/model_executor/models/gemma4_mm.py` | `ImportError`,模型检查阶段就失败(**两个变体都需要**) |
| decode fast path 的 attention metadata 判断 | `vllm/model_executor/layers/fused_moe/fused_moe.py` | GEV 的 CUDA graph capture 报 `AttributeError: no attribute 'num_decodes'` |
第二个补丁对 dense 的 Gemma-4-31B 通常用不到,但保持幂等一起打,避免同一镜像换模型后失效。
补丁是针对这个精确基础镜像生成的,换镜像需要重新生成:
```text
registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v7
vLLM 0.17.0+corex.20260612070235
```
---
## 7. 验证
```bash
# 自动找运行中的容器
./verify.sh
# 指定模型目录(推荐,能顺带校验变体)
./verify.sh --model-dir /mnt/disk0/models/google/gemma-4-31B-it
# 指定容器
./verify.sh --container gemma4-31b
# 额外图片识别
./verify.sh --model-dir /mnt/disk0/models/autotrust/GEV-26B-Decide --with-image
# 额外推理题(期望 27)
./verify.sh --model-dir /mnt/disk0/models/google/gemma-4-31B-it --with-reasoning
# 直接指定服务地址
./verify.sh --base-url http://127.0.0.1:8095 --served-name gemma-4-31B-it
```
检查项:`/health`、`/v1/models`、`/v1/chat/completions`,可选推理题和图片识别。
只依赖 `bash`、`curl`、`python3`。全部通过返回 0。
> `/health` 只看 `curl` 退出码,不看 body——vLLM 会返回 200 但 body 为空。
---
## 8. 关键参数的理由
### 8.1 Gemma-4-31B 为什么是 TP4
```text
Gemma4ForConditionalGeneration
num_attention_heads = 32
num_key_value_heads = 16 # sliding attention
num_global_key_value_heads = 4 # full attention
head_dim / global_head_dim = 256 / 512
num_hidden_layers = 60
```
`num_global_key_value_heads=4` 是最紧的约束:TP8 会切成 0.5,当前 vLLM 不支持。
TP4 下 4/4=1,权重 58.25 GiB / 4 ≈ 14.6 GiB/卡,实测加载后 15.89 GiB/卡。
### 8.2 GEV-26B-Decide 为什么是 TP2
```text
Gemma4ForConditionalGeneration
hidden_size = 2816
num_hidden_layers = 30
num_attention_heads = 16
num_key_value_heads = 8
moe_intermediate_size = 704
num_experts = 128
top_k_experts = 8
```
CoreX `ixfops.moe_w16a16_group_gemm` 要求分组 GEMM 的 K 维能被 32 整除,
`704 / TP` 的结果:TP8→88 ❌、TP4→176 ❌、TP2→352 ✅。
TP1 显存不够,所以只有 TP2。
### 8.3 GEV 为什么开 `--enforce-eager`
MoE + CoreX 的 CUDA graph 路径不稳定,实测开 eager 才能稳定启动;
dense 的 Gemma-4-31B 不需要,关掉可以拿到更好的吞吐。
### 8.4 GEV 为什么用 0.95 显存
权重 48.07 GiB / 2 ≈ 24.99 GiB/卡,0.95 下 KV 约 4.9 GiB,适合低并发。
`max-num-seqs` 同时降到 2,避免 KV 被并发打爆。
---
## 9. GPU 选择
`run.sh` 默认优先序号大的卡,避免和 16 卡大模型抢 0-11:
```text
Gemma-4-31B → 15,14,13,12
GEV-26B-Decide → 14,15
```
卡数不足会直接报错,卡数多余只警告(vLLM 只用前 TP 张):
```bash
GPUS=12,13 ./run.sh .../gemma-4-31B-it # 报错:需要 4 张
GPUS=0,1,2,3,4,5 ./run.sh .../GEV-26B-Decide # 警告:只用其中 2 张
```
查看空闲卡:
```bash
/usr/local/corex/bin/ixsmi
```
---
## 10. 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| `ImportError: cannot import name 'MultiModalDataDict'` | 补丁没打上 | 确认 `apply_patch.py` 执行过 |
| `AttributeError: 'TritonAttentionMetadata' object has no attribute 'num_decodes'` | MoE 补丁没打上 | 同上 |
| `Expected k % 32 == 0 to be true` | GEV 用了 TP4/TP8 | 回到 TP2;`detect_model.py` 会提前拦住 |
| KV head 无法切分 / head 数不整除 | dense 用了 TP8 | 回到 TP4 |
| `cudaGetDeviceCount() ... Error 1001` | 少了 `--privileged -v /dev:/dev` | 用 `run.sh`,不要手写简化版 `docker run` |
| 启动 10 分钟还没 ready | 冷读 49–59 GB 权重 | 默认给 900s;`docker logs -f` 看 `Loading weights` |
| `/health` 超时但容器在跑 | 还在加载权重 | 以 `Loading weights took` 和 `/v1/models` 为准 |
---
## 11. CI 与发布
```bash
# 首次
git tag v0.1.0 && git push origin v0.1.0
```
- 只有推送 `v*` 标签才触发;
- runner label:`amd64-ubuntu-24.04`,需要 `DOCKER_*` / `FIXED_TOKEN`;
- `task_info.env`:`FRAMEWORK=vllm_gemma_4`(必须独特,不要用泛化的 `vllm`);
- CI 被取消后**不要复用标签**,直接 bump patch;
- CI 构建 vLLM 基础镜像较久,本次预计 1.5 小时量级,别着急。
CI 产物通用,不会自动启动,用 `run.sh` 指定 entrypoint:
```bash
BUILD=0 IMAGE=<ci-image> ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
```
---
## 12. 为什么可以合并
合并前是 `gemma4-31b-deploy/` 和 `gev-26b-decide-deploy/` 两套目录。逐项对比:
| 项 | Gemma-4-31B | GEV-26B-Decide | 能否 detect |
|---|---|---|---|
| 基础镜像 | 同一个 | 同一个 | — |
| 架构 | `Gemma4ForConditionalGeneration` | 同一个 | 不需要 |
| vLLM 补丁 | 两个 | 同样两个 | 不需要 |
| docker run 参数 | 相同 | 相同 | — |
| TP | 4 | 2 | ✅ config 可判 |
| 端口 | 8095 | 8094 | ✅ config 可判 |
| served name | `gemma-4-31B-it` | `GEV-26B-Decide` | ✅ config 可判 |
| eager | 0 | 1 | ✅ config 可判 |
| 显存 / seqs | 0.90 / 4 | 0.95 / 2 | ✅ config 可判 |
差异项全部可由 `config.json` 推断,和 Llama 4 用 `num_local_experts` 分 Scout/Maverick 是同一形态,
因此按"一个模型家族一个仓库"的原则合并。
顺带解决的问题:
1. 两份 `apply_patch.py` 原本只差注释,现在只有一份;
2. 两个 Dockerfile 原本都写死了 `ENV/EXPOSE/ENTRYPOINT`,现在通用;
3. 一次 CI 覆盖两个模型,而不是两次构建(每次约 1.5 小时);
4. 新增 TP 可行性校验,把"GEV 只能 TP2、Gemma4 只能 TP4"从文档结论变成启动前断言。
---
## 13. 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-27 | 由 `gemma4-31b-deploy` + `gev-26b-decide-deploy` 合并为本目录;新增 `detect_model.py`、`verify.sh`、`.gitea`;Dockerfile 去掉 ENV/EXPOSE/ENTRYPOINT;新增 TP 可行性校验和 `GEMMA4_SKIP_TP_CHECK` |

109
apply_patch.py Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Apply the CoreX vLLM compatibility patches required by the Gemma 4 family.
Both members of the family (Gemma-4-31B-it dense, GEV-26B-Decide MoE) need the
same two fixes, generated against the exact base image
registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v7
(vLLM 0.17.0+corex.20260612070235)
1. `vllm/model_executor/models/gemma4_mm.py` imports `MultiModalDataDict`
from `vllm.inputs`, but this build exposes it from
`vllm.multimodal.inputs`. Without the fix, model inspection fails before
the server starts:
ImportError: cannot import name 'MultiModalDataDict' from 'vllm.inputs'
2. `vllm/model_executor/layers/fused_moe/fused_moe.py` checks
`TritonAttentionMetadata.num_decodes` / `num_prefills` for the decode-only
fast path. The CoreX Triton attention metadata only has `max_query_len`.
Gemma-4-31B is dense so this path is normally unused, but GEV (MoE) fails
CUDA graph capture without the fix:
AttributeError: 'TritonAttentionMetadata' object has no attribute 'num_decodes'
The script is idempotent, tolerates an already-reformatted target file, and
validates every patched file with `ast.parse`. It runs both at image build
time and again on container start, so a stale image still gets fixed.
"""
from __future__ import annotations
import ast
import os
import sys
from pathlib import Path
try:
import vllm
except ImportError as exc: # pragma: no cover - only in broken images
print(f"ERROR: cannot import vllm: {exc}", file=sys.stderr)
raise SystemExit(1)
PKG = Path(
os.environ.get(
"VLLM_PKG_ROOT",
str(Path(vllm.__file__).resolve().parent.parent),
)
)
MM_FILE = PKG / "vllm/model_executor/models/gemma4_mm.py"
MOE_FILE = PKG / "vllm/model_executor/layers/fused_moe/fused_moe.py"
MM_OLD = "from vllm.inputs import MultiModalDataDict"
MM_NEW = "from vllm.multimodal.inputs import MultiModalDataDict"
MM_MARK = "from vllm.multimodal.inputs import MultiModalDataDict"
MOE_OLD = (
" only_decode = (use_ep == False and all("
"t.num_decodes > 0 and t.num_prefills ==0 for t in "
"list(attn_metadata.values())))"
)
MOE_NEW = """ only_decode = (
use_ep == False
and all(
getattr(t, "max_query_len", 2) == 1
for t in list(attn_metadata.values())
)
)"""
# 单双引号两种写法都算已打过,避免不同格式化工具造成重复替换。
MOE_MARK = (
'getattr(t, "max_query_len", 2) == 1',
"getattr(t, 'max_query_len', 2) == 1",
)
def _patch(
path: Path,
old: str,
new: str,
marker: str | tuple[str, ...],
label: str,
) -> None:
if not path.exists():
raise SystemExit(f"target file not found: {path}")
src = path.read_text(encoding="utf-8")
markers = (marker,) if isinstance(marker, str) else marker
if any(m in src for m in markers):
print(f"[skip] {label}: already patched ({path})")
return
if old not in src:
raise SystemExit(
f"ERROR: {label}: expected code block not found in {path}.\n"
"The base image or vLLM version may differ; inspect the file manually."
)
path.write_text(src.replace(old, new, 1), encoding="utf-8")
print(f"[ok] {label}: {path}")
def main() -> None:
_patch(MM_FILE, MM_OLD, MM_NEW, MM_MARK, "Gemma4 MultiModalDataDict import")
_patch(MOE_FILE, MOE_OLD, MOE_NEW, MOE_MARK, "TritonAttentionMetadata decode check")
for path in (MM_FILE, MOE_FILE):
ast.parse(path.read_text(encoding="utf-8"))
print("all Gemma 4 family patches applied; syntax OK")
if __name__ == "__main__":
main()

236
detect_model.py Executable file
View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Detect a Gemma 4 variant on BI-150 from its config.json.
The unified image serves both members of this family:
- Gemma-4-31B-it (dense, 60 layers, 4 global KV heads)
-> TP4, 8095, eager off, util 0.90, 4 seqs
- GEV-26B-Decide (MoE, 30 layers, 128 experts)
-> TP2, 8094, eager on, util 0.95, 2 seqs
Both are `Gemma4ForConditionalGeneration` and share the same two vLLM patches,
so only the runtime profile differs. Dense vs MoE is decided by the presence
of an expert count in the config (top-level or `text_config`).
On top of the static profile the script *validates* the profile against the
model config, because both variants have a hard TP ceiling:
* dense: `num_global_key_value_heads` must be divisible by TP
(Gemma-4-31B has 4 -> TP8 would split a KV head into 0.5)
* MoE: `moe_intermediate_size / TP` must be divisible by 32
(CoreX `ixfops.moe_w16a16_group_gemm` requires k % 32 == 0;
GEV 704/2=352 ok, 704/4=176 -> RuntimeError)
Set GEMMA4_SKIP_TP_CHECK=1 to downgrade that validation to a warning.
Detected values are printed as shell assignments so run.sh / entrypoint.sh can
`eval` them; `--json` is used by verify.sh.
"""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import shlex
import sys
PROFILES = {
"gemma4-31b": {
"tp": 4,
"port": 8095,
"served_name": "gemma-4-31B-it",
"gpu_count": 4,
"max_num_seqs": 4,
"gpu_mem_util": "0.90",
"enforce_eager": 0,
"description": "Gemma 4 31B-it dense (BF16, TP4, 4 GPUs)",
},
"gev-26b-decide": {
"tp": 2,
"port": 8094,
"served_name": "GEV-26B-Decide",
"gpu_count": 2,
"max_num_seqs": 2,
"gpu_mem_util": "0.95",
"enforce_eager": 1,
"description": "AutoTrust GEV-26B-Decide MoE (BF16, TP2, 2 GPUs)",
},
}
def load_config(model_dir: str) -> dict:
path = pathlib.Path(model_dir) / "config.json"
if not path.is_file():
raise ValueError(f"config.json not found: {path}")
try:
return json.loads(path.read_text())
except json.JSONDecodeError as exc:
raise ValueError(f"invalid config.json: {exc}") from exc
def text_section(cfg: dict) -> dict:
"""Return the text sub-config if present, else the top-level config."""
text = cfg.get("text_config")
if isinstance(text, dict):
return text
return cfg
def expert_count(text: dict, cfg: dict) -> int | None:
for source in (text, cfg):
for key in ("num_experts", "num_local_experts", "n_routed_experts"):
value = source.get(key)
if value is not None:
return int(value)
return None
def divisibility_errors(kind: str, tp: int, text: dict, cfg: dict) -> list[str]:
"""Return human-readable reasons why `tp` cannot be used for this model."""
errors: list[str] = []
for key in ("num_attention_heads", "num_key_value_heads", "num_global_key_value_heads"):
value = text.get(key, cfg.get(key))
if value is None:
continue
value = int(value)
if value % tp != 0:
errors.append(
f"{key}={value} is not divisible by TP={tp} "
f"({value} / {tp} = {value / tp:g})"
)
if kind == "gev-26b-decide":
moe = text.get("moe_intermediate_size", cfg.get("moe_intermediate_size"))
if moe is not None:
moe = int(moe)
if moe % tp != 0:
errors.append(f"moe_intermediate_size={moe} is not divisible by TP={tp}")
elif (moe // tp) % 32 != 0:
errors.append(
f"moe_intermediate_size/TP = {moe}/{tp} = {moe // tp} is not a "
f"multiple of 32; CoreX ixfops.moe_w16a16_group_gemm requires "
f"k % 32 == 0"
)
return errors
def detect(model_dir: str, tp_override: int | None = None) -> dict:
cfg = load_config(model_dir)
archs = [str(a) for a in (cfg.get("architectures") or [])]
arch = archs[0] if archs else ""
if not arch.startswith("Gemma4"):
raise ValueError(
f"unsupported architecture={arch!r}; expected something like "
f"'Gemma4ForConditionalGeneration'"
)
text = text_section(cfg)
experts = expert_count(text, cfg)
if experts is not None and experts > 1:
kind = "gev-26b-decide"
detection = f"num_experts={experts}"
elif experts is not None:
# num_experts=1 is how some dense configs spell "no experts".
kind = "gemma4-31b"
detection = f"num_experts={experts} (dense)"
else:
name = pathlib.Path(model_dir).name.lower()
if "gev" in name:
kind = "gev-26b-decide"
elif "gemma" in name:
kind = "gemma4-31b"
else:
raise ValueError(
"cannot determine Gemma 4 variant: no num_experts in the config "
"and the directory name contains neither 'gemma' nor 'gev'"
)
detection = "directory name"
profile = dict(PROFILES[kind])
tp = int(profile["tp"]) if tp_override is None else int(tp_override)
errors = divisibility_errors(kind, tp, text, cfg)
if errors and os.environ.get("GEMMA4_SKIP_TP_CHECK") != "1":
raise ValueError(
f"{kind} cannot run with TP={tp} on this model config:\n "
+ "\n ".join(errors)
+ "\nThe base image or the model variant may have changed; inspect "
"config.json. If you have already verified another TP manually, "
"set GEMMA4_SKIP_TP_CHECK=1 to bypass this check."
)
if errors:
print(
"WARN: TP check skipped by GEMMA4_SKIP_TP_CHECK=1:\n "
+ "\n ".join(errors),
file=sys.stderr,
)
if tp_override is not None:
profile["tp"] = tp
profile["gpu_count"] = tp
profile.update(
{
"kind": kind,
"architecture": arch,
"num_experts": experts,
"num_hidden_layers": text.get("num_hidden_layers", cfg.get("num_hidden_layers")),
"hidden_size": text.get("hidden_size", cfg.get("hidden_size")),
"detection": detection,
"model_dir": str(pathlib.Path(model_dir).resolve()),
}
)
return profile
def main() -> int:
parser = argparse.ArgumentParser(
description="Detect a Gemma 4 variant (Gemma-4-31B / GEV-26B-Decide) from config.json"
)
parser.add_argument("model_dir", help="directory containing config.json")
parser.add_argument(
"--json",
action="store_true",
help="print JSON instead of shell assignments",
)
parser.add_argument(
"--tp",
type=int,
default=None,
help="validate against this TP instead of the profile default",
)
args = parser.parse_args()
try:
profile = detect(args.model_dir, tp_override=args.tp)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps(profile, ensure_ascii=False, indent=2))
return 0
values = {
"DETECTED_MODEL_KIND": profile["kind"],
"DETECTED_TP": profile["tp"],
"DETECTED_PORT": profile["port"],
"DETECTED_SERVED_NAME": profile["served_name"],
"DETECTED_GPU_COUNT": profile["gpu_count"],
"DETECTED_MAX_NUM_SEQS": profile["max_num_seqs"],
"DETECTED_GPU_MEM_UTIL": profile["gpu_mem_util"],
"DETECTED_ENFORCE_EAGER": profile["enforce_eager"],
"DETECTED_DESCRIPTION": profile["description"],
}
for key, value in values.items():
print(f"{key}={shlex.quote(str(value))}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

57
docker-compose.yml Normal file
View File

@@ -0,0 +1,57 @@
services:
gemma4:
build:
context: .
dockerfile: Dockerfile
image: gemma4-bi150:${TAG:-2026-10-27}
container_name: gemma4
# 镜像本身不设置启动命令,启动方式全部由这里决定。
entrypoint: ["/usr/local/bin/gemma4-entrypoint.sh"]
working_dir: /opt/gemma4
expose:
- "8094"
- "8095"
# vLLM 多卡 NCCL / 端口直通
network_mode: host
ipc: host
# Iluvatar 运行时需要 privileged + /dev 绑定。
# 只给 --device=/dev/iluvatar* 会在 worker init 时报
# cudaGetDeviceCount() ... Error 1001
privileged: true
pid: host
volumes:
- /dev:/dev
- ${MODEL_HOST_DIR:-/mnt/disk0/models/google/gemma-4-31B-it}:/models/gemma4:ro
environment:
MODEL_PATH: /models/gemma4
AUTO_DETECT: "${AUTO_DETECT:-1}"
HOST: "${HOST:-0.0.0.0}"
# 默认给 4 张(Gemma-4-31B 的 TP4);GEV 只需要 2 张,
# entrypoint 按检测出的 TP 使用前 TP 张。
CUDA_VISIBLE_DEVICES: "${GPUS:-15,14,13,12}"
# 留空时由 entrypoint 根据 config.json 自动判断。
# 需要手动覆盖时直接设置同名环境变量即可。
TP: "${TP:-}"
PORT: "${PORT:-}"
SERVED_NAME: "${SERVED_NAME:-}"
MAX_NUM_SEQS: "${MAX_NUM_SEQS:-}"
GPU_MEM_UTIL: "${GPU_MEM_UTIL:-}"
ENFORCE_EAGER: "${ENFORCE_EAGER:-}"
MAX_MODEL_LEN: "${MAX_MODEL_LEN:-8192}"
MAX_IMAGES: "${MAX_IMAGES:-4}"
PYTHONUNBUFFERED: "1"
EXTRA_ARGS: "${EXTRA_ARGS:-}"
ulimits:
memlock: -1
stack: 67108864
restart: unless-stopped

97
entrypoint.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
#
# Gemma 4 统一容器入口:
# 1. 幂等应用 CoreX vLLM 的 Gemma4 / MoE 补丁
# 2. 读取模型 config.json,自动判断 Gemma-4-31B(dense)/ GEV-26B-Decide(MoE)
# 3. 选择对应的 TP / 端口 / served name / eager / 显存,再启动 vLLM
#
# AUTO_DETECT=1(默认)按 config.json 自动选择;显式设置的
# TP / PORT / SERVED_NAME / MAX_NUM_SEQS / GPU_MEM_UTIL / ENFORCE_EAGER
# 会覆盖检测结果。AUTO_DETECT=0 完全手动。
#
set -euo pipefail
PATCH_SCRIPT="${GEMMA4_PATCH_SCRIPT:-/opt/gemma4/apply_patch.py}"
DETECT_SCRIPT="${GEMMA4_DETECT_SCRIPT:-/opt/gemma4/detect_model.py}"
VLLM_BIN="${VLLM_BIN:-vllm}"
python3 "${PATCH_SCRIPT}"
MODEL_PATH="${MODEL_PATH:-/models/gemma4}"
AUTO_DETECT="${AUTO_DETECT:-1}"
MODEL_DESCRIPTION=""
if [[ ! -f "${MODEL_PATH}/config.json" ]]; then
echo "ERROR: 模型目录不完整:${MODEL_PATH}/config.json 不存在" >&2
echo "请确认已挂载模型,例如:" >&2
echo " -v /mnt/disk0/models/google/gemma-4-31B-it:/models/gemma4:ro" >&2
echo " -v /mnt/disk0/models/autotrust/GEV-26B-Decide:/models/gemma4:ro" >&2
exit 1
fi
if [[ "${AUTO_DETECT}" == "1" ]]; then
DETECT_ARGS=()
if [[ -n "${TP:-}" ]]; then
DETECT_ARGS=(--tp "${TP}")
fi
DETECT_OUTPUT="$(python3 "${DETECT_SCRIPT}" "${MODEL_PATH}" "${DETECT_ARGS[@]}")" || exit 1
eval "${DETECT_OUTPUT}"
MODEL_KIND="${MODEL_KIND:-${DETECTED_MODEL_KIND}}"
TP="${TP:-${DETECTED_TP}}"
PORT="${PORT:-${DETECTED_PORT}}"
SERVED_NAME="${SERVED_NAME:-${DETECTED_SERVED_NAME}}"
MAX_NUM_SEQS="${MAX_NUM_SEQS:-${DETECTED_MAX_NUM_SEQS}}"
GPU_MEM_UTIL="${GPU_MEM_UTIL:-${DETECTED_GPU_MEM_UTIL}}"
ENFORCE_EAGER="${ENFORCE_EAGER:-${DETECTED_ENFORCE_EAGER}}"
MODEL_DESCRIPTION="${DETECTED_DESCRIPTION:-}"
else
MODEL_KIND="${MODEL_KIND:-unknown}"
fi
HOST="${HOST:-0.0.0.0}"
TP="${TP:-4}"
PORT="${PORT:-8095}"
SERVED_NAME="${SERVED_NAME:-gemma4}"
MAX_MODEL_LEN="${MAX_MODEL_LEN:-8192}"
MAX_NUM_SEQS="${MAX_NUM_SEQS:-4}"
GPU_MEM_UTIL="${GPU_MEM_UTIL:-0.90}"
MAX_IMAGES="${MAX_IMAGES:-4}"
ENFORCE_EAGER="${ENFORCE_EAGER:-0}"
EXTRA_ARGS="${EXTRA_ARGS:-}"
EAGER_ARGS=()
if [[ "${ENFORCE_EAGER}" == "1" ]]; then
EAGER_ARGS=(--enforce-eager)
fi
echo "============================================================"
echo "Gemma 4 family (BI-V150 unified image)"
echo " model kind : ${MODEL_KIND}"
echo " model : ${MODEL_PATH}"
echo " served name: ${SERVED_NAME}"
echo " listen : ${HOST}:${PORT}"
echo " TP : ${TP} (${TP} GPUs)"
echo " max len : ${MAX_MODEL_LEN}"
echo " max seqs : ${MAX_NUM_SEQS}"
echo " gpu util : ${GPU_MEM_UTIL}"
echo " max images : ${MAX_IMAGES}"
echo " eager : ${ENFORCE_EAGER}"
echo " visible : ${CUDA_VISIBLE_DEVICES:-all}"
echo " extra : ${EXTRA_ARGS}"
if [[ -n "${MODEL_DESCRIPTION}" ]]; then
echo " detected : ${MODEL_DESCRIPTION}"
fi
echo "============================================================"
# shellcheck disable=SC2086
exec "${VLLM_BIN}" serve "${MODEL_PATH}" \
--host "${HOST}" \
--port "${PORT}" \
--served-model-name "${SERVED_NAME}" \
--tensor-parallel-size "${TP}" \
--max-model-len "${MAX_MODEL_LEN}" \
--max-num-seqs "${MAX_NUM_SEQS}" \
--gpu-memory-utilization "${GPU_MEM_UTIL}" \
--limit-mm-per-prompt "{\"image\": ${MAX_IMAGES}}" \
"${EAGER_ARGS[@]}" \
${EXTRA_ARGS}

200
run.sh Executable file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env bash
#
# Gemma 4 家族统一构建 + 启动(Iluvatar BI-V150)
#
# 自动读取模型目录下的 config.json,判断是 dense 还是 MoE,然后选择:
# Gemma-4-31B-it : TP4 / 4 卡 / 8095 / gemma-4-31B-it / eager0 / 0.90 / 4 seqs
# GEV-26B-Decide : TP2 / 2 卡 / 8094 / GEV-26B-Decide / eager1 / 0.95 / 2 seqs
#
# 用法:
# ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
# ./run.sh /mnt/disk0/models/autotrust/GEV-26B-Decide
# MODEL_HOST_DIR=/path/to/model ./run.sh
# ./run.sh # 依次尝试两个默认模型目录
#
# 常用覆盖项:
# GPUS=12,13,14,15 ./run.sh /path/to/gemma4
# EXTRA_DOCKER_ARGS="-v /mnt/disk1:/data:ro" ./run.sh /path/to/model
# EXTRA_ARGS="--max-model-len 16384" ./run.sh /path/to/model
# TP=4 PORT=8099 SERVED_NAME=my-gemma4 ./run.sh /path/to/model
# BUILD=0 IMAGE=<ci-image> ./run.sh /path/to/model # 使用已有镜像
# DRY_RUN=1 ./run.sh /path/to/model # 只打印 docker run 命令
#
set -euo pipefail
cd "$(dirname "$0")"
usage() {
cat >&2 <<'EOF'
用法: ./run.sh [MODEL_DIR]
不传 MODEL_DIR 时,按顺序尝试:
/mnt/disk0/models/google/gemma-4-31B-it
/mnt/disk0/models/autotrust/GEV-26B-Decide
模型类型由 MODEL_DIR/config.json 自动判断,不需要 gemma4-31b / gev-26b-decide 参数。
环境变量覆盖:
IMAGE NAME MODEL_HOST_DIR MODEL_MOUNT PORT SERVED_NAME TP GPUS
MAX_MODEL_LEN MAX_NUM_SEQS GPU_MEM_UTIL MAX_IMAGES ENFORCE_EAGER
AUTO_DETECT EXTRA_ARGS EXTRA_DOCKER_ARGS BUILD=0/1 DRY_RUN=0/1
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
MODEL_HOST_DIR="${1:-${MODEL_HOST_DIR:-}}"
if [[ -z "${MODEL_HOST_DIR}" ]]; then
for candidate in \
"/mnt/disk0/models/google/gemma-4-31B-it" \
"/mnt/disk0/models/autotrust/GEV-26B-Decide"; do
if [[ -f "${candidate}/config.json" ]]; then
MODEL_HOST_DIR="${candidate}"
break
fi
done
fi
if [[ -z "${MODEL_HOST_DIR}" ]]; then
echo "ERROR: 未指定模型目录,也没有找到默认模型目录。" >&2
usage
exit 2
fi
if [[ ! -f "${MODEL_HOST_DIR}/config.json" ]]; then
echo "ERROR: 模型目录不完整: ${MODEL_HOST_DIR}/config.json" >&2
echo "请先下载模型,或把 MODEL_DIR 指向正确目录。" >&2
exit 1
fi
DETECT_ARGS=()
if [[ -n "${TP:-}" ]]; then
DETECT_ARGS=(--tp "${TP}")
fi
DETECT_OUTPUT="$(python3 detect_model.py "${MODEL_HOST_DIR}" "${DETECT_ARGS[@]}")" || exit 1
eval "${DETECT_OUTPUT}"
MODEL_KIND="${MODEL_KIND:-${DETECTED_MODEL_KIND}}"
IMAGE="${IMAGE:-gemma4-bi150:2026-10-27}"
NAME="${NAME:-${MODEL_KIND}}"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-${DETECTED_PORT}}"
MODEL_MOUNT="${MODEL_MOUNT:-/models/gemma4}"
SERVED_NAME="${SERVED_NAME:-${DETECTED_SERVED_NAME}}"
TP="${TP:-${DETECTED_TP}}"
MAX_MODEL_LEN="${MAX_MODEL_LEN:-8192}"
MAX_NUM_SEQS="${MAX_NUM_SEQS:-${DETECTED_MAX_NUM_SEQS}}"
GPU_MEM_UTIL="${GPU_MEM_UTIL:-${DETECTED_GPU_MEM_UTIL}}"
MAX_IMAGES="${MAX_IMAGES:-4}"
ENFORCE_EAGER="${ENFORCE_EAGER:-${DETECTED_ENFORCE_EAGER}}"
AUTO_DETECT="${AUTO_DETECT:-1}"
EXTRA_ARGS="${EXTRA_ARGS:-}"
EXTRA_DOCKER_ARGS="${EXTRA_DOCKER_ARGS:-}"
BUILD="${BUILD:-1}"
DRY_RUN="${DRY_RUN:-0}"
GPU_COUNT="${DETECTED_GPU_COUNT}"
# 默认优先使用序号最大的卡(12-15),避免和 16 卡大模型抢 0-11。
if [[ -n "${GPUS:-}" ]]; then
GPUS="${GPUS}"
elif [[ -n "${CUDA_VISIBLE_DEVICES:-}" ]]; then
GPUS="${CUDA_VISIBLE_DEVICES}"
elif (( GPU_COUNT == 4 )); then
GPUS="15,14,13,12"
else
GPUS="$(seq -s, $((16 - GPU_COUNT)) 15)"
fi
GPUS="${GPUS// /}"
IFS=',' read -r -a GPU_LIST <<< "${GPUS}"
if (( ${#GPU_LIST[@]} < GPU_COUNT )); then
echo "ERROR: ${MODEL_KIND} 需要 ${GPU_COUNT} 张卡,但 GPUS 只给了 ${#GPU_LIST[@]} 张: ${GPUS}" >&2
exit 1
fi
if (( ${#GPU_LIST[@]} > GPU_COUNT )); then
echo "WARN: ${MODEL_KIND} 只需要 ${GPU_COUNT} 张卡,但 GPUS 给了 ${#GPU_LIST[@]} 张;vLLM 只会使用其中 ${GPU_COUNT} 张。" >&2
fi
DOCKER_EXTRA=()
if [[ -n "${EXTRA_DOCKER_ARGS}" ]]; then
# 按空白拆分;路径中不要带空格。
read -r -a DOCKER_EXTRA <<< "${EXTRA_DOCKER_ARGS}"
fi
DOCKER_RUN=(
docker run -d --name "${NAME}"
--entrypoint /usr/local/bin/gemma4-entrypoint.sh
--workdir /opt/gemma4
--expose "${PORT}"
--privileged
--pid host
--network host
--ipc host
-v /dev:/dev
-v "${MODEL_HOST_DIR}:${MODEL_MOUNT}:ro"
--ulimit memlock=-1
--ulimit stack=67108864
-e CUDA_VISIBLE_DEVICES="${GPUS}"
-e MODEL_PATH="${MODEL_MOUNT}"
-e AUTO_DETECT="${AUTO_DETECT}"
-e MODEL_KIND="${MODEL_KIND}"
-e HOST="${HOST}"
-e SERVED_NAME="${SERVED_NAME}"
-e PORT="${PORT}"
-e TP="${TP}"
-e MAX_MODEL_LEN="${MAX_MODEL_LEN}"
-e MAX_NUM_SEQS="${MAX_NUM_SEQS}"
-e GPU_MEM_UTIL="${GPU_MEM_UTIL}"
-e MAX_IMAGES="${MAX_IMAGES}"
-e ENFORCE_EAGER="${ENFORCE_EAGER}"
-e PYTHONUNBUFFERED=1
-e EXTRA_ARGS="${EXTRA_ARGS}"
"${DOCKER_EXTRA[@]}"
--restart unless-stopped
"${IMAGE}"
)
echo "==> detected ${MODEL_KIND}: ${DETECTED_DESCRIPTION}"
echo " model : ${MODEL_HOST_DIR}"
echo " TP : ${TP} (${GPU_COUNT} GPUs), port=${PORT}, name=${SERVED_NAME}"
echo " GPUs : ${GPUS}"
echo " eager : ${ENFORCE_EAGER}, util=${GPU_MEM_UTIL}, seqs=${MAX_NUM_SEQS}"
if [[ "${DRY_RUN}" == "1" ]]; then
printf '%q ' "${DOCKER_RUN[@]}"
printf '\n'
exit 0
fi
if [[ "${BUILD}" == "1" ]]; then
echo "==> build ${IMAGE}"
docker build -t "${IMAGE}" .
else
echo "==> skip build (BUILD=0), image=${IMAGE}"
fi
echo "==> recreate ${NAME}"
docker rm -f "${NAME}" 2>/dev/null || true
echo "==> run ${MODEL_KIND}"
"${DOCKER_RUN[@]}"
# 冷启动:Gemma-4-31B 约 59 GB、GEV 约 49 GB,首次读盘实测 5-8 分钟。
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-900}"
echo "==> wait for http://127.0.0.1:${PORT}/health (up to ${HEALTH_TIMEOUT}s)"
DEADLINE=$((SECONDS + HEALTH_TIMEOUT))
while (( SECONDS < DEADLINE )); do
if curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
echo "ready: http://127.0.0.1:${PORT}/v1/models"
exit 0
fi
sleep 5
done
echo "ERROR: 服务 ${HEALTH_TIMEOUT}s 未就绪,查看日志:" >&2
echo " docker logs -f ${NAME}" >&2
exit 1

376
verify.sh Executable file
View File

@@ -0,0 +1,376 @@
#!/usr/bin/env bash
#
# Gemma 4 家族服务验证脚本(Gemma-4-31B / GEV-26B-Decide)
#
# 默认行为:
# 1. 如果 docker 可用,自动找正在运行的 gemma4-bi150 容器;
# 2. 从容器挂载/环境变量或 MODEL_HOST_DIR 推断模型目录、端口、served name;
# 3. 如果模型目录可读,调用 detect_model.py 自动判断变体;
# 4. 依次检查 /health、/v1/models、/v1/chat/completions;
# 5. 可选 --with-reasoning 和 --with-image。
#
# 常用:
# ./verify.sh
# ./verify.sh --model-dir /mnt/disk0/models/autotrust/GEV-26B-Decide --with-image
# ./verify.sh --container gemma4-gemma4-31b --with-reasoning
# ./verify.sh --base-url http://127.0.0.1:8095 --served-name gemma-4-31B-it
#
set -euo pipefail
cd "$(dirname "$0")"
MODEL_DIR="${MODEL_HOST_DIR:-}"
CONTAINER="${CONTAINER:-}"
PORT="${PORT:-}"
SERVED_NAME="${SERVED_NAME:-}"
BASE_URL="${BASE_URL:-}"
TIMEOUT="${TIMEOUT:-300}"
WITH_IMAGE=0
WITH_REASONING=0
SKIP_CHAT=0
KIND="unknown"
usage() {
cat >&2 <<'EOF'
用法: ./verify.sh [options]
--model-dir PATH 模型目录(含 config.json),用于自动判断变体
--container NAME 运行中的容器名;默认自动查找 gemma4 容器
--port PORT 服务端口;默认从模型/容器推断
--served-name NAME OpenAI API 模型名;默认从模型/容器推断
--base-url URL 直接指定服务地址,例如 http://127.0.0.1:8095
--timeout SEC 等待和请求超时,默认 300
--with-reasoning 额外跑一个简单推理题(期望 27)
--with-image 额外跑图片识别(纯红图片,期望回答红色)
--skip-chat 只检查 /health 和 /v1/models
-h, --help 显示帮助
环境变量也可以设置:MODEL_HOST_DIR CONTAINER PORT SERVED_NAME BASE_URL TIMEOUT
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--model-dir) MODEL_DIR="${2:?--model-dir 需要路径}"; shift 2 ;;
--container) CONTAINER="${2:?--container 需要名称}"; shift 2 ;;
--port) PORT="${2:?--port 需要端口}"; shift 2 ;;
--served-name) SERVED_NAME="${2:?--served-name 需要名称}"; shift 2 ;;
--base-url) BASE_URL="${2:?--base-url 需要 URL}"; shift 2 ;;
--timeout) TIMEOUT="${2:?--timeout 需要秒数}"; shift 2 ;;
--with-image) WITH_IMAGE=1; shift ;;
--with-reasoning) WITH_REASONING=1; shift ;;
--skip-chat) SKIP_CHAT=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: 未知参数: $1" >&2; usage; exit 2 ;;
esac
done
fail() { echo "FAIL: $*" >&2; exit 1; }
pass() { echo "PASS: $*"; }
command -v curl >/dev/null 2>&1 || fail "缺少 curl"
command -v python3 >/dev/null 2>&1 || fail "缺少 python3"
TMP="$(mktemp -d)"
trap 'rm -rf "${TMP}"' EXIT
# ---------------------------------------------------------------- container
if [[ -z "${CONTAINER}" ]] && command -v docker >/dev/null 2>&1; then
CONTAINER="$(docker ps --format '{{.Names}} {{.Image}}' 2>/dev/null \
| awk '/gemma4|gev-26b/ {print $1; exit}' || true)"
fi
container_env() {
local name="$1" key="$2"
docker inspect "${name}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
| awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' || true
}
if [[ -n "${CONTAINER}" ]] && command -v docker >/dev/null 2>&1; then
if [[ -z "${MODEL_DIR}" ]]; then
MODEL_DIR="$(docker inspect "${CONTAINER}" --format '{{json .Mounts}}' 2>/dev/null \
| python3 -c '
import json, sys
try:
mounts = json.load(sys.stdin)
except Exception:
sys.exit(0)
for m in mounts:
if m.get("Destination") == "/models/gemma4":
print(m.get("Source", ""))
break
' || true)"
fi
[[ -z "${PORT}" ]] && PORT="$(container_env "${CONTAINER}" PORT)"
[[ -z "${SERVED_NAME}" ]] && SERVED_NAME="$(container_env "${CONTAINER}" SERVED_NAME)"
fi
# ---------------------------------------------------------------- model dir
GEMMA4_DEFAULT="/mnt/disk0/models/google/gemma-4-31B-it"
GEV_DEFAULT="/mnt/disk0/models/autotrust/GEV-26B-Decide"
# 没有 docker 时(例如在 ugly_hopper 内运行),按端口探测正在运行的服务。
if [[ -z "${CONTAINER}" && -z "${PORT}" && -z "${MODEL_DIR}" && -z "${BASE_URL}" ]]; then
for candidate_port in 8095 8094; do
if curl -fsS --max-time 2 "http://127.0.0.1:${candidate_port}/health" >/dev/null 2>&1; then
PORT="${candidate_port}"
break
fi
done
fi
# 从 served name 反推模型目录(方便 --base-url + --served-name 的用法)。
if [[ -z "${MODEL_DIR}" && -n "${SERVED_NAME}" ]]; then
case "${SERVED_NAME}" in
*GEV*|*gev*)
[[ -f "${GEV_DEFAULT}/config.json" ]] && MODEL_DIR="${GEV_DEFAULT}"
;;
*gemma*|*Gemma*)
[[ -f "${GEMMA4_DEFAULT}/config.json" ]] && MODEL_DIR="${GEMMA4_DEFAULT}"
;;
esac
fi
if [[ -z "${MODEL_DIR}" && -z "${BASE_URL}" ]]; then
if [[ "${PORT}" == "8094" && -f "${GEV_DEFAULT}/config.json" ]]; then
MODEL_DIR="${GEV_DEFAULT}"
elif [[ -f "${GEMMA4_DEFAULT}/config.json" ]]; then
MODEL_DIR="${GEMMA4_DEFAULT}"
elif [[ -f "${GEV_DEFAULT}/config.json" ]]; then
MODEL_DIR="${GEV_DEFAULT}"
fi
fi
DETECT_JSON=""
if [[ -n "${MODEL_DIR}" ]]; then
if [[ -f "${MODEL_DIR}/config.json" ]]; then
DETECT_JSON="$(python3 detect_model.py "${MODEL_DIR}" --json)" || {
echo "ERROR: 无法识别模型: ${MODEL_DIR}" >&2
exit 1
}
elif [[ -n "${CONTAINER}" ]] && command -v docker >/dev/null 2>&1; then
# 宿主机路径不可读时,退回到容器内检测
DETECT_JSON="$(docker exec "${CONTAINER}" python3 /opt/gemma4/detect_model.py /models/gemma4 --json 2>/dev/null || true)"
else
fail "模型目录缺少 config.json: ${MODEL_DIR}"
fi
fi
if [[ -n "${DETECT_JSON}" ]]; then
KIND="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["kind"])' <<< "${DETECT_JSON}")"
DETECTED_PORT="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["port"])' <<< "${DETECT_JSON}")"
DETECTED_NAME="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["served_name"])' <<< "${DETECT_JSON}")"
[[ -z "${PORT}" ]] && PORT="${DETECTED_PORT}"
[[ -z "${SERVED_NAME}" ]] && SERVED_NAME="${DETECTED_NAME}"
fi
PORT="${PORT:-8095}"
SERVED_NAME="${SERVED_NAME:-gemma4}"
BASE_URL="${BASE_URL:-http://127.0.0.1:${PORT}}"
BASE_URL="${BASE_URL%/}"
echo "=== Gemma 4 family verification ==="
echo "container : ${CONTAINER:-<none>}"
echo "model dir : ${MODEL_DIR:-<not detected>}"
echo "kind : ${KIND}"
echo "base URL : ${BASE_URL}"
echo "model name: ${SERVED_NAME}"
echo
# ---------------------------------------------------------------- health
# vLLM 的 /health 可能返回 200 但 body 为空,所以只看 curl 退出码。
echo "==> waiting for /health (up to ${TIMEOUT}s)"
HEALTH_OK=0
DEADLINE=$((SECONDS + TIMEOUT))
while (( SECONDS < DEADLINE )); do
if curl -fsS --max-time 5 "${BASE_URL}/health" > "${TMP}/health.txt" 2>/dev/null; then
HEALTH_OK=1
break
fi
sleep 2
done
if [[ "${HEALTH_OK}" != "1" ]]; then
fail "health check 超时: ${BASE_URL}/health"
fi
HEALTH="$(cat "${TMP}/health.txt" 2>/dev/null || true)"
pass "health: ${HEALTH:-OK}"
# ---------------------------------------------------------------- models
echo "==> /v1/models"
curl -fsS --max-time "${TIMEOUT}" "${BASE_URL}/v1/models" > "${TMP}/models.json" \
|| fail "GET /v1/models 失败"
python3 - "${TMP}/models.json" "${SERVED_NAME}" <<'PY'
import json
import sys
data = json.load(open(sys.argv[1]))
expected = sys.argv[2]
ids = [m.get("id") for m in data.get("data", [])]
if expected not in ids:
print(f"FAIL: /v1/models 中没有 {expected!r}; 实际: {ids}", file=sys.stderr)
sys.exit(1)
for m in data.get("data", []):
if m.get("id") == expected:
print(f"PASS: model={expected}, max_model_len={m.get('max_model_len')}")
break
PY
if [[ "${SKIP_CHAT}" == "1" ]]; then
echo
echo "All checks passed (chat skipped)."
exit 0
fi
# ---------------------------------------------------------------- chat
echo "==> /v1/chat/completions"
python3 - "${SERVED_NAME}" > "${TMP}/chat.json" <<'PY'
import json
import sys
print(json.dumps({
"model": sys.argv[1],
"messages": [{"role": "user", "content": "你好,请用一句中文介绍你自己。"}],
"max_tokens": 64,
"temperature": 0,
}, ensure_ascii=False))
PY
curl -fsS --max-time "${TIMEOUT}" \
-H 'Content-Type: application/json' \
--data-binary @"${TMP}/chat.json" \
"${BASE_URL}/v1/chat/completions" > "${TMP}/chat_resp.json" \
|| fail "chat completion 失败"
python3 - "${TMP}/chat_resp.json" <<'PY'
import json
import sys
data = json.load(open(sys.argv[1]))
try:
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError):
print(f"FAIL: unexpected chat response: {data}", file=sys.stderr)
sys.exit(1)
if isinstance(content, list):
content = " ".join(
part.get("text", "") for part in content if isinstance(part, dict)
)
content = (content or "").strip()
if not content:
print("FAIL: chat response content is empty", file=sys.stderr)
sys.exit(1)
print(f"PASS: chat: {content[:200]}")
PY
# ---------------------------------------------------------------- reasoning
if [[ "${WITH_REASONING}" == "1" ]]; then
echo "==> reasoning"
python3 - "${SERVED_NAME}" > "${TMP}/reason.json" <<'PY'
import json
import sys
print(json.dumps({
"model": sys.argv[1],
"messages": [{"role": "user", "content": "3 个人 3 天喝 3 桶水,9 个人 9 天喝几桶水?只给最终数字。"}],
"max_tokens": 16,
"temperature": 0,
}, ensure_ascii=False))
PY
curl -fsS --max-time "${TIMEOUT}" \
-H 'Content-Type: application/json' \
--data-binary @"${TMP}/reason.json" \
"${BASE_URL}/v1/chat/completions" > "${TMP}/reason_resp.json" \
|| fail "reasoning 请求失败"
python3 - "${TMP}/reason_resp.json" <<'PY'
import json
import sys
data = json.load(open(sys.argv[1]))
content = data["choices"][0]["message"].get("content", "")
if isinstance(content, list):
content = " ".join(
part.get("text", "") for part in content if isinstance(part, dict)
)
content = (content or "").strip()
if "27" not in content:
print(f"FAIL: expected 27, got: {content!r}", file=sys.stderr)
sys.exit(1)
print(f"PASS: reasoning: {content[:100]}")
PY
fi
# ---------------------------------------------------------------- image
if [[ "${WITH_IMAGE}" == "1" ]]; then
echo "==> image (336x336 red PNG)"
python3 - "${TMP}/red.b64" <<'PY'
import base64
import struct
import sys
import zlib
w = h = 336
row = bytes((220, 20, 20)) * w
raw = b"".join(b"\x00" + row for _ in range(h))
def chunk(kind, data):
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
png = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
open(sys.argv[1], "w").write(base64.b64encode(png).decode())
PY
python3 - "${SERVED_NAME}" "${TMP}/red.b64" > "${TMP}/image.json" <<'PY'
import json
import sys
b64 = open(sys.argv[2]).read().strip()
payload = {
"model": sys.argv[1],
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "这张图片主要是什么颜色?只回答颜色。"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}},
],
}],
"max_tokens": 32,
"temperature": 0,
}
print(json.dumps(payload, ensure_ascii=False))
PY
curl -fsS --max-time "${TIMEOUT}" \
-H 'Content-Type: application/json' \
--data-binary @"${TMP}/image.json" \
"${BASE_URL}/v1/chat/completions" > "${TMP}/image_resp.json" \
|| fail "image 请求失败"
python3 - "${TMP}/image_resp.json" <<'PY'
import json
import sys
data = json.load(open(sys.argv[1]))
content = data["choices"][0]["message"].get("content", "")
if isinstance(content, list):
content = " ".join(
part.get("text", "") for part in content if isinstance(part, dict)
)
content = (content or "").strip()
if not content:
print("FAIL: image response content is empty", file=sys.stderr)
sys.exit(1)
lower = content.lower()
if "红" not in lower and "red" not in lower:
print(f"FAIL: expected red, got: {content[:200]}", file=sys.stderr)
sys.exit(1)
print(f"PASS: image: {content[:200]}")
PY
fi
echo
echo "All checks passed for ${KIND} at ${BASE_URL}."