LLMのJSONの受け方

更新: 2026-11-03
OllamaローカルLLMJSONスキーマ構造化出力thinking再現性

LLMにJSONを返させるとき、受け口は3つ要る。欄を選ぶ、JSONとして読む、中身を検証する。 format に渡す形式の指定は送る側の指示で、この3つの代わりにならない。

実機で踏んだ経緯は 返事が空だった ローカルLLMのJSONは考え中の欄にあった に書いた。ここには、再利用できる形だけを残す。

1. 受け口の判定

Ollamaの生成API(/api/generate)の応答には、通常の回答欄 response のほかに、考え中の欄 thinking が付くことがある。ある実機のQwen系モデルでは、response が空で、完成したJSONが thinking に入っていた。

LLMの応答responseに中身がある?responseを読むthinkingを読むJSONとして読める?拒否検証型・範囲・必須ある空読める読めない通れば使う弾けば拒否
responseが空のときだけthinkingを読む。どちらでも、JSONとして読めなければ拒否する。

最小の実装は2行になる。

import json

def pick_json(result):
    # response が空のときだけ thinking を読む。文章なら読めないので例外になる
    content = result.get("response") or result.get("thinking")
    return json.loads(content)

手元で6通りの応答を流した結果は次のとおり。TypeError(欄が無い)と ValueError(JSONではない文字列)を、呼び出し側で「読めない」にまとめる。

応答結果
response にJSON読める
response が空、thinking にJSON読める
response が空、thinking が文章読めない
response に文章、thinking にJSON読めない(response が優先で、thinking は見ない)
両方とも空読めない
response の欄が無く、thinking にJSON読める
  • thinking の中身はJSONしか受け付けない。 文章を取り込む作りにすると、考え中のメモを設計図として使ってしまう
  • 読み取り側で吸収しても、リクエストの think は触らなくてよい。モデルの動作モードを変えずに済む

2. format の指定とJSONスキーマ

Ollamaの公式API文書(GitHubのmainにある docs/api.md、2026-11-03に原文を取得して確認)では、format に渡せるのは json か JSONスキーマ。スキーマを渡す方法は、文書では「Structured outputs」の項にある。json のほうの項には、プロンプトでもJSONを使うよう指示することが重要、とも書かれている。

使ったスキーマの骨格は次のとおり。必須項目、型、範囲、余計な項目の禁止を書く。

SCHEMA = {
    "type": "object",
    "properties": {
        "bpm": {"type": "integer", "minimum": 60, "maximum": 200},
        "duration": {"type": "number", "minimum": 10, "maximum": 600},
        "lyrics": {"type": "string"},
    },
    "required": ["bpm", "duration", "lyrics"],
    "additionalProperties": False,
}
# リクエストの本体: {"model": ..., "prompt": ..., "format": SCHEMA, "stream": False}
送る(LLMへ)返ってくる受ける(アプリ)指示文(文章で形を書く)format(JSONスキーマ)response(通常の回答欄)thinking(考え中の欄)1 欄を選ぶ2 JSONとして読む3 中身を検証する同じ値(上限・必須項目)が書かれるのは、指示文・スキーマ・検証の3か所曲の長さの上限は、整理前はUIとAPIも含めて6か所、整理後も数値は4か所に書かれていた。1か所だけ直すと、LLMは範囲の内側のつもりで返しても、検証が弾く。
送る側の2つ(指示文・スキーマ)と、受ける側の3つ(欄・読み取り・検証)。値を書く場所が増えるほど、直し漏れが起きる。
  • スキーマは送る側の指定。返ってきたものが守っているかは、受け側で確かめるまで分からない
  • スキーマを足すと、同じ上限や必須項目を書く場所が1つ増える。指示文と検証の値を、同時に直す必要がある

3. 受信後の検証が通すもの・弾くもの

検証は、数値を float() で変換して範囲を見る作りだった。手元で流した結果は次のとおり(bpm は 60〜200、長さは 10〜600 の検証)。

LLMが返した値結果
必須の lyrics が無い弾く
必須の acestep_prompt が無い弾く
bpm が文字列の "120"通る(120になる)
bpm が "速い"弾く
duration が "30秒"弾く
duration が300通る

float("120") は通るので、数値を文字列で返す揺れは、黙って通る。厳密にしたいときは、変換の前に型を見る。

def number(value, low, high):
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(value)  # 文字列の "120" もここで弾く
    if not low <= value <= high:
        raise ValueError(value)
    return float(value)

(bool は int の仲間なので、先に外す。True が 1 として通るのを防ぐ。)

4. 同じ値が書かれた場所を数える

上限を変えるときは、変える前に、その値が書かれた場所を数える。

git grep -n "\b120\b" <コミット> -- '*.py' 'static'
  • 別の意味の同じ数字が混ざる。 実例では7件ヒットして、1件は timeout=120(待ち時間)だった。上限として書かれた行だけを数える
  • 変えた後にも、新しい値(600)で同じ検索をする。待ち時間の600が2件混ざり、上限は4か所だった
  • 指示文・スキーマ・検証に同じ値があるなら、定数1つから作れる形にするのが望ましい。実例では、まだ手で揃えている(未着手)

5. 確認していないこと

  • なぜ thinking にJSONが入ったか。 公式API文書では、think パラメータの説明は生成APIの項にあるが、応答の thinking 欄の説明はチャットAPIの message の側にあり、生成APIの応答の項には見つからなかった。think を指定すると response に戻るかも、確認していない
  • スキーマを入れた後に、欠落や型の揺れが減ったか。 入れた理由は実機で見た欠落と揺れで、入れた後の再計測はしていない
  • 上の表の結果は、受け取る側のコードを動かしたもの。モデルが実際にそう返す頻度は測っていない

再現

標準ライブラリだけで、1節の6通りを再現できる。次のコードをそのまま貼って動かす。

import json

def pick_json(result):
    content = result.get("response") or result.get("thinking")
    return json.loads(content)

cases = {
    "responseにJSON": {"response": '{"bpm": 120}'},
    "responseが空、thinkingにJSON": {"response": "", "thinking": '{"bpm": 120}'},
    "responseが空、thinkingが文章": {"response": "", "thinking": "考え中の文章"},
    "responseに文章、thinkingにJSON": {"response": "できました", "thinking": '{"bpm": 120}'},
    "両方とも空": {"response": "", "thinking": ""},
    "responseの欄が無い": {"thinking": '{"bpm": 120}'},
}
for name, result in cases.items():
    try:
        print(name, "->", pick_json(result))
    except (TypeError, ValueError):
        print(name, "-> 読めない")

出力は、6行のうち 読めない が3行(文章・文章+JSON・両方とも空)になる。

関連

WIKI一覧へ