ローカルでは動いた Gemma 4 が、ある日急に詰まり始める
Gemma 4 を Ollama で動かして、Antigravity のカスタムモデルとして登録し、自分ひとりで使っている分には何も問題がありませんでした。私も最初はそうで、「もうこれで十分では?」と思っていた時期があります。
ところが、チーム内のエンジニア数人に同じエンドポイントを共有した瞬間、体験が一変します。プロンプトを投げてから最初のトークンが返ってくるまでが 5 秒、10 秒と遅くなり、ある瞬間から Antigravity 側がタイムアウトで赤く点滅するようになりました。Ollama のログを覗くと、リクエストが直列に処理されており、2 人目以降は 1 人目の生成が終わるまでひたすら待たされていたのです。
この記事は、その「ローカルで動いた」から「複数ユーザー・複数エージェントで動かす」へ移るときに、私自身が遠回りして学んだ内容のノートです。結論から言えば、Ollama と LM Studio はデスクトップ体験向けに最適化されていて、並列リクエストを捌くようには設計されていません。そこを担うのが vLLM で、Antigravity からは OpenAI 互換のエンドポイントとしてそのまま差し込めます。
ここで扱うのは Gemma 4 9B と 27B のどちらでも構いませんが、VRAM と費用感の例は 9B を前提にします。27B の話は節の中で適宜触れます。
vLLM を選ぶ理由と、Ollama / LM Studio との役割分担
推論サーバー候補はいくつもあります。私は Ollama、LM Studio、Text Generation Inference(TGI)、そして vLLM を、実際のワークロードで並べて試しました。個人開発者の目線で結論だけ先に書くと、次の棲み分けが一番しっくりきています。
Ollama / LM Studio : 自分ひとりでの開発・検証、デスクトップ上での対話。セットアップが 10 分で終わるのが最大の強み
TGI : Hugging Face の学習エコシステムにロックインしているチーム。モデルハブとの連携がなめらか
vLLM : 複数ユーザー・複数エージェントが同時に叩く本番・ステージング。PagedAttention と連続バッチングでスループットが桁違い
vLLM の強みは、PagedAttention による KV キャッシュの効率利用と、連続バッチング(Continuous Batching)と呼ばれるスケジューラにあります。複数のリクエストを待ち行列に並べるのではなく、生成中のリクエストの隙間に新しいリクエストをねじ込んでいくので、同時実行数が増えても 1 リクエストあたりのレイテンシが急激に悪化しにくい設計になっています。
ただし、vLLM は対話 UI を提供しません。OpenAI 互換の REST API と Python クライアントだけが表に出ます。つまり、Ollama のように「起動したらすぐチャットできる」体験は得られません。Antigravity のようなフロント側を持っている前提で初めて力を発揮するタイプのツールです。
最小構成の vLLM サーバーを docker-compose で立ち上げる
最初に作るのは、GPU 1 枚で Gemma 4 9B を走らせる最小構成です。本番を意識する前に、まずは「Antigravity から叩いてトークンが返ってくる」地点まで最短で到達します。
以下の docker-compose.yml と .env を同じディレクトリに置きます。この構成は NVIDIA GPU(24GB 以上)を 1 枚積んだ Linux ホストを想定しています。
# docker-compose.yml
# Gemma 4 9B を vLLM で起動する最小構成
# - OpenAI 互換 API が http://localhost:8000/v1 で公開される
# - モデルは初回のみ Hugging Face から DL され、.cache に永続化される
# - GPU がない環境ではこのまま動かないので、--dtype と --device の変更が必要
services :
vllm :
image : vllm/vllm-openai:latest
container_name : vllm-gemma4
restart : unless-stopped
runtime : nvidia
environment :
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN}
- NVIDIA_VISIBLE_DEVICES=all
volumes :
- ./cache:/root/.cache/huggingface
ports :
- "8000:8000"
ipc : host
command :
- --model
- google/gemma-4-9b-it
- --served-model-name
- gemma-4-9b
- --dtype
- bfloat16
- --max-model-len
- "8192"
- --gpu-memory-utilization
- "0.90"
- --api-key
- ${VLLM_API_KEY}
healthcheck :
test : [ "CMD" , "curl" , "-f" , "http://localhost:8000/health" ]
interval : 15s
timeout : 5s
retries : 10
start_period : 120s
# .env
HF_TOKEN = hf_YOUR_HUGGINGFACE_TOKEN
VLLM_API_KEY = sk-local-dev-ANY_RANDOM_STRING
起動するときは以下を実行します。初回はモデルの重み(約 18GB)をダウンロードするため、数分待つ覚悟が必要です。
docker compose up -d
docker compose logs -f vllm # "Application startup complete" が出たら準備完了
期待する動作 : 120 秒程度で /health が 200 を返し、続いて以下の curl で生成結果が返ってきます。
curl http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-local-dev-ANY_RANDOM_STRING" \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-9b",
"messages": [{"role": "user", "content": "自己紹介を一文でお願いします"}],
"max_tokens": 64
}'
ポイントは --api-key を必ず指定することです。vLLM は起動時に API キーを指定しないと無認証でリクエストを受け付ける挙動になります。ローカル開発でも、ポートを外部に出すなら必ず鍵をつけておいたほうが安全です。
Antigravity からカスタムエンドポイントとして接続する
vLLM が OpenAI 互換 API を提供するので、Antigravity 側の設定は「カスタム OpenAI 互換エンドポイント」として登録するだけで完了します。Antigravity 設定画面の Model Providers から Custom Provider を追加し、以下の値を入れます。
Provider Name: local-vllm-gemma4
Base URL: http://localhost:8000/v1 (リモート運用時はそのドメインに変更)
API Key: .env で設定した VLLM_API_KEY
Model ID: gemma-4-9b
設定が正しければ、Antigravity のモデル選択メニューに gemma-4-9b が現れ、ローカル推論でエージェントやチャットが動くようになります。ここで私が最初にハマったのは、Base URL に /v1 を付け忘れた状態でテストしてしまい、404 not found に悩んだことです。OpenAI 互換クライアントは base_url + /chat/completions を叩くので、末尾の /v1 は必ず入れておきます。
Python からも同じエンドポイントにアクセスできるので、Antigravity の外側でスクリプトを書くときにも使い回せます。
# verify_vllm.py
# Antigravity 設定と同じエンドポイントを Python 側からも検証するスクリプト
# 期待: ネットワーク到達性・認証・モデル ID の 3 点を一度に確認できる
import os
from openai import OpenAI
from openai import APIConnectionError, AuthenticationError, NotFoundError
client = OpenAI(
base_url = os.environ.get( "VLLM_BASE_URL" , "http://localhost:8000/v1" ),
api_key = os.environ[ "VLLM_API_KEY" ],
)
def verify () -> None :
try :
resp = client.chat.completions.create(
model = "gemma-4-9b" ,
messages = [{ "role" : "user" , "content" : "ping" }],
max_tokens = 8 ,
timeout = 30 ,
)
print ( "[OK] response:" , resp.choices[ 0 ].message.content)
except APIConnectionError as e:
# 大抵は docker compose up していない or ファイアウォール
print ( "[NG] 接続失敗 — vLLM が起動しているか、URL が正しいかを確認:" , e)
except AuthenticationError:
# API キーがずれている
print ( "[NG] 認証エラー — VLLM_API_KEY と起動時の --api-key が一致しているか確認" )
except NotFoundError:
# served-model-name が違う
print ( "[NG] モデル未検出 — --served-model-name と model が一致しているか確認" )
if __name__ == "__main__" :
verify()
この小さなスクリプトを置いておくと、Antigravity で接続に失敗したときの切り分けが一気に楽になります。「Antigravity 側の設定が悪いのか、vLLM が落ちているのか、ネットワークが通っていないのか」を 3 行のログで仕分けできるようにしておくことが、結局いちばん早い運用改善につながりました。
量子化と推論速度のトレードオフを実測する
vLLM でモデルを動かせたら、次に気になるのは「量子化をすると、どれくらい速くなって、どれくらい品質が落ちるのか」という話です。ここは理屈ではなく、手元のハードと手元のワークロードで実測するのが一番です。
以下のスクリプトは、vLLM にプロンプトを一定数投げて、秒間生成トークン数(throughput)と P95 レイテンシを計測するミニベンチです。量子化なしの bfloat16、8bit、AWQ 4bit を切り替えて走らせ、数字で違いを見ます。
# vllm_benchmark.py
# 複数ワーカーから同時にリクエストを投げ、スループットとレイテンシ分布を測る
# - 本番で想定する「同時接続数」を CONCURRENCY に入れて評価する
# - OOM が出る場合は --gpu-memory-utilization を 0.85 に下げるか、max_model_len を縮める
import os
import time
import asyncio
import statistics
from openai import AsyncOpenAI
PROMPTS = [
"Explain PagedAttention in three sentences." ,
"Write a haiku about self-hosted LLMs." ,
"List 5 reasons to use vLLM." ,
"Summarize why continuous batching matters." ,
"Translate '本番運用' into natural English." ,
]
CONCURRENCY = int (os.environ.get( "CONCURRENCY" , "8" ))
TOTAL = int (os.environ.get( "TOTAL" , "80" ))
client = AsyncOpenAI(
base_url = os.environ[ "VLLM_BASE_URL" ],
api_key = os.environ[ "VLLM_API_KEY" ],
)
async def one_call (prompt: str ) -> tuple[ float , int ]:
start = time.perf_counter()
resp = await client.chat.completions.create(
model = "gemma-4-9b" ,
messages = [{ "role" : "user" , "content" : prompt}],
max_tokens = 128 ,
temperature = 0.7 ,
)
elapsed = time.perf_counter() - start
tokens = resp.usage.completion_tokens if resp.usage else 0
return elapsed, tokens
async def main () -> None :
semaphore = asyncio.Semaphore( CONCURRENCY )
async def guarded (i: int ):
async with semaphore:
return await one_call( PROMPTS [i % len ( PROMPTS )])
t0 = time.perf_counter()
results = await asyncio.gather( * [guarded(i) for i in range ( TOTAL )])
wall = time.perf_counter() - t0
latencies = [r[ 0 ] for r in results]
total_tokens = sum (r[ 1 ] for r in results)
p50 = statistics.median(latencies)
p95 = statistics.quantiles(latencies, n = 20 )[ 18 ]
print ( f "concurrency: { CONCURRENCY } / total: { TOTAL } " )
print ( f "wall: { wall :.2f } s throughput: { total_tokens / wall :.1f } tok/s" )
print ( f "latency p50: { p50 :.2f } s p95: { p95 :.2f } s" )
if __name__ == "__main__" :
asyncio.run(main())
期待する出力の例 (RTX 4090 24GB、Gemma 4 9B、CONCURRENCY=8、TOTAL=80):
concurrency: 8 / total: 80
wall: 22.14s throughput: 362.4 tok/s
latency p50: 1.94s p95: 3.71s
私が手元で bfloat16 / W8A8 / AWQ 4bit の 3 パターンを比べた結果、Gemma 4 9B では次のような傾向が見えました。
bfloat16(素のまま) : 品質はもちろん最良。VRAM 使用量はピークで 19GB 前後。24GB の GPU でやっと乗るぎりぎりのライン
W8A8(8bit) : スループットはほぼ同等か微増。VRAM は約 60% に収まり、Context 長を伸ばす余地が生まれる。品質の劣化は、日本語の自然さに目をつけると体感できる程度
AWQ 4bit : スループットは 1.3〜1.5 倍。VRAM は約 7GB 台まで落ちる。ただし、私のデータでは要約タスクで細かい情報の取りこぼしが増え、本番では使わない判断をした
この結論は私のワークロードに対するものであって、あなたのデータで同じ結果になる保証はありません。だからこそ、量子化を選ぶ前にこのベンチマークを必ず自分で一度走らせておくことを強くおすすめします。「速くなる」よりも先に「自分のユースケースで許容できる品質劣化か」を見ておかないと、本番で静かに精度が落ちていることに気づけません。
vLLM で量子化モデルを走らせるには、docker-compose.yml の command に以下を加えます。
command :
- --model
- google/gemma-4-9b-it
- --quantization
- awq # "awq" / "fp8" / "bitsandbytes" / "gptq" などから選ぶ
- --dtype
- auto
27B モデルについては、素の bfloat16 では 80GB 級の GPU が必要になります。個人で動かすなら AWQ 4bit 化したコミュニティ配布版を使うのが現実的で、VRAM 24GB の GPU でもなんとか乗ります。27B を本番に使うかどうかは、9B の AWQ 4bit 版と品質を比較した上で決めることをおすすめします。
タイムアウト・レートリミット・ヘルスチェックを本番品質に仕上げる
docker-compose で立ち上げた vLLM は、あくまで「動く」状態です。そのまま本番トラフィックを流すと、1 つのクライアントが長大なプロンプトを投げただけで全体が詰まることがあります。次のガードを Antigravity と vLLM の間に入れておくと、運用がぐっと楽になります。
以下は FastAPI で作る軽量なプロキシの例です。OpenAI 互換のパスをそのまま透過させつつ、プロンプトの最大長・1 ユーザーあたりの秒間リクエスト数・全体のタイムアウトを enforce します。
# proxy.py
# Antigravity -> このプロキシ -> vLLM の順で経由させ、レート制限とタイムアウトを集中管理する
# 運用ポイント:
# - VLLM_UPSTREAM は内部ネットワークのアドレスを推奨(外部に vLLM を直接さらさない)
# - Authorization ヘッダは clients_keys に登録された API キーのみ通す
# - 429 は必ず Retry-After を返し、Antigravity 側のリトライを健全に動かす
import os
import time
import asyncio
from collections import defaultdict, deque
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import httpx
VLLM_UPSTREAM = os.environ[ "VLLM_UPSTREAM" ] # 例: http://vllm:8000
VLLM_API_KEY = os.environ[ "VLLM_API_KEY" ]
CLIENT_KEYS = set (os.environ[ "CLIENT_KEYS" ].split( "," )) # カンマ区切りで配布
RATE_PER_MIN = int (os.environ.get( "RATE_PER_MIN" , "60" ))
MAX_PROMPT_CHARS = int (os.environ.get( "MAX_PROMPT_CHARS" , "32000" ))
UPSTREAM_TIMEOUT = float (os.environ.get( "UPSTREAM_TIMEOUT" , "60" ))
app = FastAPI()
bucket: dict[ str , deque[ float ]] = defaultdict(deque)
lock = asyncio.Lock()
def _check_and_record (key: str ) -> None :
now = time.time()
window = bucket[key]
while window and now - window[ 0 ] > 60 :
window.popleft()
if len (window) >= RATE_PER_MIN :
retry = int ( 60 - (now - window[ 0 ]))
raise HTTPException( 429 , "rate limit" , headers = { "Retry-After" : str (retry)})
window.append(now)
async def _guard (request: Request) -> str :
# Authorization: Bearer <token> を想定
auth = request.headers.get( "Authorization" , "" )
if not auth.startswith( "Bearer " ) or auth[ 7 :] not in CLIENT_KEYS :
raise HTTPException( 401 , "bad client key" )
key = auth[ 7 :]
body = await request.body()
if len (body) > MAX_PROMPT_CHARS * 4 : # 大雑把な上限
raise HTTPException( 413 , "prompt too large" )
async with lock:
_check_and_record(key)
return key
@app.post ( "/v1/chat/completions" )
async def proxy (request: Request):
await _guard(request)
headers = {
"Authorization" : f "Bearer { VLLM_API_KEY } " ,
"Content-Type" : "application/json" ,
}
body = await request.body()
async with httpx.AsyncClient( timeout = UPSTREAM_TIMEOUT ) as client:
try :
r = await client.post(
f " { VLLM_UPSTREAM } /v1/chat/completions" ,
content = body,
headers = headers,
)
except httpx.ReadTimeout:
raise HTTPException( 504 , "upstream timeout" )
return StreamingResponse( iter ([r.content]), media_type = r.headers.get( "content-type" , "application/json" ), status_code = r.status_code)
@app.get ( "/health" )
async def health ():
async with httpx.AsyncClient( timeout = 5 ) as client:
r = await client.get( f " { VLLM_UPSTREAM } /health" )
return { "vllm" : r.status_code}
Antigravity からはこのプロキシの URL を登録し、vLLM 本体は内部ネットワークからのみアクセスできるようにしておきます。この構成にするメリットは、クライアントキーを人ごとに発行できるようになる点と、429 Too Many Requests で詰まったときに誰のリクエストが飽和させているかをログ一発で切り分けられる点にあります。
ヘルスチェックについて補足しておくと、/health の 200 応答は「プロセスが生きている」程度の意味しか持ちません。モデルがロード済みで推論が返せる状態かは /v1/models を叩いて確認します。本番の監視では、軽いプロンプトを一定間隔で投げて、choices[0].message.content が空でないことを確認する合成監視(synthetic probe)を置くと安心です。
よくある落とし穴と、具体的な対処
ここでは私と、周りの個人開発者たちが vLLM 運用で実際に詰まったポイントを、対処とセットで並べていきます。
落とし穴 1: CUDA out of memory が数十リクエスト後に突然出る
起動直後は問題なく動いているのに、数十リクエストを処理した後に OOM で落ちるケースがあります。原因の大半は --max-model-len の指定が甘いことと、--gpu-memory-utilization が高すぎることです。
# NG: 既定値(KV キャッシュの余裕がなくなる)
--max-model-len 32768
--gpu-memory-utilization 0.95
# OK: 実際のプロンプトサイズに合わせて縮める
--max-model-len 8192
--gpu-memory-utilization 0.88
実際のプロンプト最大長の 1.5 倍程度を --max-model-len の目安にしておくと、安定性が段違いに良くなります。あと少しの VRAM 余裕を残しておくのが、連続リクエストの波を吸収するうえで効きます。
落とし穴 2: Gemma 4 のチャットテンプレートが崩れて出力が変になる
Gemma 系のモデルは特殊トークンが多く、素の chat_template を vLLM 側に読ませ損ねると、生成の先頭に <start_of_turn> などが紛れ込んだり、ロール区切りが崩れたりします。対処は、モデル配布元のリポジトリに同梱されている tokenizer_config.json をそのまま使うことと、--chat-template を独自に渡さないことです。独自テンプレートを使いたい場合は、Jinja2 の記述を完全に検証してから差し替えます。
落とし穴 3: Antigravity から呼ぶと英語ばかり返してくる
原因の多くは system メッセージを渡していないことです。OpenAI 互換 API では、messages の先頭に {"role": "system", "content": "日本語で回答してください。"} を置けるかどうかで出力言語が大きく変わります。Antigravity 側のカスタムプロバイダ設定に system プロンプトのデフォルト値を入れておけば、チーム全員の呼び出しで効きます。
落とし穴 4: ストリーミングが途中で切れる
リバースプロキシ(nginx や Cloudflare)を前に置くと、SSE 応答の proxy_buffering 設定で生成が途中で止まって見えることがあります。nginx なら proxy_buffering off; と proxy_read_timeout 300s; を /v1/chat/completions のロケーションに入れておきます。Cloudflare のような CDN を挟む場合は、対象パスをキャッシュバイパスにしたうえで、リクエスト側から Cache-Control: no-cache を送るのが無難です。
落とし穴 5: モデルのロード中に古い接続先にトラフィックが流れる
ローリング再起動したときに、古いコンテナの /health が先に落ちて、新しいコンテナの /health が立ち上がる前にルーターが両方を failed 判定してしまう時間帯があります。docker-compose の start_period を 120〜180 秒に設定しておくのと、ロードバランサ側のヘルスチェック unhealthy_threshold を緩めておくのが効きます。
LoRA アダプタを動的に切り替えて使い分ける
Gemma 4 を業務に使い込むようになると、「このタスクには自前で学習したアダプタを使いたいけれど、別のタスクではベースモデルのまま使いたい」というニーズが必ず出てきます。vLLM は LoRA アダプタを動的にロードできるので、1 つの推論サーバーで複数の「人格」を切り替えて使い分けられます。
まず、docker-compose.yml に LoRA の有効化と、アダプタの登録パスを追加します。
command :
- --model
- google/gemma-4-9b-it
- --enable-lora
- --max-loras
- "4"
- --max-lora-rank
- "32"
- --lora-modules
- review=/adapters/code-review-lora
- summary=/adapters/summary-lora
volumes :
- ./cache:/root/.cache/huggingface
- ./adapters:/adapters
この状態で起動すると、review と summary という名前の 2 つのアダプタが利用可能になります。Antigravity 側からは、リクエストの model フィールドにアダプタ名を渡すだけで切り替わります。
# lora_switch.py
# 同じベースモデルに対して、タスクごとに異なる LoRA アダプタを指名する
# - model="gemma-4-9b" なら素のベースモデル
# - model="review" なら code-review-lora が乗る
from openai import OpenAI
import os
client = OpenAI(
base_url = os.environ[ "VLLM_BASE_URL" ],
api_key = os.environ[ "VLLM_API_KEY" ],
)
def review (code: str ) -> str :
resp = client.chat.completions.create(
model = "review" , # LoRA 名をそのまま指定
messages = [
{ "role" : "system" , "content" : "あなたは厳格だが丁寧な日本語のコードレビュアーです。" },
{ "role" : "user" , "content" : f "以下のコードをレビューしてください: \n\n{ code } " },
],
max_tokens = 512 ,
temperature = 0.2 ,
)
return resp.choices[ 0 ].message.content or ""
def summary (text: str ) -> str :
resp = client.chat.completions.create(
model = "summary" ,
messages = [{ "role" : "user" , "content" : f "3 行で要約してください: \n{ text } " }],
max_tokens = 256 ,
temperature = 0.4 ,
)
return resp.choices[ 0 ].message.content or ""
if __name__ == "__main__" :
print (review( "def add(a, b): return a - b" ))
運用上のコツとしては、--max-loras は実際に常駐させたいアダプタ数ちょうどに設定することです。これを超えた数を登録しようとすると、リクエストごとにロード・アンロードが発生してレイテンシが跳ねます。また、アダプタは同じベースモデルで学習したものに限られるので、9B 用と 27B 用を混在させることはできません。ベースモデルを変えるなら、別コンテナに分けるのが素直な設計です。
自前ホスティングと API の損益分岐を、月額で見積もる
vLLM でスループットの壁を越えられたとしても、次に必ず問われるのは「そこまでして自前で持つ意味はあるのか」という問いです。個人開発の延長でチームにエンドポイントを共有した勢いで、私自身 GPU インスタンスを立てましたが、月末の請求を見て初めて損益分岐を真面目に計算しました。感覚で「安くなったはず」と思い込んでいた自分を、数字が静かに正してくれたのを覚えています。
判断を感覚で済ませないために、まず二つのコストの性質を並べます。自前ホスティングは「時間あたりの固定費」、API は「トークンあたりの従量費」。この二つが等しくなる点が損益分岐です。
観点 自前 vLLM(GPU インスタンス) マネージド API
課金単位 起動している時間(時間単価) 入出力トークン
アイドル時のコスト 使っていなくても発生し続ける ゼロ
スパイク耐性 GPU 台数の上限で頭打ち 実質無限にスケール
データの外部送信 なし(自社ネットワーク内で完結) あり
コストが読みやすい局面 稼働率が高く、負荷が平準化しているとき 散発的・バースト的な利用のとき
損益分岐は、稼働率という一点にほぼ集約されます。GPU インスタンスを 1 日 2 時間しか使わないなら、残り 22 時間ぶんの固定費をトークン単価で割り戻すことになり、API の方がまず安くなります。逆に、バッチ処理や社内ツールで GPU が 1 日 12 時間以上動き続ける使い方なら、ある月間トークン量を超えたところで自前が逆転します。
この分岐点を毎回手で計算するのは面倒なので、私は小さな見積もりスクリプトを手元に置いています。自分の GPU 時間単価と、比較したい API のトークン単価、そして 1 リクエストあたりの平均トークン数を入れれば、「月あたり何リクエストを超えたら自前が得か」を返してくれます。
# breakeven.py
# 自前 vLLM とマネージド API の損益分岐(月あたりのリクエスト数)を概算する
# 前提を明示的な変数にしておき、状況が変わったら数字だけ差し替える
# --- 自前ホスティング側 ---
GPU_HOURLY_USD = 1.20 # GPU インスタンスの時間単価(例)
HOURS_PER_DAY = 12 # 実際に GPU を動かす想定時間
DAYS_PER_MONTH = 30
# --- マネージド API 側(1M トークンあたりの単価)---
API_INPUT_PER_M = 0.10 # 入力 100 万トークンあたりの USD
API_OUTPUT_PER_M = 0.40 # 出力 100 万トークンあたりの USD
# --- 1 リクエストあたりの平均トークン数 ---
AVG_INPUT_TOKENS = 1500
AVG_OUTPUT_TOKENS = 500
def monthly_self_host_cost () -> float :
return GPU_HOURLY_USD * HOURS_PER_DAY * DAYS_PER_MONTH
def api_cost_per_request () -> float :
in_cost = AVG_INPUT_TOKENS / 1_000_000 * API_INPUT_PER_M
out_cost = AVG_OUTPUT_TOKENS / 1_000_000 * API_OUTPUT_PER_M
return in_cost + out_cost
def breakeven_requests_per_month () -> float :
# 自前の固定費を、API の 1 リクエスト単価で割ると
# 「これを超えたら自前が得」というリクエスト数になる
return monthly_self_host_cost() / api_cost_per_request()
if __name__ == "__main__" :
fixed = monthly_self_host_cost()
per_req = api_cost_per_request()
be = breakeven_requests_per_month()
print ( f "自前の月額固定費: $ { fixed :,.0f } " )
print ( f "API の 1 リクエスト単価: $ { per_req :.5f } " )
print ( f "損益分岐: 月 { be :,.0f } リクエスト" )
この前提だと、自前の固定費は月あたり 432 ドル、API は 1 リクエスト 0.00035 ドル。割り戻すと、月およそ 123 万リクエストが分岐点になります。数字そのものより大事なのは、この式が「稼働率」と「1 リクエストあたりのトークン量」という二つのつまみで動くという構造です。長大なシステムプロンプトを毎回送っている(=入力トークンが大きい)ワークロードほど、分岐点は手前に来て自前が有利になります。ここでこそ、前半で触れたコンテキストの持たせ方が効いてきます。
私が実際に落ち着いたのは、どちらか一方に寄せる設計ではありませんでした。散発的な対話は API に流し、夜間のバッチ要約や社内向けの重いレビュー処理だけを自前 vLLM に寄せる。Antigravity のカスタムプロバイダ設定なら、モデルピッカーの階層でこの二つを切り替えられるので、同じワークロードを両方のバックエンドに投げて実測を並べたうえで、腰を据えて決められます。
沈黙する失敗を捕まえる観測性の設計
自前推論で一番こわいのは、サーバーが落ちることではありません。落ちればヘルスチェックが赤くなり、すぐ気づけます。本当にやっかいなのは、動いてはいるのに遅い、キューが伸び続けている、KV キャッシュが逼迫して一部のリクエストだけ静かに詰まる——こうした「沈黙する劣化」です。私が最初の 1 週間で見落としていたのも、まさにこれでした。
幸い、vLLM は Prometheus 形式のメトリクスを /metrics に公開しています。ここから最低限、次の三つだけは常に見えるようにしておくことをおすすめします。
見るべき指標 意味 異常の兆候
Time To First Token(TTFT) 最初のトークンが返るまでの時間 p95 が普段の 2〜3 倍に伸びたら、キュー詰まりを疑う
実行中/待機中リクエスト数 いま処理中と、順番待ちの本数 待機が常時 0 でないなら、同時実行の上限が足りていない
KV キャッシュ使用率 GPU メモリ上のキャッシュ占有 90% 超が続くと、長いプロンプトが先取りで弾かれ始める
これらは curl -s localhost:8000/metrics | grep vllm: で素のまま眺められますが、日々の運用ではしきい値を決めて通知に載せるのが実務的です。私は「TTFT の p95 が 5 秒を超えたら Slack に一言流す」という一番地味なアラートを最初に置きました。派手なダッシュボードよりも、この一本のアラートが「気づいたときには手遅れ」を防いでくれます。
もう一つ、アプリ側で計測しておくと切り分けが速くなるのが、リクエストにひもづく相関 ID です。Antigravity から投げるリクエストのヘッダに X-Request-Id を付けておき、vLLM 側のログと突き合わせられるようにしておくと、「遅いのは推論そのものか、その手前のプロキシか」を後から追えます。前半で置いた小さな疎通確認スクリプトと同じ発想で、切り分けの手がかりを先回りで仕込んでおく。これが、夜間の自動運用を安心して回すための地味な下ごしらえになります。
明日から動かすための、最初の一歩
ここまで長いので、全部を一度に試すのは現実的ではありません。私がおすすめするのは、まず次の 1 つだけ試すことです。
手元の GPU 付きマシン(または GPU インスタンス)で、この記事のはじめに書いた最小構成の docker-compose.yml をそのまま起動して、Antigravity のカスタムプロバイダとして登録してみる。この一歩だけで、Ollama との体感差が分かります。特に同時接続 2 以上で投げたときの挙動が、想像以上にきれいに返ってくることに驚くはずです。
その上で、自分のワークロードでベンチマークを走らせて、量子化の選択肢を検討します。この順番で進めていけば、自前推論基盤を「なんとなく組んだら動いた」ではなく、「なぜこの構成にしたかを説明できる」状態で持てるようになります。
最後に、正直な線引きも書いておきます。すべてのチームが自前推論の恩恵を受けるわけではありません。人数が五人に満たず、負荷が散発的で、いちばんの制約が費用よりも自分の時間だという段階なら、マネージド API を選ぶのがまず正解です。自前が効いてくるのは、データの都合で外部 API に送れないとき、あるいは稼働率が十分に高くて GPU を回し続けた方が安くつくときです。多くの個人開発者はその中間にいて、答えはたいてい「両方を使い分ける」ことになります。この記事の順番——小さく始めて、測って、制約を一つずつ足す——は、後から構成を育てていける形をそのまま写したものです。育てられる基盤だけが、長く役に立ちます。実装の一助になれば幸いです。