llama.cpp で llm-jp-4-33b-thinking を動かせなかった件の調査・修正メモ

日付: 2026-08-25

TL;DR

llm-jp-4 系の thinking モデル のうち 33b (llm-jp-4-33b-thinking) の GGUF を llama-server (OpenAI 互換 API) で動かすと HTTP 500 が返り、クライアント側でストリームが途中切断される という問題があった。

スコープ補足 (2026-08-26 追記): 同じ llama.cpp で 8b-thinking / 32b-a3b / 33b を比較したところ、8b と 32b-a3b は正常動作し 33b のみ 500 になる。 違いは GGUF に埋め込まれた tokenizer.ggml.add_space_prefix の値 (詳細は §3 の「出所の特定」と §10 を参照)。

原因は「モデルの生成トークン列が <|channel|> の後に半角スペース 1 つを挟む」一方で、llama.cpp 側の Harmony (peg-native) パーサーが スペースなしの厳密形式しか受け付けない、という相性問題だった。

llm-jp 公式は llama.cpp の fork でこの問題を認識・対応済みだが、 その修正が llama-server 経路を完全にカバーしきれておらず、 さらに upstream (ggml-org) には未反映のため、 upstream / 古い fork では依然として同じ失敗が起きる。

修正は llama.cpp 側の Harmony パーサーに 1 行程度の変更を数箇所 入れるだけで済み、リビルド + サービス常駐化で完結した。

1. 環境

起動コマンド (評価用)

llama-server \
  --model <path>/llm-jp-4-33b-thinking-IQ3_XXS.gguf \
  --jinja \
  --reasoning on \
  --flash-attn on \
  --ctx-size 8192 \
  --host 0.0.0.0 \
  --port 8081 \
  --alias llm-jp-4-33b-thinking

-ngl は省略で OK (デフォルトは auto-fit: GPU に載らない層は CPU にオフロード)。

2. 症状

W common_chat_peg_parse: unparsed peg-native output
The model produced output that does not match the expected peg-native format

3. 切り分け

  1. モデル品質ではなさの確認 llama-cli で直接生成させると、日本語回答は正しく生成される (推論 → 回答の構造も正常)。つまり「モデルが壊れている」のではなく 「出力形式の解析に失敗している」。

  2. トークンレベルでの確認 llama-cli -lv 2 で生成ログを見ると、モデルは次のように出力している:

<|start|> assistant<|channel|> analysis<|message|> ...

一方、Harmony パーサーが期待している (GGUF の chat_template 側も 正規形として定義している) 形式は:

<|start|>assistant<|channel|>analysis<|message|> ...

差分は <|start|> / <|channel|> 直後の半角スペース 1 つ。

  1. 出所の特定 このトークナイザ (llm-jp-tokenizer v4.0, SentencePiece unigram ベース) は special トークンの直後にスペースプレフィックス (▁) が付く挙動があり、 detokenize 時に余分なスペースが生まれる。

正確には、llama.cpp の SPM トークナイザは add_space_prefix が 有効 (true) な場合、CONTROL トークン (<|start|> / <|channel|> 等) の直後に「空白 1 つ」を detokenize 出力の先頭に挿入する (src/llama-vocab.cpp の detokenize / token_to_piece)。 有効なのは GGUF の tokenizer.ggml.add_space_prefix の値で決まり、 キーが欠落していると SPM の既定値 true になる。 本件の 33b GGUF は add_space_prefix が true (mmnga-o) または欠落 (公式) であるためスペースが混入する。一方、正常動作する 8b / 32b-a3b の GGUF は add_space_prefix = false を埋め込んでおり 混入しない (§10 参照)。

llama.cpp 側の detokenize() は remove_space = add_space_prefix を 先頭 1 トークンにのみ適用するため、CONTROL トークン直後の スペースは除去されず、そのままの (スペース付き) 文字列が パーサに渡ってしまう。

4. 修正 (llama.cpp 側)

