APIなら1行、ローカルなら地雷原
OpenAIのAPIにはresponse_format={"type": "json_object"}がある。Claudeにも同等の機能がある。これを指定すれば、出力は必ずJSON形式になる。パースに失敗することはまずない。
ローカルLLMにはこれがない。正確に言えば、llama.cppには--grammarオプションでBNF文法を指定する機能があるが、これは「出力をJSONに強制する」のではなく「文法に違反するトークンの生成確率を0にする」という仕組みだ。結果として出力がJSON形式になるが、中身が意味のあるJSONになるかは別問題。
// APIの出力: 意図通り
{"name": "Qwen2.5-14B", "speed_tps": 31.5, "vram_gb": 7.3}
// ローカルLLM (grammar有効) の出力: JSONだが中身が壊れている
{"name": "Qwen2.5-14B", "speed_tps": "fast", "vram_gb": "enough"}
// → 型が違う。数値であるべきフィールドに文字列が入る
この「形式は正しいが中身が壊れる」問題は、モデルサイズが小さいほど頻発する。RTX 4060 8GBで動かせるモデルサイズの制約(7B〜14B)が、JSON出力の信頼性に直結する。壊れ方のパターンと、実装レベルで防ぐ方法を整理する。
検証設計
タスク定義
以下の3種類のJSON出力タスクを用意した。難易度が異なる。
タスク1: 単純抽出 (Easy)
入力: "RTX 4060は8GBのGDDR6を搭載し、TDPは115Wです。"
期待出力: {"gpu": "RTX 4060", "vram_gb": 8, "tdp_w": 115}
タスク2: 分類+数値 (Medium)
入力: "このコードはPythonで書かれたFastAPIのエンドポイントで、レスポンス時間は平均230ms。"
期待出力: {"language": "Python", "framework": "FastAPI", "avg_response_ms": 230, "type": "endpoint"}
タスク3: ネスト構造 (Hard)
入力: "Qwen2.5-14BをQ4_K_Mで量子化すると7.3GB、Q5_K_Mだと9.7GB。速度はそれぞれ31.5 tok/sと28.2 tok/s。"
期待出力: {
"model": "Qwen2.5-14B",
"quantizations": [
{"method": "Q4_K_M", "size_gb": 7.3, "speed_tps": 31.5},
{"method": "Q5_K_M", "size_gb": 9.7, "speed_tps": 28.2}
]
}
評価基準
各出力を4段階で評価:
| レベル | 定義 | 例 |
|---|---|---|
| Perfect | JSON valid + 全フィールド正確 + 型正確 | そのまま使える |
| Usable | JSON valid + 主要フィールド正確 + 軽微な型ミス | int→strなど、コードで吸収可能 |
| Broken | JSON valid だが内容が壊れている | フィールド欠落、値が捏造 |
| Failed | JSON invalid またはJSON以外の出力 | パース不能 |
実行方法
llama.cppサーバーのOpenAI互換APIを使用。
import json
import httpx
from dataclasses import dataclass
@dataclass
class TestCase:
task_id: str
difficulty: str
input_text: str
expected: dict
@dataclass
class Result:
task_id: str
model: str
level: str # perfect, usable, broken, failed
output: str
errors: list[str]
def test_json_output(
model_name: str,
base_url: str,
test_cases: list[TestCase],
runs_per_case: int = 5,
) -> list[Result]:
results = []
for tc in test_cases:
for run in range(runs_per_case):
prompt = f"""以下の情報からJSONを生成してください。
フォーマット: {json.dumps(tc.expected, ensure_ascii=False)}と同じ構造で。
入力: {tc.input_text}
JSONのみを出力してください。説明や前置きは不要です。"""
try:
resp = httpx.post(
f"{base_url}/v1/chat/completions",
json={
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 512,
"temperature": 0.1,
},
timeout=30,
)
raw = resp.json()["choices"][0]["message"]["content"]
# JSON抽出(markdown code blockの場合を考慮)
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0]
parsed = json.loads(raw)
level = evaluate(parsed, tc.expected)
errors = get_errors(parsed, tc.expected)
except json.JSONDecodeError:
level = "failed"
errors = ["JSON parse error"]
except Exception as e:
level = "failed"
errors = [str(e)]
results.append(Result(
task_id=tc.task_id,
model=model_name,
level=level,
output=raw[:200],
errors=errors,
))
return results
def evaluate(parsed: dict, expected: dict) -> str:
"""出力JSONを期待値と比較して品質レベルを判定"""
if not isinstance(parsed, dict):
return "broken"
expected_keys = set(expected.keys())
parsed_keys = set(parsed.keys())
if not expected_keys.issubset(parsed_keys):
missing = expected_keys - parsed_keys
if len(missing) > len(expected_keys) * 0.5:
return "broken"
return "usable"
type_errors = 0
value_errors = 0
for key in expected_keys:
exp_val = expected[key]
got_val = parsed.get(key)
if type(exp_val) != type(got_val):
type_errors += 1
elif isinstance(exp_val, (int, float)):
if abs(exp_val - got_val) > exp_val * 0.1: # 10%以上の乖離
value_errors += 1
if type_errors == 0 and value_errors == 0:
return "perfect"
elif type_errors <= 1 and value_errors == 0:
return "usable"
else:
return "broken"
def get_errors(parsed: dict, expected: dict) -> list[str]:
errors = []
for key in expected:
if key not in parsed:
errors.append(f"missing: {key}")
elif type(expected[key]) != type(parsed.get(key)):
errors.append(f"type mismatch: {key} (expected {type(expected[key]).__name__}, got {type(parsed.get(key)).__name__})")
return errors
壊れ方のパターン — 何がどう崩壊するのか
JSON出力の壊れ方には明確なパターンがある。モデルサイズが小さいほど重度の壊れ方が増える傾向があるが、具体的な壊れ率はモデル・プロンプト・タスクに強く依存するため、自分の環境で上記のテストコードを走らせて計測することを強く推奨する。
ここでは壊れ方の分類と、各モデルサイズで経験的に観測される傾向を記述する。
パターン1: Failed (JSON自体が壊れる)
// 典型: 説明文がJSONの前後に付く
Here is the JSON output:
{"name": "Qwen2.5-14B", ...}
I hope this helps!
// → json.loads() でパース不能
傾向: 7Bクラスで頻発。grammarを有効にすると消える。14B以上ではgrammarなしでも稀。
パターン2: Broken (形式は正しいが中身が壊れている)
// 期待: {"speed_tps": 31.5, "vram_gb": 7.3}
// 実際: {"speed_tps": "fast", "vram_gb": "7.3GB"}
// → 型が違う。数値に文字列が入る。単位が混入する。
傾向: 7Bで頻繁、14Bでも散発的に発生。JSON Schemaをプロンプトに含めると大幅に改善する。grammarだけでは防げない(形式は合っているため)。
パターン3: ネスト構造の崩壊
// 期待: {"items": [{"a": 1}, {"a": 2}]}
// 実際: {"items": [{"a": 1}, {"b": 2}]} // フィールド名が変わる
// または: {"items": [{"a": 1}, 2]} // 型が途中で崩れる
傾向: これが最も厄介。配列の中のオブジェクトを複数生成するとき、最初のオブジェクトは正しいが2つ目以降でフィールド名や型が崩れる。モデルサイズが大きくても完全には解消しない。ネスト構造はモデルに作らせないのが最善(後述の2段階生成パターン参照)。
grammarの効果と限界
llama.cppの--grammarはFailed(パース不能)を確実に0にする。だがBroken(中身が壊れる)は防げない。文法はトークン列の形式を制約するだけで、意味的な正しさは保証しない。grammarは必要条件であって十分条件ではない。
実戦的な対処法
パターン1: 出力スキーマを明示する
JSON Schemaをプロンプトに含めると劇的に改善する。
schema = {
"type": "object",
"properties": {
"model": {"type": "string"},
"quantizations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"method": {"type": "string"},
"size_gb": {"type": "number"},
"speed_tps": {"type": "number"}
},
"required": ["method", "size_gb", "speed_tps"]
}
}
},
"required": ["model", "quantizations"]
}
prompt = f"""以下のJSON Schemaに従って出力してください:
{json.dumps(schema, indent=2)}
入力: {input_text}"""
スキーマ明示でネスト構造の出力品質が明確に改善する。モデルに「何を出すべきか」の構造を事前に教えるため、フィールド名の一貫性が保たれやすくなる。
パターン2: grammarでフォーマットを強制 + リトライ
# llama.cppのgrammar指定
./llama-server \
-m model.gguf \
--grammar-file json.gbnf \
...
grammarはFailed(パース不能)を0にする。だがBroken(中身が壊れる)は防げない。grammarとスキーマ明示の組み合わせが最善で、それでもダメならリトライ。
def reliable_json(prompt: str, max_retries: int = 3) -> dict:
for attempt in range(max_retries):
raw = call_llm(prompt)
try:
parsed = json.loads(raw)
if validate_schema(parsed):
return parsed
except (json.JSONDecodeError, ValidationError):
continue
raise RuntimeError(f"JSON生成失敗 ({max_retries}回)")
リトライを3回まで許容すると、実効的な成功率は大幅に上がる。リトライのコストは最大3倍のレイテンシだが、信頼性が必要なパイプラインではトレードオフとして合理的。 何回リトライが必要かは自分のモデルとタスクで計測すべき — 上記のテストコードがそのまま使える。
パターン3: 2段階生成で型を分離する
ネスト構造が特に壊れやすいので、フラットなJSONを2回生成して結合する。
# Step 1: メタ情報を抽出
meta_prompt = "モデル名だけJSONで出してください: {\"model\": \"...\"}"
meta = call_llm(meta_prompt)
# Step 2: 配列部分を別途抽出
array_prompt = "各量子化の情報をJSONの配列で出してください: [{\"method\": ..., \"size_gb\": ..., \"speed_tps\": ...}]"
items = call_llm(array_prompt)
# Step 3: プログラムで結合
result = {**json.loads(meta), "quantizations": json.loads(items)}
ネスト構造をモデルに一発で作らせるのと、フラットなJSONを2回生成して結合するのでは、後者が圧倒的に安定する。7Bでネスト構造が必要なら、この方法が事実上唯一の実用的な選択肢。
モデルサイズ別の推奨構成
[JSON出力が必要なシステムの設計ガイド]
推奨構成
高信頼 (決済, 医療): 32B + grammar + リトライ
→ 8GBでは厳しい。APIを使え
標準 (RAG, 分析): 14B + grammar + スキーマ明示 + リトライ
→ RTX 4060 8GBで動く最適解
軽量 (ログ抽出, 分類): 7B + grammar + 2段階生成
→ フラットなJSONに限定すれば実用域
ネスト必須: 14B以上 + 2段階生成
→ 7Bでは安定しない
具体的な成功率は自分の環境で計測してくれ。上のテストコードをコピペして、自分のモデル × 自分のタスクで走らせれば10分で数字が出る。その数字が、あなたの環境での本当の信頼性だ。他人のベンチマーク(この記事を含む)を鵜呑みにするな。