Claude Media
Claudeのtool_useでJSONエスケープが変わりバグる理由と対処

Claudeのtool_useでJSONエスケープが変わりバグる理由と対処

Opus 4.6以降、tool_use inputのJSON表現がUnicodeとスラッシュのエスケープでモデルバージョンにより変わります。生文字列比較が壊れる原因と正しい直し方を解説します。

tool_useブロックのinputをシリアライズした文字列を、テストのフィクスチャやキャッシュキーとして生の文字列比較に使っている実装があります。この実装は、Opus 4.6以降のモデルに切り替えた途端に一致しなくなることがあります。原因は、意味的に同じ値でもJSONとしての文字表現がモデルバージョンによって変わりうる点にあります。どちらもJSON仕様上は正当な表現です。

ツールのinputとして渡ってくる値の意味は正しいまま、壊れるのはアプリ側の比較ロジックだけです。モデルを切り替えたタイミングでいきなり症状が出るため、原因の切り分けに時間がかかりがちです。

症状と原因

公式のトラブルシューティングページには「JSON escaping differences (Opus 4.6+)」という項目があり、表の3列で現象・原因・直し方が示されています。

列内容
Symptom内容新しいモデルでツール入力の文字列比較が失敗する
Cause内容Unicodeとスラッシュ(/)のエスケープがモデルバージョン間で異なる
Fix内容json.loads()やJSON.parse()でパースする。シリアライズ済みの入力に生の文字列照合をしない

対象はOpus 4.6以降と見出しに明記されています。直し方は1つで、比較の前にパースを挟むことです。

同じ値が2通りの文字列になる

JSON仕様(RFC 8259)は、同じ値を複数の文字表現で書くことを許容しています。代表的なのが次の2箇所です。

  • Unicode文字: ASCII範囲外の文字は、UTF-8の文字のままでも\uXXXX形式でも有効なJSONです。パースすれば同じ値になります
  • スラッシュ: /はエスケープしてもしなくてもよい文字で、/でも\/でも、パースすれば同じ文字列になります
くらべる

同じ値を表す2つのJSON

文字をそのまま

エスケープなし

{"path":"docs/setup","msg":"東京"}

記号で書く

エスケープあり

{"path":"docs\/setup","msg":"東京"}

手元のPython 3.12.2で、この2つの文字列を比べた結果は次のとおりです。

import json
a = '{"path":"docs\\/setup","msg":"\\u6771\\u4eac"}'
b = '{"path":"docs/setup","msg":"東京"}'
print(a == b)                          # False
print(json.loads(a) == json.loads(b))  # True

文字列としては別物で、パースすれば等しくなります。ハッシュ化すると差はもっと分かりやすく、SHA-256の先頭12桁はb8ea28334cafと76061ff95fbdで一致しません。キャッシュキーに使えば、同じ入力でも別のキーになります。

この表現の選び方は、書き出す側の実装に左右されます。同じPython 3.12.2のjson.dumpsは既定でASCII範囲外を\u6771\u4eacのように書き出し、ensure_ascii=Falseを付けると東京のままです。Node.js v20.18.0のJSON.stringifyはスラッシュも日本語もそのまま出力します。

書き出す側による違いを、手元の環境で並べると次のとおりです。

書き出し方/日本語絵文字(😀)
Python 3.12.2 json.dumps(既定)/そのまま日本語\uXXXX絵文字(😀)\ud83d\ude00
Python 3.12.2 ensure_ascii=False/そのまま日本語そのまま絵文字(😀)そのまま
Node.js v20.18.0 JSON.stringify/そのまま日本語そのまま絵文字(😀)そのまま
Ruby 2.6.10 JSON.generate/そのまま日本語そのまま絵文字(😀)そのまま

BMP外の文字は、\ud83d\ude00のように2つの\uを組にしたサロゲートペアで表されます。JSON.parseに通すと、Node.jsでは元の絵文字と等しい文字列に戻ります。コマンドラインのjq -c .も、{"p":"a\/b"}を{"p":"a/b"}に整えて出力します。

つまり「どの表現が正しいか」ではなく、「書き出す側が変われば表現も変わる」が前提です。トラブルシューティングページは、モデルバージョン間でエスケープが異なると説明しています。どのバージョンがどちらの表現を選ぶかは、そのページに載っていません。JSON全体を固定フォーマットで受け取りたい場合はClaude JSONモードの使い方の設計が役立ちますが、表現のゆらぎ自体はモード設定では消えません。