Harmony (peg-native) パーサーは common/chat.cpp (common_chat_params_init_gpt_oss) にあり、 各ルールが リテラル完全一致 になっているため、 <|channel|> / <|start|> の直後に 任意の 1 スペースを許容 するよう変更する。

// 変更前
auto start     = p.rule("start", p.literal("<|start|>assistant"));
auto channel   = p.rule("channel", p.literal("<|channel|>")
                        + p.either(p.literal("analysis"), p.literal("final")));
// ...

// 変更後 (opt_space = p.optional(p.space()))
auto start     = p.rule("start", p.literal("<|start|>") + opt_space
                        + p.literal("assistant"));
auto channel   = p.rule("channel", p.literal("<|channel|>") + opt_space
                        + p.either(p.literal("analysis"), p.literal("final")));
// start_analysis / preamble / final / tool-call / response-format 系も同様に

この変更は既存の (スペースなしの) 正規出力に対する挙動を変えない (オプションのスペースであるため) ので、既存モデルへの影響はない。

リビルド:

cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON
cmake --build build --config Release -j12 --target llama-server

補足: WebUI を埋め込みたい場合は -DLLAMA_BUILD_UI=ON を付けるとよい。 (ローカル npm build 経路。fork のビルド番号は HF の prebuilt に 存在しないため、prebuilt ダウンロードは失敗して fallback する)

5. 検証

チェック 修正前 修正後
POST /v1/chat/completions HTTP 500 HTTP 200
reasoning_content null 推論部分が分離されて返る
content null 最終回答が返る
finish_reason null stop
サーバーログの unparsed 警告 出る 0 件
ストリーミング 途中切断 正常配信 + [DONE] 終了
test-chat --filter gpt_oss (リグレッション) - 全合格

systemd 常駐化後の unit 例 (設定値):

[Unit]
Description=Local LLM Server (llm-jp-4-33b-thinking)
After=network-online.target
Wants=network-online.target

[Service]
WorkingDirectory=<models dir>
Environment=CUDA_VISIBLE_DEVICES=0
ExecStart=<path>/build/bin/llama-server \
  --model llm-jp-4-33b-thinking-IQ3_XXS.gguf \
  --jinja --reasoning on --flash-attn on \
  --ctx-size 8192 --host 0.0.0.0 --port 8081 \
  --alias llm-jp-4-33b-thinking
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

6. 注意点・ハマりどころ

  1. 33b だけが失敗する (8b / 32B-A3B は動作する) 2026-08-26 に 8b-thinking / 32b-a3b / 33b (mmnga-o と公式の2系統) を 最新の llama.cpp で比較した結果、失敗するのは 33b のみ (詳細は §10)。 違いは GGUF の tokenizer.ggml.add_space_prefix の値: 8b / 32b-a3b は false、33b は true (mmnga-o) または欠落で既定 true (公式)。つまり「llm-jp-4 系列すべてで起きる」のではなく、 GGUF 側で add_space_prefix が有効な場合に起きる。
  2. --ngl の扱い: この fork のビルドでは --ngl auto を long 形で 渡すと invalid argument: --ngl になる (デフォルトが auto-fit なので 省略するのが安全)。-ngl 99 を指定すると全層を GPU に載せようと して VRAM 不足で落ちる。
  3. 評価・ベンチ用で ctx を大きくするだけでは VRAM 不足は解消しない (重さはモデル層が主体)。auto-fit で CPU 側に落ちる分だけ遅くなる。
  4. fork と upstream の混在: llama.cpp のソースツリーと build ディレクトリが 別物になる状態 (CMakeCache が別ツリーを参照) だと、 「存在しないファイル」エラーでビルドが死ぬ。rm -rf build して再 configure。

7. 関連リンク (GitHub)

8. まとめ

9. 完全セットアップ手順 (clone → Web チャット稼働まで)

