以下の内容はhttps://uepon.hatenadiary.com/entry/2026/02/28/020005より取得しました。


【Ollama Cloud×Python】ローカルLLMとクラウドLLMをプライバシーで振り分けるルーターを作ってみた

OllamaはローカルでのLLM実行ツールとしておなじみですが、2025年9月から「クラウドモデル」がプレビューとして提供されています。手元のPCでは動かせないような大規模モデルを、Ollamaのデータセンター上で実行できる機能です。

ちょっと思ったのは、「ローカルの軽量モデルとクラウドの大規模モデルを、入力内容に応じて自動で振り分けられないか?」ということ。例えばプライベートな情報はローカルで処理し、重い推論だけクラウドに投げる。本来であれば、フレームワークを使用するような場面ではありますが、今回は標準的なPythonを使って実験してみました。

実験環境

  • OS … Windows 11、WSL2(Ubuntu 24.04)
  • モデル実行環境 … Ollama(0.17.4)、Python 3.12
  • Ollamaバージョン … 0.12以上(クラウドモデルはこのバージョンから利用可能)
  • ローカルモデル … gemma3:4b
  • クラウドモデル … gpt-oss:20b-cloud

事前にOllamaの最新版を以下のようにインストールしておいてください。

# LinuxおよびWSL2上でのインストール
$ curl -fsSL https://ollama.com/install.sh | sh

参考

github.com



Ollamaクラウドの概要

今回はOllamaクラウド環境を使用する前提なので、その仕組みを整理しておきます。

基本的な仕組み

  • 普段のOllamaと同じCLI・API(localhost:11434)からシームレスに利用できる
  • クラウド対応モデルは モデルライブラリcloud タグが付いているもの。呼び出し名はgpt-oss:120b-cloudglm-4.7:cloud のようにモデルごとに異なるようです。
  • プロンプト、レスポンスは保持・学習に使用されない(内容を含まない利用メタデータは収集される)
  • OLLAMA_NO_CLOUD=1 でクラウド機能を完全に無効化することも可能

ollama.com

まずは、利用者登録を行い、クラウド対応モデルが利用可能な状態にしておきましょう。

続いて、利用開始では ollama signinをCLIで実行し、クラウド対応モデル名を指定します。

$ ollama signin
$ ollama run gpt-oss:20b-cloud

Web認証画面

なお、ローカルOllamaを経由せず、APIキーで https://ollama.com を直接叩くといった使用方法もあります。

docs.ollama.com

プランと制限について

プラン 月額 想定用途 クラウド同時実行
Free $0 チャット、モデル試用(Light usage) 明示なし
Pro $20 RAG、コーディング(Day-to-day work) 複数モデル
Max $100 バッチ処理、エージェント(Heavy usage) 5+モデル
  • Freeプランでも gpt-oss:120b-cloud などの主要クラウドモデルにアクセス可能の模様です
  • ローカル実行はプランに関係なく常に無制限
  • 利用上限の具体的な数値(リクエスト数・トークン数)は非公開。制限超過の場合にはHTTP 429が返される
  • 公式曰く「制限は乱用防止のためであり、作業を遅くする目的ではない」

詳細は、以下の公式Pricingを参照してください。

ollama.com


ローカル + クラウドの同時実行

今回の実験の前提として、ローカルの gemma3:4b とクラウドの gpt-oss:20b-cloud を同時に動かせるか確認します。

結果としては問題なく可能でしたローカルモデルは手元のGPU/RAMで、クラウドモデルはOllamaのデータセンターで処理されるため、リソースは競合しません。APIエンドポイントは同じ localhost:11434 で、モデル名を変えるだけです。

# ターミナル1: ローカルモデル
$ ollama run gemma3:4b

# ターミナル2: クラウドモデル
$ ollama run gpt-oss:20b-cloud

👀クラウドモデルの複数同時実行はPro以上で保証される機能とのことですが、実際には動作はしました。 Freeプランでの並列数は明示されていませんが、「ローカル + クラウド1本」の組み合わせであれば問題ないはずです。

👉️今回は小さな規模での実験なので、ローカルモデルはgemma3:4b、クラウドモデルはgpt-oss:20b-cloudを使用します。 不要なモデルは ollama delete で削除しておくと、リソース管理が楽になります。


Pythonでルーター的な振り分けを実装する

では、ローカルとクラウドを同時に使えることがわかったところで、ユーザーの入力内容に応じて自動的に振り分ける仕組みを作ってみましょう。

