ローカルでは動いた 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
- auto27B モデルについては、素の 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 用を混在させることはできません。ベースモデルを変えるなら、別コンテナに分けるのが素直な設計です。
明日から動かすための、最初の一歩
ここまで長いので、全部を一度に試すのは現実的ではありません。私がおすすめするのは、まず次の 1 つだけ試すことです。
手元の GPU 付きマシン(または GPU インスタンス)で、この記事のはじめに書いた最小構成の docker-compose.yml をそのまま起動して、Antigravity のカスタムプロバイダとして登録してみる。この一歩だけで、Ollama との体感差が分かります。特に同時接続 2 以上で投げたときの挙動が、想像以上にきれいに返ってくることに驚くはずです。
その上で、自分のワークロードでベンチマークを走らせて、量子化の選択肢を検討します。この順番で進めていけば、自前推論基盤を「なんとなく組んだら動いた」ではなく、「なぜこの構成にしたかを説明できる」状態で持てるようになります。
本記事の内容をマルチノードに広げる段階で、手元に置いておくと迷いが減る一冊です。