どこで壊れるか

tool_use.inputのシリアライズ済み文字列をそのまま比較・保存に使う実装は、次の場面で壊れます。

  • スナップショットテスト: 過去のモデルで記録したフィクスチャ文字列を、新しいモデルの出力とassert equalで突き合わせる
  • キャッシュキー: 生の入力文字列をハッシュ化してキーにする。モデルを切り替えた瞬間にキャッシュミスが多発する
  • 重複排除・冪等性チェック: 同じツール呼び出しを弾くために、入力文字列をキーにした集合や辞書を使う
  • 差分ログ・監査ログ: ターンごとのtool_use.inputを文字列diffで比べ、「変更あり」を検知する

共通するのは、JSON文字列をパースせずにキーやフィクスチャとして扱っている点です。書いた時点のモデルの出力をハードコードしたテストは、モデルを更新するまで何か月も通り続けます。更新の日に初めて落ちるので、直前のコード変更を疑って遠回りしがちです。

テストで起きる失敗の実物

フィクスチャの文字列と、エスケープ付きの新しい出力をunittestで比べると、失敗は次のように見えます。Python 3.12.2で実行した結果です。

FAIL: test_raw (__main__.T.test_raw)
AssertionError: '{"path":"docs\\/setup","msg":"\\u6771\\u4eac"}' != '{"path":"docs/setup","msg":"東京"}'
- {"path":"docs\/setup","msg":"\u6771\u4eac"}
+ {"path":"docs/setup","msg":"東京"}
 
Ran 2 tests in 0.000s
FAILED (failures=1)

同じファイルに置いたjson.loads同士を比べるテストは、通っています。差分の表示は\/と\u6771\u4eacの有無だけを指していて、値の違いは出ていません。失敗メッセージにこの形が見えたら、まず表現差を疑う手がかりになります。

この原因かどうかを切り分ける

テストやキャッシュが突然壊れたら、次の順で見ます。

手順

エスケープ差かどうかの切り分け

  1. 1

    失敗した2つの文字列を取り出す

    フィクスチャ側と、新しいモデルが返したinput側を、そのまま保存します。

  2. 2

    それぞれパースする

    json.loads()かJSON.parse()を通します。ここで例外が出るなら、文字列が壊れています。

  3. 3

    パース後を比べる

    一致すればエスケープの表現差です。一致しなければ、別の原因を探します。

パース後も値が違う場合は、モデルが別の引数を選んだなど、エスケープとは無関係な変化です。先にこの切り分けを済ませると、直し方を誤って別の問題を追わずに済みます。

パースしてから比較する

直し方は、比較の前に必ずパースを挟むことです。Pythonの例です。

import json
 
def inputs_are_equivalent(raw_a: str, raw_b: str) -> bool:
    # 生文字列同士の比較は避け、パースしたオブジェクトを比較する
    return json.loads(raw_a) == json.loads(raw_b)

TypeScriptでも考え方は同じです。

function inputsAreEquivalent(rawA: string, rawB: string): boolean {
  // JSON.parse() を経由してから比較する。生文字列の === は使わない
  return JSON.stringify(JSON.parse(rawA)) === JSON.stringify(JSON.parse(rawB));
}

このJSON.stringifyは、パース済みの値を自分たちのシリアライザで文字列に戻すためのものです。Claudeが書き出した文字列を直接比べていないので、エスケープの選び方には影響されません。ただしキーの順番は元のオブジェクトの並びを引き継ぎます。順番違いも同一視したいなら、キーを並べ替えて書き出す処理が別に要ります。

キャッシュキーを作るときも同じ形で、パースしたあとにキーを昇順に並べ、自分たちのシリアライザで書き出してからハッシュ化します。次のコードで、エスケープもキーの順番も違う2つの入力を試しました。

import hashlib, json
 
def cache_key(raw: str) -> str:
    canonical = json.dumps(json.loads(raw), sort_keys=True,
                           ensure_ascii=False, separators=(",", ":"))
    return hashlib.sha256(canonical.encode()).hexdigest()[:12]
 
