---
slug: "local-llm-on-m4-mac-mini-16gb"
title: "M4 Mac mini 16GB でローカルLLM を動かす — 具体的な選択肢と現実"
description: "M4 Mac mini 16GB でローカルLLM(Qwen3.5-9B)を動かすための、モデル選定・ランタイム選定・セットアップと、実際に動かして踏んだ落とし穴(thinking / presence_penalty / num_predict)の記録。"
url: "https://www.ytyng.com/blog/local-llm-on-m4-mac-mini-16gb"
publish_date: "2026-06-08T14:29:23Z"
created: "2026-06-08T14:29:23.439Z"
updated: "2026-06-08T15:06:40.792Z"
categories: ["AI"]
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260608/996f395308814938a05ce89966f0c1ab.png.webp?width=768"
has_video: false
has_music: false
video_urls: []
music_urls: []
lang: "ja"
---

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

## はじめに

16GB の Mac mini でローカル LLM の動作を検証した。速くはないが実用範囲には乗るし、消費電力(負荷時で 30W 前後)まで含めて考えれば悪くない。

この記事は、M4 Mac mini base(16GB unified memory)でローカル LLM を動かすための、モデル選定・ランタイム選定・セットアップ手順と、実際に動かして踏んだ落とし穴の記録だ。後半の落とし穴 — Qwen3.5 系を Ollama で動かしたときの「応答が遅い・本文が帰ってこない」の正体 — がこの記事の本題になる。

![画像](https://media.ytyng.com/20260608/7100a6ba0cb14cb389714345593ad60a.png)

## 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. セットアップ

基本はこれだけだ。

```bash
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 で固定しておく。

```bash
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 トークン分埋まっていた。「空返答」の正体はこれだった。

直し方は `think` を **false** にする。ただし 2 点注意がある (Ollama issue [#14793](https://github.com/ollama/ollama/issues/14793))。

1. `think` は `options` の中ではなく**トップレベル**のパラメータとして渡す。
2. chat API で渡す(generate API は `think: false` を無視するバグがある)。

```bash
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 年」などと返すが、これは信用できない。モデルは自分の学習データの最終日を取り出せる事実として保持しているわけではなく、それっぽい値を生成しているだけだ。ベンチマークの指標には使えない。

![画像](https://media.ytyng.com/20260608/90c5faf12d06417eb826e4ccf758531e.png)

[Redditでも書かれていた](https://www.reddit.com/r/ollama/comments/1s1aorc/cutoff_2026_do_not_ask_qwen35_about_2025/)

### 確定した設定

```
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 ファイル完結する。

![画像](https://media.ytyng.com/20260608/500c068c4c10448fb3b94385be5f6a9a.png)

(ちなみに casper は検証用 Mac mini の名前)

```python
#!/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 だけ返ってきたら「思考だけで終わっている」警告を出して、無言の空返答を防ぐ。

ストリーミング受信の核はこのあたり。

```python
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 のポータブルスクリプトの書き方そのものは別記事に書いた。

### 画像認識もできる

![画像](https://media.ytyng.com/20260608/43c2b559dd8948b9a699466a3985cb2a.png)

## 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: false` は `options` の外に置く。
