Claudeのtool_useでJSONエスケープが変わりバグる理由と対処
Opus 4.6以降、tool_use inputのJSON表現がUnicodeとスラッシュのエスケープでモデルバージョンにより変わります。生文字列比較が壊れる原因と正しい直し方を解説します。
tool_useブロックのinputをシリアライズした文字列を、テストのフィクスチャやキャッシュキーとして生の文字列比較に使っている実装があります。この実装は、Opus 4.6以降のモデルに切り替えた途端に一致しなくなることがあります。原因はモデル本体のバグではありません。意味的に同じ値でも、JSONとしての文字表現がモデルバージョンによって変わりうる、JSON仕様上正当な差異です。
この種の不具合は、モデルの出力精度そのものには問題がなく、ツールのinputとして渡ってくる値の意味も正しいまま起こります。壊れているのはアプリ側の比較ロジックであり、モデルアップグレードのタイミングでいきなり症状が出るため、原因の切り分けに時間がかかりがちです。
症状と原因
公式のトラブルシューティングページは、この現象に対する直し方を一文で示しています。
Fix: Parse with
json.loads()orJSON.parse(). Never do raw string matching on serialized input.
壊れるのは、シリアライズ済みのinputをそのまま突き合わせている箇所です。Unicodeとスラッシュのエスケープの選び方がモデルバージョンで変わるため、値は同じでも文字列としては一致しなくなります。公式が示す直し方は1つで、パースを必ず挟むことです。対象はOpus 4.6以降と明記されています。
JSONエスケープの基本 — Unicodeとスラッシュは表現に幅がある
JSON仕様(RFC 8259)は、同じ値を複数の文字表現で書くことを許容しています。代表的なのが次の2箇所です。
- Unicode文字: ASCII範囲外の文字は、UTF-8の文字のままでも
\uXXXX形式のエスケープシーケンスでも、どちらも有効なJSONです。パースすれば同じ値になります - スラッシュ(
/):/はエスケープしてもしなくてもよい文字で、/のままでも\/と書いても、パースすれば同じ文字列になります
JSON全体を固定フォーマットで受け取りたい場合の話はClaude JSONモードの使い方で扱っていますが、今回の問題はモード設定では解決しません。同じ値が複数の文字表現を持ちうること自体は仕様どおりだからです。次の2つのJSONは、キーも値も文字としては異なりますが、パースすれば同一のオブジェクトになります。
{"path": "docs/setup"}{"path": "docs\/setup"}JSON.parse()やjson.loads()を通せばどちらも{"path": "docs/setup"}という同じオブジェクトになります。ですがシリアライズされた文字列同士を===や==で比較すると一致しません。Claudeがどちらの表現を選ぶかはモデルのバージョンに依存する、というのが今回のトラブルシューティング項目の趣旨です。どちらのバージョンがどちらの表現を選ぶかという具体的な方向性は、公式ページに明記されていません。確実なのは「バージョンをまたぐと表現が変わりうる」という事実そのものです。
なぜJSON仕様がこうしたゆらぎを許しているかというと、JSONの文字列型はもともと2つの目的を両立させる設計になっているためです。ひとつは「人間が読み書きしやすい表現」、もうひとつは「あらゆる文字を安全に運べる表現」です。日本語のようなASCII範囲外の文字も、そのままUTF-8で埋め込めば人間には読みやすくなります。\uXXXXのエスケープに変換すれば、非ASCII文字を扱えない環境でも安全に運べます。スラッシュのエスケープも同様です。HTMLの</script>のような文字列をJSON内に埋め込む際の安全策として仕様上は許容されていますが、必須ではありません。生成する側(この場合はClaudeのtool_use出力を組み立てるレイヤー)がどちらの書き方を選ぶかは、仕様の範囲内であれば自由です。モデルのバージョンが変われば、この実装の詳細が変わって当然という位置づけになります。
どこで壊れるか — テスト・キャッシュキー・差分ログ
tool_use.inputをシリアライズした文字列をそのまま比較・保存に使う実装は、次のような場面で壊れやすくなります。
- スナップショットテスト: 過去のモデルで記録したフィクスチャ文字列と、新しいモデルの出力を
assert equalで突き合わせるテスト - キャッシュキー: 生の入力文字列をハッシュ化してキャッシュキーにする実装。意味的に同じ入力でもモデルを切り替えた瞬間にキャッシュミスが多発する
- 重複排除・冪等性チェック: 同じツール呼び出しを弾くために入力文字列をキーにしたセット・辞書
- 差分ログ・監査ログ: 会話ターンごとの
tool_use.inputを文字列diffで比較し、「変更あり」を検知する仕組み
いずれも共通するのは「JSON文字列を、パースせずにそのままキーやフィクスチャとして扱っている」点です。単体テストのフィクスチャは、書いた時点のモデルバージョンでの出力をそのまま文字列としてハードコードしがちです。モデルを更新するまでは何年も問題なく通り続けます。エスケープの選び方が変わるモデル更新のタイミングで、初めて失敗し始めます。テストが落ちたときにモデル更新が原因だと気づきにくく、直近のアプリケーションコードの変更を疑って時間を浪費するケースもあります。
この原因かどうかを切り分ける方法
テストやキャッシュが突然壊れたとき、この記事で扱っているエスケープ差が原因かどうかは切り分けられます。失敗しているテストのフィクスチャ文字列と、実際にモデルから返ってきた文字列を、それぞれ一度パースしてから再比較してみるのが確実です。パース後のオブジェクトが完全に一致するなら、原因はエスケープの表現差である可能性が高いといえます。対処は前述のとおり、パース経由の比較に切り替えることです。パース後も値が一致しないなら、エスケープとは無関係な別の原因(モデルの出力内容そのものの変化など)を疑う必要があります。この切り分けを先に済ませておくと、直し方を誤って別の問題を追いかけてしまう遠回りを避けられます。
正しい実装 — パースしてから比較する
修正の方向は公式の一文どおりで、比較の前に必ずパースを挟みます。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));
}TypeScript側の例ではJSON.stringifyを使っていますが、これは「パース済みオブジェクトを、自分たちが管理するシリアライザで再度文字列化する」ためのものです。Claudeが生成した文字列同士を直接比較しているわけではないので、エスケープの選び方に依存しません。キャッシュキーを作る場合も同様に、パース後のオブジェクトをキーの昇順で再シリアライズしてから使うと、モデルのバージョンによるエスケープの違いに影響されない安定したキーになります。JSON Schemaで構造そのものを固定してから同じようにパース経由で扱う設計はAgent SDKの構造化出力入門で解説しているので、あわせて参照してください。
ストリーミングで蓄積する場合も同じ落とし穴がある
Fine-grained tool streamingでは、tool_useのinputはinput_json_deltaイベントのpartial_jsonフラグメントとして断片的に届きます。公式ドキュメントが示す蓄積の手順は、次の3段階です。
content_block_start(type: "tool_use")で空文字列を初期化するcontent_block_delta(type: "input_json_delta")のたびにpartial_jsonを追記するcontent_block_stopで、蓄積した文字列をパースする
重要なのは、サーバーはフラグメントを検証せずにストリームするため、蓄積した文字列は完全なJSONになっているとは限らないという注記です。途中経過のフラグメントを比較・ログ出力の対象にすると、そもそも不完全なJSON断片同士を比べることになり、パースの有無以前の問題が起きます。パースは必ずcontent_block_stopで蓄積が完了してから行い、パースに失敗した場合は例外を捕捉して不正なJSONとして扱います。
try:
parsed = json.loads(raw_input)
except json.JSONDecodeError:
# 蓄積した文字列が有効なJSONとは限らない
handle_invalid_input(raw_input)
else:
handle_tool_call(parsed)不正なJSONをツールに渡さずエラーとしてtool_resultに返す場合は、生の不正入力を独自のJSON文字列で包んでから返します。そうするとClaude側にも「不正なJSONを受け取った」ことが明確に伝わります。このラッパー自体を文字列連結で組み立てるのは避けます。不正入力に含まれる引用符や特殊文字が正しくエスケープされない可能性があるため、ラッパーの生成にも自分たちのJSONライブラリを使うべきだと公式は注記しています。tool_resultに何をどう返すかという設計は、外部から渡ってきた文字列をそのまま含めてよいかという別の論点も伴います。信頼できない文字列をtool_resultに混ぜる際のリスクはtool_resultへのプロンプトインジェクション対策で扱っています。
Opus 4.6以降に限らず、この対策自体は普遍的に有効
公式のトラブルシューティングページは対象を「Opus 4.6以降」と明記していますが、それより前のモデルや他のモデルファミリーで将来同様の差異が生じないと確定しているわけではありません。一方で、json.loads() / JSON.parse()を経由してから比較するという対策自体は、モデルやバージョンを問わず有効な書き方です。JSONのエスケープは仕様上どちらの表現も許容しているためです。ツール入力の生文字列を直接比較・キャッシュ・diffする実装をやめておけば、今後どのモデルでエスケープの選び方が変わっても影響を受けません。
まとめ
Opus 4.6以降、tool_use.inputのJSON表現はUnicodeとスラッシュのエスケープの選び方がモデルバージョンによって変わることがあります。意味的には同じ値でも文字列としては一致しなくなるため、テストのフィクスチャ・キャッシュキー・差分ログのように生文字列比較に依存した実装は壊れます。直し方は一貫していて、json.loads() / JSON.parse()でパースしてから値として比較することです。ストリーミングで断片を蓄積する場合も、パースは必ず蓄積が完了してから行い、途中経過の文字列同士を比較しないようにしてください。