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 行程度の変更を数箇所 入れるだけで済み、リビルド + サービス常駐化で完結した。
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 にオフロード)。
POST /v1/chat/completions が HTTP 500 を返す
(finish_reason / content が null)W common_chat_peg_parse: unparsed peg-native output
The model produced output that does not match the expected peg-native format
モデル品質ではなさの確認
llama-cli で直接生成させると、日本語回答は正しく生成される
(推論 → 回答の構造も正常)。つまり「モデルが壊れている」のではなく
「出力形式の解析に失敗している」。
トークンレベルでの確認
llama-cli -lv 2 で生成ログを見ると、モデルは次のように出力している:
<|start|> assistant<|channel|> analysis<|message|> ...
一方、Harmony パーサーが期待している (GGUF の chat_template 側も 正規形として定義している) 形式は:
<|start|>assistant<|channel|>analysis<|message|> ...
差分は <|start|> / <|channel|> 直後の半角スペース 1 つ。
正確には、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 トークン直後の
スペースは除去されず、そのままの (スペース付き) 文字列が
パーサに渡ってしまう。
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 する)
| チェック | 修正前 | 修正後 |
|---|---|---|
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
http://<host>:8081/http://<host>:8081/v1/chat/completionstokenizer.ggml.add_space_prefix の値:
8b / 32b-a3b は false、33b は true (mmnga-o) または欠落で既定 true
(公式)。つまり「llm-jp-4 系列すべてで起きる」のではなく、
GGUF 側で add_space_prefix が有効な場合に起きる。--ngl の扱い: この fork のビルドでは --ngl auto を long 形で
渡すと invalid argument: --ngl になる (デフォルトが auto-fit なので
省略するのが安全)。-ngl 99 を指定すると全層を GPU に載せようと
して VRAM 不足で落ちる。rm -rf build して再 configure。tokenizer.ggml.add_space_prefix で決まり、33b の GGUF だけが true
(明示または SPM既定) であることが直接原因 (§10)。8b / 32b-a3b は
false なので同じ llama.cpp で正常動作するtokenizer.ggml.add_space_prefix = false を設定し直す
(モデル再エクスポート)
のどちらかが必要この節をそのまま実行すれば、パッチ適用済み llama.cpp + llm-jp-4-33b-thinking + 埋め込み Web チャット UI + OpenAI 互換 API が稼働する構成が得られる。以下は全て実機で検証済み手順である。
| 項目 | 条件 |
|---|---|
| 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 |
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が失敗し得るから。
本記事の同梱ファイル
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
rm -rf build
cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON -DLLAMA_BUILD_UI=ON
cmake --build build --config Release -j$(nproc)
LLAMA_BUILD_UI=ON が Web チャット UI の埋め込み。
UI が不要ならこのフラグを外すとビルドが短くなる。Could not resolve ./Icon.svelte 等で失敗する場合は
tools/ui/node_modules が壊れているので:
cd tools/ui && rm -rf node_modules && npm ci
3. build/tools/ui/dist に 0 バイトの bundle.css が残っていると
asset provisioning が「最新」と誤認して UI が空になる。
該当ファイル (または dist) を削除して再ビルドする。rm -rf build して再 configure する (上記手順どおり)。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
./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
起動後:
http://<host>:8081/http://<host>:8081/v1/chat/completions (OpenAI 互換)
--nglは指定しないこと (この fork バイナリは--ngl autoを long 形で渡すとinvalid argument: --nglで死ぬ。 デフォルトが auto-fit なので省略が安全)。
[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
# 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 |
全合格 (リグレッションなし) |
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) で比較した。
| モデル | 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 になる。
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 |
直接原因は 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 を埋め込んで
おりスペースが混入しない。
detokenize() の remove_space は先頭 1 トークンのみに効くため、
CONTROL トークン直後のスペースは除去されない → Harmony パーサーが
厳密一致に失敗する。
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 テンプレートの 構造化トークン列との非互換。該当行は履歴として残す。
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.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 |
--chat-template llama3 を指定すると add_space_prefix の設定値に
かかわらず 200 で正常生成。また chat/completions でなく
/v1/completions(非 chat 補完)なら peg-native パーサーを通らないため
control / fixed とも正常(200)。common_chat_peg_parse)と、llm-jp の Harmony
テンプレートが使う構造化特殊トークン列(<|channel|> <|message|>
<|start|> <|end|> 等)との非互換 であって、add_space_prefix ではない。--chat-template を明示指定するのが即効(モデル内蔵テンプレートの
peg-native パースを回避)。テンプレート指定で add_space_prefix の値は
無関係になる。/v1/chat/completions)vs 非 chat(/v1/completions)」を分けること。
後者は peg-native を経由しないため、モデル/トークナイザ由来の問題か
chat format 由来の問題かを即座に判別できる。§10.5 の「次はテンプレート側を疑え」という教訓に基づき、さらに 2 種類の追検証を行った。
llm-jp のネイティブターントークン(<|start|><role><|message|>...<|end|>)だけを使った
小型テンプレート llm_jp_min.jinja を 2 バリアント作成し、
--chat-template-file で Q3_K_M(未パッチ・add_space_prefix=true 既定)に適用した。
<|channel|>final を含む): 500<|channel|> を一切含まない): 500B のログが決定的だった:
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 になる。
add_space_prefix=false を明示適用した q3_fixed.gguf(16GB、再エンコード検証済み)でも
ログの生成出力に <|channel|> analysis のスペースが残っていた。
つまり、スペースは add_space_prefix(= トークナイザの先頭スペース付与)によるものではなく、
モデルが特殊トークン直後にスペーストークンを生成していることを実測で確認。
KV パッチでは絶対に直らない。
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 にはこの修正は未マージ(確認済み)。
検証用にビルド済みの 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 |
reasoning_content(analysis チャンネル)と content(final チャンネル)が正しく分離される--chat-template 指定・KV 改編は一切不要add_space_prefix の KV パッチ / カスタムテンプレートは無意味(実測で反証)。3c7d74fea〜)がデコード時にスペーストークンを除去し、
無改造 GGUF + モデル内蔵テンプレートのままで chat 補完が正常動作する。