a = '{"path":"docs\\/setup","msg":"\\u6771\\u4eac"}'
b = '{"msg":"東京","path":"docs/setup"}'
print(cache_key(a), cache_key(b))  # dfb56ffd9734 dfb56ffd9734

出力は2つともdfb56ffd9734で、同じキーになります。生の文字列をそのままハッシュ化すると、aはb8ea28334cafで別のキーになります。JSON Schemaで構造そのものを固定する設計はAgent SDKの構造化出力入門にあります。

フィクスチャの持ち方を変える

テストのフィクスチャは、期待値を文字列で持つのをやめ、パース済みの値で持つ形にします。.jsonファイルに期待値を置くなら、読み込み時にパースされるので、エスケープの書き方はテストの結果に影響しません。

expected = {"path": "docs/setup", "msg": "東京"}
assert json.loads(block_input) == expected

モデルのバージョンを上げる前に、記録済みの出力を新しい出力と比べる回帰テストも同じ形にしておきます。差が出たら、文字の違いではなく値の違いとして報告されるので、本当に挙動が変わった箇所だけが見えます。

保存済みのログをdiffにかけたいときは、コマンドラインのjq -S -c .で整えてから比べる方法があります。-Sでキーを並べ替え、-cで1行にします。ここまでの例の2つの入力は、どちらも{"msg":"東京","path":"docs/setup"}と出力され、差が消えます。ログ上の差分検知は、整形したあとの文字列同士で行えば、エスケープの違いによる誤検知を避けられます。

ストリーミングで蓄積するときの注意

tool_useのinputは、ストリーミングではinput_json_deltaイベントのpartial_jsonという断片で届きます。content_block_startの時点のinputは空のオブジェクト{}で、実際の値は断片の連結から作ります。公式が示す手順は3段階です。

手順

断片から input を組み立てる

  1. 1

    content_block_start

    type: "tool_use"のブロックが始まったら、空文字列を用意します。

  2. 2

    content_block_delta

    type: "input_json_delta"のたびに、partial_jsonを末尾へ追記します。

  3. 3

    content_block_stop

    ブロックが閉じたら、蓄積した文字列を一度だけパースします。

この蓄積の手順は、eager_input_streamingの有無にかかわらず同じです。違いは検証にあります。

ツールごとにeager_input_streamingをtrueにした細粒度ストリーミングでは、サーバーが入力をバッファも検証もせずに流します。そのため、不完全なJSONや不正なJSONが届くことがあります。省略した標準のストリーミングでは、サーバーが各パラメーターの値をバッファして検証してから返します。

どちらでも、max_tokensで止まった応答はパラメーターの途中で切れることがあります。途中の断片を比較やログの対象にすると、不完全なJSON同士を突き合わせてしまいます。パースはcontent_block_stopのあとに限り、失敗に備えて例外を捕捉します。

try:
    parsed = json.loads(raw_input)
except json.JSONDecodeError:
    # 蓄積した文字列が有効なJSONとは限らない
    handle_invalid_input(raw_input)
else:
    handle_tool_call(parsed)

パースできなかった入力は、ツールを実行せずにClaudeへ失敗として返します。tool_resultのcontentはJSONである必要はありません。ただし、生の文字列を1つのキーで包んでおくと、不正なJSONを受け取ったことが明確に伝わり、元の入力も残ります。

{
  "type": "tool_result",
  "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
  "is_error": true,
  "content": "{\"INVALID_JSON\": \"<the unparseable input you received>\"}"
}

この包みは、文字列の連結で組み立てずにJSONライブラリで作ります。不正な入力に含まれる引用符や特殊文字が、正しくエスケープされるためです。外から来た文字列をtool_resultにそのまま入れてよいかは別の論点で、tool_resultへのプロンプトインジェクション対策で扱っています。

Opus 4.6以降に限らない対策

トラブルシューティングページが対象にしているのは「Opus 4.6以降」ですが、パースしてから比較する書き方は、モデルやバージョンを選びません。JSONのエスケープは仕様上どの表現も許されているからです。生文字列の比較・キャッシュ・差分取得をやめておけば、今後どのモデルで表現が変わっても影響を受けません。

まとめ

tool_use.inputは、パースした値として扱うのが前提です。テストのフィクスチャ、キャッシュキー、差分ログのどれも、生文字列のままでは次のモデル更新で壊れます。

この記事を共有:XはてブLinkedIn