実験用のPythonの仮想環境のセットアップ

# プロジェクトディレクトリの作成
$ mkdir ollama-router-experiment
$ cd ollama-router-experiment

# uvで仮想環境を初期化と有効化
$ uv venv
$ source .venv/bin/activate

# 必要なパッケージをインストール
$ uv pip install ollama pydantic

モデルの準備(上記でモデルのインストールを行なっていなかった場合のみ)

# ローカルモデルのダウンロード(約14GB)
$ ollama pull gemma3:4b

# クラウドモデルのメタデータ取得
$ ollama pull gpt-oss:20b-cloud

アーキテクチャ

ユーザーの入力内容に応じて、自動的に振り分ける仕組みを作ります。

  • ルーター(入力内容を分類)→ ローカル gemma3:4b
  • プライベートルート(個人情報・機密情報を含む)→ ローカル gemma3:4b
  • オープンルート(一般的な質問・公開情報)→ クラウド gpt-oss:20b-cloud

👉️処理のポイントは、振り分けの判断自体をローカルLLMで行うことです。これにより、入力内容を外部に送るかどうかの判断がローカルで完結します。プライバシー観点で理にかなった設計になります。より高速性を求める場合には定型的な正規表現なども含めて処理を行うとよいでしょう。

gemma3:4b は相対的に小さいモデルなので、ローカル環境でも軽量に動作します。

実装1:シンプル版

まずは最小限の実装です。OllamaのPythonライブラリを使用しています。 routing_sample.py

from ollama import chat

def route(prompt: str) -> str:
    """ローカルのLLMで入力を分類し、ルーティング先を決定する"""
    response = chat(
        model="gemma3:4b",
        messages=[{
            "role": "user",
            "content": f"""あなたはルーターです。以下の質問を分類してください。
個人情報・社内データ・機密情報を含む場合は「PRIVATE」と答えてください。
それ以外の一般的な質問の場合は「OPEN」と答えてください。
「PRIVATE」か「OPEN」のどちらか一語だけを回答してください。

質問: {prompt}

分類:"""
        }]
    )
    result = response["message"]["content"].strip().upper()
    return "PRIVATE" if "PRIVATE" in result else "OPEN"


def ask(prompt: str) -> str:
    """ルーティング結果に応じてモデルを切り替えて回答を取得する"""
    print(f"[入力] {prompt}")
    print(f"[ルーター] gemma3:4b(ローカル)で判定中...")

    route_result = route(prompt)
    print(f"[判定結果] {route_result}")

    if route_result == "PRIVATE":
        model = "gemma3:4b"
        print(f"[実行] → ローカルモデル({model})で処理します")
    else:
        model = "gpt-oss:20b-cloud"
        print(f"[実行] → クラウドモデル({model})で処理します")

    print("-" * 40)
    response = chat(
        model=model,
        messages=[{"role": "user", "content": prompt}]
    )
    return response["message"]["content"]


if __name__ == "__main__":
    print("=" * 60)
    print("テスト1: プライベートな質問")
    print("=" * 60)
    print(ask("山田太郎さんの昨年の売上データを教えてください"))

    print()

    print("=" * 60)
    print("テスト2: 一般的な質問")
    print("=" * 60)
    print(ask("Pythonでリスト内包表記の書き方を教えてください"))

テスト1では、ローカルモデルの gemma3:4b が「PRIVATE」と正しく判定し、テスト2では、クラウドモデルの gpt-oss:20b-cloud が一般的な質問に対して回答を返しています。

実行結果

$ python routing_sample.py
============================================================
テスト1: プライベートな質問
============================================================
[入力] 山田太郎さんの昨年の売上データを教えてください
[ルーター] gemma3:4b(ローカル)で判定中...
[判定結果] PRIVATE
[実行] → ローカルモデル(gemma3:4b)で処理します
----------------------------------------
申し訳ありませんが、私は個人を特定できる情報にアクセスすることができません。山田太郎さんの昨年の売上データは、私のデータベースには含まれていません。

もし、山田太郎さんの売上データが必要な場合は、以下の方法を検討してください。

*   **山田太郎さん本人に直接尋ねる:** もし山田太郎さんと関係があるなら、直接問い合わせるのが最も確実です。
*   **山田太郎さんの会社に問い合わせる:** 会社であれば、売上データに関する情報を提供できる可能性があります。
*   **関連するデータベースを調査する:** 業種や地域によっては、公開されている売上データデータベースが存在する場合があります。

