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
確かめていないこと
- 画像の入力に使う mmproj は作っていません(文章だけです)
- トークナイザの設定の扱いの違いが、出力に影響しないか。語彙は
tokenizer.jsonから作られるので影響は無いと見ていますが、比べてはいません
おわりに
llama.cpp の変換器が語彙の段で 'list' object has no attribute 'keys' を出したら、tokenizer_config.json の extra_special_tokens が配列になっていないかを見てください。キャッシュも venv も触らずに、1ファイルだけ直した置き場で変換を通せます。
※ llama.cpp の変換器と transformers の振る舞い、モデルの配布物の中身は執筆時点の情報で、いまは変わっている可能性があります。


