この記事で分かること
- 推論モデルの出力からJSONを取り出すとき、エラーにならないのに、間違った値が返ってくることがある
- その原因と、手元で10秒で再現できるコード
- 標準ライブラリだけで書ける対策(そのままコピーして使えます)
はじめに
LLMの出力からJSONを取り出すとき、json.loads がエラーを出してくれるなら、まだ話は簡単です。困るのは、エラーにならずに、間違ったJSONがすっと返ってくるときです。
推論モデル(考える過程を <think>...</think> のようなタグで出力するモデル)を使い始めてから、この落とし穴を踏みやすくなりました。この記事では、実際に動かして確かめた結果を見ながら、原因と対策を整理します。
私は専門のプログラマーではなく、AI(Claude)に手伝ってもらいながら、壊れたJSONを直すAPIを作っています。その改良の途中で、この問題が見つかりました。
何が起きるのか
推論モデルの中には、答えの前に「考えた内容」を出力するものがあります。その考えの中に、JSONの下書きが混ざることがあります。
<think>
ユーザーは {"name": "...", "age": ...} の形式を求めている。例: {"x": 1}
</think>
{"name": "Ann", "age": 30}
(モデルや配信の方法によっては、考えが別のフィールドで返り、本文には混ざらないこともあります。ここでは、本文に混ざって届く場合の話をします。)
まず、よくある取り出し方を試す
「最初の { から最後の } まで」を切り出す方法です。
text[text.find("{"): text.rfind("}") + 1]
結果は JSONDecodeError でした。考えの中の {...} と、答えの {...} が、ひとつながりで切り出されてしまうためです。この場合は、少なくともエラーで気づけます。
次に、修復ライブラリを試す
JSONの壊れを直してくれるライブラリ(json-repair 0.63.5)に渡すと、今度はエラーになりません。
from json_repair import repair_json
repair_json(text, return_objects=True)
[{'name': '...', 'age': '...'}, {'x': 1}, {'name': 'Ann', 'age': 30}]
3つのJSONが、リストになって全部返ってきます。 エラーが出ないので、気づかないまま先へ進んでしまいます。スキーマで検証していれば「オブジェクトのはずが、リストになっている」と気づけますが、検証していなければ素通りです。
さらに危ないのは、考えの途中で切れたとき
トークン上限などで、考えている途中で出力が止まることがあります。
<think>うーん、{"name": "Ann"} かな
同じライブラリに渡すと、こうなります。
{'name': 'Ann'}
まだ答えを出していない段階の下書きが、完成した答えとして返ってきます。 値がたまたま合っていることも多いので、気づくのが遅れます。
手元で試すなら、次の2行で再現できます。
pip install json-repair
python -c "from json_repair import repair_json; print(repair_json('<think>うーん、{\"name\": \"Ann\"} かな', return_objects=True))"
対策は「考えを先に捨てる」
方針は2つだけです。
- JSONを探す前に、
<think>ブロックを丸ごと捨てる(閉じていないものも含めて)。 - 残った文章から、最後に完全な形で読めたJSONを採用する。
標準ライブラリだけで書くと、次のようになります。
import json
import re
THINK_BLOCK = re.compile(r"<(think|thinking|reasoning)\b[^>]*>.*?</\1\s*>", re.S | re.I)
THINK_OPEN = re.compile(r"<(think|thinking|reasoning)\b[^>]*>.*\Z", re.S | re.I)
def extract_json(text: str):
"""推論モデルの出力から、最後に見つかった「完全なJSON」を返す。"""
text = THINK_BLOCK.sub("", text) # 閉じた思考ブロックを捨てる
text = THINK_OPEN.sub("", text) # 閉じ忘れ(途中で切れた思考)も捨てる
dec = json.JSONDecoder()
found, i = None, 0
while i < len(text):
if text[i] in "{[":
try:
found, end = dec.raw_decode(text, i)
i = end # 読めた範囲は飛ばす(入れ子の中身を拾わない)
continue
except json.JSONDecodeError:
pass
i += 1
return found
ポイントは raw_decode です。「ここからJSONとして読めるか」を試して、読めた長さまで教えてくれます。そのため、前置きや後書きの文章が混ざっていても、JSONの部分だけを取り出せます。
動作確認
| 入力 | 結果 |
|---|---|
| 思考に下書きのJSONがある | 答えのJSONだけを返す |
| 前置き「こちらです:」と後書きがある | JSONだけを返す |
| Markdownのコードブロック(json)で囲まれている | JSONだけを返す |
| 入れ子のJSON | 外側のJSON全体を返す |
| 途中で切れたJSON | None |
| 思考が閉じていない | None |
(実際に実行した結果です。)
大事なのは、最後の2行です。答えが出ていないときは、推測で埋めずに None を返します。 呼び出し側で「失敗」として扱えるので、静かな誤りを防げます。
この関数だけでは直せないもの
次のようなものは、この関数では直せません。
- 末尾カンマ
{"a": 1,} - シングルクォート
{'a': 1} - 途中で切れたJSON
{"a": 1
こうした構文の崩れは、先ほどの json-repair のようなライブラリの出番です。ただし、前に見たとおり、そのまま渡すと考えの中のJSONを拾ってしまうので、順番が大切です。
text = THINK_BLOCK.sub("", text) # まず思考を捨てる
text = THINK_OPEN.sub("", text)
data = repair_json(text, return_objects=True) # そのあと構文を直す
「考えを捨てる → 構文を直す → スキーマで検証する」の順にすると、安全です。
設計で気をつけたいこと
-
直せなかったことを、成功として返さない:
Noneや例外で返して、呼び出し側が判断できるようにします。 - 何を捨てたかを記録する:ログに「思考ブロックを1つ除去」と残しておくと、あとで原因を追いやすくなります。
- スキーマで検証する:「リストで返ってきた」「必須項目がない」といった問題を、最後の砦として見つけられます。
なお、私自身はこの分野の専門家ではありません。この記事のコードと実行結果は、AIと一緒に実際に動かして確かめたものです。モデルや配信の方法によって出力の形は変わるので、お使いの環境では結果が違うかもしれません。
おわりに
推論モデルは便利ですが、出力が「答えだけ」ではなくなる点には注意が必要でした。考えを先に捨てる、という小さな前処理だけで、静かな誤りの多くは防げます。
ほかの壊れ方や、もっと良い対策をご存じでしたら、コメントで教えていただけるとうれしいです。
この記事はAI(Claude)と一緒に書きました。コードはAIが実際に実行し、結果を確認しています。
この記事は、Zennに投稿した内容をQiita向けに掲載したものです。
元記事:https://zenn.dev/tonbifun/articles/ed67a9f4526c7e
補足:壊れたJSON・SQL・Mermaidを自動で直すAPI(FixMyシリーズ)も、RapidAPIで公開しています。思考ブロックの除去は、2026年10月のv0.2で取り込みました。