M4 Mac mini 16GB でローカルLLM を動かす — 具体的な選択肢と現実

AI
2026-06-08 14:29 (6 days ago)
M4 Mac mini 16GB でローカルLLM を動かす — 具体的な選択肢と現実

はじめに

16GB の Mac mini でローカル LLM の動作を検証した。速くはないが実用範囲には乗るし、消費電力(負荷時で 30W 前後)まで含めて考えれば悪くない。

この記事は、M4 Mac mini base(16GB unified memory)でローカル LLM を動かすための、モデル選定・ランタイム選定・セットアップ手順と、実際に動かして踏んだ落とし穴の記録だ。後半の落とし穴 — Qwen3.5 系を Ollama で動かしたときの「応答が遅い・本文が帰ってこない」の正体 — がこの記事の本題になる。

画像

1. 16GB という制約

16GB の unified memory で快適に動くのは、Q4 量子化で 7〜9B クラスまでだ。14B 超を Q4 で動かすと仮想メモリにスワップして 5 tok/s を割り込み、実用にならない。

そして本当の限界はモデルサイズだけではない。KV キャッシュとコンテキスト長がメモリを食う。モデル本体が載っても、長いコンテキストや裏で他のアプリが動いている状況だと、そこから先に破綻する。16GB は「モデルが載るか」ではなく「モデル + KV キャッシュ + OS + 作業アプリが同時に載るか」で考える必要がある。

2. Unified memory と NVIDIA GPU のアーキテクチャ差

比較対象として、手元の NVIDIA GeForce RTX 3060 12GB と並べてみる。

観点 RTX 3060 12GB M4 Mac mini 16GB
メモリ帯域 360 GB/s (GDDR6) 120 GB/s (LPDDR5X)
7-8B Q4 速度 約 38-45 tok/s 約 20-35 tok/s
メモリ容量 専用 12GB(容量の壁が硬い) unified 16GB(劣化が緩やか)
prefill 強い(CUDA) 弱い
消費電力 170W 級 約 30W
エコシステム CUDA(成熟) MLX / Metal

LLM のトークン生成はメモリ帯域律速なので、帯域が 3 倍ある NVIDIA GPU のほうが素の生成速度は速い。一方 Mac は容量が unified で融通が利き、消費電力が小さく静音で 24 時間稼働に向く。どちらが上という話ではなく、性質が違う。

3. モデル選定

daily driver は Qwen3.5-9B(Q4_K_M, 約 6.6GB, Ollama タグ qwen3.5:9b)。16GB に余裕で載り、256K コンテキスト・vision・tools・thinking 対応。

一点訂正しておくと、「Qwen3.6-9B」というモデルは存在しない。9B は Qwen3.5 系だ。Qwen3.6 ファミリーは dense 27B と 35B-A3B(MoE)のみ。MoE の A3B(active 3B)は「1 トークンあたり 3B だけ動く」という速度・演算量の話で、メモリには総量 35B 全部を載せる必要がある。ここを取り違えると「3B 相当だから 16GB で動く」と誤解する。

4. ランタイム選定

ローカル推論のスタックは 3 層で捉えると正確だ。

  • GGML: C のテンソル演算ライブラリ(行列計算・メモリ管理・GPU カーネル)。土台。
  • llama.cpp: GGML の上で推論そのもの(モデルロード、forward pass、サンプリング、GGUF、llama-server)を実装。ほぼ全てのローカル推論ツールの中核。
  • Ollama: llama.cpp の上に乗る運用ラッパー(ollama pull、Modelfile、自動メモリ管理、OpenAI 互換 API)。

Ollama は一度 llama.cpp から離れて独自エンジンを試したが、2026 年 5 月に upstream の llama.cpp(llama-server)へ出戻った。今は「GGUF は llama.cpp、Apple Silicon の safetensors は MLX」の二本立てだ。

16GB では Ollama-GGUF が現実解になる。Ollama の MLX バックエンドは 32GB+ の unified memory 推奨で、16GB は対象外として llama.cpp に fallback するからだ。MLX 速度を本気で取りたいなら Ollama ではなく mlx-lm を直接使うことになるが、16GB の常時稼働サーバーなら素直に Ollama-GGUF でいい。

5. セットアップ

基本はこれだけだ。

ollama pull qwen3.5:9b
OLLAMA_HOST=0.0.0.0 ollama serve   # LAN 内の他デバイスから叩けるよう公開

