【昇腾】在华为昇腾 910B4 上使用 vllm-ascend 跑通 Gemma 4 31B 全记录
前提:本文写在 vllm-ascend:nightly-releases-v0.22.1rc-openeuler 发布的时候,这个时候 vllm-ascend 官方还没有支持 gemma 4,但官方 github 中已经有关于 gemma 4 的 PR:https://github.com/vllm-project/vllm-ascend/pull/9222,只是存在合并冲突的状态,但自己部署使用是没有问题的。
目标:910B4 × 2 上把 Gemma 4 31B 跑起来,多模态可用。所有代码、镜像、补丁都已开源,跟着下面的步骤走就能复现。
涉及的几个外部资源先列一下:
- 基线镜像:
quay.io/ascend/vllm-ascend:v0.20.2rc1-openeuler- PR 分支:
https://github.com/zhangshuai404/vllm-ascend/tree/fix-gemma4-ascend-inference-clean- 模型:
google/gemma-4-31B(约 59GB)
步骤 1: 拉取基线镜像
1.1 目标
获取一个已经内置 CANN 9.0.0、torch、vllm 0.20.2 的 openEuler 基础镜像。这里选 vllm 0.20.2 的原因是 PR 是基于这个开发,不过我估计更新版本的也能用。vllm-ascend 的 C++ 扩展重编一次大约 7 分钟,基线里的 vllm + vllm-ascend 编译产物能直接复用。
1.2 操作
docker pull quay.io/ascend/vllm-ascend:v0.20.2rc1-openeuler
docker images | grep vllm-ascend
1.3 预期结果
quay.io/ascend/vllm-ascend v0.20.2rc1-openeuler <IMAGE_ID> 25.4GB
步骤 2: 编译 PR #9222 源码并生成 Layer 1 镜像
2.1 目标
官方 vllm-ascend 还没支持 Gemma 4,需要把 PR #9222 的源码覆盖到容器里,并重新编译 C++ 扩展。编译产物提交为新镜像 vllm-ascend-gemma4:full,即 Layer 1。
2.2 操作
# 1) 克隆 PR 分支(host 上执行,目录按你的环境调整)
cd /path/to/dir
git clone --depth 1 -b fix-gemma4-ascend-inference-clean \
https://github.com/zhangshuai404/vllm-ascend.git
# 2) 启动一个 sleep 容器作为编译环境
docker run -d --name build \
--network host \
quay.io/ascend/vllm-ascend:v0.20.2rc1-openeuler \
sleep 3600
# 3) 把 PR 源码拷进容器(注意:一定要用 docker cp,不能用 volume 挂载!
# 因为后面 docker commit 不保存 volume 里的内容,会导致修复丢失)
docker cp /path/to/dir/vllm-ascend build:/src
# 4) 容器内覆盖官方 vllm-ascend,编译 PR #9222 版本
# 注意:host 上 git clone 的目录 owner 与容器内用户不同,必须加 safe.directory,
# 否则 setuptools_scm 和子模块拉取都会报 "detected dubious ownership" 错误。
docker exec build bash -c '
source /usr/local/Ascend/ascend-toolkit/set_env.sh
export SOC_VERSION=ascend910b4
export CMAKE_PREFIX_PATH=/usr/local/python3.11.15/lib/python3.11/site-packages/torch/share/cmake
git config --global --add safe.directory "*"
cd /src
pip install --no-build-isolation -e .
'
# 5) 提交为 Layer 1
docker commit build vllm-ascend-gemma4:full
2.3 预期结果
docker images | grep gemma4
vllm-ascend-gemma4 full sha256:d22f13c96ba8 27.0GB
编译日志最后会看到 Successfully installed vllm-ascend-0.1.dev1+... 和 Finished processing dependencies,耗时 6-8 分钟。
步骤 3: 启动服务容器并完成首次运行
3.1 目标
用 Layer 1 镜像启动长期运行容器,拉起 vllm serve,确认模型能加载、API 能响应。
3.2 操作
# 1) 启动容器,挂 NPU 设备、模型目录、driver 文件、补丁目录
# --privileged + -e ASCEND_RT_VISIBLE_DEVICES=2,3 是实测可指定具体卡的方案:
# 容器内 NPU 会被重编号为 0,1,其它 4 张卡被屏蔽但 devfs 层仍可见。
# 其它挂载(dcmi/npu-smi/driver/version.info)和 --group-add 必须保留。
docker run -d --name gemma4-pr9222-test \
--network host \
--privileged \
--device /dev/davinci_manager \
-e ASCEND_RT_VISIBLE_DEVICES=2,3 \
-v /usr/local/dcmi:/usr/local/dcmi \
-v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
-v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \
-v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \
-v /data/models:/data/models \
-v /data/wuyuming/vllm-ascend-gemma4/patches:/patches \
--group-add $(getent group HwHiAiUser | cut -d: -f3) \
vllm-ascend-gemma4:full \
sleep 3600
# 2) 启动 vllm serve
# 注意:VLLM_USE_V1=0 + --enforce-eager 是当前实测可启动的组合。
# V1 引擎在该镜像下加载权重后 engine core 会卡在 do_poll 上不再响应,
# 所以先关掉图编译跑通验证流程。
docker exec -d gemma4-pr9222-test bash -c '
export VLLM_USE_V1=0
export ASCEND_RT_VISIBLE_DEVICES=2,3 # 跟 docker -e 保持一致
vllm serve /data/models/gemma-4-31B \
--served-model-name gemma-4-31B \
--port 8002 \
--dtype auto \
--gpu-memory-utilization 0.9 \
--trust-remote-code \
--trust-request-chat-template \
--tensor-parallel-size 2 \
--max-model-len 8192 \
--max-num-seqs 4 \
--mm-processor-cache-gb 0 \
--enforce-eager > /tmp/vllm.log 2>&1
'
# 📖 关于 VLLM_USE_V1 / V1 引擎 / --enforce-eager
#
# V1 引擎是 vLLM 在 v0.20.0+ 引入的新一代执行引擎(vLLM V1 Engine),
# 相比 V0(也叫 legacy engine):
# - 统一的 Scheduler + Executor 抽象,吞吐更高
# - 默认情况下由 VLLM_USE_V1 控制,未设置时按版本自动检测
# (vllm 0.20.2 默认开 V1,看到 "Initializing a V1 LLM engine" 即为 V1)
# - 在 GPU 上跑得好,但 vllm-ascend 的 v0.20.2rc1 对 V1 的支持有 bug
# ——EngineCore 加载完权重后会卡在 do_poll 上不再响应,
# service 永远不报 Application startup complete。解决方法:用 VLLM_USE_V1=0
# 切回 V0 引擎即可绕过该 bug。
#
# --enforce-eager 是关闭 torch.compile + ACL Graph 编译。在 V0 + Ascend 组合下
# 跳过图捕获可以避免潜在的 compilation hang,启动更快但吞吐略低 (约 10-15%)。
# 若未来 vllm-ascend 修复了 V1 引擎,可以同时去掉 VLLM_USE_V1=0 和 --enforce-eager
# 恢复默认配置。
# 3) 等待并查看日志
sleep 180 && docker exec gemma4-pr9222-test tail -80 /tmp/vllm.log
3.3 预期结果
-
容器运行中:
docker ps | grep gemma4-pr9222-test docker exec gemma4-pr9222-test npu-smi infonpu-smi info能看到 NPU 及其 HBM 使用率。 -
模型列表接口返回正常:
curl -s http://localhost:8002/v1/models | python3 -m json.tool应返回包含
gemma-4-31B的模型列表。
步骤 4: 修复 Chat Stop Strings 问题
4.1 目标
修复 vllm 在不传 stop 时不停止生成的问题。先复现问题,再应用补丁,最后验证修复效果。
4.2 操作
# 0) 写一个观察脚本到 host(一次写完多次复用,避免 shell 转义 f-string 的坑)
cat > /tmp/observe.py << 'PYEOF'
import json, sys
d = json.load(sys.stdin)["choices"][0]
t = d["text"]
eot = "<" + "end_of_turn" + ">"
print(f"text : {t!r}")
print(f"finish : {d['finish_reason']}")
print(f"stop : {d.get('stop_reason')}")
print(f"turns : {t.count(eot)} 个 <end_of_turn>")
print()
print("=" * 50)
print("原始生成内容:")
print("=" * 50)
print(t)
PYEOF
# 1) 复现问题:不传 stop,模型生成 <end_of_turn> 后仍继续
curl -s http://localhost:8002/v1/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-31B",
"prompt": "<start_of_turn>user\nWhat is 1+1?<end_of_turn>\n<start_of_turn>model\n",
"max_tokens": 100,
"temperature": 0.0
}' | python3 /tmp/observe.py
# 1b) 流式观察(看着更直观)
# 不修 stop 时,会看到 2 → <end_of_turn> → 然后继续吐
# 如
# 2<end_of_turn>
# <start_of_turn>user
# What is 2+2?<end_of_turn>
# <start_of_turn>model
# 4<end_of_turn>
# <start_of_turn>user
# What is 3+3?<end_of_turn>
# <start_of_turn>model
# 6<end_of_turn>
# <start_of_turn>user
# 2) 应用补丁(patches 目录已挂到 /patches)
docker exec gemma4-pr9222-test python3 /patches/apply_all_patches.py
# 3) 清 Python 缓存,避免 .pyc 覆盖新打的补丁
docker exec gemma4-pr9222-test bash -c '
find /vllm-workspace/vllm -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null
find /vllm-workspace/vllm -name "*.pyc" -delete 2>/dev/null
'
# 4) 重启 vllm
docker exec gemma4-pr9222-test bash -c 'pkill -f "vllm serve" || true'
sleep 5
docker exec -d gemma4-pr9222-test bash -c '
export VLLM_USE_V1=0
export ASCEND_RT_VISIBLE_DEVICES=2,3
vllm serve /data/models/gemma-4-31B \
--served-model-name gemma-4-31B \
--port 8002 --dtype auto --gpu-memory-utilization 0.9 \
--trust-remote-code --trust-request-chat-template \
--tensor-parallel-size 2 --max-model-len 8192 --max-num-seqs 4 \
--mm-processor-cache-gb 0 \
--enforce-eager > /tmp/vllm.log 2>&1
'
sleep 90
# 5) 再次验证(同一脚本对比修复前后差异)
curl -s http://localhost:8002/v1/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-31B",
"prompt": "<start_of_turn>user\nWhat is 1+1?<end_of_turn>\n<start_of_turn>model\n",
"max_tokens": 100,
"temperature": 0.0
}' | python3 /tmp/observe.py
补丁核心修改涉及 4 个文件 + 1 个新增 utils:
# (1) vllm/entrypoints/openai/chat_completion/protocol.py
# to_sampling_params() 里替换 stop 字段
def to_sampling_params(self, default_sampling_params):
# ... 其它字段合并 ...
_req_stop = self.stop if self.stop else []
if isinstance(_req_stop, str):
_req_stop = [_req_stop]
_default_stop = default_sampling_params.get("stop", [])
_merged_stop = list(dict.fromkeys(_req_stop + _default_stop)) or None
# stop_token_ids 同理
return SamplingParams.from_optional(
stop=_merged_stop,
stop_token_ids=_merged_stop_ids,
...
)
# (2) vllm/entrypoints/openai/completion/protocol.py
# 同样的 to_sampling_params() 修复
# (3) vllm/entrypoints/openai/completion/serving.py
# __init__ 里注入 default stop strings(之前漏了)
from vllm.entrypoints.openai.parser.chat_stop_utils import (
get_default_chat_stop_strings,
)
# 把 get_default_chat_stop_strings(self.tokenizer) 的返回值
# 合并进 self.default_sampling_params["stop"]
# (4) vllm/entrypoints/openai/chat_completion/serving.py
# 和 (3) 同款注入——/v1/chat/completions 的 init 路径同样需要,
# 否则 default_sampling_params["stop"] 在 chat 路径上是空的,
# to_sampling_params 也没东西可 merge,结果就是 chat completion
# 飞过 <end_of_turn> 一直跑到 max_tokens。
from vllm.entrypoints.openai.parser.chat_stop_utils import (
get_default_chat_stop_strings,
)
# 在 self.renderer.tokenizer 上拿 stop strings,extend 进
# self.default_sampling_params["stop"](与 (3) 的 (3) 同款逻辑)
# (5) vllm/entrypoints/openai/parser/chat_stop_utils.py
# 新增: 从 tokenizer 拿候选 stop strings(能 tokenize 的那些)
def get_default_chat_stop_strings(tokenizer):
candidates = (
"<end_of_turn>",
"<|eot_id|>",
"<|im_end|>",
"<|end_of_text|>",
)
return [s for s in candidates if tokenizer.encode(s, add_special_tokens=False)]
4.3 预期结果
-
修复前观察脚本输出(关键三个字段):
finish : length # 撞 max_tokens 被强制截断 stop : None # 没有 stop_reason turns : 4 个 <end_of_turn> # ← bug 特征:模型答完后还在续写多轮 text : '2<end_of_turn>\n<start_of_turn>user\nWhat is...\n'也就是:模型输出
2后遇到了<end_of_turn>,但 vllm 没把它当停止信号,继续吞 token 直到max_tokens耗尽,
生成伪多轮对话。 -
修复后观察脚本输出:
finish : stop stop : <end_of_turn> turns : 1 个 <end_of_turn> text : '2'关键标志:
finish_reason='stop'(不再是'length')stop_reason='<end_of_turn>'(vllm 真正在停字符串上停下来了)text='2',单 token 数字2后立刻停下turns=1,只触发了一次<end_of_turn>
-
想直观感受 streaming 行为,启用 4.2 中的
curl -Ns ... --data '... "stream":true}'即可,
修复前会看到2→<end_of_turn>→<start_of_turn>user\n...继续蹦。
步骤 5: 验证多模态、流式与 Chat 补全
5.1 目标
确认 Gemma 4 的图像理解、SSE 流式输出、以及 inline chat_template 的 chat 接口都能正常工作,且 stop strings 在三种场景下都生效。
5.2 操作
# 1) 多模态测试(host 上准备脚本)
# 图像占位符必须是 "<|image|>"(Gemma4 processor 的 image_token,token_id 258880)。
cat > /tmp/test_mm.py << 'PYEOF'
import requests, base64
from pathlib import Path
img_b64 = base64.b64encode(Path("/data/wuyuming/vllm-ascend-gemma4/test_images/text_hello.png").read_bytes()).decode()
TEMPLATE = (
"{{ bos_token }}"
"{% for message in messages %}"
"<start_of_turn>{{ message['role'] }}\n"
"{% if message['content'] is string %}"
"{{ message['content'] }}<end_of_turn>\n"
"{% else %}"
"{% for content in message['content'] %}"
"{% if content['type'] == 'text' %}{{ content['text'] }}"
"{% elif content['type'] == 'image' %}<|image|>"
"{% endif %}"
"{% endfor %}"
"<end_of_turn>\n"
"{% endif %}"
"{% endfor %}"
"<start_of_turn>model\n"
)
resp = requests.post("http://localhost:8002/v1/chat/completions", json={
"model": "gemma-4-31B",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What color is this image?"},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_b64}"}},
],
}],
"max_tokens": 64,
"temperature": 0.0,
"chat_template": TEMPLATE,
})
print("status:", resp.status_code)
print("content:", resp.json()["choices"][0]["message"]["content"])
PYEOF
python3 /tmp/test_mm.py
# 2) 流式 + 文本补全(实时打出 text delta,结尾给 finish/stop_reason)
curl -N -s http://localhost:8002/v1/completions \
-H "Content-Type: application/json" \
-d '{"model":"gemma-4-31B","prompt":"<start_of_turn>user\nTell me a story<end_of_turn>\n<start_of_turn>model\n","max_tokens":500,"stream":true}' \
| python3 -u -c '
import sys, json
last = None
for line in sys.stdin:
line = line.strip()
if not line.startswith("data: "): continue
body = line[6:]
if body == "[DONE]": break
try:
ch = json.loads(body)["choices"][0]
sys.stdout.write(ch.get("text", "")); sys.stdout.flush()
last = ch
except Exception: pass
if last:
fin = last.get("finish_reason"); sr = last.get("stop_reason")
print("\n\n--- finish=" + repr(fin) + " stop_reason=" + repr(sr))
'
# 3) chat 补全(inline template):提取 answer / finish / stop / 轮数
curl -s http://localhost:8002/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-31B",
"messages": [{"role": "user", "content": "What is 1+1?"}],
"max_tokens": 100,
"chat_template": "{{ bos_token }}{% for message in messages %}<start_of_turn>{{ message[\"role\"] }}\n{{ message[\"content\"] }}<end_of_turn>\n{% endfor %}<start_of_turn>model\n"
}' | python3 -u -c '
import sys, json
ch = json.load(sys.stdin)["choices"][0]
msg = ch["message"]["content"]
eot = "<" + "end_of_turn" + ">"
print("answer :", repr(msg[:80]) + ("..." if len(msg) > 80 else ""))
print("finish :", repr(ch.get("finish_reason")))
print("stop :", repr(ch.get("stop_reason")))
print("turns :", str(msg.count(eot)) + " 个 <end_of_turn>")
'
5.3 预期结果
-
多模态:每张图能拿到简短的颜色/物体描述(英文字符串)。
-
流式:每个 chunk 正常吐出 token,且能正常停止。
-
chat:直接给
2,停在<end_of_turn>上(finish='stop'),不再续写多轮。
步骤 6: 固化镜像并跑完整回归
6.1 目标
把打过补丁的容器封装成 vllm-ascend-gemma4:pr9222-chat-stop-v3,并用它直接起服务跑 stop strings 6/6 和多模态 5/5 完整回归,确认 bake 出来的镜像就是能用的。
6.2 操作
# 1) 停掉 vllm,准备 commit
docker exec gemma4-pr9222-test bash -c 'pkill -f "vllm serve" || true'
sleep 5
# 2) 把 entrypoint 拷进容器
# 该脚本会先把 VLLM_USE_V1=0 和 ASCEND_RT_VISIBLE_DEVICES=2,3 注入环境,
# 然后 exec vllm serve,比 docker commit --change='ENV ...' 更可控
# (可以被 docker run -e 覆盖)。
docker cp /data/wuyuming/vllm-ascend-gemma4/patches/entrypoint.sh gemma4-pr9222-test:/entrypoint.sh
docker exec gemma4-pr9222-test chmod +x /entrypoint.sh
# 3) 提交为新镜像,设置 ENTRYPOINT 为脚本,CMD 为空(让 entrypoint 走默认 vllm serve)
# 跑 v3 镜像时如果想覆盖默认参数,docker run <image> --port 9000 即可
docker commit \
--change='ENTRYPOINT ["/entrypoint.sh"]' \
--change='CMD []' \
gemma4-pr9222-test vllm-ascend-gemma4:pr9222-chat-stop-v3
# 3) 停掉旧容器,用 v3 镜像重新启动
docker stop gemma4-pr9222-test
docker rm gemma4-pr9222-test
docker run -d --name gemma4-pr9222-test \
--network host --privileged \
--device /dev/davinci_manager \
-e ASCEND_RT_VISIBLE_DEVICES=2,3 \
-v /usr/local/dcmi:/usr/local/dcmi \
-v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
-v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \
-v /data/models:/data/models \
--group-add $(getent group HwHiAiUser | cut -d: -f3) \
vllm-ascend-gemma4:pr9222-chat-stop-v3
# 4) 等待服务就绪
for i in $(seq 1 30); do
sleep 10
if curl -s http://localhost:8002/v1/models > /dev/null 2>&1; then
echo "service ready after ${i}0s"
break
fi
done
6.3 预期结果
-
镜像存在:
docker images | grep pr9222-chat-stop-v3输出:
vllm-ascend-gemma4 pr9222-chat-stop-v3 sha256:9b09fbfa79ad 27.1GB -
服务启动正常,日志看到
Application startup complete.。
步骤 7: 服务日常管理
7.1 目标
把启停、监控、清理跑顺。
7.2 操作
# 启停
docker start gemma4-pr9222-test
docker stop gemma4-pr9222-test
docker restart gemma4-pr9222-test
# 日志
docker logs gemma4-pr9222-test --tail 100 -f
# NPU 监控
watch -n 1 npu-smi info
# 镜像清理(Layer 1 是冗余的,可以删)
docker rmi vllm-ascend-gemma4:full
7.3 预期结果
docker start后 60-90 秒服务就绪。npu-smi info显示 HBM 使用率与请求量正相关。- 删了
vllm-ascend-gemma4:full后vllm-ascend-gemma4:pr9222-chat-stop-v3仍然能跑(因为 Layer 0 还在)。
附: 从 Base 镜像一键重建 v3
跳过中间层,从官方 Base 直接 bake v3(只适用于能访问外网的环境):
docker run --rm \
-v /data/wuyuming/vllm-ascend-gemma4/patches:/patches \
--network host --privileged --device /dev/davinci_manager \
quay.io/ascend/vllm-ascend:v0.20.2rc1-openeuler bash -c '
# 1) 装 PR #9222 源码(覆盖 base 镜像里的官方 vllm-ascend)
git clone --depth 1 -b fix-gemma4-ascend-inference-clean \
https://github.com/zhangshuai404/vllm-ascend.git /tmp/va
source /usr/local/Ascend/ascend-toolkit/set_env.sh
export SOC_VERSION=ascend910b4
git config --global --add safe.directory "*"
pip install --no-build-isolation -e /tmp/va
# 2) 装上游 vllm 0.20.2(跟 base 镜像一致)
git clone --depth 1 -b v0.20.2 \
https://github.com/vllm-project/vllm.git /vllm-workspace/vllm
pip install -e /vllm-workspace/vllm/[audio] \
--extra-index https://download.pytorch.org/whl/cpu/
# 3) 应用 RoPE UB 溢出补丁
python3 /patches/apply_rope_patch.py
# 4) 应用 stop strings 补丁
python3 /patches/apply_all_patches.py
# 5) 安装 entrypoint 脚本(让镜像自带 VLLM_USE_V1=0 + ASCEND_RT_VISIBLE_DEVICES=2,3)
cp /patches/entrypoint.sh /entrypoint.sh
chmod +x /entrypoint.sh
'
之后步骤 3-6 照走,最后能 bake 出一个等效的 v3 镜像。
相关资源
- PR #9222: https://github.com/vllm-project/vllm-ascend/pull/9222
- PR 分支: https://github.com/zhangshuai404/vllm-ascend/tree/fix-gemma4-ascend-inference-clean
- vLLM-Ascend 官方: https://github.com/vllm-project/vllm-ascend
- 基线镜像: https://quay.io/ascend/vllm-ascend:v0.20.2rc1-openeuler
- Gemma 4 模型: https://huggingface.co/google/gemma-4-31B
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐


所有评论(0)