LLMのAPIは、出力が上限(max_tokens)に届いて途中で止まっても、HTTPのステータスは200で返ってきます。エラーではないので、JSONが途中で切れていても、呼び出した側はそのまま先へ進んでしまいがちです。
そこで json-repair のような修復ライブラリを挟んでいる人も多いと思います。閉じ忘れた括弧を補って、読めるJSONにしてくれる便利な道具です。
ただ、途中で切れたJSONをこれで「直す」と、切れたこと自体が見えなくなります。考えてみれば当たり前の話なのですが、実際どのくらいの割合で起きるのかは知りませんでした。そこで、切れ方を全部試して数えてみました。
先に結果を書いておくと、こうなりました。
- 修復ライブラリに通すと、ほぼ全部(98.5%)が「それらしいJSON」になった
- 項目をすべて必須にしたスキーマで検証しても、853通り中137通り(16.1%)はすり抜けた
- JSONの最後に「書き終えた」目印を1つ置くと、すり抜けは0になった
私は専門のプログラマーではなく、AI(Claude)に手伝ってもらいながら、壊れたJSONを直すAPIを作っています。今回の実験も、AIと一緒にコードを書いて動かしたものです。
どうやって試したか
LLMが返しそうな、正しいJSONを5つ用意しました。
| サンプル | 中身 | 文字数 |
|---|---|---|
| 注文の抽出 | 注文番号・顧客名・商品3件・合計・備考 | 233 |
| ツール呼び出しの引数 | ファイルパス・置換前・置換後・全置換フラグ | 126 |
| メールの分類 | ラベル・確信度・理由3件 | 106 |
| 文章の要約 | タイトル・要約文・タグ3件 | 135 |
| 予定の抽出 | 日付・時刻・件名を4件 | 258 |
これを1文字目から、最後の1文字の手前まで、1文字ずつずらして切ります。5つ合わせて853通りの「切れたJSON」ができます。
それぞれを、次の3つに通しました。
- A:そのまま
json.loads - B:
json-repair(0.63.5)で修復 - C:B で直したものを、JSON Schema で検証(項目はすべて必須。型・範囲・形式も指定)
ここで「静かに壊れた」と呼んでいるのは、エラーにならずに値が返ってきたのに、中身が元のJSONと違うものです。
結果
| サンプル | 切れ方 | A 読めた | B 修復で静かに壊れた | C 検証もすり抜けた |
|---|---|---|---|---|
| 注文の抽出 | 232 | 0 | 230(99.1%) | 11(4.7%) |
| ツール呼び出しの引数 | 125 | 0 | 124(99.2%) | 0(0.0%) |
| メールの分類 | 105 | 0 | 102(97.1%) | 51(48.6%) |
| 文章の要約 | 134 | 0 | 131(97.8%) | 17(12.7%) |
| 予定の抽出 | 257 | 0 | 253(98.4%) | 58(22.6%) |
| 合計 | 853 | 0 | 840(98.5%) | 137(16.1%) |
json.loads は、853通りすべてでエラーになりました。これは悪いことではありません。エラーが出れば、少なくとも「何かおかしい」と気づけます。
修復ライブラリのほうは、98.5%で何かしらのJSONを返しました。ライブラリが悪いわけではなく、括弧を閉じて読める形にするのが、もともとの仕事です。ただ、その仕事ぶりのおかげで、切れた跡がきれいに消えてしまいます。
スキーマ検証まで入れると、だいぶ止まるようになります。それでも137通りは通ってしまいました。
すり抜けたものを調べると、壊れ方は2種類しかありませんでした(1つの切れ方に両方が入っていることもあります)。
| 壊れ方 | 件数 | 例 |
|---|---|---|
| 配列の要素が減った | 107 | 理由が3件あったのに ["差"] の1件だけ |
| 文字列が途中で切れた | 91 | 「差出人のドメインが…」が「差」だけ |
配列の途中で切れると、修復ライブラリはそこで配列を閉じます。すると、後ろの要素は最初から無かったことになります。必須項目のチェックは「項目があるか」しか見ないので、これには気づけません。
ツール呼び出しの引数だけ0件だったのは、配列がなく、最後の項目が真偽値だったからです。真偽値は途中で切れると "fa" のような文字列になるので、型の検証で止まりました。
数値が最後にあると、金額がずれる
今回の5つのサンプルでは、数値が途中で切れても、その後ろにある必須項目が欠けるので、検証で止まっていました。気になったので、数値が最後の項目になっている場合も試しました。
'{"product": "メカニカルキーボード", "price": 1' → price: 1 検証を通過
'{"product": "メカニカルキーボード", "price": 128' → price: 128 検証を通過
'{"product": "メカニカルキーボード", "price": 1280' → price: 1280 検証を通過
本当の値は12800円です。1280円は10分の1の金額ですが、型としては正しい整数なので、検証は何も言いません。
手元で試せるコード
メールの分類だけを使った、短い再現コードです。
import json
from json_repair import repair_json # pip install json-repair jsonschema
from jsonschema import Draft202012Validator
answer = {
"label": "spam",
"confidence": 0.97,
"reasons": [
"差出人のドメインが本文の会社名と一致しない",
"至急の振込を求めている",
"短縮URLが含まれている",
],
}
schema = {
"type": "object",
"required": ["label", "confidence", "reasons"], # 項目はすべて必須にしておく
"properties": {
"label": {"type": "string", "enum": ["spam", "ham"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"reasons": {"type": "array", "items": {"type": "string"}, "minItems": 1},
},
}
text = json.dumps(answer, ensure_ascii=False)
validator = Draft202012Validator(schema)
slipped = 0
for k in range(1, len(text)): # 1文字目〜最後の1文字手前まで、全部の位置で切る
got = repair_json(text[:k], return_objects=True)
if got != answer and not list(validator.iter_errors(got)):
slipped += 1 # 直した結果がスキーマを通ったのに、中身は元と違う
if slipped <= 3:
print(json.dumps(got, ensure_ascii=False))
print(f"{slipped} / {len(text) - 1} 通りの切れ方で、壊れたデータがスキーマ検証を通過")
動かすと、こう出ます。
{"label": "spam", "confidence": 0.97, "reasons": ["差"]}
{"label": "spam", "confidence": 0.97, "reasons": ["差出"]}
{"label": "spam", "confidence": 0.97, "reasons": ["差出人"]}
51 / 105 通りの切れ方で、壊れたデータがスキーマ検証を通過
対策1:止まった理由を見る
いちばん確実なのは、APIが返してくる「出力が止まった理由」を確認することです。名前はサービスごとに違います。
| サービス | 見る場所 | 途中で切れたときの値 |
|---|---|---|
| Anthropic(Claude) | stop_reason |
max_tokens |
| OpenAI(Chat Completions) | finish_reason |
length |
| OpenAI(Responses API) |
status と incomplete_details.reason
|
incomplete / max_output_tokens
|
| Google(Gemini) | finishReason |
MAX_TOKENS |
切れていたら、修復には回さずに、上限を上げて取り直すのが基本です。
とはいえ、フレームワークを挟むとこの値が見えにくくなることがあります。Geminiでは、上限に達したのに finishReason が付いてこない不具合の報告もありました(Google AI Developers Forum)。なので、JSONの側にも保険をかけておきたいところです。
対策2:最後に「書き終えた」目印を置く
途中で切れるときは、必ず後ろから失われます。これを逆手に取ります。
JSONの最後に "_complete": true という項目を置いて、スキーマで「true 以外は不合格」にしておきます。最後まで書き終えていなければ、この項目は存在しないか、"tr" のような中途半端な値になるので、検証で止まります。
answer = {
"label": "spam",
"confidence": 0.97,
"reasons": ["差出人のドメインが本文の会社名と一致しない", "至急の振込を求めている", "短縮URLが含まれている"],
"_complete": True, # ← 最後に「書き終えた」目印を置く
}
schema = {
"type": "object",
"required": ["label", "confidence", "reasons", "_complete"],
"properties": {
"label": {"type": "string", "enum": ["spam", "ham"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"reasons": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"_complete": {"const": True}, # true 以外は不合格
},
}
5つのサンプルすべてにこの目印を足して、同じように切ってみました。943通りのうち、検証をすり抜けたものは1つもありませんでした。
1つだけ前提があります。モデルが、スキーマの順番どおりに項目を書くことです。構造化出力(Structured Outputs)を使うと順番どおりになることが多いですが、念のため、お使いの環境で一度確かめてみてください。プロンプトにも「_complete は必ず最後に書く」と書いておくと、より確実です。
修復の仕組みを自分で作る場合
自作のFixMyJSONにも、同じ853通りを通してみました。
853通りすべてで、「JSONが閉じていない(途中で切れた可能性)」という警告文は出していました。ところが、そのうち141通り(16.5%)では、結果として success: true を返していました。
警告が文章の形だと、呼び出す側のプログラムは、その文字列を読まないと判定できません。修復の仕組みを自分で作るなら、truncated: true のように、途中で切れた可能性を専用の項目で返しておくと、呼び出す側が「このまま使うか、取り直すか」を決めやすくなります。
おわりに
修復ライブラリは便利ですが、途中で切れたJSONに対しては、切れた跡を消してしまいます。スキーマで必須項目を決めておけば多くは止まりますが、配列の要素が減ったり、文字列が途中で切れたりしたものは残ります。今回の実験では、それが16.1%でした。
止まった理由を確認すること。それに加えて、最後に目印を置いておくこと。この2つを組み合わせるのが、今のところいちばん安心できるやり方だと思います。
なお、今回の割合は「どの位置でも同じ確率で切れる」と仮定して数えたものです。実際の出力は長さも切れる場所もまちまちなので、現場の数字とは違うはずです。サンプルも5つだけなので、「このくらいは起こりうる」という目安として見てください。
ほかの壊れ方や、もっと良い対策をご存じでしたら、コメントで教えていただけるとうれしいです。
この記事はAI(Claude)と一緒に書きました。コードはAIが実際に実行し、結果を確認しています。
この記事は Zenn にも投稿しています。
壊れたJSON・SQL・Mermaidを自動で直すAPI(FixMy シリーズ)を、RapidAPIで公開しています。よければ覗いてみてください。→ FixMyJSON