この節をそのまま実行すれば、パッチ適用済み llama.cpp + llm-jp-4-33b-thinking + 埋め込み Web チャット UI + OpenAI 互換 API が稼働する構成が得られる。以下は全て実機で検証済み手順である。

9.1 前提 (Prerequisites)

項目 条件
OS Linux (CUDA 対応 NVIDIA GPU 推奨)
VRAM 12GB で検証。少ない場合は auto-fit で CPU に溢れて動作する (低速化)
ツールチェーン git, cmake (3.13 以降), gcc/clang (C++17)
Node.js 18 以上 (WebUI を埋め込む場合のみ)
ディスク モデル 13GB (IQ3_XXS) + ソース/build 合計 数 GB

9.2 ソース取得 (llm-jp fork, 検証済みコミットに固定)

git clone https://github.com/llm-jp/llama.cpp.git llama.cpp.jp
cd llama.cpp.jp
git checkout 26b0e874a     # 検証済みバージョン (2026-06-16, build 9376)

コミットを固定しておく理由は、9.3 のパッチが git diff 形式のため、 上流が進んだツリーではコンテキスト不一致で git apply が失敗し得るから。

9.3 パッチ適用

本記事の同梱ファイル patch-harmony-stray-space.patch (common/chat.cpp の Harmony peg-native パーサーが <|start|> / <|channel|> 直後のスペース 1 つを許容するよう変更する) を適用する:

# このリポジトリから取得する
curl -O https://raw.githubusercontent.com/naokifkuma3-hash/llm-jp4-llamacpp-note/main/patch-harmony-stray-space.patch
git apply patch-harmony-stray-space.patch
# 検証: 変更は common/chat.cpp のみのはず
git diff --stat

9.4 ビルド (WebUI 埋め込み付き)

rm -rf build
cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON -DLLAMA_BUILD_UI=ON
cmake --build build --config Release -j$(nproc)

9.5 モデル取得 (GGUF)

HuggingFace の mmnga-o 公開 GGUF (IQ3_XXS, 約 13GB):

mkdir -p models
wget -P models \
  https://huggingface.co/mmnga-o/llm-jp-4-33b-thinking-gguf/resolve/main/llm-jp-4-33b-thinking-IQ3_XXS.gguf
# または
# pip install -U "huggingface_hub[cli]"
# huggingface-cli download mmnga-o/llm-jp-4-33b-thinking-gguf \
#   llm-jp-4-33b-thinking-IQ3_XXS.gguf --local-dir models

9.6 起動 (Web チャット + API)

./build/bin/llama-server \
  --model models/llm-jp-4-33b-thinking-IQ3_XXS.gguf \
  --jinja \
  --reasoning on \
  --flash-attn on \
  --ctx-size 8192 \
  --host 0.0.0.0 \
  --port 8081 \
  --alias llm-jp-4-33b-thinking

起動後:

--ngl は指定しないこと (この fork バイナリは --ngl auto を long 形で渡すと invalid argument: --ngl で死ぬ。 デフォルトが auto-fit なので省略が安全)。

9.7 常駐化 (systemd unit, 検証済みそのまま)

[Unit]
Description=Local LLM Server (llm-jp-4-33b-thinking, llm-jp fork llama.cpp)
After=network-online.target
Wants=network-online.target

[Service]
User=<your-user>
WorkingDirectory=/path/to/models
Environment=CUDA_VISIBLE_DEVICES=0
ExecStartPre=/usr/bin/sleep 3
ExecStart=/path/to/llama.cpp.jp/build/bin/llama-server \
  --model /path/to/models/llm-jp-4-33b-thinking-IQ3_XXS.gguf \
  --jinja \
  --reasoning on \
  --flash-attn on \
  --ctx-size 8192 \
  --host 0.0.0.0 \
  --port 8081 \
  --alias llm-jp-4-33b-thinking
Restart=always
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now llm-jp-4-33b-thinking

9.8 動作検証チェックリスト

# 1) WebUI (HTTP 200 の HTML が返る = UI 埋め込み成功)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8081/

