AI が生成した JSON が JSON.parse() で失敗する理由:原因と直し方

2026年9月17日時点:チャット返信をそのまま JSON.parse() すると、失敗の主因は「モデルが JSON を書けない」ことではなく、フェンス、前後の説明、末尾カンマ、打ち切り、JS 方言である。原因分類と修復順。

結論から:JSON.parse() の失敗は、たいてい「モデルが JSON を書けない」ことではない。JSON 値を一つだけ受け取るパーサに、チャット返信の全体を渡していることである。JSON.parse が受け取るのは JSON 文法(ECMA-262 / RFC 8259)の値、ちょうど一つ。Markdown フェンス、前後の説明、末尾カンマ、生の改行、打ち切り、JS / Python 方言は、いずれも即座に SyntaxError を投げる。2026 年の直し順はこうだ:Structured Output を使える、または Tool Calling の arguments を読めるなら、チャット本文をパースするな。パースせざるを得ないなら、先に抽出し、それから parse し、成功したら JSON Schema で検証する——最初から正規表現で「なんとかパースできるまで直す」な。

本稿は 2026年9月17日時点。本サイトにはすでに Structured Output とは何か、Prompt から Structured Output へ、OpenAI vs Gemini Structured Output、Gemini API で構造化 JSON を生成する、Tool Calling と JSON Schema がある。本稿が答えるのは次だけ:モデル出力がなぜ JSON.parse を通らないか、どの順で直すか。

JSON.parse が実際に受け取るもの

ブラウザと Node の JSON.parse が実装するのはJSON テキストであり、「それっぽい JavaScript オブジェクトリテラル」ではない。空白(スペース、タブ、改行、復帰)は値の両側に置ける。それ以外、入力はちょうど一つの値でなければならない:オブジェクト、配列、文字列、数値、true / false / null。値の後ろに非空白が来れば失敗する——Chrome はよく Unexpected non-whitespace character after JSON と書く。

これらは JS では動き、JSON では死ぬ。モデルは訓練データから日常的に写してくる:

書き方JS オブジェクト / JSON5JSON.parse
末尾カンマ{"ok": true,} 通る例外
単引用符{'ok': true} 通る例外
コメント// note 通る例外
裸のキー{ok: true} 通る例外
undefined / NaN / Infinity言語にある例外
文字列内の生改行テンプレート文字列は許す例外;\n にせよ

切り分けは一文:渡したのは「JSON 値一つ」か、「読みやすく包んだ段落」か。前者はパーサの仕事。後者は先に抽出せよ。

失敗原因の分類表

正確な文言と格闘する前に SyntaxError を分類せよ。Chrome、Safari、Node は同じバグを別の言い方で書く。クラスは少ない:

