NIIが公開した LLM-jp-4 を、手元の MacBook Pro M4 Max 128GB でローカル実行し、OllamaOpenAI互換API 経由で既存のバッチ処理から使えるかを検証しました。

結論から言うと、8B は十分実用的です。32B-A3B も、このクラスのマシンならローカルで動きます。ただし、今回の GGUF + Ollama 構成では 32B-A3B の出力品質が安定せず、現時点では 8B のほうが実務上は扱いやすいという結果になりました。

背景

今回やりたかったことは、単にローカルで対話することではなく、以下の構成を作ることでした。

  • LLM-jp-4 をローカルサーバとして起動する
  • 他のクライアントソフトウェアから使えるようにする
  • 既存の抽出バッチから OpenAI互換API として呼び出す

用途は、日本語OCRテキストから表情・感情表現を抽出するバッチ処理です。

結論

今回の時点での実務的な判断は以下です。

  • MacBook Pro M4 Max 128GB なら LLM-jp-4 8B は十分動く
  • LLM-jp-4 32B-A3B も量子化版ならローカルで実際に動く
  • OpenAI互換API をすぐ使いたいなら Ollama が最短
  • ただし LLM-jp-4 の公式配布をそのまま使うのではなく、今回は GGUF 変換版を使う
  • 抽出品質の観点では、今回の設定では 32B-A3B より 8B のほうが安定していた

ここは重要です。LLM-jp-4 の公式公開は Hugging Face 上の transformers 向けですが、ローカルで Ollama から使うため、今回は GGUF 変換版を使いました。そのため、厳密には「公式実装そのまま」ではありません。

使用した環境

  • マシン: MacBook Pro M4 Max 128GB
  • 実行基盤: Ollama 0.20.3
  • API: http://localhost:11434/v1/
  • モデル: llm-jp-4-8b-thinking-Q4_K_M.gguf

8BQ4_K_M は約 5.3GB です。ローカルのテスト用途にはかなり扱いやすいサイズでした。

モデルの導入

Ollama は Homebrew で入れました。

brew install ollama
brew services start ollama

次に GGUF をダウンロードし、Modelfile を作成します。

curl -L --fail -o ~/git/llm/ollama/models/llm-jp-4-8b-thinking-Q4_K_M.gguf \
  'https://huggingface.co/mmnga-o/llm-jp-4-8b-thinking-gguf/resolve/main/llm-jp-4-8b-thinking-Q4_K_M.gguf?download=true'
FROM /Users/yourname/git/llm/ollama/models/llm-jp-4-8b-thinking-Q4_K_M.gguf

PARAMETER num_ctx 8192
PARAMETER temperature 0.7
ollama create llm-jp-4-8b-q4 -f ~/git/llm/ollama/modelfiles/llm-jp-4-8b-q4.Modelfile

これで OllamaOpenAI互換API から llm-jp-4-8b-q4 として呼べます。

OpenAI互換APIとして使う

接続先は以下です。

base_url = "http://localhost:11434/v1/"
api_key = "ollama"

Python からは普通に openai SDK で呼べます。

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:11434/v1/",
    api_key="ollama",
)

response = client.chat.completions.create(
    model="llm-jp-4-8b-q4",
    messages=[
        {"role": "user", "content": "これはテストです。JSONだけ返してください。"}
    ],
)

print(response.choices[0].message.content)

バッチ抽出の試験用スクリプト

今回は既存の本番コードを直接書き換えず、試験用スクリプトを別で用意しました。

  • 入力: /Users/nakamura/git/ndl/face/data/886000
  • 出力: JSON
  • 呼び出し先: ローカル Ollama API

最初は「JSON配列だけ返せ」とプロンプトで指示していましたが、実際には余計な文字列や ... が混ざり、パースに失敗しました。

そこで以下を適用しました。

  • response_format による JSON Schema 制約
  • temperature=0
  • ストリーミングログによる進捗表示
  • チャンク単位の計測

なぜ構造化出力が必要だったか

この種の抽出では、プロンプトだけで「JSONのみ返して」と言っても崩れます。特にローカルモデルでは、

  • 余計な説明文が付く
  • コードフェンスが付く
  • 一部のフィールドが省略される
  • フィールドの値が想定外になる

ということが普通に起きます。

今回のスクリプトでは response_format で JSON Schema を渡し、最低限の安定性を確保しました。これで「すぐ壊れる状態」からは脱しました。

実測

まず 1ページ で試しました。

chunk 1/1 pages=8-8 chars=1116
  first_token=1.2s
  streaming=5.0s chars=287
  completed=5.1s response_chars=290
  extracted=1

1ページ なら、今回の条件では約 5秒 でした。

次に 5ページ を回しました。最初は chunk_size=2500 で見えづらかったので、chunk_size=1200 で小さく切り、進捗が見えるようにしました。

chunk 1/5 pages=8-8 chars=1116
  first_token=1.2s
  streaming=5.0s chars=287
  completed=5.1s response_chars=290
  extracted=1