ここで一つ落とし穴がある。Homebrew の formula 版 ollama(0.30.x 系)は llama-server バイナリが同梱されておらず起動できない(GitHub issue #16535)。llama-server binary not found で止まる。公式ビルド(ollama.com の .dmg、または brew install --cask ollama-app)を使えば解決する。CLI はアプリ同梱の llama-server を参照する。

コンテキスト長は最初から欲張らず、8K〜16K で固定して始めるのが無難。常時稼働サーバーなら Modelfile で固定しておく。

cat > Modelfile <<'EOF'
FROM qwen3.5:9b
PARAMETER num_ctx 8192
PARAMETER temperature 0.3
EOF
ollama create qwen-dev -f Modelfile

モデルが実際に unified memory に載っているかは ollama ps で確認する。スワップに落ちると体感速度が死ぬので、ここは必ず見る。

6. 実測と落とし穴(本題)

ここからが本題。素の生成速度は 9B / M4 base で約 18 tok/s。これがこの構成の地力だ。

ところが最初、「こんにちは」程度の入力に 30〜60 秒かかり、しかも時々空の返答が返ってきた。原因を順に潰していった結果、真因は 3 つに整理できた。

真因① thinking(最重要)

Qwen3.5 は推論モデルで、think を明示しないとデフォルトで思考が ON になる。このとき出力は message.thinking フィールドに流れ、message.content は空のまま進む。そして思考が num_predict(生成トークン上限)を使い切ると、答えに到達する前に done_reason: length で打ち切られ、content が空のまま終わる

実際に curl で叩くと、content が空で thinking だけが 256 トークン分埋まっていた。「空返答」の正体はこれだった。

直し方は thinkfalse にする。ただし 2 点注意がある (Ollama issue #14793)。

  1. thinkoptions の中ではなくトップレベルのパラメータとして渡す。
  2. chat API で渡す(generate API は think: false を無視するバグがある)。
curl http://your-host:11434/api/chat -d '{
  "model": "qwen3.5:9b",
  "messages": [{"role": "user", "content": "あなたの知識カットオフの年月日を教えて。1文で簡潔に。"}],
  "stream": false,
  "think": false,
  "options": {"presence_penalty": 0.0, "num_predict": 256}
}'

think: false にしたら、同じ質問が 16 トークン・6 秒・content にちゃんと答えが入って返ってきた。

ただ think の場合に比べて精度は大きく落ちる。ここはマシンスペックの限界があるので妥協するしかない。

真因② presence_penalty = 1.5

Ollama の qwen3.5 デフォルトの presence_penalty が 1.5 と高い。これは既出トークンを強く罰して新規性を促すパラメータで、1.5 は「話を締めずに新しいことを言い続ける」方向に効き、長文化の二次要因になる。0 に下げると応答が適切な長さで自然に終わる。

num_predict の設定上の注意

num_predict を 4096 にしていると、上記の暴走が起きたときに 4096 トークンをフルに生成し、18 tok/s では 3 分 48 秒かかる。失敗のたびにこの時間を待たされるのは消耗するので、512〜1024 に下げて被害を限定する。thinking を切れば正常な応答はずっと手前で終わるので、512 で十分だ。

誤診と訂正

最初はマルチターンで起きる空返答を「Qwen3.5 hybrid アーキテクチャのコンテキストキャッシュのバグ」だと疑っていた。だがレスポンスの JSON を見たら、真因は単純に thinking がトークン予算を食い尽くしていたことだった。「単発なら返るがマルチターンだと空」という挙動も、思考が予算内で終わるか溢れるかの差で全部説明がつく。手元で検証して結論を訂正した。

自己申告のカットオフは当てにならない

ついでに。モデルに「君の知識カットオフはいつ?」と聞くと「2026 年」などと返すが、これは信用できない。モデルは自分の学習データの最終日を取り出せる事実として保持しているわけではなく、それっぽい値を生成しているだけだ。ベンチマークの指標には使えない。

画像

Redditでも書かれていた

確定した設定

think: false
presence_penalty: 0.0
num_predict: 512

7. 接続用チャットアプリ

Mac mini 上の Ollama に LAN 経由で繋ぐ簡易チャットを、PEP 723 + uv の 1 ファイルで書いた。#!/usr/bin/env -S uv run --script のシェバンと inline script metadata で、依存(streamlit / ollama / watchdog)込みで 1 ファイル完結する。

画像

(ちなみに casper は検証用 Mac mini の名前)

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "streamlit>=1.43",
#     "ollama",
#     "watchdog",
# ]
# ///
"""
casper_chat_vision.py — casper の Ollama と通信する画像入力対応チャット(ポータブル1ファイル版)

casper_chat.py に「画像添付」を足したもの。Qwen3.5-9B など vision 対応モデルに
画像を渡せる。チャット入力欄のクリップ(📎)から画像を添付して送る。

実行:
    chmod +x casper_chat_vision.py
    ./casper_chat_vision.py
    → uv が依存を隔離環境に自動インストールし、ブラウザで起動

shebang が使えない環境では:
    uv run --script casper_chat_vision.py

前提:
    casper 側で Ollama が LAN 公開で起動していること
        OLLAMA_HOST=0.0.0.0 ollama serve
    vision 対応モデルが pull 済みであること(qwen3.5:9b は対応)
        ollama pull qwen3.5:9b

注意:
    - 画像添付は file_type の拡張子のみ。vision 非対応モデルに送るとエラーになる。
    - 画像は会話履歴に積まれ、毎ターン再送(再 prefill)される。M4 は prefill が弱いので
      大きい画像・複数画像・長い会話だと遅くなる。重い時は「会話をクリア」するか画像を縮小する。
"""
# pyright: reportMissingImports=false
import sys


def run_app() -> None:
    """Streamlit ランタイム上で動くアプリ本体。"""
    import streamlit as st
    from ollama import Client

    # ---- デフォルト設定(サイドバーで上書き可)----
    DEFAULT_HOST = "http://casper.local:11434"
    DEFAULT_MODEL = "qwen3.5:9b"
    IMAGE_TYPES = ["png", "jpg", "jpeg", "webp", "gif", "bmp"]

    st.set_page_config(page_title="casper chat (vision)", page_icon="🦊")
    st.title("🦊 casper chat (vision)")

    with st.sidebar:
        st.header("設定")
        host = st.text_input("Ollama host", DEFAULT_HOST)
        model = st.text_input("Model", DEFAULT_MODEL)
        st.caption("画像を送るなら vision 対応モデル(例: qwen3.5:9b)にすること。")

        num_ctx = st.selectbox(
            "context window (num_ctx)",
            [4096, 8192, 16384, 32768],
            index=2,  # 既定 16384
            help="会話履歴+生成の合計上限。変更すると Ollama がモデルを再ロードする。"
            "画像は多くのトークンを消費するので、画像を使うなら大きめが安全。",
        )

        unlimited = st.checkbox(
            "出力上限なし (EOS まで, num_predict=-1)", value=False
        )
        num_predict_slider = st.slider(
            "max tokens (num_predict)", 256, 8192, 2048, 256
        )
        num_predict = -1 if unlimited else num_predict_slider

        temperature = st.slider("temperature", 0.0, 1.5, 0.3, 0.1)

        # presence_penalty: qwen3.5 の Ollama 既定は 1.5 で、これが「だらだら長文化」の主因。
        # 0 にすると応答が適切な長さで自然に終わる。繰り返しが気になれば 0.3 程度に上げる。
        presence_penalty = st.slider(
            "presence_penalty", 0.0, 1.5, 0.0, 0.1,
            help="モデル既定の 1.5 が長文暴走の原因。0 で適切な長さに。繰り返すなら 0.3 程度。",
        )

        # thinking: ON だと思考が thinking フィールドに出て content が空になりやすい(#14793)。
        # 既定 OFF = 直接 content に答えが出て速い。
        think_enabled = st.checkbox(
            "thinking を有効化(推論モデルの思考)", value=False,
            help="OFF 推奨。ON だと思考に予算を使い遅くなる/空返答になりやすい。",
        )

        if st.button("会話をクリア", use_container_width=True):
            st.session_state.messages = []
            st.rerun()

    np_label = "無制限(EOSまで)" if num_predict == -1 else str(num_predict)
    st.caption(
        f"接続先: {host} / model: {model} / num_ctx: {num_ctx} / "
        f"num_predict: {np_label} / presence_penalty: {presence_penalty}"
    )

    client = Client(host=host)

    # ---- 会話履歴(マルチターン保持)----
    # 各 user メッセージは {"role","content","images"?} 形式。
    # images は bytes のリスト(ollama-python が自動で base64 化する)。
    if "messages" not in st.session_state:
        st.session_state.messages = []

    def render_message(m: dict) -> None:
        """履歴1件を表示。content と添付画像を出す。"""
        with st.chat_message(m["role"]):
            if m.get("content"):
                st.markdown(m["content"])
            for img in m.get("images", []):
                st.image(img, width=240)

    for m in st.session_state.messages:
        render_message(m)

    # ---- 入力(テキスト+画像添付)→ ストリーミング応答 ----
    chat = st.chat_input(
        "メッセージを入力(📎 から画像も添付可)",
        accept_file="multiple",
        file_type=IMAGE_TYPES,
    )
    if chat:
        text = (chat.text or "").strip()
        images = [f.getvalue() for f in (chat.files or [])]

        if not text and not images:
            st.stop()  # 空送信は無視

        user_msg = {"role": "user", "content": text}
        if images:
            user_msg["images"] = images
        st.session_state.messages.append(user_msg)
        render_message(user_msg)

        with st.chat_message("assistant"):
            thinking_box = None
            if think_enabled:
                thinking_box = st.expander("🧠 thinking", expanded=False).empty()
            content_box = st.empty()
            thinking_text = ""
            content_text = ""
            try:
                # think は options ではなくトップレベルに渡す(Ollama chat API 仕様 / #14793)。
                for chunk in client.chat(
                    model=model,
                    messages=st.session_state.messages,
                    stream=True,
                    think=think_enabled,
                    options={
                        "num_ctx": num_ctx,
                        "num_predict": num_predict,
                        "temperature": temperature,
                        "presence_penalty": presence_penalty,
                    },
                ):
                    msg = chunk["message"]
                    t = msg.get("thinking") or ""
                    c = msg.get("content") or ""
                    if t and thinking_box is not None:
                        thinking_text += t
                        thinking_box.markdown(thinking_text)
                    if c:
                        content_text += c
                        content_box.markdown(content_text)
            except Exception as e:
                content_text = (
                    f"⚠️ 通信エラー: {e}\n\n"
                    f"確認: ①casper で `OLLAMA_HOST=0.0.0.0 ollama serve` が起動しているか "
                    f"②host({host})と model({model})が正しいか "
                    f"③画像を送ったなら model が vision 対応か ④同一 LAN にいるか。"
                )
                content_box.markdown(content_text)

            # thinking しか返らず content が空 = num_predict 不足で思考中に打ち切られた合図
            if not content_text and thinking_text:
                content_box.markdown(
                    "⚠️ 思考だけで終わって答えが出てない。"
                    "thinking を OFF にするか num_predict を上げてくれ。"
                )

        st.session_state.messages.append(
            {"role": "assistant", "content": content_text}
        )


def _running_under_streamlit() -> bool:
    """streamlit ランタイム上で実行されているかを判定する。"""
    try:
        from streamlit.runtime import exists
        return exists()
    except Exception:
        return False


if __name__ == "__main__":
    if _running_under_streamlit():
        run_app()
    else:
        from streamlit.web import cli as stcli

        sys.argv = ["streamlit", "run", __file__, "--server.headless=true"]
        sys.exit(stcli.main())

ポイントは 3 つ。

  1. Streamlit は streamlit run でしか起動できないので、streamlit.runtime.exists() で判定し、素の python で起動されたら stcli.main() で自分自身を streamlit run で再起動するブートストラップを入れた。
  2. サイドバーで host / model / num_ctx / num_predict / temperature / presence_penalty と、thinking の ON/OFF トグルを操作できる。think はトップレベルで渡す。
  3. thinking を ON にしたときは message.thinking を expander に、message.content を本文に分けて表示する。content が空で thinking だけ返ってきたら「思考だけで終わっている」警告を出して、無言の空返答を防ぐ。

ストリーミング受信の核はこのあたり。

for chunk in client.chat(
    model=model,
    messages=st.session_state.messages,
    stream=True,
    think=think_enabled,           # options ではなくトップレベル
    options={
        "num_ctx": num_ctx,
        "num_predict": num_predict,
        "temperature": temperature,
        "presence_penalty": presence_penalty,
    },
):
    msg = chunk["message"]
    t = msg.get("thinking") or ""
    c = msg.get("content") or ""
    if t and think_enabled:
        thinking_text += t
        thinking_box.markdown(thinking_text)
    if c:
        content_text += c
        content_box.markdown(content_text)

PEP 723 + uv のポータブルスクリプトの書き方そのものは別記事に書いた。

画像認識もできる

画像

8. まとめ

  • 16GB の Mac mini は「軽作業なら yes、本気のエージェントなら no」。7〜9B の軽量タスクのオフロード先としては高 ROI だが、重いコーディングエージェントの常用には向かない。
  • モデルは Qwen3.5-9B、ランタイムは Ollama-GGUF で十分。MLX を本気で取るなら mlx-lm 直。
  • 重い推論は素直に NVIDIA の GPU(CUDA)を使ったほうがいい。帯域が効く。
  • 一番実用的な学びは、qwen3.5 系の「遅い・空返答」は thinking + presence_penalty + num_predict の 3 点で説明できること。特に think: falseoptions の外に置く。
評価をお願いします (会員登録・ログイン不要)
まだ評価がありません
著者は、アプリケーション開発会社 Cyberneura を運営しています。
開発相談をお待ちしています。

アーカイブ