生成AI開発の最初の壁「JSONで返して」を無料のローカルLLMで実測する

目次
  1. 実験設計 — 3モデル×2方式×5件
  2. 「JSONだけ返して」は7Bでも全滅する
  3. フェンス剥がしで救えるのはqwen系だけ
  4. structured outputsなら1.3Bでも「パースは」全部通る
  5. 実測から引ける線 — どのモデル・どの方式を選ぶか

「生成AI 開発」で検索すると、技術選定や実装ステップの解説記事が並ぶ。だが解説を読み終えて実際に生成AIを組み込んだ機能を作り始めると、最初に手が止まるのはモデル選定でもプロンプト設計でもなく、「LLMの返事をプログラムで受け取る」ところだ。チャット画面なら自由文で構わないが、アプリに組み込むなら json.loads が一発で通る形で返ってこないと、その先の処理が一行も書けない。

この受け取りの検証のためだけに課金APIを叩きたくなかったので、手元のMacに入れた無料のローカル小型モデル3種で「非構造テキスト→JSON抽出」がどこまで安定するかを測った。比べたのは、プロンプトで「JSONだけ返して」と頼む方式と、ollama の structured outputsformat にJSON Schemaを渡す)の2方式。結果は思っていたより極端だった。

実験設計 — 3モデル×2方式×5件

環境はこのとおり。

$ sh -c 'sw_vers; echo; ollama --version; echo; ollama list | grep -E "qwen2.5-coder|deepseek-coder"; echo; python3 -V'
ProductName:		macOS
ProductVersion:		26.6.1
BuildVersion:		25G76

ollama version is 0.31.1
...
Python 3.14.6
# exit=0 (257 ms)

モデルは取得済みの deepseek-coder:1.3b(776 MB)、qwen2.5-coder:1.5b(986 MB)、qwen2.5-coder:7b(4.7 GB)。1.5Bと1.3Bはメモリの弱いマシンでも動くサイズ、7Bはローカルのコーディング支援でよく名前が挙がるサイズという意図で選んだ。タスクは生成AIアプリ開発で頻出する「非構造テキストの構造化」にした。日本語のイベント告知文5件から {title, date, price_yen, online} の4フィールドを抜き出させる。日付の年が書かれていない告知文が混ざるので、プロンプトに「今年は2026年です」とだけ補っている。

方式は2つ。

  • prompt方式: プロンプトで「JSONだけを返してください。説明文やコードフェンスは不要です」と指示する。format は指定しない
  • schema方式: プロンプトは同じまま、リクエストの format にJSON Schemaを渡す

リクエストはPython標準ライブラリだけで http://127.0.0.1:11434/api/chat に送る。クライアントライブラリは要らない。スクリプトの中心部はこうなる。

SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "date": {"type": "string", "description": "YYYY-MM-DD"},
        "price_yen": {"type": "integer"},
        "online": {"type": "boolean"},
    },
    "required": ["title", "date", "price_yen", "online"],
}
...
PROMPT = (
    "次の日本語の告知文から情報を抽出し、JSONだけを返してください。"
    "説明文やコードフェンスは不要です。"
    'キーは title(文字列), date(YYYY-MM-DD形式の文字列), price_yen(整数), online(真偽値) の4つ。'
    "今年は2026年です。\n\n告知文:\n{text}"
)


def call(model, text, use_schema):
    body = {
        "model": model,
        "messages": [{"role": "user", "content": PROMPT.format(text=text)}],
        "stream": False,
        "options": {"temperature": 0, "seed": 42},
    }
    if use_schema:
        body["format"] = SCHEMA
    req = urllib.request.Request(
        OLLAMA, json.dumps(body).encode(), {"Content-Type": "application/json"}
    )
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
        res = json.load(r)
    return res["message"]["content"], time.time() - t0

採点は救済なしの json.loads、4キーの型適合、値の正答(4フィールド×5件=20点)、所要秒数。temperature=0 で3モデル×2方式×5件=計30リクエストを流した。白状すると、ベンチの初回実行は測定以前のところで落ちている。実行ラッパーが作業ディレクトリを移して起動する仕様を見落とし、スクリプトへのパスを二重に指定して exit=2(ファイルが無い)で即死した。この失敗ログも残してある。「救済なし」にしたのは、フェンス剥がしのような前処理コードはそれ自体がアプリの保守対象になるからで、まず素の挙動を知りたい。前処理で救える範囲は後半で別に測る。