chunk 2/5 pages=9-9 chars=836
  first_token=1.0s
  completed=3.6s response_chars=302
  extracted=1
chunk 3/5 pages=10-10 chars=1113
  first_token=2.1s
  streaming=5.0s chars=256
  completed=7.1s response_chars=356
  extracted=1
chunk 4/5 pages=11-11 chars=869
  first_token=1.7s
  streaming=5.0s chars=365
  streaming=10.0s chars=867
  streaming=15.0s chars=1402
  completed=15.8s response_chars=1457
  extracted=5
chunk 5/5 pages=12-12 chars=777
  first_token=1.6s
  completed=4.3s response_chars=262
  extracted=1
saved=/Users/nakamura/git/llm/outputs/886000_extraction_test_5pages.json
total=9

この結果から分かるのは、5ページ にしたからといって単純に 5秒 x 5 にはならない、ということです。

時間が伸びる主な理由は以下です。

  • ページ数ではなく、実際のテキスト内容で難しさが変わる
  • JSON Schema に合わせた構造化出力は遅くなりやすい
  • 1つのチャンクで抽出件数が増えると、その分だけ応答も長くなる

実際、page 11 のチャンクだけ 15.8秒 かかりました。

出力品質

フォーマットとしては JSON で保存できましたが、内容はまだ荒いです。

たとえば以下のような問題がありました。

  • surfacecontext... になる項目がある
  • pages が空配列になる項目がある
  • body_part に想定外の値が出る
  • character が長すぎたりノイズを含んだりする

つまり、8B で「APIとして使える」ことは確認できましたが、抽出品質まで含めて即本番投入という段階ではありません。

32B-A3Bは現実的か

私の結論は「このマシンなら現実的」です。

ただし、8B でも連続推論時にはファンが回ります。32B-A3BQ4_K_M はさらに重いので、短時間の比較テストには向いていても、静かで快適とは言いにくいはずです。

実際に 32B-A3B Q4_K_M もローカルに導入し、1ページ だけ抽出を試しました。

chunk 1/1 pages=8-8 chars=1116
  first_token=7.9s
  streaming=12.9s chars=246
  completed=13.0s response_chars=251
  extracted=1

このログだけ見ると「遅いが動く」です。ただし、問題は内容でした。出力は JSON としては返るものの、surfacecontext が本文に根拠づかない文字列になり、pages が空になるケースがありました。

たとえば以下のような応答でした。

[
  {
    "surface": "あやま後から〓〓さいづちあたぶお才槌頭ハちいてほ%",
    "context": "あやま後から〓〓さいづちあたぶお才槌頭ハちいてほ%",
    "emotion": "その他",
    "body_part": "涙",
    "valence": "ambiguous",
    "intensity": "low",
    "character": "あやま後から〓〓さいづちあたぶお才槌頭ハちいてほ%",
    "pages": []
  }
]

さらに制約を強めて再試行すると、今度は以下のような不自然な文字列が surface に入りました。

[
  {
    "surface": "/c/co/n/t/e/x/t/",
    "context": "/c/co/n/t/e/x/t/",
    "emotion": "悲しみ・哀れ",
    "body_part": "口",
    "valence": "ambiguous",
    "intensity": "low",
    "character": "ださいづちあたぶお才槌頭ハちいてほ%",
    "pages": [8]
  }
]

つまり、今回の GGUF + Ollama 構成では 32B-A3B は「動く」が、「抽出品質が良い」とは言えませんでした。

用途別には以下のように考えるのがよさそうです。

  • プロンプト調整、API接続確認、小規模バッチ: 8B
  • 精度比較、少量の本気評価: 32B-A3B ただし GGUF/Ollama ではなく、できれば公式 Hugging Face 実装で再検証
  • 長時間の大量処理: できれば外部GPU

今回の結果を見ると、32B-A3B の評価は GGUF + Ollama だけで決めないほうがよさそうです。ローカル API 用には 8B を使い、32B-A3BTransformers ベースで別経路から試すのが妥当だと感じました。

まとめ

MacBook Pro M4 Max 128GBLLM-jp-4 をローカル実行し、OllamaOpenAI互換API から使う構成は十分成立します。

今回の時点での整理は以下です。

  • 8B はローカルAPIサーバとして十分使える
  • 構造化出力を使わないと、抽出バッチはかなり不安定
  • 5ページ 程度でも、チャンクによっては 15秒 前後かかる
  • 32B-A3B もこのクラスの Mac なら動く
  • ただし今回の GGUF + Ollama では、抽出品質は 8B のほうが安定していた

ローカルで日本語向けLLMを検証したい場合、LLM-jp-4 はかなり有力な選択肢だと思います。ただし、抽出系タスクでは「動く」と「品質が十分」は別です。今回の範囲では、ローカル API 用の現実解は 8B で、32B-A3B は公式 Transformers 実装でもう一段検証する価値がある、という結論になりました。

参考