iPhone 上の WebView アプリ(Capacitor)で、GGUF モデルを llama.cpp / Metal で動かす構成のデバッグ記録です。「たまに空応答(0トークン)が返る」という現象の根本原因が、思い込んでいた場所とことごとく違ったという、切り分け方法込みの実話です。
同じ構成(Capacitor + llama.cpp)の日本語記事はほぼ見当たらず(ネイティブSwiftやReact Nativeの記事は多数)、ハマりどころが未共有だったので残します。
構成
- Capacitor(WebView)+ Vue 3
- オンデバイス LLM:
llama-cpp-capacitorプラグイン経由で llama.cpp(Metal 埋め込みシェーダ) - モデル: Qwen2.5-3B-Instruct Q4_K_M(GGUF, Apache 2.0)
- 実機: iPhone 12 Pro Max(A14 / RAM 6GB)
現象
実機で生成を回すと、生成レスの歩留まりが run ごとに 1〜10/10 と激しくばらつく。悪い run では tokens_predicted: 0・本文空の「空応答」が多発する。Mac の llama-server では同じモデル・同じプロンプトで全く問題なし。
誤診の記録(正直に)
最初、私は根本原因を 3回連続で外した。
- 「3Bが6GB機に重すぎる(メモリ不足)」→ 1.5Bに落としても改善せず
- 「数時間叩いてサーマルスロットリング」→ 25分アイドルでも改善せず
- 「端末再起動で直る」→
devicectl device reboot直後の冷えた端末でも改善せず
さらに JS 側の緩和(temperature 段階上げ / プロンプト・プライミング / リトライ回数増)も試したが、いずれも歩留まりを安定させられなかった。推測ベースの対処で半日溶かした。
切り分け1: Mac と実機を「同一入力」で比較する
推測をやめ、device が送るのと寸分違わぬプロンプト・パラメータを Mac の llama-server に投げて比較した。
① 手動ChatML → /completion(device方式): 即EOS 0/20
② messages → /v1/chat/completions(jinja方式): 即EOS 0/20
Mac では 0%。プロンプト・テンプレート・サンプリングパラメータ・モデル・端末HW はすべて無罪と確定。差分は「実行時の llama.cpp ビルドそのもの」だけに絞られた。
教訓: prompt 層か framework 層かは、同一入力を別ランタイムに投げれば1回で切り分けられる。
切り分け2: ネイティブログを直接読む
Capacitor アプリは JS の console.log しか見えないと思いがちだが、devicectl でネイティブの stdout/stderr が取れる。
xcrun devicectl device process launch --console --terminate-existing \
--device <UDID> <bundle-id>
これで llama.cpp の ggml_metal_* や自前の fprintf が全部見える。ここから真因が2つ出てきた。
真因A: 再ビルドした framework の Metal シェーダが実行時コンパイル失敗
そもそも「ネイティブを直したい→framework を再ビルド」で最初に踏んだ地雷。手元の cpp/ ソースからビルドすると、実行時に:
lm_ggml_metal_load_library: error: MTLLibraryErrorDomain Code=3
unknown type name 'block_q1_0'
use of undeclared identifier 'FC_UNARY'
unknown type name 'ggml_metal_kargs_unary'
= 埋め込み .metal シェーダと ggml ヘッダのバージョンが不整合(.metal が新しい量子化型・カーネル引数構造体を参照するが、コンパイル対象のコードに定義が無い)。Metal ライブラリのロードに失敗し CPU フォールバック → 0.2 tok/s の激遅になっていた。これを当初「ハング」と誤認していた。
正しく動く framework は、別の内部整合したソーススナップショット(モジュラ構成の新しい ggml-metal、ggml_metal_library_init API)からビルドされていた。そちらから再ビルドすると Metal 正常・14 tok/s に復帰。
(動作版のログ)
lm_ggml_metal_library_init: using embedded metal library
lm_ggml_metal_library_init: loaded in 0.020 sec
教訓: 「全モデルが遅い」時は、サーマルやメモリを疑う前に
--consoleでmetal_library_init: loaded(正常)かmetal_load_library: error(CPUフォールバック)かを見る。ログ1本で確定できる。
真因B: 空応答の正体は prefill の llama_decode 失敗
Metal を直しても歩留まりの分散は残った。ここで自前の fprintf を仕込んで追うと、決定打が出た:
- 空応答(tp=0)は
stopped_eos: false… EOS で止まったのではない - ログを数えると
prefill decode failedの回数が空応答の数と完全一致
つまり空応答の正体は「即EOS」ではなく、プロンプトの prefill 中に llama_decode が失敗して 0 トークンで返っていた。さらに戻り値を出力させ、決定的パラメータを特定した:
n_batch を 512 → 128 に下げると、decode 失敗が 0 になった。
6GB 機では、n_batch=512 の prefill 用 compute buffer がメモリを圧迫し、大きいプロンプト(=会話が進んだ後半)ほど llama_decode が失敗していた。これが「run ごとの激しい分散」の正体だった。
修正
内部整合したソースから再ビルドする前提で、3点:
-
n_batchを 128 に(主因。prefill の compute buffer を縮小) - prefill 前に KV キャッシュを全クリアしてフル prefill(コンテキスト使い回しの不整合予防)
- 生成初手で EOG(終了トークン)を弾く際のフォールバックを、
llama_get_logits_ith依存からサンプラーの候補配列ベースに変更(保険)
結果、実機で 追試 5/5 run が 10/10・空応答 0(以前は 5/10 で空多発)に安定。
まとめ / 教訓
- オンデバイス LLM の生成不良は、JS(prompt / param / リトライ)を疑う前に native の loadPrompt / decode を計器で追う。今回、JS 緩和で3周空転した後、
fprintf1発で真因に到達した。 -
n_batchは、メモリ制約端末でllama_decodeの成否を分ける見落としやすい重要パラメータ。 - 切り分けは推測ではなく「同一入力を別ランタイムへ」「
--consoleでネイティブログ」の2手で機械的に。 - Metal 埋め込みシェーダは、ソースツリーのバージョン整合が崩れていると実行時コンパイルで静かに失敗する。ビルドは内部整合したスナップショットから。
Mac で動いたから実機でも動く、は嘘。実行時のメモリ制約で挙動が変わるという、オンデバイス特有の教訓でした。
参考
この「Capacitor + llama.cpp」構成の直接の解法記事は見つけられませんでしたが、背景理解・切り分けに役立ったものを挙げます。
ビルド / Metal(真因Aで直接役立った)
-
llama.cpp
build-xcframework.sh— iOS 向けの正しい cmake フラグ(-DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON)の一次資料。再ビルドが CPU フォールバックしていた原因の特定に直結した - llama.cpp
docs/build.md
日本語の先行記事(構成把握・RAM律速の裏取り)
- ローカルLLM on iOS の現状まとめ(shu223) — llama.cpp / Core ML / MLX の総覧
- iOSでLlama.cppを使ったローカルLLMアプリの構築2024(giginet) — 「計算量よりRAMがボトルネック」の指摘が今回の真因(メモリ圧)と一致
- iOSアプリでローカルLLMを動かそう!LocalLLMClient(tattn)
- iPhone単体でオンデバイスLLMを試してみた(LLMFarm)
- スマホでLlama 3.2 3Bを使う(PocketPal)
Capacitor 系プラグインの現状(黎明期)
「空応答」の別要因(今回とは原因が違ったが切り分けの参考)
- llama.cpp issue #23438(empty response / keeps generating)
-
--ignore-eosは冗長か #309 / EOS トークンの上書き #4000 — 一般に「空応答」は stop文字列やEOSトークン設定が原因のことが多い。今回は prefill decode 失敗という別要因だった