ROCm でローカル LLM が動くかを技術検証したときに、候補の1つとして google/gemma-4-31B-it を llama.cpp で GGUF(Q4_K_M)に変換しました。ところが変換器が、語彙を作る段で AttributeError を出して止まります。原因は、トークナイザの設定ファイルの書き方と、変換用の環境の transformers の版の食い違いでした。

結論

  • 原因: いまの gemma-4-31B-it の tokenizer_config.json は、"extra_special_tokens": ["<|video|>"] と配列で書いています(transformers 5 の書き方)。llama.cpp の requirements が固定している transformers 4.57.6 は辞書を前提にしていて、special_tokens.keys() で落ちます。
  • 変換器は語彙を作るときに AutoTokenizer を通すので、語彙の段で止まります。
  • 直し方: venv も Hugging Face のキャッシュも変えずに「影の置き場」を作り、そこを変換器に渡しました。重みなどは元へのシンボリックリンクにし、tokenizer_config.json だけを写して辞書の形に直します。
  • 長い変換を | tail で繋ぐときは set -e と set -o pipefail を合わせて付けます。私たちの台本では、変換が落ちたのに失敗が伝わらず、次の量子化に進んでもう一度落ちました。

試した環境

  • Ubuntu、llama.cpp v0.6.0(HIP でビルド)
  • 変換用の venv: transformers 4.57.6(llama.cpp の requirements が固定している版と同じ)
  • モデル: google/gemma-4-31B-it(リビジョンを固定して取得。62.5 GB、取得に 8 分。2つの safetensors の sha256 は Hugging Face の値と一致)

何が起きたか

1回目の変換は、次のように落ちました。パスは縮め、トレースバックの途中のソースの行は省いています(special_tokens.keys() を呼ぶ1行です)。

  File ".../site-packages/transformers/tokenization_utils_base.py", line 1210, in _set_model_specific_special_tokens
AttributeError: 'list' object has no attribute 'keys'
llama_quantize: quantizing '.../gemma-4-31B-it-BF16.gguf' to '.../gemma-4-31B-it-Q4_K_M.gguf' as Q4_K_M using 16 threads
gguf_init_from_file: failed to open GGUF file '.../gemma-4-31B-it-BF16.gguf' (No such file or directory)
llama_model_quantize: failed to quantize: llama_model_loader: failed to load model from .../gemma-4-31B-it-BF16.gguf
llama_quantize: failed to quantize model from '.../gemma-4-31B-it-BF16.gguf'
rm: cannot remove '.../gemma-4-31B-it-BF16.gguf': No such file or directory

前半の2行が本当の原因です。extra_special_tokens が配列で書かれているのに、transformers 4.57.6 は辞書だと思って .keys() を呼ぶので、AttributeError になります。

落ちたのに量子化に進んだ

ログの後半は、別の失敗です。変換と量子化を1本の台本で続けて回していて、変換の出力を | tail に通していました。set -o pipefail を付けていなかったので、パイプの終了コードは最後の tail の値(成功)になり、変換が落ちても失敗が台本に伝わりません。台本は止まらずに次の量子化に進み、「BF16 の GGUF が無い」でまた落ちています。最後の rm も、できていないファイルを消そうとして失敗しました。

ログの最後だけを見ると量子化が失敗したように見えるので、本当の原因を見落としやすいところです。長い変換を繋ぐ台本では、頭に次を置きます。

set -e -o pipefail

set -e は失敗した行で台本を止め、set -o pipefail はパイプの途中の失敗もパイプ全体の失敗として扱います。片方だけでは、| tail を挟んだ変換の失敗で止まりません。

最初の作業台が紙詰まりで止まっているのに、次の作業台が空の皿に押し型を下ろし続けている

直し方: 影の置き場を変換器に渡す

変換用の venv は llama.cpp の requirements の版のままにしておき、ダウンロードしたキャッシュの中身も書き換えない、という条件で直しました。そのために作業用の場所に「影の置き場」を作り、そこを変換器に渡します。

  • 重みとほかのファイルは、元のファイルへのシンボリックリンクにする
  • tokenizer_config.json だけを写し、extra_special_tokens を {"extra_special_token_0": "<|video|>"} の辞書に直す
  • 語彙の本体の tokenizer.json は元のまま

次の図は、キャッシュから置き場を作り、変換と量子化に進む流れです。キャッシュの中身には手を付けません。

キャッシュから影の置き場を作って変換器に渡す

台本は次のとおりです。python は変換用の venv の物です。

SNAPSHOT=/path/to/snapshot            # Hugging Face のキャッシュの snapshots/<リビジョン>
SHADOW=/path/to/work/gemma-4-31B-it   # 影の置き場
OUT=/path/to/gguf
mkdir -p "$SHADOW"
for f in "$SNAPSHOT"/*; do
  n=$(basename "$f")
  [ "$n" = tokenizer_config.json ] || ln -s "$f" "$SHADOW/$n"
done
python3 - "$SNAPSHOT/tokenizer_config.json" "$SHADOW/tokenizer_config.json" <<'PY'
import json, sys
c = json.load(open(sys.argv[1]))
x = c.get("extra_special_tokens")
if isinstance(x, list):
    c["extra_special_tokens"] = {f"extra_special_token_{i}": t for i, t in enumerate(x)}
json.dump(c, open(sys.argv[2], "w"), ensure_ascii=False, indent=2)
PY
# 先に語彙だけで確かめる(数秒)
python llama.cpp/convert_hf_to_gguf.py "$SHADOW" --vocab-only --outfile /tmp/vocab-test.gguf
# 変換と量子化
python llama.cpp/convert_hf_to_gguf.py "$SHADOW" --outtype bf16 --outfile "$OUT/gemma-4-31B-it-BF16.gguf"
llama.cpp/build-hip/bin/llama-quantize "$OUT/gemma-4-31B-it-BF16.gguf" "$OUT/gemma-4-31B-it-Q4_K_M.gguf" Q4_K_M 16

配列のときだけ辞書に直すので、extra_special_tokens がもともと辞書のファイルは、そのまま写るだけです。

確かめ方と結果

先に --vocab-only で語彙だけを書き出しました。数秒で終わるので、直しが効いたかをすぐに確かめられます。これが通るのを見てから、変換と量子化を通しで回しました。

  • BF16(61.4 GB)への書き出し: 3 分
  • Q4_K_M への量子化: 3 分。できた GGUF は 18.7 GB

元の重みが 62.5 GB、BF16 の GGUF が 61.4 GB、Q4_K_M に量子化した GGUF が 18.7 GB

確かめていないこと

  • 画像の入力に使う mmproj は作っていません(文章だけです)
  • トークナイザの設定の扱いの違いが、出力に影響しないか。語彙は tokenizer.json から作られるので影響は無いと見ていますが、比べてはいません

おわりに

llama.cpp の変換器が語彙の段で 'list' object has no attribute 'keys' を出したら、tokenizer_config.json の extra_special_tokens が配列になっていないかを見てください。キャッシュも venv も触らずに、1ファイルだけ直した置き場で変換を通せます。

※ llama.cpp の変換器と transformers の振る舞い、モデルの配布物の中身は執筆時点の情報で、いまは変わっている可能性があります。