Yyatmita

並列 ReAct エージェントでローカル LLM と Claude を本気で比べた

ドキュメント自動チェック基盤を 4 並列 ReAct エージェントで組み、ローカル gemma4 8B と Claude Haiku / Sonnet を実測比較。recall・FP・速度・コスト、そしてローカル LLM の「並列の落とし穴」までの記録。

自分のエージェント基盤を組む#agent-stack#ralph-loop#react#llm#ollama#claude

並列 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 で起動)。

経路プロバイダモデルサイズ
Lopencode → ollamagemma4:latest8B / Q4_K_M / 5GB
Hclaude CLIhaiku 4.5API
Sclaude CLIsonnet 4.6API

結果

検出すべき項目 5 件 (形式チェックの典型誤りを意図的に混入) と false positive を計測:

経路実行時間検出 recall誤検出 (FP)推定コスト
L (gemma4 / ローカル)3m54s60% (3/5)0$0
H (haiku / API)1m02s100% (5/5)4 件 (過検出)~$0.10
S (sonnet / API)2m36s100% (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約 5GB3〜4 並列 OK
qwen3 8B Q4_K_M約 5GB同上
glm-4.7-flash 30B Q4_K_M約 19GB1 ロードで満杯、直列化
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 隔離と人格テンプレの剥がし方 に整理した。