これらの方法を試すことで、山田太郎さんの昨年の売上データを見つけられる可能性があります。

============================================================
テスト2: 一般的な質問
============================================================
[入力] Pythonでリスト内包表記の書き方を教えてください
[ルーター] gemma3:4b(ローカル)で判定中...
[判定結果] OPEN
[実行] → クラウドモデル(gpt-oss:20b-cloud)で処理します
----------------------------------------
## Pythonで**リスト内包表記(list comprehension)**を書く基本構文

[  表現 (expression)  for 変数 in イテラブル (iterable)  if 条件 (optional) ]

| 位置 | 説明 |
|------|------|
| `表現` | 生成したい各要素に対する式(値・演算・関数呼び出しなど) |
| `for 変数 in イテラブル` | ループのイテレータ部分。`変数`はイテラブルの各要素を表す |
| `if 条件` | **省略可能**。条件を満たす要素だけを抽出するフィルタ |

(以下略)

ルーターモデルである gemma3:4b は、軽量なモデルですが、データの判別では十分な性能を発揮しているようです。エッジデバイスでは、このような形でのルーティングが有効になりそうですね😊

実装2:より堅牢なStructured Outputs版

先程の実装1には問題があります。LLMはプロンプトで「PRIVATEかOPENの一語だけ」と指示しても、必ずしもその通りに返してくれるとは限りません。たとえば以下のような表記ゆれが起こり得ます。(最近はかなりうまくいくようになったけど)

  • "PRIVATE" → 期待通り 😊
  • "Private です" → 余計な文字が付くが、"PRIVATE"を含むので判定は通る 😊
  • "この質問はPRIVATEではありません。OPENです。" → "PRIVATE"も含まれているため、本来OPENなのにPRIVATEと誤判定してしまう 😟

このように、LLMの出力形式が安定しないと判定ロジックが狂う可能性があります。そこでStructured Outputs(構造化出力)を使えば、LLMの出力をJSON形式に強制できるため、こうした表記ゆれの問題を解消できます。これはOpenAIが導入した仕組みで、Ollamaも format パラメータで対応しています。

これを使用した実装例が以下になります。Pydanticを使って、LLMの出力を厳密に検証する形にしています。

routing_structured.py

from typing import Literal
from pydantic import BaseModel
from ollama import chat

class RouteDecision(BaseModel):
    route: Literal["OPEN", "PRIVATE"]
    reason: str

def route(prompt: str) -> RouteDecision:
    """Structured Outputsを使った堅牢なルーティング判定"""
    resp = chat(
        model="gemma3:4b",
        messages=[{
            "role": "user",
            "content": (
                "You are a router that classifies user input.\n\n"
                "PRIVATE: input contains specific person names, "
                "internal company data, salary, personal addresses, "
                "medical records, or confidential business information.\n"
                "Examples of PRIVATE:\n"
                "- 山田太郎さんの売上データを教えて\n"
                "- 社内の人事評価の基準は?\n"
                "- 田中さんの住所を確認したい\n\n"
                "OPEN: general knowledge, public information, "
                "programming questions, science, history, etc.\n"
                "Examples of OPEN:\n"
                "- Pythonでリスト内包表記の書き方を教えて\n"
                "- 富士山の高さは?\n"
                "- 機械学習のアルゴリズムを比較して\n\n"
                "If the input contains a real person's name or "
                "company-specific data, choose PRIVATE.\n"
                "Otherwise, choose OPEN.\n\n"
                f"User input: {prompt}"
            )
        }],
        format=RouteDecision.model_json_schema(),
        options={"temperature": 0},
    )
    return RouteDecision.model_validate_json(resp.message.content)

def ask(prompt: str) -> str:
    """ルーティング結果に応じてモデルを切り替えて回答を取得する"""
    print(f"[入力] {prompt}")
    print(f"[ルーター] gemma3:4b(ローカル)で判定中...")

    decision = route(prompt)
    print(f"[判定結果] {decision.route}")
    print(f"[判定理由] {decision.reason}")

    if decision.route == "PRIVATE":
        model = "gemma3:4b"
        print(f"[実行] → ローカルモデル({model})で処理します")
    else:
        model = "gpt-oss:20b-cloud"
        print(f"[実行] → クラウドモデル({model})で処理します")

    print("-" * 40)
    response = chat(
        model=model,
        messages=[{"role": "user", "content": prompt}]
    )
    return response["message"]["content"]