「JSONだけ返して」は7Bでも全滅する

まず prompt方式。厳密パースは3モデルとも 0/5 だった。qwen2.5-coder:7b の出力がこれで、不要と明示したコードフェンスで包んでくる。

$ python3 bench_json.py
...
[qwen2.5-coder:7b/prompt/case1] 12.6s json=False schema=False fields=0/4 out=```json\n{\n  "title": "Pythonもくもく会 #12",\n  "date": "2026-08-30",\n  "price_yen": 500,\n  "online": false\n}\
[qwen2.5-coder:7b/prompt/case2] 4.0s json=False schema=False fields=0/4 out=```json\n{\n  "title": "はじめてのRustハンズオン",\n  "date": "2026-09-05",\n  "price_yen": 0,\n  "online": true\n}\n```
...
# exit=0 (139838 ms)

1.5b も 7b も、5件全部がこの ```json で始まる形だった。読解力が足りないわけではない。フェンスの中身を取り出して採点し直すと 7b は 20/20、1.5b も 19/20 まで正しい(次節の救済パースで確認できる)。ほぼ正しく抽出した上で、頼んでいない包装を付けてくる。

deepseek-coder:1.3b はフェンス以前の問題で、頼んでいない説明文が付き、中国語が混ざり、日付形式が崩れ、JSONの中に // コメントを書き、しまいには抽出ではなくTypeScriptのコードを生成し始める。

