前提:本文写在 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 info
    

    npu-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:fullvllm-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 镜像。


相关资源

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