最小構成の 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 で作る軽量なプロキシです。認証を通し、本文の大きさを見て、上流へ投げ、返ってきたものを返す——最初に書いたときの私は、その程度の仕事だと思っておりました。
ところが Antigravity から使ってみると、様子が違います。vLLM を直に叩けば 1 秒足らずで最初のトークンが動き出すのに、プロキシ越しだと二十秒近く何も出ず、出た瞬間に全文がまとめて現れるのです。後述する落とし穴で SSE のバッファリングを自分で書いておきながら、その手前に置いた私のプロキシ自身が、ストリームを一度ぜんぶ受け取ってから流し直しておりました。
原因は一行でした。await client.post(...) は本文を最後まで読み切ってから返ります。その戻り値に StreamingResponse をかぶせても、流れているように見えるだけで、実体は一括転送です。通すつもりのものは、受け取り方から素通しに。 バッファするかどうかは体裁ではなく、上流を読み始める最初の一行で決まってしまうのだと気づいたのは、ログを三度見返したあとでした。
同じときにもう一つ直したのがタイムアウトです。timeout=60 を「応答全体の上限」だと思い込んでいたのですが、httpx の read は次の 1 チャンクが届くまでの待ち時間で、応答全体を縛る設定ではありません(Timeouts — HTTPX )。ストリーミングではこれがむしろ好都合で、長い生成を切らずに、上流が黙り込んだときだけ落とせます。
# proxy.py
# Antigravity -> このプロキシ -> vLLM の順で経由させ、認証・レート制限・タイムアウトを集中管理する
# 運用ポイント:
# - VLLM_UPSTREAM は内部ネットワークのアドレスを推奨(外部に vLLM を直接さらさない)
# - Authorization は CLIENT_KEYS に登録された鍵だけ通し、上流用の鍵に差し替える
# - 429 は必ず Retry-After を返し、Antigravity 側のリトライを健全に動かす
# - SSE はバッファせず素通しする。ここを閉じるとストリーミング表示が死ぬ
import os
import time
import asyncio
from collections import defaultdict, deque
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask
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_BODY_BYTES = int (os.environ.get( "MAX_BODY_BYTES" , "131072" ))
# httpx の read は「次の 1 チャンクを待つ上限」であって、応答全体の上限ではない。
# 長い生成は切らず、上流が黙り込んだときだけ落ちる値にする。
TIMEOUT = httpx.Timeout(
connect = float (os.environ.get( "CONNECT_TIMEOUT" , "5" )),
read = float (os.environ.get( "IDLE_TIMEOUT" , "60" )), # チャンク間の無音の上限
write = 10.0 ,
pool = 5.0 ,
)
# 上流へそのまま渡さないヘッダ。
# 長さと転送方式はこちら側で決め直し、Accept-Encoding は落として素のバイト列を受ける。
DROP_HEADERS = {
"host" , "content-length" , "transfer-encoding" ,
"connection" , "authorization" , "accept-encoding" ,
}
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 :
# 0 を返すと Antigravity が即座に投げ直して雪崩れるので、最低 1 秒は待たせる
retry = max ( 1 , int ( 60 - (now - window[ 0 ])))
raise HTTPException( 429 , "rate limit" , headers = { "Retry-After" : str (retry)})
window.append(now)
async def _guard (request: Request) -> bytes :
auth = request.headers.get( "Authorization" , "" )
if not auth.startswith( "Bearer " ) or auth[ 7 :] not in CLIENT_KEYS :
raise HTTPException( 401 , "bad client key" )
body = await request.body()
if len (body) > MAX_BODY_BYTES :
raise HTTPException( 413 , "request body too large" )
async with lock:
_check_and_record(auth[ 7 :])
return body
@app.post ( "/v1/chat/completions" )
async def proxy (request: Request):
body = await _guard(request)
headers = {k: v for k, v in request.headers.items() if k.lower() not in DROP_HEADERS }
headers[ "Authorization" ] = f "Bearer { VLLM_API_KEY } "
# StreamingResponse が読み終わるまでクライアントを生かす必要がある。
# `async with` で閉じると、まだ読んでいる途中で切れて ReadError になる。
client = httpx.AsyncClient( timeout = TIMEOUT )
req = client.build_request(
"POST" , f " { VLLM_UPSTREAM } /v1/chat/completions" , content = body, headers = headers
)
try :
# stream=True は「ヘッダだけ受け取って本文は保留」の意味。ここが素通しの要
upstream = await client.send(req, stream = True )
except httpx.ConnectError:
await client.aclose()
raise HTTPException( 502 , "upstream unreachable" )
except httpx.TimeoutException:
await client.aclose()
raise HTTPException( 504 , "upstream timeout" )
async def close () -> None :
await upstream.aclose()
await client.aclose()
return StreamingResponse(
upstream.aiter_raw(), # 届いたチャンクをそのまま 1 つずつ返す
status_code = upstream.status_code,
media_type = upstream.headers.get( "content-type" , "application/json" ),
background = BackgroundTask(close), # 応答を送り終えてから後始末
)
@app.get ( "/health" )
async def health ():
async with httpx.AsyncClient( timeout = 5.0 ) as client:
try :
r = await client.get( f " { VLLM_UPSTREAM } /health" )
except httpx.HTTPError:
raise HTTPException( 503 , "upstream down" )
return { "vllm" : r.status_code}
StreamingResponse に渡した反復子は、レスポンスヘッダを返し終えたあとも読まれ続けます。だから後始末は BackgroundTask に預けます——Background - Starlette にあるとおり、応答を送り切ったあとで一度だけ走る場所です。ここを async with で括ると、まだ本文を読んでいる最中にコネクションが閉じられ、原因の見えない ReadError になります。
素通しできているかどうかは、curl の素の挙動で分かります。-N(バッファ無効)を付けて "stream": true を投げ、data: の行が少しずつ増えていけば通っています。二十秒黙ったあとに全部まとめて出るなら、まだどこかで溜め込んでいます。
curl -N http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer YOUR_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-9b",
"messages": [{"role": "user", "content": "1 から 30 まで、間に短い説明を挟みながら数えてください"}],
"stream": true,
"max_tokens": 300
}'
私はこの確認を、プロキシに手を入れるたびの決まりごとにしました。nginx の設定を触った日も、Cloudflare の前段を挟んだ日も、同じ一行を流すだけで「どこで溜まったか」がすぐ分かります。同じ思い込みのまま「プロキシは薄いから大丈夫」と流している方も、いらっしゃるかもしれません。
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 は「トークンあたりの従量費」。この二つが等しくなる点が損益分岐です。
損益分岐は、稼働率という一点にほぼ集約されます。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 に公開しています。ここから最低限、次の三つだけは常に見えるようにしておくことをおすすめします。
これらは 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 を回し続けた方が安くつくときです。多くの個人開発者はその中間にいて、答えはたいてい「両方を使い分ける」ことになります。この記事の順番——小さく始めて、測って、制約を一つずつ足す——は、後から構成を育てていける形をそのまま写したものです。育てられる基盤だけが、長く役に立ちます。実装の一助になれば幸いです。