LLMのJSONの受け方
LLMにJSONを返させるとき、受け口は3つ要る。欄を選ぶ、JSONとして読む、中身を検証する。
format に渡す形式の指定は送る側の指示で、この3つの代わりにならない。
実機で踏んだ経緯は 返事が空だった ローカルLLMのJSONは考え中の欄にあった に書いた。ここには、再利用できる形だけを残す。
1. 受け口の判定
Ollamaの生成API(/api/generate)の応答には、通常の回答欄 response のほかに、考え中の欄 thinking が付くことがある。ある実機のQwen系モデルでは、response が空で、完成したJSONが thinking に入っていた。
最小の実装は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}
- スキーマは送る側の指定。返ってきたものが守っているかは、受け側で確かめるまで分からない
- スキーマを足すと、同じ上限や必須項目を書く場所が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・両方とも空)になる。
関連
- 返事が空だった ローカルLLMのJSONは考え中の欄にあった — 経緯。15:32から16:11までの3回の修正と、再現の条件
- 歌入りAI曲の再現レシピ — このJSONの受け取り先、音楽生成モデルACE-Stepに渡す条件
- 制約がツールを封じる — 出力形式の指定が、エージェントの動作に及ぼす別の副作用
- 作っているもの — 開発日記の元になっているプロダクト