並列 ReAct エージェントでローカル LLM と Claude を本気で比べた
ドキュメント自動チェック基盤を 4 並列 ReAct エージェントで組み、ローカル gemma4 8B と Claude Haiku / Sonnet を実測比較。recall・FP・速度・コスト、そしてローカル LLM の「並列の落とし穴」までの記録。
並列 ReAct エージェントによるドキュメント自動レビュー基盤を作る過程で、ローカル LLM (Ollama) と API 系 LLM (Claude Sonnet / Haiku) を同一タスクで比較したメモ。エージェント基盤の設計面の学びも併記する。ralph-loop を 1 ショット runner として転用する構造についても触れる(ralph-loop と autoresearch の設計思想の違いは 自律ループの設計思想、autoresearch と ralph-loop で別途整理している)。
想定タスク
入力: .docx 形式のドキュメント (典型 5〜10 ページ、本文 3〜5 KB 程度)。
パイプライン:
.docx → パース (Markdown 正規化)
→ 匿名化 (固有名詞をエイリアス置換、後段の API 送信に備える)
→ 4 並列チェック
├─ チェック A: 形式 (誤字脱字相当)
├─ チェック B: 構造整合性 (連番の飛び・参照漏れ)
├─ チェック C: ロール整合性 (定義部と本文の役割語の食い違い)
└─ チェック D: 引用整合性 (参照先の存在・意味的妥当性)
→ 集約 (エイリアスを復元しつつ Markdown レポート生成)
各チェックは 独立した ReAct エージェント で実装。エージェントは作業ディレクトリ内の input.md を読み、指定 JSON ファイル (findings.json 等) を書く。応答テキストを stdout 経由で受け取らないことで、呼び出し元プロセスのコンテキストを汚染しない。
エージェント実行系の構造
[呼び出し側]
│
│ tempdir 作成 (input.md / prompt.md / validate.py / ralph_loop.toml)
│
↓
ralph-loop (1 ショット runner)
│
│ provider preset で subprocess 起動
│
↓
opencode / claude / codex / gemini ← ReAct (ファイル読み書き)
│
↓
findings.json (作業ディレクトリ内)
│
│ validate.py が JSON 妥当性を判定
│
↓
[呼び出し側] が findings.json を読み込んで結果取得
なぜ ralph-loop を 1 ショット runner として転用するか
ralph-loop は本来「コードを修正してテストを通す」ループツールだが、
- provider abstraction で複数の AI CLI を統一的に呼べる
[[checks.items]]で「ファイルの妥当性」をシェル exit code 0/1 で判定できるmax_iterationsで失敗時の自動リトライ- JSON が壊れていた場合に「JSON parse 失敗」のフィードバックを次のイテに渡せる
という ReAct エージェントの薄い実行基盤として有用。run_provider を直接呼ぶより、validate.py と組み合わせて 「JSON を書け、書けるまで再試行せよ」 を 1 行で表現できる。
比較した経路
同一の入力ドキュメントを 4 並列でチェック (4 つの ReAct エージェントを asyncio.gather + asyncio.to_thread で起動)。
| 経路 | プロバイダ | モデル | サイズ |
|---|---|---|---|
| L | opencode → ollama | gemma4:latest | 8B / Q4_K_M / 5GB |
| H | claude CLI | haiku 4.5 | API |
| S | claude CLI | sonnet 4.6 | API |
結果
検出すべき項目 5 件 (形式チェックの典型誤りを意図的に混入) と false positive を計測:
| 経路 | 実行時間 | 検出 recall | 誤検出 (FP) | 推定コスト |
|---|---|---|---|---|
| L (gemma4 / ローカル) | 3m54s | 60% (3/5) | 0 | $0 |
| H (haiku / API) | 1m02s | 100% (5/5) | 4 件 (過検出) | ~$0.10 |
| S (sonnet / API) | 2m36s | 100% (5/5) | 0 | ~$0.40 |
速度
- Haiku が最速 (1 分)
- Sonnet と gemma4 は 2〜4 分のオーダー
- gemma4 が「並列なのに遅い」のは後述の VRAM 制約
精度
- 8B ローカルは recall 60% で実用には不足
- Haiku は recall 完璧だが「字面に厳しすぎる」傾向で過検出
- Sonnet は慣用表現を許容できる文脈理解があり最も安定
Haiku の過検出例 (ドキュメント引用整合性チェック)
「冒頭で定義した X を後段で『X の Y』と参照する」ような慣用的引用に対し、Haiku は「X の本文に Y という語は出現しないので不整合」と判定する例が複数発生。字面に強く・文脈に弱い 性格が出る。
コスト
- ローカル: ハード+電気代のみ
- Haiku: ~$0.1/タスク
- Sonnet: ~$0.4/タスク (Haiku の 4 倍)
ローカル LLM での「並列」の落とし穴
asyncio.gather でランタイム並列化しても、LLM プロバイダ側で直列化されるケース がある。実測で確認したポイント:
Ollama の並列性
OLLAMA_NUM_PARALLELのデフォルト: auto (空き VRAM に応じて自動)- 同じモデルなら 1 ロードで複数推論を同時処理できる
- ただし VRAM が足りなければ追加リクエストは queue に並ぶ
モデルサイズと VRAM の関係 (24GB VRAM 環境)
| モデル | サイズ | 並列性 |
|---|---|---|
| gemma4 8B Q4_K_M | 約 5GB | 3〜4 並列 OK |
| qwen3 8B Q4_K_M | 約 5GB | 同上 |
| glm-4.7-flash 30B Q4_K_M | 約 19GB | 1 ロードで満杯、直列化 |
| qwen3.6 35B-a3b MoE Q4_K_M | 約 22GB | ギリギリ、ピーク OOM 危険 |
MoE モデルの誤解 (active param と VRAM サイズ)
qwen3.6:35b-a3b のような MoE は「active 3B」だが、これは 推論速度の指標 であって VRAM サイズの指標ではない。
- 全エキスパート (256 / Q4_K_M で 22GB) を VRAM にロードする必要あり
- 推論時のフォワードパスは活性化する 8 expert (3B 相当) で済むので速い
- 24GB VRAM だと Q4 のままだと KV キャッシュ含めて OOM ギリギリ
つまり「MoE だから小さい VRAM で動く」は誤解。Mixtral 8x7B が A100 80GB を要求するのと同じ理屈。
帰結
ローカル LLM の並列性は「軽量モデルでのみ恩恵がある」。精度向上のため大型モデルに切り替えると、VRAM がボトルネックになって並列がきかない (= API モデルの方が並列性も良い)。
エージェント基盤の設計上の学び
1. コンテキスト汚染を避ける: 応答は「ファイルに書かせる」
LLM の応答を stdout で受けると、
- 呼び出し元のログがすぐに数千トークンで埋まる
- 並列実行すると複数の応答が混ざる
- 上位プロセスのコンテキスト窓 (Claude Code セッション等) に流入する
これらを避けるため、prompt に 「指定 JSON ファイルに書け」 と明示し、ralph-loop の [[checks.items]] で python validate.py が exit 0/1 で妥当性を返す方式にした。これで応答テキストは作業ディレクトリ内に閉じる。
2. 完成品質は「人間が事前に定義」する
ralph-loop の流儀「完成品質を LLM の runtime 出力に依存させない」を踏襲。
- prompt: 「
findings.jsonに書け」と指示 - validator: 人間が書いたスクリプトで JSON 構造を判定
- max_iterations: 失敗したら LLM に再試行させる
LLM が「自分でテストも書いて自分で合格」できない仕掛けにしておく。完成定義の隙間に LLM が intent を捨てた経路を選んでしまう構造分析は LLM ループの『完成定義』を間違えると、100 点合格でも intent が骨抜きになる に詳しい。
3. Provider を env var で切り替える
_DEFAULT_PROVIDER = os.environ.get("LEGAL_CHECK_PROVIDER", "claude")開発時はローカル (タダ)、本番は Sonnet (精度)、デバッグは Haiku (速い) をコード変更なしで切替可能に。同じ prompt で全 provider が動く。
4. 各チェックは独立した tempdir で動かす
並列実行時、4 つの ReAct エージェントは 完全に独立した一時ディレクトリ で動く。共有 state なし、ファイル衝突なし、片方が失敗しても他は影響なし。
/tmp/ralph-runner-XXXXXX/
├── input.md (呼び出し側が書く、エージェントが読む)
├── prompt.md
├── validate.py
├── ralph_loop.toml
└── findings.json (エージェントが書く、呼び出し側が読む)
5. 「fixture 駆動の golden 比較」と「LLM ベース判定」を分ける
開発時は環境変数でモック経路を立て (*_FAKE=1)、決定論的なルールベース実装で fixture と golden を完全一致で検証する。テストは LLM を叩かないので CI で再現性が保てる。本物 LLM 経路は別途手動で検証する。
これで「LLM 出力のゆらぎでテストが赤くなる」事故を避けつつ、ロジックの退行も検出できる。なお、本物 LLM 経路を CI から切り離したまま「LLM 判定をテストに乗せる」やり方は pytest で LLM-as-judge を組む — deepeval × Claude Code CLI に分けて書いた。pytest marker でコストを封じつつ、judge LLM を Claude Code CLI で差し込む構成。
6. 1 ジョブ = 1 一時 git レポジトリ
ralph-loop は cwd の git 状態を見るので、tempdir で git init + 空コミットを作る必要がある。これで provider が「git diff を見て前回からの変化を判定」するモード (Sonnet の Claude Code CLI 等) でもクリーンに動く。
モデル選択の指針 (今回のタスクでは)
| 用途 | 推奨 |
|---|---|
| 本番 (1 件 ~$0.5 まで許容) | Sonnet (精度 100% / FP なし / 2-3 分) |
| 大量処理 (コスト重視) | Haiku + prompt チューニング (FP を抑える) |
| 開発・デバッグ・モック | gemma4 (ローカル、無料、recall 不足は許容) |
| オフライン強制 (機密性) | 大型ローカル + 直列実行を覚悟 (例: glm-4.7-flash 30B) |
まとめ
- ローカル LLM の並列は軽量モデルでのみ機能。精度を上げるため大型化すると VRAM 制約で並列性が消える
- API モデルは VRAM 制約から解放 される代わりにレート制限とコストが付く
- ReAct + ファイル出力 で呼び出し元のコンテキスト汚染を回避できる
- ralph-loop は 1 ショットランナーとして転用可能。
[[checks.items]]でファイル妥当性検証、max_iterationsで再試行 - provider 切替を env var に逃がす ことで、prompt・コードはそのままでローカルと API を行き来できる
ローカル LLM は「無料で動くが精度のために環境を整える必要がある」、API モデルは「VRAM・並列の悩みなしに高品質、ただし課金」、というのがこのタスクでの結論。エージェント基盤の枠組み自体は両者を同じインターフェースで扱えるよう設計しておくのが筋。
なお、ralph-loop に Gemini 系経路として OpenClaw provider を追加したときに発生した「チャットエージェント前提のデフォルト人格汚染」の罠と対処は OpenClaw を one-shot で呼ぶ: profile 隔離と人格テンプレの剥がし方 に整理した。