0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AIエージェントシリーズ #1 番外編】外部APIが使えない環境でReActエージェントを動かす ── ローカルLLM(Ollama + Gemma3)への移植

0
Posted at

第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 良好 Google
gemma3:12b ~8GB 優秀 Google
phi4:14b ~12GB 良好 Microsoft

今回の検証環境はRTX 3050(VRAM 4GB)。gemma3:4b はVRAMに完全に収まるのでフルGPU推論で動く。


第1弾との構造比較

移植前後で何が変わるかを整理する。下図の左がAnthropicAPI版、右がOllama版だ。

react_agent_api_vs_local_comparison.png

図の見方:グレーがエントリーポイント、紫/青が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 でも実用レベルの精度が出る。


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?