分類モデルがよく出すもの典型結果先にすること
包装```json フェンス、「JSON はこちら」先頭が { / [ ではないフェンスを剥ぎ、バランスした値を切り出す
方言末尾カンマ、単引用符、コメント、裸のキーUnexpected tokenStructured Output に切り替え;JS としてパースするな
壊れた文字列未エスケープの "、生改行、全角カンマ文字列が早く終わる、またはキーの後ろに : がない列番号を見る;フィールド長に上限を付ける
打ち切り閉じないオブジェクトや配列Unexpected end of JSON input出力上限を上げる;ストリーム終了を待つ
複数値JSON が二つ、または最初の値の後ろに散文最初の値の後ろに文字が残る最初の完全な値だけ切り出す
エンコードBOM、ゼロ幅文字、二重 stringify奇妙なトークン、または parse が文字列を返すBOM を除く;再パース前に typeof を見る

Agent ではもう一条:Tool Calling の arguments は、すでにオブジェクトであるか、ベンダーが制約した JSON 文字列であることが多い。assistant メッセージ全体を JSON.parse に通すな。別チャネルである——Tool Calling が JSON Schema に依存する理由 を見よ。

フェンスと前後の説明

チャットモデルはコードをフェンスに入れるよう訓練されている。「JSON だけ」と書いても、返信はしばしばこうなる:

```json
{"ok": true, "id": "A-1024"}
```
Here is the result. I can explain the fields if you want.

先頭はバッククォートであり、{ ではない。JSON.parse は 0 列目で失敗する。先頭の「了解、JSON はこちら:」も、末尾の免責も同じバグ。より悪いのは値が二つ——サンプル、それから本物。塊全体をパースすれば、最初の } の直後で落ちる。

抽出は一条:最初のバランスした {} または [] を見つけ(文字列内の括弧は飛ばす)、そのスライスだけを JSON.parse に渡せ。フェンスは先に剥がせ。最初の { から最後の } まで欲張って切るな——文字列内の括弧や、説明に続く第二のオブジェクトで切る位置が狂う。

方言:末尾カンマ、単引用符、コメント、裸のキー

モデルは大量の JavaScript、Python、JSON5、YAML を見ている。「構造化データ」を求められると方言を混ぜる。以下はすべて JSON.parse では違法:

{
  ok: true,          // bare key + comment
  'name': 'Ada',     // single quotes
  "tags": ["a",],    // trailing comma
  "flag": True       // Python boolean
}

加えて undefined、NaN、Infinity、None。それぞれの言語では意味がある。JSON にあるのは null と有限の数だけ。JSON.parse を eval や new Function に差し替えて「受け入れる」と、パーサが任意コードの流入口になる。本番でやるな。

JSON5 と JSONC はコメントと末尾カンマを飲める。人が設定を手で書くならよい。モデル出力の既定パーサとしては弱い。文法を緩めた瞬間、「余分なカンマ」と「壊れた文字列」を区別できなくなる。緩い層が要るなら、抽出 + parse が失敗したあとに置き、修復後も Schema を通せ。

文字列と句読点:エスケープ、改行、全角とスマートクォート

合法な JSON 文字列は二重引用符で囲む。内側の " とバックスラッシュはエスケープ必須。制御文字は \n、\t、または \uXXXX。モデルがユーザーコメントを写すと、生の引用符と改行がフィールドに落ちる。文字列は早く終わり、次のカンマや CJK 文字が unexpected token になる。

CJK 出力には頻出の汚れがある:全角カンマ ,、全角コロン :、湾曲引用符 “” / ‘’。句読点に見えるが、コードポイントは 0x2C / 0x3A / 0x22 ではない。この「ほぼ JSON」は name の値の後ろで死ぬ:

{
  "name": "Ada",
  "ok": true
}

「半角の句読点を使え」をもう一文足しても直らない。長い文字列フィールドに maxLength を付け、モデルには句読点を打ち直させず原文を参照させ、最終チャネルは Structured Output にせよ。切り分けでは JSONバリデーター に貼り、ハイライトが何列で止まるかを見よ——全角カンマは一目で分かる。

打ち切りとストリーミング:Unexpected end of JSON input

Unexpected end of JSON input はほぼ常に、文法が終わる前にテキストが切れたことを意味する:} 欠け、] 欠け、閉じない文字列。2026 年のよくある源は三つ:出力 token 上限、安全フィルタの途中切断、未完了のストリーム chunk に JSON.parse を呼んだこと。

ストリーミング API が渡すのは差分である。最初の数 chunk は {"ok": tr かもしれない。それをパースすれば必ず失敗する。代わりにこうせよ:

  • ストリーム終了(finish_reason / stop)を待ってから、完全なバッファをパースする;
  • または token 単位で進む本物のストリーミング JSON パーサを使う——半端な値に JSON.parse を呼ぶな;
  • 終了理由が length / max_tokens なら、パースのバグではない。生成が終わっていない——上限を上げる、Schema を小さくする、または分割して出させよ。

打ち切り後に「} を自動補完」するのは下書き用の技だ。形はパースできても、フィールド欠けや文字列の途中切断が残る。修復したあとは必ず Schema 検証;失敗したら再試行。黙って保存するな。

不可視文字と二重エンコード

UTF-8 BOM(U+FEFF)は JSON の空白ではない。一部のコピー経路やゲートウェイが先頭に付ける;JSON.parse は 0 列目で unexpected token と報じる。ゼロ幅スペースとソフトハイフンも同じ。抽出前に replace(/^\uFEFF/, "")、続けて trim。

二重エンコードはより静かだ。一度 JSON.stringify すると文字列 "{\"ok\":true}" になる。外側の引用符付きのそれをパースすると、得られるのは文字列 {"ok":true} であり、オブジェクトではない。もう一度パースしてオブジェクトになる。一回で止めて .ok を読むと undefined——「パースは成功した」のにフィールドがない。再パースの前に typeof を見よ。「常に二回パース」と決め打ちするな;本物のオブジェクトなら例外になる。

直し順:先にチャネルを変え、次に抽出し、修復は最後

この順のほうが、プロンプトを積み増すより安定する:

  1. チャネルを変えよ。最終返答は Structured Output(OpenAI response_format.json_schema、Gemini responseMimeType + Schema、Claude output_config.format)。ツール引数は Tool Calling の arguments を通せ。散文から掻き出すな。原理は Structured Output とは何か。
  2. 抽出。```json フェンスを剥ぐ;最初のバランスした値を切り出す;BOM を落とす。
  3. 厳格に parse。JSON.parse だけを使う。失敗したら原文とエラー位置を残せ。eval するな。
  4. Schema 検証。parse 成功は文法が合法だというだけ。欠けたフィールド、誤った型、余分なキーは JSON Schema / ajv が要る。Prompt から Structured Output へ を見よ。
  5. 修復は最後。jsonrepair のような道具は括弧を閉じ、末尾カンマを落とせる。抽出 + parse が失敗したあと、かつ「直したテキストが意味を変え得る」と受け入れるときだけ使え。そのあとも 3 と 4 を通せ。修復器をグローバル既定パーサにするな。

プロンプトはなお有用だ:「フェンスなし、説明なし」。包装層の確率は下がる。しかし Schema の代替にはならず、JSON.parse を緩くもしない。2026 年にチャット本文を API として扱うと、フェンスと打ち切りで繰り返し払うことになる。