# 2) ヘルスチェック
curl -s http://localhost:8081/health          # {"status":"ok"}

# 3) モデル登録
curl -s http://localhost:8081/v1/models | grep llm-jp-4-33b-thinking

# 4) 非ストリーム chat (本修正の核心チェック)
curl -s http://localhost:8081/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"llm-jp-4-33b-thinking","messages":[{"role":"user","content":"テスト"}],"stream":false}'

合格の目安:

チェック 期待値
HTTP ステータス 200 (修正前は 500)
choices[0].message.reasoning_content 推論 (analysis) 部分が分離されて返る
choices[0].message.content 最終回答 (final) が返る
choices[0].finish_reason stop
サーバーログ (journalctl) unparsed peg-native output が 0 件
./build/bin/test-chat --filter gpt_oss 全合格 (リグレッションなし)

10. モデル横断比較: なぜ 33b だけが失敗するのか (2026-08-26 追記)

llm-jp-4 系列 4 モデル (全て Q4_K_M) を 最新の llama.cpp (ggml-org, commit 758443071, 2026-08-24) の llama-server で統一条件 (--jinja --reasoning on --flash-attn on --ctx-size 4096)、 1 リクエスト (「こんにちは。自己紹介を一言で。」, non-stream) で比較した。

10.1 動作結果

モデル arch HTTP content / reasoning unparsed peg 警告
mmnga-o 8b-thinking llama 200 分離されて返る 0
mmnga-o 32b-a3b-thinking qwen3moe 200 分離されて返る 0
mmnga-o 33b-thinking llama 500 null / null 1
llm-jp (公式) 33b-thinking llama 500 null / null 1

失敗する 33b の 2 系統 (mmnga-o / 公式) は同型の生出力を返し、 失敗原因が GGUF 共通の挙動であることが確認できた:

<|channel|> analysis<|message|> ...(推論)...<|end|><|start|> assistant<|channel|> final<|message|> ...(回答)

<|start|> / <|channel|> 直後の半角スペース 1 つが Harmony peg-native パーサーのリテラル厳密一致に抵触し common_chat_peg_parse: unparsed peg-native output → 「The model produced output that does not match the expected peg-native format」 で HTTP 500 になる。

10.2 GGUF メタデータ比較

llama-gguf / 自作パーサーで GGUF のメタデータを抽出し比較した。 トークナイザ (vocab 196608, ▁ word-start マーカー, bos=1/eos=2, <|start|>=10 / <|channel|>=9 / <|end|>=11) は全 4 モデルで同一。

項目 8b (OK) 32b-a3b (OK) 33b mmnga-o (NG) 33b 公式 (NG)
tokenizer.ggml.add_space_prefix false false true 欠落 → 既定 true
tokenizer.ggml.eot_token_id 11 11 なし なし
tokenizer.ggml.remove_extra_whitespaces なし なし false なし
tokenizer.ggml.model llama llama t5 (誤推測の痕跡) llama
tokenizer.chat_template 別 (17013字) 同一 (16982字) 同一 同一
general.architecture llama qwen3moe llama llama

10.3 因果の確定(初版・後述 §10.5 により訂正)

  1. 直接原因は tokenizer.ggml.add_space_prefix と考えた。 llama.cpp (src/llama-vocab.cpp) では SPM トークナイザが add_space_prefix = true のとき、CONTROL トークンの直後に空白 1 つを detokenize 先頭に挿入する。GGUF にこのキーが欠落している場合、 SPM の既定値 true が採用されるため、公式 33b も mmnga-o 33b (明示 true) も同じ挙動になる。8b / 32b-a3b は false を埋め込んで おりスペースが混入しない。

  2. detokenize() の remove_space は先頭 1 トークンのみに効くため、 CONTROL トークン直後のスペースは除去されない → Harmony パーサーが 厳密一致に失敗する。

  3. eot_token_id の欠落 (33b のみ) は副次的な差分。 llama.cpp は <|end|> を EOG (eos) リストから除外する挙動 (special_eog_ids contains both '<|return|>' and '<|call|>'... removing '<|end|>' token from EOG list) を持つため、 メッセージ区切りは <|return|> / <|end|> 文字列に依存し、 eot token の有無で本件の 500 が分かれるわけではない。 直接原因は 1 の add_space_prefix である。

