CrewAI×LM Studioを32GB Mac miniで回す|14B+8B構成と4つの落とし穴
この記事でわかること
- 32GBのApple Silicon Mac miniで、CrewAIマルチエージェントを「スワップせず・落ちずに」回すモデル構成
Invalid response from LLM call - None or emptyで CrewAI が止まる原因と対処- 8B級の小型モデルでも出力を崩さない
expected_outputの書き方対象読者
- LM Studio / Ollama などでローカルLLMを動かしていて、CrewAIでマルチエージェントを組みたい人
- 「エージェントごとに別モデル」にしてメモリ不足・激遅になった人
- 32GB前後のMac(Mac mini / MacBook Pro)でどこまでできるか知りたい人
TL;DR
- 32GB機では 14B級instruct(司令塔)×1 + 8B級instruct(作業者)×1 の2モデル構成が現実的なライン。
- 作業者エージェントが何体いても 同じ8Bを共有 する。「エージェント数=モデル数」にしない。
-
thinking系モデルは避ける。
contentが空になりInvalid response from LLM call - None or emptyで落ちる原因になる。 - LM Studioは
lmsCLIで 事前ロード+JITロード無効、コンテキスト長は 8Kから 始める。 - 小型モデルの品質は、モデルを大きくするより
expected_outputにテンプレートを丸ごと書く ほうが効きやすい。
動作環境 / 前提
| 項目 | 内容 |
|---|---|
| ハード | Apple Silicon Mac mini(ユニファイドメモリ 32GB) |
| 推論サーバー | LM Studio(OpenAI互換API: http://localhost:1234/v1)、付属の lms CLI |
| フレームワーク | CrewAI(LLM クラス経由で LiteLLM から接続) |
| モデル形式 | GGUF、Q4_K_M〜Q5_K_M 前後 |
速度(tokens/s)やメモリ使用量の具体値は、モデル・量子化・コンテキスト長・LM Studio/CrewAIのバージョンで大きく変わります。本記事では厳密なベンチマークは取っておらず、数値は目安です(未検証)。自分の環境で lms ps やアクティビティモニタを見ながら調整してください。
なぜローカル×マルチエージェントは詰まるのか
ローカルLLMでCrewAIを組むと、だいたい次のどれかにぶつかります。
- 「エージェントごとに別モデル」にすると、メモリが足りずスワップが始まり、極端に遅くなる
- 大きいモデル1つに寄せると、全エージェントが遅くなり、数ステップのタスクでも待ち時間が長い
- 小さいモデルは指示に従わず、ツール呼び出しの書式や最終回答のフォーマットが崩れる
- thinking(推論)系モデルを使うと、
contentが空で返ってきて CrewAI が例外で止まる
解決の発想はシンプルで、「役割の重さ」でモデルを2段階に分けることです。
推奨構成:14B(司令塔)+ 8B(作業者)
| 役割 | モデル規模 | 例 | 量子化の目安 |
|---|---|---|---|
| 司令塔(manager / planner / 最終レビュー) | 14B級 instruct | Qwen2.5-14B-Instruct など | Q4_K_M 前後 |
| 作業者(リサーチ・下書き・要約・整形) | 8B級 instruct | Dolphin3.0-Llama3.1-8B など | Q4_K_M〜Q5_K_M |
ポイントは3つです。
- 同時ロードは「14B×1 + 8B×1」まで。作業者が複数いても同じ8Bを共有する。
- thinking系モデルは使わない(または thinking を無効化する)。CrewAIのループと相性が悪いケースがある。
- 小型モデルの品質は出力フォーマットの厳密指定で底上げする。
メモリ見積もり:32GBすべては使えない
Apple SiliconはCPUとGPUがメモリを共有します。32GBから次を差し引いた残りがモデル用です。
- macOS本体・常駐アプリ・ブラウザなど
- Python(CrewAI)プロセス
- GPUが使えるメモリの上限(macOSが既定で上限を設けている)
GPUに割り当てられるのは物理メモリの一部なので、32GB機なら 「モデル重み+KVキャッシュの合計で20GB前後」 を安全圏と考えておくと事故が少ないです(経験則・未検証)。
GGUFの4bit系量子化(Q4_K_M など)は、おおむね「パラメータ数 × 0.6バイト前後」がファイルサイズの目安です。
| モデル | 重みの目安 |
|---|---|
| 8B級 Q4_K_M | 5GB前後 |
| 14B級 Q4_K_M | 9GB前後 |
| 32B級 Q4_K_M | 20GB近く |
正確な値は LM Studio のモデル一覧に表示されるファイルサイズで確認してください。これに KVキャッシュ(コンテキスト長に比例) が乗ります。
[OK] 14B(Q4) + 8B(Q4) … 重み合計 14GB前後 + KVキャッシュ
[注意] 14B(Q4) + 8B(Q4) + 8B(Q4) … 別の8Bを2種類載せると余裕がなくなりやすい
[NG] 32B(Q4) + 8B(Q4) … 32B単体で20GB近くになり、ほぼ確実に窮屈
コンテキスト長は8Kから
CrewAIはロール・ゴール・バックストーリー・タスク説明・過去の出力・ツール説明をまとめてプロンプトに入れるため、思った以上にトークンを食います。一方で最大まで広げるとKVキャッシュがメモリを圧迫します。
- 作業者 8B:8K〜16K
- 司令塔 14B:8K〜16K
まず8Kで動かし、「context length exceeded」系のエラーや出力の途中切れが出たら上げる、という順番がおすすめです。
LM Studio を lms CLI で事前ロードする
エージェント実行前にロード状態を固定しておくと再現性が上がります。
# サーバー起動
lms server start
# 使うモデルの識別子を確認
lms ls
# 使うモデルだけをロード(コンテキスト長を明示)
lms load qwen2.5-14b-instruct --context-length 8192
lms load dolphin3.0-llama3.1-8b --context-length 8192
# いまロードされているモデルを確認
lms ps
# 片付け
lms unload --all
LM Studioには「未ロードのモデルがリクエストされたら自動でロードする(JITロード)」設定があります。便利な反面、意図しないモデルが勝手に載ってメモリを圧迫する原因にもなるので、マルチエージェント運用では 事前ロード+JIT無効 のほうが挙動を把握しやすいです。
実行中のメモリはこれで確認します。
# スワップ発生状況
sysctl vm.swapusage
# メモリ圧迫の概況
memory_pressure
アクティビティモニタの「メモリプレッシャー」が黄色〜赤になる、スワップが増え続ける、トークン生成速度が急に落ちる——このどれかが出たら、まずスワップを疑って構成を一段軽くしてください。
macOSでは sysctl iogpu.wired_limit_mb でGPUが使えるメモリ上限を引き上げられることが知られています。ただしOSの安定性に影響しうる操作で、OSバージョンによって挙動が違う可能性もあります。本記事の構成はこの設定を変えずに収まる範囲を想定しています。
CrewAI から LM Studio に接続する
CrewAIの LLM クラスは LiteLLM 経由で OpenAI互換APIに接続できます。モデル名に openai/ プレフィックスを付け、base_url を指定します。
from crewai import LLM
BASE_URL = "http://localhost:1234/v1"
# 司令塔(14B)
manager_llm = LLM(
model="openai/qwen2.5-14b-instruct",
base_url=BASE_URL,
api_key="lm-studio", # ダミーでよいが空文字だと怒られることがある
temperature=0.2,
max_tokens=2048,
)
# 作業者(8B)
worker_llm = LLM(
model="openai/dolphin3.0-llama3.1-8b",
base_url=BASE_URL,
api_key="lm-studio",
temperature=0.4,
max_tokens=2048,
)
openai/ 以降は LM Studio 側の識別子(/v1/models の id)と一致させます。
curl -s http://localhost:1234/v1/models | python3 -m json.tool
役割分担:重い判断だけ14Bに寄せる
from crewai import Agent, Task, Crew, Process
researcher = Agent(
role="調査担当",
goal="与えられたテーマについて要点を箇条書きで集める",
backstory="事実と推測を区別して書く調査担当。",
llm=worker_llm,
allow_delegation=False,
verbose=True,
)
writer = Agent(
role="執筆担当",
goal="調査結果をもとに指定フォーマットの下書きを作る",
backstory="指定されたフォーマットを厳守するテクニカルライター。",
llm=worker_llm, # 同じ8Bを共有する
allow_delegation=False,
verbose=True,
)
reviewer = Agent(
role="レビュー担当",
goal="下書きの論理破綻・フォーマット違反を指摘し、修正版を出す",
backstory="細部に厳しい編集者。",
llm=manager_llm, # 判断が要る役割だけ14B
allow_delegation=False,
verbose=True,
)
crew = Crew(
agents=[researcher, writer, reviewer],
tasks=[research_task, write_task, review_task],
process=Process.sequential,
verbose=True,
)
result = crew.kickoff(inputs={"topic": "Apple Silicon でのローカルLLM運用"})
Process.hierarchical を使う場合は Crew に manager_llm=manager_llm を渡します。ただし階層型は manager の委任判断がそのまま品質に効き、往復回数も増えます。ローカルの14B級では委任判断が不安定になることがあるため、まず Process.sequential で動かし、必要になってから階層型を試すのが無難です。
つまずきポイント(4つの落とし穴)
1. Invalid response from LLM call - None or empty で CrewAI が落ちる
Qwen3 や DeepSeek-R1 系の蒸留モデルなど、<think>...</think> で推論過程を出すモデルを使うと、次のエラーで止まることがあります。
ValueError: Invalid response from LLM call - None or empty.
考えられる原因は主に2つです。
-
推論部分が
reasoning_contentに分離され、contentが空になる
LM Studioには推論部分をcontentから切り離して別フィールドで返す設定があります。CrewAIは基本的にcontentを読むので、推論だけで終わったレスポンスは「空」と判断されます。 -
max_tokensを推論で使い切る
thinkingが長引くと最終回答の前にトークン上限に達し、contentが空または途中で切れます。
まず生レスポンスを見て原因を切り分けます。
curl -s http://localhost:1234/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3-14b",
"messages": [{"role": "user", "content": "1+1は? 数字だけ答えて"}],
"max_tokens": 256
}' | python3 -m json.tool
choices[0].message.content が空で、reasoning_content 側だけに文章が入っていれば分離が原因です。
対策は次の順で試すのがおすすめです。
- thinkingしない instruct モデルを使う(いちばん確実)。8B級なら Llama3.1系 instruct や Dolphin3.0 などの非推論モデル。
- thinkingのオン/オフに対応したモデルなら無効化する。例えば Qwen3 はプロンプトに
/no_thinkを入れて抑制できる仕組みがあります(チャットテンプレート次第なので、LM Studio上での挙動は実際にAPIを叩いて確認してください。未検証)。 - どうしても thinking モデルを使うなら
max_tokensを大きめにとり、contentに最終回答が入っているかを確認する。
2. 小型モデルが ReAct の書式(Action: / Final Answer:)を守れない
CrewAIのエージェントはツール使用時に Thought: / Action: / Action Input: / Final Answer: の書式を期待します。8B級ではこれが崩れ、パースエラー → リトライの繰り返しになることがあります。
- 8Bの作業者にはツールを持たせない、または1〜2個に絞る(ツールが多いほどプロンプトが長く、選択も不安定)
- ツール呼び出しが必要な役割だけ14Bに寄せる
-
max_iterを小さめにして、暴走したら早めに止める
researcher = Agent(
role="調査担当",
goal="...",
backstory="...",
llm=worker_llm,
tools=[], # まずはツールなしで安定させる
max_iter=5,
allow_delegation=False,
)
3. allow_delegation=True で往復が増えて終わらない
委任を許可すると、エージェント同士で「確認」「依頼」が往復し、ローカル環境ではそれだけで数分単位の時間を消費することがあります。作業者は allow_delegation=False、委任は司令塔だけに限定すると挙動が読みやすくなります。
4. 同じ8Bを共有しても並列で速くはならない
1モデルインスタンスへの同時リクエストが並列処理されるか順番待ちになるかは、LM Studioのバージョンや設定で異なります(未検証)。いずれにしてもメモリ帯域とGPUを共有するので、32GB機で「並列化して速くする」はあまり期待せず、Process.sequential で直列に流す前提で設計するほうが現実的です。
8Bでも崩れない expected_output の書き方
8B級で品質を上げるのに最も効きやすかったのは、Taskの expected_output を具体的なテンプレートにすることでした。
悪い例
write_task = Task(
description="調査結果をもとに記事の下書きを書いてください。",
expected_output="記事の下書き",
agent=writer,
)
良い例
write_task = Task(
description=(
"調査結果をもとに記事の下書きを書く。\n"
"制約:\n"
"- 見出しは以下のテンプレートの4つだけを使う。追加・削除・改名しない\n"
"- 各セクションは3〜5文\n"
"- 調査結果にない数値は書かない。不明な点は『未検証』と書く\n"
"- 前置き・後書き・自己紹介・『以下が下書きです』のような文は書かない"
),
expected_output=(
"次のMarkdownテンプレートを埋めたものだけを出力する:\n"
"## 課題\n"
"<本文>\n"
"## 解決策\n"
"<本文>\n"
"## 手順\n"
"1. <手順>\n"
"2. <手順>\n"
"## 注意点\n"
"- <注意点>\n"
),
agent=writer,
)
| 効くコツ | 理由 |
|---|---|
| テンプレートを丸ごと見せる | 「見出しは4つ」より実物のほうが従いやすい |
| 禁止事項を列挙する | 前置き・創作数値・見出し追加を防ぐ |
| 分量を数値で指定する | 「簡潔に」より「3〜5文」が守られやすい |
temperature を0.1〜0.3に下げる |
フォーマット遵守タスクで崩れにくくなる傾向 |
機械処理したい場合は JSON を指定し、Pydantic モデルで受けます。
from pydantic import BaseModel
class ResearchResult(BaseModel):
facts: list[str]
unknowns: list[str]
research_task = Task(
description="テーマ {topic} について、確認できた事実と不明点を分けて列挙する。",
expected_output="facts と unknowns の2キーを持つJSON",
output_pydantic=ResearchResult,
agent=researcher,
)
ただし8B級ではJSONが崩れることもあります。構造化出力が必須のタスクは 14Bに担当させるか、後段に整形専用ステップを挟む ほうが安定します。アイデア出しなど多様性が欲しいタスクだけ temperature を上げられるよう、LLMインスタンスは用途別に分けておくと調整が楽です。
まとめ
- 32GBのApple Silicon Mac miniでは 14B級(司令塔)×1 + 8B級(作業者)×1 が現実的なライン。
- 作業者が複数でも 同じ8Bを共有。メモリは「重み+KVキャッシュ」で見積もり、コンテキスト長は8Kから。
- LM Studioは
lmsCLIで 事前ロード+JIT無効。スワップが出たら構成を一段軽くする。 -
Invalid response from LLM call - None or emptyは thinking系モデルのcontent空が主因。非thinkingの instruct モデルが最も確実。 - 小型モデルは テンプレート丸ごと・禁止事項列挙・分量を数値指定 で底上げできる。
未検証の点(正直に)
- tokens/s・メモリ使用量の実測値は取っていません。表の数値はファイルサイズからの目安です。
- 「20GB前後が安全圏」は経験則で、macOSのGPUメモリ上限の正確な値は確認していません。
- LM Studioでの同時リクエストの並列処理可否、Qwen3の
/no_thinkの挙動はバージョン依存で、本記事では検証していません。
次に読む / 続編
この記事は「ローカルLLM×マルチエージェント」シリーズの入口(ピラー記事)として、今後次のテーマを個別に深掘りする予定です。
-
Invalid response from LLM call - None or emptyをthinkingモデルで回避する設定手順(予定) -
lmsCLI でモデルのロード状態をスクリプト化する手順(予定) - 8B / 14B モデルで CrewAI の ReAct パースエラーを減らすプロンプト集(予定)
続編を見逃したくない方は フォロー、手元で構成を試すときの手順書として ストック しておくと便利です。「うちの環境ではこう動いた」という実測値もコメントで歓迎します。