小さな抽出 + parse パイプライン

教材サイズの最小パイプライン:フェンスを剥ぎ、BOM を落とし、バランスした値を切り出し、それから JSON.parse。よくある包装は処理する。末尾カンマや全角句読点は直さない——それは Structured Output か、明示した修復層へ回せ。

function stripFence(text) {
  const m = String(text).match(/```(?:json|JSON)?\s*([\s\S]*?)```/);
  return m ? m[1] : String(text);
}

function sliceBalancedJson(text) {
  const src = text.replace(/^\uFEFF/, "").trim();
  const start = src.search(/[\{\[]/);
  if (start < 0) throw new SyntaxError("No JSON value found");
  const open = src[start];
  const close = open === "{" ? "}" : "]";
  let depth = 0, inStr = false, esc = false;
  for (let i = start; i < src.length; i++) {
    const ch = src[i];
    if (inStr) {
      if (esc) { esc = false; continue; }
      if (ch === "\\") { esc = true; continue; }
      if (ch === '"') inStr = false;
      continue;
    }
    if (ch === '"') { inStr = true; continue; }
    if (ch === open) depth++;
    else if (ch === close) {
      depth--;
      if (depth === 0) return src.slice(start, i + 1);
    }
  }
  throw new SyntaxError("Unterminated JSON value");
}

function parseModelJson(raw) {
  return JSON.parse(sliceBalancedJson(stripFence(raw)));
}

スライサは「文字列の中にいるか」を追跡しなければならない。さもなくばフィールド値の { が早く閉じる。入れ子のオブジェクトと配列は depth で扱う。スライス成功後も parse が失敗するなら、失敗テキストをバリデータに貼り、上の分類表と照合せよ。この層に正規表現を積み増すな。

ローカルでエラー位置を見る

モデル出力をいきなり本番パーサへ送るな。ブラウザで三つ見よ:合法 JSON か;違法なら何列か;すでに Schema があるなら契約を満たすか。

  • JSONバリデーター ——SyntaxError の位置を見る;Schema があれば合わせて検証する。
  • JSONフォーマッター ——整形できれば、たいていパースできる;失敗したら原文の全角カンマやフェンスを探す。
  • JSON Diff ——parse 成功後、「モデルのオブジェクト」と「許す最小オブジェクト」を比べる。

データはブラウザを出ない。失敗したモデル返信、Schema、Tool Calling の arguments を並べて見るのに向く。フィールド名と required が安定してから、Host に配線せよ。

FAQ

「JSON に見える」のに JSON.parse が拒むのはなぜか?

人の目はフェンス、末尾カンマ、湾曲引用符、前後の説明を許す。JSON.parse は RFC 8259 の値をちょうど一つだけ受け取る。似ていることは、合法であることではない。

正規表現で ```json フェンスを消せば足りるか?

足りない。フェンスは包装の一層にすぎない。後ろに説明、第二の JSON、末尾カンマ、打ち切りが残る。フェンスを剥いだあとも、バランス切り出しと厳格な parse が要る。

JSON Mode と Structured Output の違いは何か?

JSON Mode はだいたい「JSON らしく見える」ことだけを縛り、フィールドと型は見ない。Structured Output はデコード時に JSON Schema で不正トークンを止める。プログラムが消費するなら Structured Output を優先せよ。JSON Mode だけ開いてチャット本文を JSON.parse するな。

jsonrepair や JSON5 を既定パーサにしてよいか?

いけない。失敗すべき入力を飲み、意味を変え得る。抽出 + JSON.parse が失敗したあとの修復層としてだけ使え。修ったあとも Schema 検証せよ。

ストリーム応答ではいつ JSON.parse を呼んでよいか?

ストリームが終わり、バッファが完全な値になってから。半端な chunk をパースすれば、毎回 Unexpected end of JSON input になる。到着と同時に消費するなら、ストリーミングパーサを使え。JSON.parse ではない。

パースは成功したがフィールドが違う。この記事の範囲か?

次の層である。JSON.parse が保証するのは文法だけ。欠けたフィールド、誤った型、余分なキーは Schema の問題——本サイトの Structured Output と Tool Calling 検証の二本を見よ。

まとめ

JSON.parse の失敗はチャネルの問題である。「JSON を出力せよ」をもう一文足しても直らない。チャットモデルはフェンスで包み、方言を混ぜ、token 上限で止まる。パーサが受け取るのはきれいな JSON 値、一つだけ。2026 年、モデルをプログラムへつなぐ正しい順は Structured Output / Tool Calling の arguments、次いで抽出 + 厳格な parse + Schema、最後が修復器である。

プロンプトは包装層を減らせる。文法は緩められない。失敗テキストをローカルのバリデータに貼り、何列で止まるかを見てから決めよ:フェンスを剥ぐか、チャネルを変えるか、出力上限を上げるか。モデルは変わってよい。JSON.parse が受け取るものと、フィールド契約は、変えてはならない。