【訂正】 上記 1 は 2026-08-26 の実測検証(§10.5)で反証された。 add_space_prefix を false に書き換えた GGUF(fixed) でも chat 補完の 500 は解消しなかった。真の原因は §10.5 のとおり llama.cpp の chat format(peg-native パーサー)と Harmony テンプレートの 構造化トークン列との非互換。該当行は履歴として残す。

10.4 修正方向 (モデル側)(初版・§10.5 により修正)

GGUF を再エクスポートし tokenizer.ggml.add_space_prefix = false (eot_token_id = 11 も併せて) を埋め込めば、本パッチなしの upstream llama.cpp でも 33b は正常にチャットパースされる。 llama.cpp 側で直すなら §4 のパーサー許容パッチが有効。

【修正】 2026-08-26 の実測検証(§10.5)では、この想定どおりにはならなかった。 モデル側の add_space_prefix 修正だけでは chat 補完の 500 は解消しない。 upstream llama.cpp で 33b を chat 補完できるようにするには サーバー側で --chat-template llama3 等を明示指定するのが実用的。 (詳細は §10.5)

10.5 実測検証: add_space_prefix=false にしても 500 は解消しなかった (2026-08-26)

前節 10.4 の提言を、実際の Q4_K_M GGUF を改編して検証した。 検証サーバー上で、公式 Q4_K_M を元に gguf-py(GGUFWriter)で全メタデータ・ 全テンソルを再シリアライズし tokenizer.ggml.add_space_prefix = false / tokenizer.ggml.eot_token_id = 11 を明示追加した fixed GGUF を生成。 元ファイル(既存・add_space_prefix 欠落→既定 true)を control とした。 無改造 upstream llama.cpp(ggml-org, commit 10bf611, git clean)の llama-server で両者を起動し、同一プロンプト(「こんにちは」, non-stream max 8 tokens, CPU 実行・ctx 2048・1slot)で比較した。

# GGUF add_space_prefix chat-template /v1/chat/completions
1 control true(既定) (モデル由来) 500 peg-native
2 fixed false (モデル由来) 500 peg-native
3 control true(既定) llama3 200
4 fixed false llama3 200

教訓・結論

  1. chat 補完の 500 の直接原因は、llama.cpp の「chat format」の peg-native パーサー(common_chat_peg_parse)と、llm-jp の Harmony テンプレートが使う構造化特殊トークン列(<|channel|> <|message|> <|start|> <|end|> 等)との非互換 であって、add_space_prefix ではない。
  2. add_space_prefix は SP2/SPM 由来のスペース挙動に影響はするが、 今回の chat 500 の分岐要因にはならない(fixed で 500、control+llama3 で 200)。
  3. upstream llama.cpp で 33b を chat 補完したい場合は --chat-template を明示指定するのが即効(モデル内蔵テンプレートの peg-native パースを回避)。テンプレート指定で add_space_prefix の値は 無関係になる。
  4. llama.cpp の chat 500 を切り分ける際は、まず 「chat(/v1/chat/completions)vs 非 chat(/v1/completions)」を分けること。 後者は peg-native を経由しないため、モデル/トークナイザ由来の問題か chat format 由来の問題かを即座に判別できる。

10.6 真因確定: 特殊トークン直後のスペーストークンはモデル自身が生成する (2026-08-27 追記)

§10.5 の「次はテンプレート側を疑え」という教訓に基づき、さらに 2 種類の追検証を行った。

(1) カスタムテンプレートでも 500 は解消しない

