Antigravity でローカル LLM を動かしていて、こんな経験はないでしょうか。「Gemma 4 に JSON で答えて」とお願いしたら、半分は整形された JSON が返ってくるのに、残りは Markdown のコードブロックに包まれていたり、余計な前置き文が付いてきたり、プロパティ名のクォートが抜けていたり。クラウドの Gemini API であれば response_schema で型を強制できますが、ローカル推論の世界では、モデルに「頼む」だけでは本番には耐えられません。
ここで効いてくるのが**制約デコード(constrained decoding)**という考え方です。モデルが次のトークンを選ぶときに、文法的に許される候補だけに絞り込むことで、出力が必ずスキーマに合致するよう物理的に強制します。Gemma 4 のような小型モデルほど、この仕組みがあるかないかで本番適応度が大きく変わります。
ここではllama.cpp / Outlines / vLLM の3系統を横断しながら、Antigravity のエージェントパイプラインに組み込む実装パターンを、私自身が本番で運用してきた判断軸とともにお伝えします。ローカルモデルの出力が壊れた経験のある方に、そのまま投入できるコードと設計の型をお持ち帰りいただければ幸いです。
なぜ Gemma 4 ローカル推論は JSON が壊れやすいのか
原因はシンプルで、Gemma 4 が小型(4B〜9B 相当のパラメータ規模)であるぶん、「自然言語で出力する訓練」と「厳格な構造化出力の訓練」のトレードオフが大きいためです。クラウドの大型モデルであれば、プロンプトで「JSON のみで返答せよ」と書けば十分に動きますが、ローカルモデルは以下のような揺らぎを見せます。
プロパティ名を camelCase で指示したのに snake_case で返す
配列の要素数を「最大3つ」と指定しても5つ返してくる
値が null 許容のはずなのに空文字列 "" を入れてくる
JSON の前後に「了解しました」「以下が結果です」などの前置きが混じる
コードブロック \``json ... ```` に包まれて返ってくる
プロンプトエンジニアリングで対処しようとすると、プロンプトが肥大化し、トークンコストが増え、さらにモデルが指示に疲れて忘れ始めます。これは根本解決ではありません。
制約デコードは、この問題をトークン生成レイヤーで解決 します。スキーマが {"name": "string", "age": "int"} なら、{ を出した直後のモデルは "name" というトークン列しか選べず、"name": の後は文字列開始の " しか選べません。つまり文法的に外れることが物理的に不可能になります。
この考え方を押さえたうえで、Antigravity でどう組み込むかを順に見ていきましょう。
3つの選択肢を「用途別」に使い分ける
ローカルモデルに制約デコードをかける主要な選択肢は、大きく3系統あります。Antigravity で使う場合、どれを選ぶかはインフラ構成と性能要件で決まります。
llama.cpp + GBNF 文法 : .gguf 形式のモデルをローカル CPU/GPU で動かすなら第一候補です。Gemma 4 GGUF が Hugging Face で広く配布されており、M1/M2 Mac でも快適に動作します。GBNF(llama.cpp 独自の BNF 風文法)で直接文法を書きます
Outlines(Python ライブラリ) : Pydantic や JSON Schema を書けば、内部で自動的に有限状態機械を構築してトークン制約を掛けてくれます。Hugging Face Transformers、vLLM、llama-cpp-python と連携でき、Python 側で完結するのが魅力です
vLLM の guided_json / guided_regex : 本番の高スループットサーバーを建てるならこちら。OpenAI 互換 API として動作し、Antigravity からはクラウド API と区別せず呼べます。大量並行リクエストに強いです
私は「開発・検証は Outlines、本番高並列は vLLM、軽量ユーティリティは llama.cpp 直叩き」という使い分けで落ち着いています。理由は、Outlines は Pydantic モデルをそのままスキーマ化できるので、型定義が IDE で効くこと、そして Antigravity の補完が強く効くことです。
選択基準のフローチャート
判断に迷ったら、以下の順で見ていけば外しません。
1 リクエスト数秒の軽いユーティリティ(要約、ラベリング等)→ llama.cpp + GBNF
Pydantic で型定義済みの構造がある → Outlines(最短)
秒間10リクエスト超のエージェント本番環境 → vLLM + guided_json
GPU が弱い/CPU 推論のみ → llama.cpp 一択
ここから先は、それぞれの具体的な実装を、Antigravity のエージェントパイプラインに組み込む想定で見ていきます。
実装パターン1: llama.cpp + GBNF で軽量に強制する
最もセットアップが軽い方法です。Gemma 4 の GGUF を落として、GBNF 文法を書き、コマンドラインか Python バインディングで呼び出します。
GBNF 文法の基本形
たとえば、ユーザーの意図を3つのラベルから1つ選ばせる単純な分類タスクを考えます。出力は {"label": "..."} 形式の JSON に制約したいとします。GBNF は以下のように書きます。
root ::= "{" ws "\"label\"" ws ":" ws label ws "}"
label ::= "\"billing\"" | "\"support\"" | "\"general\""
ws ::= [ \t\n]*
この文法ファイルを classify.gbnf として保存し、llama.cpp に渡します。
./llama-cli \
-m ./models/gemma-4-9b-it-Q4_K_M.gguf \
--grammar-file ./classify.gbnf \
-p "Classify this message: I want a refund for order #1234" \
-n 64 --temp 0.1
実行すると、モデルの出力は必ず {"label": "billing"} のような形になります。前置きも、コードブロックも、揺らぎも入りません。
Python バインディング(llama-cpp-python)から使う
Antigravity でエージェントを組むなら、Python から呼ぶのが自然です。llama-cpp-python を使えば、GBNF 文法を文字列として渡せます。
"""Gemma 4 + llama.cpp + GBNF で意図分類を行う最小実装。
前提: pip install llama-cpp-python==0.3.5
モデル: gemma-4-9b-it-Q4_K_M.gguf を ./models/ に配置済み
想定出力: {"label": "billing"} のような JSON
"""
from llama_cpp import Llama, LlamaGrammar
from typing import Literal
import json
GRAMMAR = r '''
root ::= "{" ws " \" label \" " ws ":" ws label ws "}"
label ::= " \" billing \" " | " \" support \" " | " \" general \" "
ws ::= [ \t\n ] *
'''
llm = Llama(
model_path = "./models/gemma-4-9b-it-Q4_K_M.gguf" ,
n_ctx = 4096 ,
n_gpu_layers =- 1 , # Apple Silicon なら全レイヤーを GPU に
verbose = False ,
)
grammar = LlamaGrammar.from_string( GRAMMAR )
def classify (message: str ) -> Literal[ "billing" , "support" , "general" ]:
"""顧客メッセージを3ラベルに分類する。制約デコードにより戻り値は保証される。"""
prompt = f "<start_of_turn>user \n Classify: { message } <end_of_turn> \n <start_of_turn>model \n "
resp = llm(
prompt,
grammar = grammar,
max_tokens = 32 ,
temperature = 0.1 ,
)
text = resp[ "choices" ][ 0 ][ "text" ]
data = json.loads(text) # 文法で保証されているので 100% 成功する
return data[ "label" ]
if __name__ == "__main__" :
print (classify( "I want a refund for order #1234" )) # => billing
print (classify( "Where is my order?" )) # => support
print (classify( "Hello, how are you?" )) # => general
json.loads() が必ず成功するので、try/except で包む必要がありません。この保証は、プロンプトベースの実装から移行したときに、本番ログの 5xx エラー率がはっきり下がるところで実感できます。
よくあるつまずき — トークナイザのずれ
GBNF は Unicode レベルで文法を記述しますが、内部ではトークン列にマッチングされます。Gemma 4 のトークナイザは日本語を1文字単位で分割することが多く、GBNF で [あ-ん]+ のような範囲指定をするとマッチしないことがあります。日本語を扱うときは、GBNF 側は「JSON の構造」だけ制約し、値の中身は自由にさせる戦略が安定します。
root ::= "{" ws "\"intent\"" ws ":" ws value ws "}"
value ::= "\"" char* "\""
char ::= [^"\\] | "\\" (["\\/bfnrt] | "u" [0-9a-fA-F]{4})
ws ::= [ \t\n]*
構造だけを強制し、文字列内容はモデルに任せるという発想です。これは Outlines/vLLM でも共通する設計原則になります。
実装パターン2: Outlines で Pydantic と直結する
GBNF を手書きするのは、複雑なスキーマになると辛くなります。Outlines を使えば、Pydantic モデルを書くだけで文法が自動生成されます。
基本実装
Antigravity でツール呼び出しの引数を抽出するエージェントを考えます。ユーザーの自由入力から、「誰に、いつ、何分の会議を設定するか」を JSON 化したいとしましょう。
"""Outlines + Gemma 4 でツール引数を型安全に抽出する。
前提: pip install outlines==0.1.11 transformers==4.45.0 torch
用途: エージェントが自然文リクエストからツール呼び出し引数を生成するケース
"""
import outlines
from pydantic import BaseModel, Field
from typing import Literal
from datetime import datetime
class MeetingRequest ( BaseModel ):
"""会議設定リクエストの構造化表現。"""
attendee: str = Field( ... , description = "相手の名前" )
start_iso: str = Field( ... , description = "開始時刻 ISO 8601" )
duration_minutes: int = Field( ... , ge = 15 , le = 480 )
priority: Literal[ "low" , "medium" , "high" ] = "medium"
model = outlines.models.transformers( "google/gemma-4-9b-it" , device = "cuda" )
generator = outlines.generate.json(model, MeetingRequest)
def extract_meeting (user_text: str ) -> MeetingRequest:
"""自然文から会議リクエストを抽出する。失敗しうるのは Pydantic バリデーションのみ。"""
prompt = f """次のリクエストを構造化データに変換してください。
今日は 2026-04-24 です。
リクエスト: { user_text }
"""
# generator の戻り値は MeetingRequest インスタンス。型が保証される。
return generator(prompt, max_tokens = 256 )
if __name__ == "__main__" :
result = extract_meeting( "明日の15時から鈴木さんと1時間、緊急で打ち合わせしたい" )
print (result.model_dump_json( indent = 2 ))
# {
# "attendee": "鈴木",
# "start_iso": "2026-04-25T15:00:00",
# "duration_minutes": 60,
# "priority": "high"
# }
戻り値が MeetingRequest のインスタンスで、Antigravity のコード補完がそのまま効きます。result.attendee と書けばタイプエラーで守られ、プロパティ名のタイポを本番前に潰せます。
なぜ ge=15, le=480 のような制約が効くのか
Pydantic の Field(ge=, le=) は、Outlines 側が JSON Schema に変換する際に数値範囲として採用され、さらにサンプリング段階でバリデーションされます。ただし、数値範囲までは文法として完全に強制されるわけではありません(実装上は生成後にチェック)。このため、範囲を外したときは ValidationError が発生します。これは設計上は「バリデーション層でトラップ」する想定にしておきます。
Outlines をエージェント全体に組み込む
Antigravity のエージェントは「ユーザー入力 → 意図分類 → ツール引数抽出 → ツール実行 → 応答整形」のような多段パイプラインになることがほとんどです。Outlines は各段で Pydantic モデルを切り替えて使えるため、段階ごとに型安全を積み上げられます。
"""多段エージェントの各段で Outlines を使い分ける例。"""
from enum import Enum
from pydantic import BaseModel
import outlines
class Intent ( str , Enum ):
SCHEDULE = "schedule"
CANCEL = "cancel"
QUERY = "query"
class IntentClassification ( BaseModel ):
intent: Intent
confidence: float
class CancelRequest ( BaseModel ):
meeting_id: str
reason: str
model = outlines.models.transformers( "google/gemma-4-9b-it" )
intent_gen = outlines.generate.json(model, IntentClassification)
cancel_gen = outlines.generate.json(model, CancelRequest)
def agent_pipeline (user_text: str ) -> dict :
"""意図を分類し、その結果に応じて適切な引数抽出を行う。"""
intent_result = intent_gen( f "Classify intent: { user_text } " )
if intent_result.confidence < 0.6 :
return { "status" : "clarify_needed" , "confidence" : intent_result.confidence}
if intent_result.intent == Intent. CANCEL :
cancel = cancel_gen( f "Extract cancel info: { user_text } " )
return { "status" : "ok" , "action" : "cancel" , "data" : cancel.model_dump()}
# 他の意図も同様に分岐 ...
return { "status" : "ok" , "intent" : intent_result.intent.value}
型安全が段階的に保証され、各段のバリデーションエラーを独立してハンドリングできます。意図分類が低信頼度のときに「再度尋ね返す」というフォールバック経路も、型が明確だからこそ書きやすくなります。
実装パターン3: vLLM + guided_json で本番並列化する
秒間10リクエストを超えるような本番ワークロードになると、Outlines の単体プロセスではスループットが不足します。ここで vLLM を使い、OpenAI 互換 API として動かすのが定番の落とし所です。
vLLM の立ち上げ
python -m vllm.entrypoints.openai.api_server \
--model google/gemma-4-9b-it \
--dtype bfloat16 \
--max-model-len 8192 \
--port 8000 \
--guided-decoding-backend outlines
--guided-decoding-backend outlines を指定すると、OpenAI 互換の response_format や guided_json パラメータで JSON Schema を渡せるようになります。
Antigravity エージェントからの呼び出し
"""vLLM サーバーを OpenAI SDK 経由で呼び出す。Antigravity のエージェント本番構成。
前提: pip install openai==1.55.0
vLLM サーバーが localhost:8000 で起動していること
"""
from openai import OpenAI
from pydantic import BaseModel
import json
class ProductReview ( BaseModel ):
sentiment: str
summary: str
key_points: list[ str ]
rating: int
client = OpenAI(
base_url = "http://localhost:8000/v1" ,
api_key = "EMPTY" , # vLLM はデフォルトで認証なし
)
def analyze_review (review_text: str ) -> ProductReview:
"""商品レビューを構造化解析する。スキーマ違反はサーバー側で物理的に防止される。"""
resp = client.chat.completions.create(
model = "google/gemma-4-9b-it" ,
messages = [
{ "role" : "system" , "content" : "You are a product review analyzer. Output JSON only." },
{ "role" : "user" , "content" : review_text},
],
extra_body = {
"guided_json" : ProductReview.model_json_schema(),
},
temperature = 0.3 ,
max_tokens = 512 ,
)
raw = resp.choices[ 0 ].message.content
return ProductReview.model_validate_json(raw)
if __name__ == "__main__" :
review = analyze_review(
"届いた商品は想像以上でした。梱包も丁寧で、配送も早く、価格も納得です。"
"ただ色だけは写真と少し違った印象でした。"
)
print (review.model_dump_json( indent = 2 ))
vLLM 側で文法制約がかかっているため、model_validate_json は「構造は保証済み、ビジネスルールだけチェックする」用途になります。JSON パースエラーを握りつぶす必要がなくなるのは、本番運用で静かに効いてくる利点です。
スループット設計のコツ
vLLM は内部で PagedAttention と連続バッチ処理を使うため、同時リクエスト数が増えるほど単位時間あたりのトークン生成効率が上がります。ただし、guided_json はスキーマごとに内部キャッシュを作るため、毎リクエストで異なるスキーマを渡すとキャッシュミスが頻発します。本番では、エージェントのスキーマを数種類に固定してプリロードする のが効きます。
具体的には、起動直後にダミーリクエストを各スキーマで1回ずつ投げてキャッシュを温めておくと、実運用の初回レイテンシが 3〜5 倍改善することを私の環境では確認しました。
よくある落とし穴と、その対処法
ここからは、実際に本番で遭遇した非自明な失敗モードを共有します。制約デコードは強力ですが、万能ではありません。
落とし穴1: 文法が厳しすぎて「出力不能」になる
たとえば「最低3件の要素を持つ配列」を minItems: 3 で強制すると、モデルが生成しきれずに max_tokens に到達してしまうことがあります。この場合、文法的には無効な途中状態で切れるため、後処理でエラーが発生します。
対処 : max_tokens をスキーマの理論最大値の 1.5 倍程度に余裕を持って設定し、finish_reason が length のときは再生成する分岐を入れる。Outlines であれば max_tokens=512 のようにトークン予算を厚めに。
落とし穴2: 日本語の構造化出力で「値が空文字列」になる
Gemma 4 は日本語の学習量が英語より少なく、構造制約がかかっていると「答えが思いつかない=空文字列で妥協する」という挙動を取ることがあります。
対処 : Pydantic 側で min_length=1 を設定してバリデーションエラーで弾き、上位ロジックで再試行に回す。あるいは、プロンプトに「答えが不明な場合は unknown と書く」旨を明記し、Literal["unknown"] を許容する Union 型にしておく。
落とし穴3: 入れ子が深いスキーマで生成が極端に遅くなる
Outlines は JSON Schema を有限状態機械に変換しますが、入れ子が4階層を超えると状態空間が爆発し、トークン生成が 3〜10 倍遅くなることがあります。
対処 : 深い階層は JSON Schema の $ref で分割し、複数段のパイプラインに分解します。エージェント的には「まず外側の構造を決めて、内側は別の生成ターンで埋める」という2段階アプローチが結果的に速く・安くなります。
落とし穴4: モデル差し替えでプロンプトテンプレートがずれる
Gemma 4 と Gemma 4 Instruct、さらに Gemma 4 の fine-tuned 派生(たとえば Japanese continual pretraining 版)では、期待される chat template が微妙に異なります。llama.cpp では --chat-template gemma が通るモデルとそうでないモデルがあり、ローダー側で扱いを統一しておかないと、構造制約以前の段階でおかしくなります。
対処 : モデルロード時に chat template を明示的に固定し、ユニットテストで「既知入力→既知出力」が保たれるかを CI で回す。テンプレート差異は本番切り替え時に最も事故が起きやすいポイントです。
落とし穴5: GBNF の文字クラスに穴がある
GBNF で JSON 文字列を "\"" [^"]* "\"" のように書くと、エスケープされた \" が通らず、本文に「」や引用符が含まれるケースで生成が止まります。
対処 : GBNF の JSON 文字列はサンプル文法をそのまま使うのが安全です。以下は llama.cpp 公式リポジトリに近い形で、実運用で問題が出ていない定義です。
string ::= "\"" ( [^"\\] | "\\" (["\\/bfnrt] | "u" [0-9a-fA-F]{4}) )* "\""
既存のリソースをそのまま流用したほうが、後で「なぜ一部の入力で出力が止まるのか」に悩まずに済みます。
本番運用のモニタリングとフォールバック設計
制約デコードで「文法的には必ず JSON になる」状態を作れても、ビジネスルール上の正しさまでは保証されません。ここでフォールバック設計が重要になります。
3段フォールバックの設計
私の本番では以下のような3段構成を採用しています。
1段目: 制約デコード付き Gemma 4 ローカル(速い・安い・大半を処理)
2段目: Pydantic バリデーションで弾かれたら、温度を下げて同じモデルで再試行(失敗理由をプロンプトに追加)
3段目: 2段目でも失敗したら、Gemini API(クラウド)にフォールバック(精度最優先・コストは許容)
この構成で、99.5% 以上のリクエストが1段目で完了し、2段目が 0.4%、3段目が 0.1% という分布になります。コスト効率と信頼性のバランスが一番取れた設計だと考えています。
観測すべき3つのメトリクス
本番では以下を継続的に見ます。
スキーマ違反率(バリデーションエラー発生率) : 文法制約があるので構文エラーはゼロのはずですが、ビジネスルール違反(数値範囲・Enum 値以外等)は残ります。これが 1% を超えたらプロンプトかスキーマを見直すサインです
リトライ率 : 1段目失敗→2段目到達の割合。5% を超えるなら温度設定か max_tokens を疑います
p99 レイテンシ : 制約デコードは有限状態機械の遷移で CPU が動くため、深いスキーマでは思ったより遅くなります。p99 が SLO を超えたら、スキーマ分割を検討します
Antigravity 内の可観測性
Antigravity のエージェントパイプラインにこれを組み込むなら、Langfuse か OpenTelemetry で trace を残します。当ラボの関連記事として、Antigravity × Langfuse エージェント可観測性ガイド と Antigravity の OpenTelemetry AI 可観測性パイプライン が具体的な計装例を扱っていますので、合わせて参照していただくと全体像が見えやすいと思います。
ローカル LLM の内部メトリクスは「黙って壊れる」ケースが多いので、計装は最初に作り込んでおくのがおすすめです。
性能を引き出す小さな工夫
最後に、同じ制約デコードでも「速い実装」と「遅い実装」を分ける細かな工夫を、実測ベースで共有します。
JSON Schema の additionalProperties: false を明示する : デフォルトだとモデルが余計なフィールドを生成しようとしてトークンを消費します。禁止を明示すると、1レスポンスあたり 10〜20 トークン減ることがあります
Enum を Literal で明示し、値の数を最小化する : 選択肢が3つと20個では有限状態機械の複雑度が桁違いです。ビジネスロジック上許容できるなら、エージェントの意思決定は3〜5肢に絞るのが高速化に効きます
description フィールドを付けない : Pydantic の Field(description=...) は JSON Schema に反映されますが、制約デコード時の文法には影響しません。それどころか、Outlines/vLLM のスキーマキャッシュキーが無駄に長くなります。型ヒントで十分な場合は省略するのが速いです
温度は低めに(0.1〜0.3) : 制約デコードでも、温度が高いと低確率パスを探索してトークン数が増えます。構造化出力タスクではほぼ常に低温度が勝ちます
プロンプトで「JSON のみで答えろ」と書かない : 文法で強制されているので言及する必要がなく、むしろトークンの無駄です。system メッセージは空か、ドメイン知識だけに使うほうが効果的です
こうした積み重ねで、同じスキーマ・同じモデルでも p50 レイテンシが 2 割縮んだ経験があります。些細に見えますが、大量処理では積分効果が効いてきます。
全体を振り返って — 今日できる一歩
Gemma 4 ローカル推論でエージェントを組むなら、プロンプトで「JSON で答えて」と書くのは今日でやめて、制約デコードを入り口にする のがおすすめです。具体的な次の一歩として、今のコードベースで最もフォーマットが壊れやすい 1 箇所を選び、Pydantic モデルと Outlines で置き換えてみてください。置き換えの手応えで、残りのコードに広げる判断ができるはずです。
Outlines のインストールとモデルロードに 30 分、既存のエージェント関数の書き換えに 1〜2 時間、バリデーションエラーのフォールバック実装に半日といったところです。半日の投資で、本番の静かな壊れ方が大きく減るなら、費用対効果は悪くありません。
ローカル LLM の世界は、「賢いモデルを呼び出す」ステージから「出力を構造的に制御する」ステージへと確実に移りつつあります。この記事が、政樹さんの読者のみなさまが次のステージに踏み出すきっかけになれば嬉しいです。最後までお読みいただきありがとうございました。