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?

OllamaでローカルLLMを動かして、クラウドAPIのトークン課金と比較してみた

0
Posted at

はじめに

LLMのAPIは便利ですが、使い込むほどトークン課金が積み重なっていきます。特に個人開発やちょっとした自動化スクリプトだと、「毎回クラウドAPIを叩くのはコスト的にどうなんだろう」と気になることがあります。

この記事では、Ollamaを使ってローカルでLLMを動かし、

  • シンプルなチャットCLIラッパーを作って実際に対話する
  • サイズの違う複数モデル(1B〜4B程度)で応答速度を比較する
  • クラウドAPIのトークン課金と、ローカル実行(電気代)のコストを試算して比べる

というところまでをやります。「ローカルなら何でも安くなる」と決めつけず、どんな使い方だとローカルが有利になるのかを、実際の数字で確認するのが目的です。

Ollamaとは

Ollamaは、LLMをローカル環境で手軽に動かすためのツールです。内部的にはllama.cpp系の推論エンジンをベースにしていますが、Dockerのような感覚でollama pull llama3.2のようにモデルを取得でき、起動すると自動でローカルAPIサーバー(http://localhost:11434)が立ち上がります。モデルのダウンロード・管理・API提供までを1つのツールで完結できる手軽さが特徴です。

スクリーンショット 2026-08-22 172223.png

環境

  • Ollama(Windows版インストーラー、またはollama.com/downloadから入手)
  • Python 3 / uv
  • 比較に使うモデル(サイズ違いで4つ)
モデル パラメータ数 目安サイズ
llama3.2:1b 1B 約1.3GB
llama3.2:3b 3B 約2GB
phi3:mini 3.8B 約2.3GB
gemma2:2b 2B 約1.6GB

この記事のPythonコード(chat.py / benchmark.py)は、Ollama互換の疑似APIサーバーを自前で用意してロジック面(通信・タイマー計測・結果の保存/比較)を検証済みです。ただし実際のモデルでの応答内容や、実機でのGPU/CPU速度は検証環境の制約(ネットワーク制限でOllama自体をインストールできない)により確認できていません。速度の実測結果は、この記事を読んでいる方の環境で試してもらう形になります。

Ollamaのインストールとモデル取得

ollama.com/downloadからインストーラーを入手して実行します(Windowsは管理者権限不要)。インストール後は自動でバックグラウンドサービスとして起動し、システムトレイに常駐します。

インストールできたか確認します。

ollama --version

今回使う4モデルを取得します(初回のみダウンロードが走ります)。

ollama pull llama3.2:1b
ollama pull llama3.2:3b
ollama pull phi3:mini
ollama pull gemma2:2b

取得済みモデルの一覧は以下で確認できます。

ollama list

プロジェクトの作成

uv init ollama-bench
cd ollama-bench
uv add requests

シンプルなチャットCLIラッパー

書き込み先: chat.py

まずは「動かして楽しい」を優先して、ターミナルでOllamaのモデルとチャットできるだけのシンプルなラッパーを作ります。Ollamaの/api/chatエンドポイントにリクエストを送るだけです。

"""
Ollamaのローカルモデルとターミナルでチャットするだけの、シンプルなCLIラッパー。

前提: `ollama serve` が起動しており、使いたいモデルを `ollama pull <model>` 済みであること。

使い方:
    uv run python chat.py                  # デフォルトモデル(llama3.2:3b)で対話開始
    uv run python chat.py --model phi3:mini # モデルを指定
"""

import argparse
import json
import sys

import requests

OLLAMA_URL = "http://localhost:11434/api/chat"
DEFAULT_MODEL = "llama3.2:3b"


def stream_chat(model: str, messages: list[dict]) -> str:
    """Ollamaにチャットリクエストを送り、ストリーミングで届く応答をその場で表示しながら、
    最終的な応答テキスト全体を返す。"""
    payload = {"model": model, "messages": messages, "stream": True}

    full_response = []
    try:
        with requests.post(OLLAMA_URL, json=payload, stream=True, timeout=120) as res:
            res.raise_for_status()
            for line in res.iter_lines():
                if not line:
                    continue
                chunk = json.loads(line)
                content = chunk.get("message", {}).get("content", "")
                print(content, end="", flush=True)
                full_response.append(content)
                if chunk.get("done"):
                    print()  # 応答の最後に改行
    except requests.exceptions.ConnectionError:
        print("\n[エラー] Ollamaに接続できませんでした。`ollama serve` は起動していますか?", file=sys.stderr)
        sys.exit(1)
    except requests.exceptions.HTTPError as e:
        print(f"\n[エラー] {e}\n"
              f"モデル '{model}' が見つからない場合は `ollama pull {model}` を先に実行してください。",
              file=sys.stderr)
        sys.exit(1)

    return "".join(full_response)


def main():
    parser = argparse.ArgumentParser(description="Ollamaとターミナルでチャットする")
    parser.add_argument("--model", default=DEFAULT_MODEL, help=f"使用するモデル名(デフォルト: {DEFAULT_MODEL})")
    args = parser.parse_args()

    print(f"モデル: {args.model}")
    print("Ctrl+C または 'exit' で終了します\n")

    messages: list[dict] = []

    while True:
        try:
            user_input = input("あなた> ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n終了します")
            break

        if user_input.lower() in ("exit", "quit"):
            break
        if not user_input:
            continue

        messages.append({"role": "user", "content": user_input})

        print(f"{args.model}> ", end="", flush=True)
        assistant_reply = stream_chat(args.model, messages)
        messages.append({"role": "assistant", "content": assistant_reply})


if __name__ == "__main__":
    main()

実行はこうです。

uv run python chat.py --model llama3.2:3b

ポイントは2つだけです。

  • stream: trueにしているので、応答をトークンが届くたびにprint(content, end="", flush=True)でその場に表示していく。ChatGPTのような「文字が流れてくる」体験になる
  • messagesのリストにユーザー発言とアシスタント応答を積み重ねていくことで、複数ターンの会話の文脈を保持する(Ollama側は会話履歴を覚えていないので、毎回全部渡す必要がある)

接続エラー・モデル未取得エラーはそれぞれ分かりやすいメッセージにして、sys.exit(1)で終了するようにしています。

複数モデルの速度ベンチマーク

書き込み先: benchmark.py

次に、同じプロンプトを複数モデルに投げて、応答速度(全体の所要時間)を比較するスクリプトです。GPU/CPUの切り替えは、Ollamaサーバーの起動オプション(環境変数)で行うため、このスクリプト自体は「今のサーバー設定でどれだけ速いか」を計測し、ラベル付きで結果を保存する役割に専念させています。

"""
複数のOllamaモデルに同じプロンプトを送り、応答速度(全体の所要時間)を計測・比較する。

GPU/CPUの切り替えは、このスクリプト自体では行わない(Ollamaサーバー起動時の設定による)。
そのため「GPU版」「CPU版」を比較したい場合は、以下のように2回に分けて実行する想定:

    # 1回目: 通常通り(GPUが使える環境ならGPUが使われる)
    ollama serve
    uv run python benchmark.py run --label gpu

    # 2回目: 別ターミナルでCPU限定にしてから再計測
    OLLAMA_NUM_GPU=0 ollama serve
    uv run python benchmark.py run --label cpu

    # 結果を比較
    uv run python benchmark.py compare gpu.json cpu.json
"""

import argparse
import json
import statistics
import time
from pathlib import Path

import requests

OLLAMA_GENERATE_URL = "http://localhost:11434/api/generate"

MODELS = [
    "llama3.2:1b",
    "llama3.2:3b",
    "phi3:mini",
    "gemma2:2b",
]

# 応答速度を見るための、短すぎず長すぎないプロンプト
PROMPT = "日本の四季について、100文字程度で説明してください。"

# 本計測の回数(平均を取るため複数回実行する)
N_RUNS = 3


def call_ollama(model: str, prompt: str) -> tuple[float, str]:
    """1回リクエストを送り、(所要時間[秒], 応答テキスト) を返す"""
    payload = {"model": model, "prompt": prompt, "stream": False}

    start = time.perf_counter()
    res = requests.post(OLLAMA_GENERATE_URL, json=payload, timeout=300)
    elapsed = time.perf_counter() - start

    res.raise_for_status()
    body = res.json()
    return elapsed, body.get("response", "")


def benchmark_model(model: str) -> dict:
    print(f"\n[{model}]")

    # ウォームアップ(1回目はモデルのロードが走るため、計測対象からは除外する)
    print("  ウォームアップ中(モデルのロード)...")
    try:
        load_time, warmup_response = call_ollama(model, PROMPT)
    except requests.exceptions.ConnectionError:
        print("  [エラー] Ollamaに接続できません。`ollama serve` を起動してください。")
        raise SystemExit(1)
    except requests.exceptions.HTTPError as e:
        print(f"  [エラー] {e}")
        print(f"  モデルが未取得の場合は `ollama pull {model}` を実行してください。スキップします。")
        return {"model": model, "error": str(e)}

    print(f"  ウォームアップ完了(ロード込み: {load_time:.2f}秒)")

    # 本計測(応答テキストも記録して、後から精度・内容を見比べられるようにする)
    times = []
    responses = []
    for i in range(N_RUNS):
        elapsed, response_text = call_ollama(model, PROMPT)
        times.append(elapsed)
        responses.append(response_text)
        preview = response_text[:60].replace("\n", " ")
        print(f"  試行{i + 1}: {elapsed:.2f}秒 / 応答: {preview}{'...' if len(response_text) > 60 else ''}")

    return {
        "model": model,
        "load_time_sec": round(load_time, 3),
        "warmup_response": warmup_response,
        "times_sec": [round(t, 3) for t in times],
        "avg_sec": round(statistics.mean(times), 3),
        "min_sec": round(min(times), 3),
        "max_sec": round(max(times), 3),
        "responses": responses,
    }


def cmd_run(args):
    print(f"プロンプト: {PROMPT}")
    print(f"対象モデル: {', '.join(MODELS)}")
    print(f"ラベル: {args.label}\n")

    results = []
    for model in MODELS:
        results.append(benchmark_model(model))

    output = {"label": args.label, "prompt": PROMPT, "n_runs": N_RUNS, "results": results}

    out_path = Path(f"{args.label}.json")
    out_path.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")

    print(f"\n結果を {out_path} に保存しました")
    print_summary(output)

    # 応答テキストは長くなりがちなので、速度サマリとは別のテキストファイルにも書き出しておく
    responses_path = Path(f"{args.label}_responses.txt")
    with responses_path.open("w", encoding="utf-8") as f:
        f.write(f"プロンプト: {PROMPT}\n")
        f.write("=" * 60 + "\n")
        for r in output["results"]:
            if "error" in r:
                f.write(f"\n[{r['model']}] エラーのためスキップ: {r['error']}\n")
                continue
            f.write(f"\n[{r['model']}] (平均 {r['avg_sec']:.2f}秒)\n")
            for i, resp in enumerate(r["responses"]):
                f.write(f"--- 試行{i + 1} ---\n{resp}\n")
    print(f"応答テキストを {responses_path} に保存しました")


def print_summary(output: dict):
    print(f"\n--- {output['label']} の結果サマリ ---")
    for r in output["results"]:
        if "error" in r:
            print(f"  {r['model']}: エラーのためスキップ")
            continue
        print(f"  {r['model']}: 平均 {r['avg_sec']:.2f}秒 (min {r['min_sec']:.2f} / max {r['max_sec']:.2f})")


def cmd_compare(args):
    data_a = json.loads(Path(args.file_a).read_text(encoding="utf-8"))
    data_b = json.loads(Path(args.file_b).read_text(encoding="utf-8"))

    results_a = {r["model"]: r for r in data_a["results"] if "error" not in r}
    results_b = {r["model"]: r for r in data_b["results"] if "error" not in r}

    print(f"\n比較: {data_a['label']} vs {data_b['label']}\n")
    header = f"{'モデル':<16}{data_a['label']:>12}{data_b['label']:>12}{'倍率':>10}"
    print(header)
    print("-" * len(header))

    for model in MODELS:
        a = results_a.get(model)
        b = results_b.get(model)
        if not a or not b:
            print(f"{model:<16}{'(データなし)':>12}")
            continue
        ratio = b["avg_sec"] / a["avg_sec"]
        faster = data_a["label"] if a["avg_sec"] < b["avg_sec"] else data_b["label"]
        print(f"{model:<16}{a['avg_sec']:>10.2f}s{b['avg_sec']:>10.2f}s{ratio:>9.2f}x  ({faster}の方が速い)")


def main():
    parser = argparse.ArgumentParser(description="Ollamaモデルの応答速度ベンチマーク")
    subparsers = parser.add_subparsers(dest="command", required=True)

    run_parser = subparsers.add_parser("run", help="ベンチマークを実行して結果を保存")
    run_parser.add_argument("--label", required=True, help="結果ファイル名・比較時の識別子(例: gpu, cpu)")
    run_parser.set_defaults(func=cmd_run)

    compare_parser = subparsers.add_parser("compare", help="2つの結果ファイルを比較")
    compare_parser.add_argument("file_a", help="1つ目の結果JSON(例: gpu.json)")
    compare_parser.add_argument("file_b", help="2つ目の結果JSON(例: cpu.json)")
    compare_parser.set_defaults(func=cmd_compare)

    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()

実行はこうです。

# 現在のOllamaサーバー設定でベンチマークを実行し、labelをつけて結果を保存
uv run python benchmark.py run --label default

# 保存されたJSONの中身はこんな感じ(モデルごとの試行結果・平均・最小最大・応答テキスト)
cat default.json

# 応答テキストだけを読みやすい形でまとめたファイルも生成される
cat default_responses.txt

# 2回分の結果を比較したい場合(バージョン違い・設定違いなどで使う)
uv run python benchmark.py compare gpu.json cpu.json

runサブコマンドは--labelで指定した名前で<label>.json(速度データ+応答テキスト)と<label>_responses.txt(応答テキストだけを読みやすくまとめたもの)の2つのファイルを保存します。まずは--label defaultのように適当な名前で1回実行し、MODELSに書いたモデルが全部ollama pull済みであることと、応答速度・応答内容がひとまず確認できることを見ておくとよいです。

設計のポイント

  • ウォームアップを1回挟んで計測から除外しています。Ollamaはモデルをメモリに読み込む(ロードする)のに時間がかかり、1回目のリクエストだけ極端に遅くなります。これを含めたまま平均を取ると「モデルの実行速度」ではなく「ロード時間込みの数字」になってしまうため、ウォームアップはload_time_secとして別に記録し、本計測(N_RUNS回)には含めません
  • 所要時間は、Ollamaが返す内部的なtotal_durationではなく、Pythonのtime.perf_counter()でリクエスト送信から応答受信までを直接計測しています。これはネットワークやJSONパースのオーバーヘッドも含めた、実際にユーザーが体感する「応答速度」に近い数値にするためです
  • GPU/CPUの切り替え自体はこのスクリプトの外(Ollamaサーバーの起動方法)で行います。理由は次の章で説明します
  • 応答テキストもresponsesとして記録し、<label>.jsonに含めています。速度だけでなく「そのモデルが実際にどんな回答を返したか」を後から見比べられるようにするためです。JSON本体は速度情報中心で見づらくなりがちなので、応答テキストだけを読みやすい形で<label>_responses.txtにも別途書き出しています(uv run python benchmark.py run --label gpuを実行するとgpu.jsongpu_responses.txtの両方が生成されます)

CPU限定計測でつまずいた話、そしてGPU実行の実測結果

当初はGPU/CPU両方を計測して比較する予定でしたが、実際に試したところ、手元の環境ではCPU限定への切り替えがかなり手こずるポイントでした。参考までにハマった流れを残しておきます。

つまずいたポイント

  1. OLLAMA_NUM_GPU=0を設定してollama serveを再起動しても、ollama psで見ると100% GPUのまま変わらない
  2. リクエストごとにoptions: {"num_gpu": 0}を指定しても、モデルが既にロード済みだと反映されない
  3. ollama stop <model>で明示的にアンロードしてからoptions: {"num_gpu": 0}付きでリクエストを送り直すと、ようやく100% CPUに切り替わった
# 1. ロード済みのモデルを明示的にアンロード
ollama stop llama3.2:1b

# 2. num_gpu:0を指定して"初回ロード"として投げ直す
Invoke-RestMethod -Uri "http://localhost:11434/api/generate" -Method Post `
  -Body '{"model":"llama3.2:1b","prompt":"test","stream":false,"options":{"num_gpu":0}}' `
  -ContentType "application/json"

# 3. ollama ps で 100% CPU になっているか確認
ollama ps

OLLAMA_NUM_GPU環境変数だけでは、既にGPUにロード済みのモデルの配置を切り替えられないケースがありました。一度ollama stop <model>で明示的にアンロードしてから、options.num_gpu付きでリクエストを送り直すことで初めて反映されました。この記事のために当初想定していた「サーバーを2回起動し直してGPU/CPUを比較する」という単純な方式は、環境によってはうまくいかない可能性があります。もし同じ状況になったら、モデルの明示的なアンロードを挟んでみてください。

この手順を全4モデルに適用するのは手間がかかりすぎるため、この記事ではGPU実行時の実測結果に絞って以降を進めます。CPU限定との比較に興味がある場合は、上記の手順を参考にollama stopを挟みながら試してみてください。

GPU実行での実測結果

スクリーンショット 2026-08-22 171423.png

書き込み先: cost_estimate.py

ここからが本題です。「トークン課金を抑えるためにローカルLLMを使う」といっても、実際どれくらいコストが変わるのかを試算してみます。ハードウェアはすでに持っている前提とし、ローカル実行のコストは電気代だけで計算します。

"""
「個人開発者が日常的にLLMを使う」という想定で、クラウドAPI課金とローカル実行(電気代のみ)の
コストを比較する。ハードウェアはすでに持っている前提で、追加コストは電気代だけとして計算する。

前提(すべて下の定数として編集可能):
- 1日50回のやり取り、月20日利用(平日想定)
- 1回あたり入力300トークン・出力500トークン
- ローカルはGPU消費電力150W、1リクエストあたり3秒(benchmark.pyの実測値に置き換えて精度を上げられる)
- 電気料金は31円/kWh(2026年8月時点の目安単価)
"""

# ---- 利用シナリオ ----
REQUESTS_PER_DAY = 50
DAYS_PER_MONTH = 20
INPUT_TOKENS_PER_REQUEST = 300
OUTPUT_TOKENS_PER_REQUEST = 500

# ---- クラウドAPI料金(2026年8月時点、$ per 1M tokens) ----
CLOUD_PRICING = {
    "Claude Haiku 4.5": {"input": 1.00, "output": 5.00},
    "GPT-4o mini": {"input": 0.15, "output": 0.60},
}
USD_TO_JPY = 150  # 概算レート。実際の為替レートに合わせて調整してください

# ---- ローカル実行(電気代)の前提 ----
GPU_POWER_WATT = 150       # 推論中のGPU消費電力の目安(W)。benchmark.pyの実測構成に合わせて調整
SECONDS_PER_REQUEST = 3.0  # 1リクエストあたりの平均応答時間の目安(秒)。実測値があれば置き換える
ELECTRICITY_YEN_PER_KWH = 31  # 2026年8月時点の家庭用電気料金の目安単価


def cloud_cost_jpy(input_tokens: int, output_tokens: int, pricing: dict) -> float:
    cost_usd = (input_tokens / 1_000_000) * pricing["input"] + (output_tokens / 1_000_000) * pricing["output"]
    return cost_usd * USD_TO_JPY


def local_cost_jpy(total_requests: int) -> float:
    total_seconds = total_requests * SECONDS_PER_REQUEST
    total_hours = total_seconds / 3600
    kwh = (GPU_POWER_WATT / 1000) * total_hours
    return kwh * ELECTRICITY_YEN_PER_KWH


def print_scenario(label: str, requests_per_day: int, days_per_month: int):
    total_requests = requests_per_day * days_per_month
    input_tokens = total_requests * INPUT_TOKENS_PER_REQUEST
    output_tokens = total_requests * OUTPUT_TOKENS_PER_REQUEST

    print(f"=== シナリオ: {label} ===")
    print(f"1日{requests_per_day}回 × 月{days_per_month}日 = 月{total_requests:,}")
    print(f"月間トークン量: 入力 {input_tokens:,} / 出力 {output_tokens:,}")

    cloud_costs = {}
    for name, pricing in CLOUD_PRICING.items():
        cost = cloud_cost_jpy(input_tokens, output_tokens, pricing)
        cloud_costs[name] = cost
        print(f"  クラウド({name}): 約{cost:,.1f}円/月")

    local_cost = local_cost_jpy(total_requests)
    print(f"  ローカル(電気代): 約{local_cost:,.2f}円/月")

    cheapest_cloud_name = min(cloud_costs, key=cloud_costs.get)
    cheapest_cloud_cost = cloud_costs[cheapest_cloud_name]
    if local_cost > 0:
        ratio = cheapest_cloud_cost / local_cost
        diff = cheapest_cloud_cost - local_cost
        print(f"  → 最安クラウド({cheapest_cloud_name})との差額: 約{diff:,.1f}円/月(ローカルの約{ratio:.0f}倍)")
    print()


def main():
    print_scenario("個人利用(日常的な質問・コーディング補助)", REQUESTS_PER_DAY, DAYS_PER_MONTH)
    print_scenario("自動化ワークフロー(バッチ処理・エージェント等で高頻度呼び出し)",
                    REQUESTS_PER_DAY * 20, DAYS_PER_MONTH)

    print("※ ハードウェア購入費・保守は含めていません(すでに持っている前提)")
    print("※ SECONDS_PER_REQUESTはbenchmark.pyの実測値に置き換えると精度が上がります")


if __name__ == "__main__":
    main()

実行します。

uv run python cost_estimate.py

料金は2026年8月時点の目安です。Claude Haiku 4.5・GPT-4o miniの料金は各社公式サイトの公表値、電気料金単価(31円/kWh)は「全国家庭電気製品公正取引協議会」が示す目安単価を参照しています。実際の料金・為替レートは変動するため、記事を読んでいるタイミングでの最新情報に置き換えてください。

試算結果

=== シナリオ: 個人利用(日常的な質問・コーディング補助) ===
1日50回 × 月20日 = 月1,000回
月間トークン量: 入力 300,000 / 出力 500,000
  クラウド(Claude Haiku 4.5): 約420.0円/月
  クラウド(GPT-4o mini): 約51.7円/月
  ローカル(電気代): 約3.88円/月
  → 最安クラウド(GPT-4o mini)との差額: 約47.9円/月(ローカルの約13倍)

=== シナリオ: 自動化ワークフロー(バッチ処理・エージェント等で高頻度呼び出し) ===
1日1000回 × 月20日 = 月20,000回
月間トークン量: 入力 6,000,000 / 出力 10,000,000
  クラウド(Claude Haiku 4.5): 約8,400.0円/月
  クラウド(GPT-4o mini): 約1,035.0円/月
  ローカル(電気代): 約77.50円/月
  → 最安クラウド(GPT-4o mini)との差額: 約957.5円/月(ローカルの約13倍)

スクリーンショット 2026-08-22 172446.png

個人利用の規模だと、クラウドAPIも十分安いことが分かりました。最安のGPT-4o miniなら月1,000回使っても約52円で、ローカルの電気代(約4円)との差額は月48円にしかなりません。この程度の差なら、モデル品質やメンテナンスの手間を考えると、クラウドAPIの方が総合的に見て合理的な場合も多いはずです。

差が意味を持ってくるのは、自動化ワークフローのような高頻度利用のケースです。月20,000回まで増えると、最安クラウドとの差額は月958円になり、年間だと1万円を超えてきます。「ローカルLLMでコストを抑える」効果は、利用頻度が上がるほど大きくなる、という当たり前のようで実際の数字で確認できて良かった発見でした。

なお、この試算はSECONDS_PER_REQUEST(1リクエストあたりの応答時間)を3秒と仮定しています。ここはbenchmark.pyで実測した値に差し替えると、より精度の高い試算になります。モデルが大きい・CPU限定で動かしている場合はこの数値が伸びるため、電気代も比例して増える点に注意してください。

コスト以外のメリット: レート制限がない

ここまでコストで比較してきましたが、実際に使ってみて一番大きいと感じたのは金額差よりレート制限を気にしなくていいことでした。

クラウドAPIは、利用量(累計の支払い額)に応じた利用枠(Tier)ごとに、RPM(1分あたりリクエスト数)・ITPM/OTPM(1分あたりのトークン数)の上限が設定されています。例えばAnthropic APIの場合、最初の利用枠は目安として50 RPM程度から始まり、上限を超えると429エラーが返ってきて、retry-afterヘッダーの指示に従って待つ必要があります。

これが具体的にどこで効いてくるかというと:

  • 開発中の試行錯誤: プロンプトを少し変えては実行、を素早く繰り返したいときに、上限に引っかかって待たされる
  • バッチ処理・エージェント的な使い方: 前章の「自動化ワークフロー」シナリオ(月20,000回)のように、短時間に集中してリクエストを送るタスクだと、コスト以前にレート制限の方が先にボトルネックになることがある
  • エラーハンドリングの実装コスト: 429が返ってきた場合のリトライ・バックオフ処理を自前で書く必要がある(benchmark.pyのような単純なループスクリプトでは特に気をつけたいポイント)

ローカルのOllamaには、こうした人為的な上限がありません。制限があるとすれば、手元のハードウェアの生の処理能力だけです。GPUのVRAMやCPUコア数がボトルネックにはなりますが、「利用量に応じて事業者が意図的に絞る」という制約そのものがなくなります。benchmark.pyで3回連続リクエストを送るような使い方も、クラウドAPIの入門枠だと運が悪ければ引っかかりますが、ローカルなら気にする必要がありません。

スクリーンショット 2026-08-22 172455.png

利用枠が上がれば上限も緩和されます(Anthropic APIの最上位枠だと数千RPMまで拡張可能)。とはいえ上位枠に上がるには累計利用額の実績が必要なため、「使い始めたばかりの個人開発」や「急にバーストするバッチ処理」では、この制限が実際に手足を縛ることがあります。

まとめ

  • Ollamaを使うと、モデルの取得からAPIサーバーの起動まで数コマンドで完結し、ローカルLLMを試すハードルはかなり低いことを実感しました
  • シンプルなチャットCLIラッパーは、ストリーミング処理と会話履歴の管理さえ押さえれば、100行程度で実用的な形にできました
  • GPU/CPUの切り替えは、環境変数(OLLAMA_NUM_GPU)だけでは反映されないことがあり、ollama stopでモデルを明示的にアンロードしてからoptions.num_gpu付きでリクエストを送り直す必要がありました。「サーバーを再起動するだけで切り替わる」という単純な想定は崩れ、この記事では最終的にGPU実行の実測に絞ることにしました
  • コスト面では、個人利用の規模だとクラウドAPI(特にGPT-4o miniのような低価格モデル)も十分安く、ローカルとの差額はごくわずかでした。差が効いてくるのは、自動化ワークフローのような高頻度利用のケースだと試算で確認できました
  • ただし実際に使ってみると、コストよりも「レート制限を気にしなくていい」ことの方が体感的なメリットとして大きいと感じました。クラウドAPIの入門枠(目安50 RPM程度)は、開発中の素早い試行錯誤やバッチ処理で意外と簡単に引っかかります。ローカルはハードウェアの生の処理能力以外に上限がありません
  • 「ローカルなら常に安い」という思い込みではなく、自分の利用頻度・用途に照らして試算してみることが大事だと実感しました。そして「コスト」と「制限のなさ」は別の軸として、それぞれの利用シーンに応じて重視すべき比重が変わってきそうです

この記事のPythonコードはロジック面をモックサーバーで検証済みで、GPU実行の実測値も反映しています。CPU限定での正確な比較は、上記のつまずきポイントもあって今回は見送りましたが、ollama stopを使った切り替え手順自体は記事内に残してあるので、興味があれば試してみてください。

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?