$ python3 bench_json.py
[deepseek-coder:1.3b/prompt/case1] 5.9s json=False schema=False fields=0/4 out=以下のJSON形式に変換後返却します:\n```json\n{\n    "title": "#12", \n    "date":"8/30(日) 13時よりPythonもくもく会 #12開催中。参加費500円.渋谷コ
[deepseek-coder:1.3b/prompt/case2] 1.5s json=False schema=False fields=0/4 out=以下是提供的告知文中提取出的JSON表示:\n```json\n{\n    "title": "First Rust Hands-On", \n    "date": "2026-9-5",  \n    "price
[deepseek-coder:1.3b/prompt/case3] 4.5s json=False schema=False fields=0/4 out=以下のJSON形式で、告知文中抽出した情報を返しています: \n```json\n{\n    "title": "生成AI実践LT大会",\n    "date": "2026-09-12T00:00:00Z", //
[deepseek-coder:1.3b/prompt/case4] 31.8s json=False schema=False fields=0/4 out=以下は告知文の情報抽出部分以外の処理内容:\n```typescript\nconst text = "TypeScript勉強会 vol.3 のお知らせ。日時: 2026-08-22 19:00-21:00、場所: オ
...
# exit=0 (139838 ms)

今回の3モデル×5件では、プロンプトの指示だけで裸のJSONが返ってきたケースは一度もなかった。

フェンス剥がしで救えるのはqwen系だけ

「フェンスは正規表現で剥がせば済む」という反論は当然あるので、それも測った。フェンスの中身を取り出してからパースする救済を挟み、同じ prompt方式をもう一度回す。

def strip_fence(raw):
    m = re.search(r"```(?:json)?\s*(.*?)```", raw, re.DOTALL)
    return m.group(1).strip() if m else raw.strip()
$ python3 bench_rescue.py
...
=== 集計(prompt方式のみ)===
model                 厳密json妥当      剥がし後json妥当        剥がし後の値の正答   
deepseek-coder:1.3b   0/5           1/5               2/20
qwen2.5-coder:1.5b    0/5           5/5               19/20
qwen2.5-coder:7b      0/5           5/5               20/20
# exit=0 (94156 ms)

qwen系は 5/5 まで復活し、値の正答もschema方式と同水準に戻る。qwen系に限れば「フェンス剥がしで済む」は正しかった。一方 deepseek-coder:1.3b は 1/5 のままだった。先ほどのログのとおり、フェンスの中身自体がJSONとして壊れているケース(// コメント、日付でない日付、そもそもJSONを返さない)は、フェンス剥がしでは直らない。救済パースはモデルの崩れ方に合わせた対症療法で、どこまで書けば足りるかがモデルごとに変わる。

structured outputsなら1.3Bでも「パースは」全部通る

次に schema方式。format にスキーマを渡しただけで、3モデルとも厳密パース 5/5・型適合 5/5 になった。プロンプトは一文字も変えていない。救済コードも要らない。

ただし「パースが通る」と「中身が正しい」は別物だった。deepseek-coder:1.3b の schema方式の出力を見てほしい。

$ python3 bench_json.py
...
[deepseek-coder:1.3b/schema/case1] 1.7s json=True schema=True fields=2/4 out={\n    "title": "Pythonもくもく会 #12", "date":"8/30(日) 13時以降開催中", "price_yen":500, "online":true\n}
[deepseek-coder:1.3b/schema/case2] 0.9s json=True schema=True fields=2/4 out={ "title": "最初のRustハンズオン", "date":"2026-9-5","price_yen":18, "online":true }
[deepseek-coder:1.3b/schema/case3] 0.8s json=True schema=True fields=1/4 out={\n    "title": "Generation AI Execution LT Conference", "date":"September 12th, Tokyo Shimogawa.", "price_yen
...
# exit=0 (139838 ms)

形式は完全なJSONだが、date に「8/30(日) 13時以降開催中」のような日付でない文字列が入り、参加無料のイベント(case2のRustハンズオン)に "price_yen":18 という告知文のどこにもない数値が湧く。値の正答は20点満点で deepseek-coder:1.3b が 9、qwen2.5-coder:1.5b が 19、7b が 20。スキーマ強制が保証するのは形式であって、中身ではない。

しかもパースエラーが消えた分、間違いは静かになる。例外で落ちる代わりに、もっともらしい誤値が黙って下流に流れる。形式が保証されるほど、値のバリデーションは省けなくなる。

もう一つの効果は速度に出た。deepseek-coder:1.3b の平均応答は prompt方式 9.4秒 → schema方式 1.1秒。prompt方式では頼んでいない説明文やコード例まで生成していたので、出力がJSONだけに絞られた分だと見ているが、ollama内部の動作までは確認していない。

実測から引ける線 — どのモデル・どの方式を選ぶか

集計はこうなった。

$ python3 bench_json.py
...
=== 集計 ===
model                 mode    json妥当    schema適合    値の正答      平均秒     
deepseek-coder:1.3b   prompt  0/5       0/5         0/20      9.4
deepseek-coder:1.3b   schema  5/5       5/5         9/20      1.1
qwen2.5-coder:1.5b    prompt  0/5       0/5         0/20      1.8
qwen2.5-coder:1.5b    schema  5/5       5/5         19/20      1.2
qwen2.5-coder:7b      prompt  0/5       0/5         0/20      7.9
qwen2.5-coder:7b      schema  5/5       5/5         20/20      6.5
# exit=0 (139838 ms)
0点deepseek-coder:1.3b / prompt9点deepseek-coder:1.3b / schema0点qwen2.5-coder:1.5b / prompt19点qwen2.5-coder:1.5b / schema0点qwen2.5-coder:7b / prompt20点qwen2.5-coder:7b / schema
値の正答(20点満点)

この結果から私が引いた線は3つ。

  • 方式はschema一択。 今回の30リクエストの範囲で、prompt方式の厳密パースが通ったことは一度もなく、schema方式が落ちたことも一度もない
  • 開発中の検証は qwen2.5-coder:1.5b で足りる。 986 MBのモデルで妥当率 5/5・正答 19/20・平均 1.2秒。プロンプトやスキーマの試行錯誤はこれで回し、精度が要る段になったら 7b(正答 20/20、ただし平均 6.5秒)に上げる
  • deepseek-coder:1.3b は構造化抽出に使わない。 パースは通るが、中身が入力に無い値で埋まることがある

限界も書いておく。測ったのは日本語イベント告知の抽出という1タスク×5件、Mac 1台、temperature=0 の各1試行で、この数字を他のタスクや環境にそのまま外挿はできない。クラウドの課金APIとの精度比較もしていない。

この構成が向かないケースも挙げておく。スキーマで表現しにくい自由要約はそもそも format の出番がないし、誤答1件が事故になる本番用途を小型モデル単体に任せるのは、この正答率を見る限り勧めない。

逆に言えば、「LLMの出力を受け取る部分」の設計・実装・テストは、課金を始める前にローカルで全部済ませられる。ollama で上の3モデルを pull し、記事中の SCHEMAPROMPTcall に自分のタスクの入力文と採点を足せば、標準ライブラリだけで同じ実測が組める。計30リクエスト・数分で、自分の環境・自分のタスクの数字が出る。生成AI開発の入り口で必要なのはクレジットカードではなく、この程度の実測だった。

関連書籍