0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

途中で切れたJSONを「直す」と、切れたことに気づけなくなる ― 853通りの切れ方で測った

0
Posted at

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?