llm-jp のネイティブターントークン(<|start|><role><|message|>...<|end|>)だけを使った 小型テンプレート llm_jp_min.jinja を 2 バリアント作成し、 --chat-template-file で Q3_K_M(未パッチ・add_space_prefix=true 既定)に適用した。

B のログが決定的だった:

W common_chat_peg_parse: unparsed peg-native output:
  <|channel|> analysis<|message|> Need concise answer: Tokyo.<|end|>
  <|start|> assistant<|channel|> final<|message|> 東京(とうきょう)です。

テンプレートに <|channel|> が存在しないにもかかわらず、モデル自身が <|channel|> analysis<|message|> を生成する(llm-jp-4 は常に analysis チャンネルから 応答を始めるよう訓練されている)。llama.cpp 側は生成テキスト中に <|channel|> を検出すると gpt-oss 系の peg-native パーサー(strict literal 一致)で解析しにいくため、 どのテンプレートを渡しても 500 になる。

(2) スペースはトークナイザ設定由来ではない

add_space_prefix=false を明示適用した q3_fixed.gguf(16GB、再エンコード検証済み)でも ログの生成出力に <|channel|> analysis のスペースが残っていた。 つまり、スペースは add_space_prefix(= トークナイザの先頭スペース付与)によるものではなく、 モデルが特殊トークン直後にスペーストークンを生成していることを実測で確認。 KV パッチでは絶対に直らない。

(3) 公式解決策: llm-jp フォーク (llama.cpp.jp) の lstrip 修正

llm-jp 公式の llama.cpp フォークに、まさにこの問題への修正が入っていた。

コミット 日付 内容
3c7d74fea 2026-06-02 strip leading space after special tokens
e13f9c20c 2026-06-16 fix(条件式の型修正)
26b0e874a — merge(llama.cpp.jp HEAD)

修正の本質は「生成ストリームのデコード時に、特殊トークン (LLAMA_TOKEN_ATTR_CONTROL | LLAMA_TOKEN_ATTR_USER_DEFINED、すなわち <|start|> <|channel|> <|message|> 等)の直後トークンの先行スペースを lstrip する」こと(src/llama-vocab.cpp の detokenize と tools/server/server-context.cpp の lstrip_next_token)。 生成トークンをスペースなしの <|channel|>analysis<|message|> に正規化するので、 peg-native の strict literal 一致が通る。

※ 2026-08-27 現在、upstream ggml-org/llama.cpp にはこの修正は未マージ(確認済み)。

(4) 実測: フォーク + 無改造 GGUF で Q3/Q4 とも 200

検証用にビルド済みの llama.cpp.jp(HEAD = 26b0e874a)を使い、 未パッチの元 GGUF + モデル内蔵テンプレート + 特別な引数なしで確認した。

| モデル | add_space_prefix | プロンプト | 結果 | |---|---|---:|---|---| | Q3_K_M | true(既定) | 日本の首都は? | 200 content=「日本の首都は東京です。」+ reasoning_content | | Q3_K_M | true | 2 ターン目(マルチターン) | 200 prompt_tokens=129 正常 | | Q4_K_M | true(既定) | 日本で一番高い山は? | 200 content=富士山 3,776m + reasoning_content |

最終結論

  1. 33b の chat 500 は「llama.cpp の peg-native パーサー」×「モデルが生成する 特殊トークン直後のスペーストークン」の複合であり、どちらか一方の回避では解消しない。
  2. add_space_prefix の KV パッチ / カスタムテンプレートは無意味(実測で反証)。
  3. 正攻法は llm-jp 公式フォーク(llama.cpp.jp)を使うこと。 フォークの lstrip 修正(3c7d74fea〜)がデコード時にスペーストークンを除去し、 無改造 GGUF + モデル内蔵テンプレートのままで chat 補完が正常動作する。
  4. §9 のセットアップ手順は、llm-jp フォークを clone して使う前提だったので そのまま有効。upstream llama.cpp を使う場合は §9.3 のパッチに代えて フォークの lstrip 修正相当の適用が必要。