if __name__ == "__main__":
    print("=" * 60)
    print("テスト1: プライベートな質問")
    print("=" * 60)
    print(ask("山田太郎さんの昨年の売上データを教えてください"))

    print()

    print("=" * 60)
    print("テスト2: 一般的な質問")
    print("=" * 60)
    print(ask("Pythonでリスト内包表記の書き方を教えてください"))

プライベートな情報か否かを例文を交えて明確に指示している点がポイントです。これにより、LLMが意図した通りの出力を返しやすくなります。また、Pydanticで出力を厳密に検証しているため、万が一LLMが想定外の形式で返してきても、コード側でエラーとして検出できます。

実行結果

$ python routing_structured.py
============================================================
テスト1: プライベートな質問
============================================================
[入力] 山田太郎さんの昨年の売上データを教えてください
[ルーター] gemma3:4b(ローカル)で判定中...
[判定結果] PRIVATE
[判定理由] The input contains a specific person's name (山田太郎) and refers to sales data, which is company-specific information.
[実行] → ローカルモデル(gemma3:4b)で処理します
----------------------------------------
申し訳ありませんが、山田太郎さんの昨年の売上データに関する情報にアクセスすることができません。私は個人を特定できる情報やプライベートなデータにアクセスする権限を持っていません。

もし、山田太郎さんの売上データについて知りたい場合は、以下の方法を検討してください。

*   **関係者に直接問い合わせる:** 山田太郎さんご本人、または山田太郎さんを雇用している会社に問い合わせる。
*   **公開されている情報源を探す:** 山田太郎さんが経営する企業や事業が、公開されている情報(年次報告書、プレスリリースなど)で売上に関する情報を開示している可能性がある。

============================================================
テスト2: 一般的な質問
============================================================
[入力] Pythonでリスト内包表記の書き方を教えてください
[ルーター] gemma3:4b(ローカル)で判定中...
[判定結果] OPEN
[判定理由] The input is a general programming question about Python list comprehensions.
[実行] → クラウドモデル(gpt-oss:20b-cloud)で処理します
----------------------------------------
## Pythonでリスト内包表記(List Comprehension)を使う方法
リスト内包表記は「短く、可読性が高く、効率的にリストを作る」ための構文です。
以下では基本構文、よく使うパターン、注意点、実際の応用例をまとめます。

(以下略)

⚠️プライバシー観点で一番危険なのは、本来プライベートであるべき入力を誤ってクラウドに送る「偽陰性」です。このあたりはプロンプトの工夫が求められるかなと思います。今回は「If you're not sure, choose PRIVATE」と入れることで、安全側に倒す設計にしています。


おわりに

今回わかったことを整理します。

  • Ollamaのクラウドモデルは、Freeプランでもgpt-oss:20b-cloudなどの大規模モデルを試せました(ただし同時実行数は不明)。Pro以上で複数モデルの同時利用が必要かも?
  • ローカルとクラウドは処理場所が異なるため同時利用が可能
  • Pythonだけでルーター的なモデル振り分けを実装でき、Structured Outputsを使えばより堅牢にできそう
  • 「判断はローカル、重い処理はクラウド」というプライバシー重視のアーキテクチャが手軽に実現できる

⚠️セキュリティの注意点

Ollamaはデフォルトで 127.0.0.1:11434 にバインドされています。OLLAMA_HOST で公開範囲を変更できますが、外部公開されたOllamaインスタンスが放置されていることもあるようなので、運用と設定には注意してください。

わりと簡単にローカルとクラウドを組み合わせた運用ができることがわかったので、今後はこのようなハイブリッドなアーキテクチャの可能性をさらに探ってみたいと思いますし、エージェントフレームワークの活用も試してみたいと考えています。

試すとしたら、LangGraph(条件分岐のあるワークフローをグラフとして定義・可視化できる)でしょうかね。

ちなみにOllama自体もWeb search API(ollama.web_search)を提供しており、同じエコシステム内で検索ルートを追加できるのも面白いですね。例えば「最新のニュースを知りたい」という質問が来たら、Web search APIにルーティングして検索結果を返すみたいなこともできそうです。


参考リンク




以上の内容はhttps://uepon.hatenadiary.com/entry/2026/02/28/020005より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14