0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルLLMのJSON出力はなぜ壊れるのか — 壊れ方のパターンと実装で防ぐ方法

0
Posted at

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分で数字が出る。その数字が、あなたの環境での本当の信頼性だ。他人のベンチマーク(この記事を含む)を鵜呑みにするな。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?