第1弾でReActエージェントを実装したとき、LLMにはAnthropicのAPIを使った。クラウド側で推論が走るので、コードは送信される。
社内システムの開発や、金融・医療・行政といったセキュリティ要件が厳しい現場では、これがそのまま採用できないケースがある。「ソースコードを外部に出してはいけない」「インターネット接続自体が制限されている」という環境だ。
この記事では、第1弾のコードレビューエージェントを完全ローカル動作に移植する。外部APIへの通信はゼロ、コードは一切クラウドに送らない。
なぜローカルLLMか
クラウドAPIを使わない理由として現場でよく挙がるのはこのあたりだ。
- ソースコードや設計書などの機密情報を社外送信できない規定がある
- ネットワークがエアギャップ環境(物理的にインターネット非接続)
- APIコストを従量ではなくハードウェア投資で固定したい
- 障害時にクラウド側の影響を受けたくない
今回のターゲットは「普通のPC1台で動かす」だ。GPUサーバーは不要。
技術選定
LLM実行基盤: Ollama
ローカルLLMの実行環境として現時点で最も導入が簡単なフレームワーク。Windows/Mac/Linuxに対応し、インストールは公式サイトからのインストーラー1本で完了する。推論はREST APIで提供されるため、SDKへの依存が不要で移植しやすい。
モデル: Gemma3:4b(Google)
8GB RAMのPCで動くモデルを選ぶと gemma3:4b が現時点のベストになる。Googleが多言語データで訓練しており、日本語の理解精度も実用レベルに達している。
| モデル | 必要VRAM | 日本語能力 | 供給元 |
|---|---|---|---|
| gemma3:4b | ~3GB | 良好 | |
| gemma3:12b | ~8GB | 優秀 | |
| phi4:14b | ~12GB | 良好 | Microsoft |
今回の検証環境はRTX 3050(VRAM 4GB)。gemma3:4b はVRAMに完全に収まるのでフルGPU推論で動く。
第1弾との構造比較
移植前後で何が変わるかを整理する。下図の左がAnthropicAPI版、右がOllama版だ。
図の見方:グレーがエントリーポイント、紫/青がLLM呼び出し層、コーラル/ティールが推論先、下段ティールの execute_tool() は両側で同じ色=変更なし。
Anthropic SDKには tools= パラメータがあり、LLMが「このツールを呼びたい」と判断すると構造化された tool_use ブロックとして返してくる。Ollamaにはこの仕組みがないため、プロンプトでツール呼び出し形式を指示し、テキストから自前でパースする方式に切り替える。
変更しない部分も明確にしておく。
- ツール定義(
TOOLS配列): そのまま流用 - ツール実行関数(
execute_tool): 変更なし - ReActループの構造(Thought→Action→Observation): 変更なし
-
main()の処理フロー: 変更なし
実質的に変わるのは「LLMとの通信方法」だけだ。
実装:変更した3箇所
1. インポートと設定
# 変更前
import anthropic
MODEL = "claude-haiku-4-5-20251001"
client = anthropic.Anthropic()
# 変更後
import requests
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:4b")
anthropic パッケージへの依存がなくなり、標準ライブラリに近い requests だけになる。ANTHROPIC_API_KEY の環境変数設定も不要になる。
2. LLMへのリクエスト(_call_ollama)
def _call_ollama(prompt: str) -> str:
resp = requests.post(
f"{OLLAMA_BASE_URL}/api/generate",
json={
"model": OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.1, "num_predict": 2048},
},
timeout=180,
)
resp.raise_for_status()
return resp.json()["response"]
Anthropic版の client.messages.create() に相当する部分。temperature: 0.1 で低温度にしているのは、分類・構造化タスクでは出力を安定させるためだ。
3. ツール呼び出しのパース(_parse_tool_call)
Anthropic版ではSDKが tool_use ブロックを自動で返してくれた。ローカル版では生テキストから自前で取り出す。
プロンプトにこう指示する:
【ツール呼び出し方法】
レスポンスの末尾に必ず以下の形式でツール呼び出しを記述してください:
TOOL_CALL: {"name": "ツール名", "input": {...パラメータ...}}
受け取ったテキストからJSONを抽出するのが _parse_tool_call だ:
def _parse_tool_call(text: str) -> tuple[str | None, dict | None]:
# パターン1: TOOL_CALL: {...}
match = re.search(r"TOOL_CALL:\s*(\{.+\})\s*$", text, re.DOTALL)
if match:
json_str = _extract_balanced_json(match.group(1))
if json_str:
try:
data = json.loads(json_str)
return data.get("name"), data.get("input", {})
except json.JSONDecodeError:
pass
# パターン2: ```json ブロック(モデルによっては使う)
match = re.search(r"```json\s*(\{.+?\})\s*```", text, re.DOTALL)
if match:
try:
data = json.loads(match.group(1))
return data.get("name"), data.get("input", {})
except json.JSONDecodeError:
pass
return None, None
_extract_balanced_json は括弧の深さを数えてネストされたJSONオブジェクトを正確に切り出す関数だ。comment ツールの issues 配列のように深くネストしたJSONでも誤切断しない。
ローカルLLMならではの課題と対処
実際に動かしてわかった問題が2つある。
課題1: TOOL_CALL: フォーマットを守らないことがある
Anthropic SDKはツール呼び出しをプロトコルレベルで保証するが、プロンプト指示ベースでは確率的にフォーマットを外れる。
ログに実際に出た例:
[assistant/tool_call]: {"name": "comment", "input": {...}}
TOOL_CALL: ではなく [assistant/tool_call]: で出力されており、パースに失敗する。
対処: リトライロジック
TOOL_CALL: が検出できず、かつ comment がまだなら、追加メッセージで明示的に促す:
if review_result is None:
messages.append({"role": "user", "content": (
"TOOL_CALL: の記述が見つかりませんでした。"
"必ず comment ツールを呼び出してレビューを完了してください。\n"
'TOOL_CALL: {"name": "comment", "input": {"filename": "...", '
'"severity": "...", "issues": [...], "summary": "..."}}'
)})
課題2: 会話履歴の扱い
Anthropic SDKは system= パラメータで会話履歴と切り分けてくれる。Ollama の /api/generate は単一の prompt 文字列のみのため、会話履歴を1つに結合する必要がある。
def _build_prompt(system: str, messages: list[dict]) -> str:
parts = [system, ""]
for msg in messages:
role = msg["role"]
content = msg["content"]
if isinstance(content, str):
parts.append(f"[{role}]: {content}")
elif isinstance(content, list):
for block in content:
if block.get("type") == "tool_use":
parts.append(
f"[assistant/tool_call]: {block['name']}("
f"{json.dumps(block['input'], ensure_ascii=False)})"
)
elif block.get("type") == "tool_result":
parts.append(f"[tool_result]: {block['content']}")
return "\n".join(parts)
実行結果
コードレビューエージェント開始(ローカルLLM)
対象: 5ファイル
モデル: gemma3:4b
sample_01.py(SQLインジェクション)の実際のログ:
--- イテレーション 1 ---
Thought: レビュー対象のファイルを読み込みます。
Action: read_code({"filepath": "review_targets/sample_01.py"})
Observation: === review_targets/sample_01.py (48行) ===...
--- イテレーション 2 ---
Thought: コードを分析します。SQLインジェクション、フォーマット文字列...
[retry] TOOL_CALL未検出 → comment を要求します
--- イテレーション 3 ---
Action: comment({...})
コードレビュー: sample_01.py / 重大度: 重大
【問題 1】Security(行11): SQLインジェクション脆弱性...
【問題 2】Security(行25): フォーマット文字列によるSQL構築...
【問題 3】Security(行42): %演算子によるSQL構築...
レビュー完了(3イテレーション)
5ファイル全結果:
| ファイル | 仕込んだバグ | 検出 | イテレーション |
|---|---|---|---|
| sample_01 | SQLインジェクション×3 | 3件全て ✅ | 3 |
| sample_02 | 未使用変数×4・複雑度超過 | 検出 ✅ | 6 |
| sample_03 | 型エラー・例外処理なし×8 | 8件全て ✅ | 3 |
| sample_04 | 平文パスワード・APIキー | 10件 ✅ | 3 |
| sample_05 | N+1クエリ・Python側SUM | 全パターン ✅ | 3 |
処理ファイル数: 5
合計イテレーション: 18
実行時間: 401秒(約80秒/ファイル)
モデル: gemma3:4b(VRAM 4GB フルGPU推論)
精度面では仕込んだバグを全て検出できている。速度はクラウドAPIより遅いが、ファイル単位のバッチ処理として使う分には問題ない範囲だ。
Anthropic API版との対比
| 項目 | Anthropic API版 | Ollama版(ローカル) |
|---|---|---|
| データ送信先 | Anthropicサーバー(外部) | localhost のみ |
| ツール呼び出し | SDKが自動パース(安定) | プロンプト指示 + 自前パース(確率的) |
| 推論速度 | 速い | ハードウェア依存(RTX 3050で約80秒/ファイル) |
| コスト構造 | 従量課金 | ハードウェア投資のみ(稼働後コストなし) |
| APIキー | 必要 | 不要 |
| セキュリティ要件 | 外部API利用規約に依存 | 自社ポリシーのみ(金融・医療・行政対応) |
| 変更が不要な箇所 | ― | TOOLS定義 / execute_tool() / ReActループ / main() |
ツール呼び出しの安定性はAnthropicの方が高い。ただしリトライロジックを入れれば実用上は十分で、今回の5ファイルは全て完走した。
セットアップ手順
# 1. Ollama インストール
# https://ollama.com からインストーラーをダウンロード
# 2. モデル取得(約2.5GB)
ollama pull gemma3:4b
# 3. 依存パッケージ(anthropic が不要になる)
pip install requests
# 4. 実行
python react_agent_local.py
Anthropic版と違い、ANTHROPIC_API_KEY の設定は不要だ。
モデルを変えたい場合は環境変数で切り替える:
rem Windows CMD
set OLLAMA_MODEL=gemma3:12b
python react_agent_local.py
まとめ
変更した本質的な部分は「LLMとの通信方法」だけだった。ツール定義、ツール実行、ReActループの構造はそのまま流用できる。Anthropic SDKのツール呼び出し機能をプロンプトとパーサーで代替したことで、完全ローカル動作を実現した。
セキュリティ要件が厳しい現場でも、このアプローチなら外部API依存を排除してAIエージェントを導入できる。モデルの性能はクラウド上の大規模モデルには及ばないが、コードレビューのような構造化タスクであれば gemma3:4b でも実用レベルの精度が出る。
