【結論】XMLデリミタ+正規表現でJSONパース問題を根本解決できる
記事リライトシステムの applyDirect() 関数で Gemini に { rewrittenContent: string } というスキーマで記事全文を返させていた。日本語の引用符や複数行のコードブロックが本文に含まれると、JSON.parse が position 1727 付近で例外を投げる問題が繰り返し発生した。Gemini には OpenAI の JSON mode 相当の機能がなく、generateContent の出力をそのまま JSON.parse にかけるとエスケープが不完全になる。失敗のたびにリトライが走り、APIコストが目に見えて積み上がっていった。
対策として採用したのが XMLデリミタ+正規表現の組み合わせだ。プロンプト末尾に と タグを追記し、generateText で受け取った後に正規表現1行で抽出する方式に切り替えた。既存の記事生成パイプライン実装への適用はプロンプトの追記だけで完了し、JSONスキーマ定義もバリデーションロジックも一切追加しなかった。あわせて maxOutputTokens を 8192 から 16384 に拡張し、長文記事の途中切り捨ても同時に解消している。
// プロンプト末尾に追記するだけ — スキーマ変更不要
const prompt = `
...(記事リライト指示)...
以下の形式で出力してください:
リライト後の本文をそのまま出力(エスケープ処理不要)
100文字以内の要約
`;
// Gemini テキスト生成で受け取り、LLM 出力抽出を正規表現1行で完結
const text = await generateText(prompt, { maxOutputTokens: 16384 });
const content = text.match(/([\s\S]*?)<\/REWRITTEN_CONTENT>/)?.[1]?.trim() ?? '';
const summary = text.match(/([\s\S]*?)<\/SUMMARY>/)?.[1]?.trim() ?? '';
XML区切りタグが安定する構造的な理由は、JSON特殊文字の影響をそもそも受けない点にある。"・\n・\ が本文に含まれていても、正規表現の抽出範囲は XML タグの開閉だけで決まるため干渉しない。generateJson が失敗するケースの大半はこのエスケープレイヤーに起因するが、XMLデリミタ方式ではそのレイヤー自体が存在しない。LLM JSON パースエラー対策としての追加コストはゼロで、設計上の複雑度も下がる。
- JSON特殊文字(
"・\n・\)をエスケープする必要がなく、エラー原因を根本から除去できる generateTextは Gemini テキスト生成の基本 API であり、モード切替や追加設定が不要- 正規表現1行で LLM 出力抽出が完結し、外部ライブラリへの依存がゼロのまま維持できる
- プロンプトの追記だけで既存の記事生成パイプラインにそのまま適用できる
LLM JSONパースエラーが発生する3つの根本原因
記事リライトシステムの applyDirect() 関数で { rewrittenContent: string } スキーマを組んだとき、短い記事では問題が出ないのに日本語の引用符(「」)や複数行のコードブロックを含む記事を処理した瞬間に JSON.parse が position 1727 付近でクラッシュした。最初はエスケープ処理の実装ミスを疑ったが、掘り下げると原因は別のところにあった。Gemini には OpenAI の response_format: { type: "json_object" } に相当するネイティブ JSON モードが存在せず、generateContent はテキスト生成の結果をそのまま返す。モデルが「JSON に見える文字列」を出力するだけで、構文的に正しい JSON を保証する仕組みがない。
generateJson 相当の実装が失敗するとき、エラーメッセージは次の3パターンに収束しやすい。
SyntaxError: Unexpected token at position N— 長大フィールド内のバックティック・引用符・改行でエスケープが崩れた位置を示す。N が 1,000 を超えていれば長大テキスト起因とほぼ断定できるSyntaxError: Unterminated string in JSON— コードブロック末尾の```が JSON 文字列の終端と衝突したケース。Markdown を含む記事では頻出する- 閉じ括弧の消失・不完全な JSON 構造 —
maxTokens不足で出力が途中で切れた状態。8,192 トークン設定のまま長文記事を処理すると再現する。この場合はエラーメッセージより「フィールドがundefined」として現れることが多い
LLM JSON パース エラー対策 XMLデリミタの話に入る前に、リトライコストの実態を押さえておく必要がある。記事生成パイプライン実装では通常3回リトライを設定するため、パース失敗率10%でも月1,000記事を処理すると100回分の余分な Gemini テキスト生成が走る。入力5,000トークンの記事を100回リトライすれば、Gemini 1.5 Pro の料金水準($3.50 / 1M tokens)で月に追加コストが積み上がる。さらにリトライが遅延を生み、タイムアウト設定次第ではパイプライン全体が停止する。2,000字超の本文を含む記事では体感でパース失敗率が3〜4割に達し、処理コストが実質倍以上になった。
根本原因を構造的に整理すると3点になる。
- Gemini にはネイティブ JSON モードがない — モデルはテキストを出力するだけであり、JSON 構文の保証はプロンプト次第になる。プロンプトで強制しても長大テキストが絡むと崩れる
- 長大テキストフィールドでエスケープが崩壊する — 数千文字の本文を1つの JSON 文字列フィールドに詰め込むと、改行・引用符・バックスラッシュの一貫したエスケープが破綻する。フィールド長が長いほど崩壊確率は高くなる
- スキーマのフィールド数がエラー率を乗算する —
{ title, content, summary, tags[] }のように複数フィールドを持たせると、どれか1つでエスケープが崩れただけで全体のパースが失敗し、すべての値がundefinedになる
GeminiでgenerateTextを選ぶべき設計判断の根拠
GeminiのAPIには、OpenAIのresponse_format: { type: "json_object" }に相当するネイティブJSONモードが存在しない。generateContentが返すのはプレーンテキストであり、「JSONフォーマットで出力してください」とプロンプトで指定しても、エスケープ処理の保証はAPI側にない。この仕様差分こそが、LLM JSONパース エラー 対策を設計する際の出発点になる。OpenAI前提で書かれた記事生成パイプライン実装をGeminiに移植すると、まずここで詰まる。
記事リライトシステムでapplyDirect()を実装した際、{ rewrittenContent: string }というスキーマで記事全文を返させていた。日本語の引用符や複数行のコードブロックが本文に含まれると、JSON.parseがSyntaxError: Unexpected token at position 1727で落ちる問題が頻発した。バックスラッシュや改行文字のエスケープが不完全なまま混入するためで、コードブロックを含む記事では失敗率が体感で30〜40%に達し、パイプライン全体が止まった。
Vercel AI SDKのgenerateJsonはスキーマ検証付きで型安全に見えるが、内部では同じGeminiのgenerateContentを呼び出しており、長文テキストをvalueに持つケースでの失敗は防げない。generateText+XMLデリミタ方式に切り替えた後、同一記事セットでのパースエラーはゼロになった。あわせてmaxTokensを8192から16384へ引き上げたことで長文記事の途中切断も解消し、再試行によるトークン二重消費も削減できた。
- generateJson失敗の典型パターン — 日本語引用符・コードブロック・複数改行を含む長文テキストがvalueになるとエスケープが崩れる
- generateTextのフォールバック設計 — XMLデリミタが返ってこない場合の処理(
null返却か再試行か)を明示的に実装しないと障害点になる - maxTokens設定の落とし穴 — 上限を低く設定したまま長文記事を処理すると閉じタグが欠落し、正規表現がマッチしない
- コスト面の優位性 — generateJson失敗による再試行が月100回発生すると仮定した場合、generateText一発成功の方がトークン消費で実質的に安くなる
LLM JSONパースエラー対策:XMLデリミタの実装手順
記事リライトシステムの applyDirect() 関数で実際に踏んだ問題から手順を整理する。当初は { rewrittenContent: string } というJSONスキーマで記事全文を返させていたが、日本語の引用符や複数行のコードブロックが本文に含まれると JSON.parse が position 1727 付近でクラッシュした。GeminiはネイティブのJSON modeを持たないため、エスケープが不完全な状態でテキストを返すケースが構造的に内在しており、スキーマ定義を調整しても根本解決にならなかった。
プロンプトでXMLデリミタを確実に出力させる設計パターン
プロンプトの末尾に出力形式を明示し、JSONの使用を禁止する一文を追記する。タグ名は大文字スネークケース(REWRITTEN_CONTENT、SUMMARY)にすると、記事本文中に偶然出現する確率がほぼゼロになる。「必ず」という強調と「JSONやマークダウンは使わない」という禁止の両方を入れることで、Geminiがコードブロック装飾を余分に出力する頻度が下がる。
以下の形式のみで出力してください。JSONやマークダウンは使わないでください。
リライト後の記事本文をここに記述
200文字以内の要約をここに記述
正規表現でLLM出力からコンテンツを安全に抽出する完全実装
抽出関数はタグ名を引数に取るユーティリティとして切り出し、複数フィールドを同じロジックで処理できる構造にする。[\s\S]*? で改行を含む複数行コンテンツに対応し、非貪欲マッチ(?)で複数タグが存在する場合の誤取得を防ぐ。戻り値を string | null にしておくと、呼び出し側でnullチェックが強制されてバグの混入が減る。記事生成パイプラインの実装では、この関数を共通モジュールに置いて全ルートから参照するのが保守上の正解だった。
const extractXmlContent = (text: string, tag: string): string | null => {
const pattern = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`);
const match = text.match(pattern);
return match ? match[1].trim() : null;
};
// 使用例
const raw = await generateText({ prompt, maxTokens: 16384 });
const rewrittenContent = extractXmlContent(raw, 'REWRITTEN_CONTENT');
const summary = extractXmlContent(raw, 'SUMMARY');
エラーハンドリングとフォールバック処理の設計
抽出結果が null になる原因は主に2パターンある。タグ自体が出力されていないケースと、maxTokens 不足で閉じタグが切れるケースだ。エラーメッセージにタグ名と生テキストの先頭200文字を含めると、ログを見ただけでどちらの原因か即座に判断できる。閉じタグ欠落への最終手段として、開きタグ以降を全取得するフォールバックも用意しておくと長文記事でのロスを抑えられる。
if (!rewrittenContent) {
const preview = raw.slice(0, 200).replace(/\n/g, '\\n');
throw new Error(
`XML extraction failed: not found.\n` +
`Output preview: ${preview}`
);
}
// 閉じタグ欠落時のフォールバック(maxTokens不足を疑う場合)
const extractWithFallback = (text: string, tag: string): string | null => {
const strict = extractXmlContent(text, tag);
if (strict) return strict;
const openMatch = text.match(new RegExp(`<${tag}>([\\s\\S]+)$`));
return openMatch ? openMatch[1].trim() : null;
};
generateJson実装からの移行手順とbefore/after比較
既存の generateJson 実装から移行する作業は、実際に30分以内で完了した。変更箇所は3点に絞られる。
- プロンプト生成関数の末尾にXMLデリミタの出力形式指示を追記する
generateJsonの呼び出しをgenerateTextに差し替え、maxTokensを8192から16384に変更する- 戻り値の受け取りを
result.rewrittenContentからextractXmlContent(raw, 'REWRITTEN_CONTENT')に変更する
// Before: generateJson — JSON.parse が position 1727 でクラッシュ
const result = await generateJson<{ rewrittenContent: string }>({
schema: ArticleRewriteSchema,
prompt: buildPrompt(article),
maxTokens: 8192,
});
const content = result.rewrittenContent; // ❌ エスケープ不完全でクラッシュ
// After: generateText + XMLデリミタ — 移行後クラッシュ率ゼロ
const raw = await generateText({
prompt: buildPromptWithXmlInstruction(article),
maxTokens: 16384, // 長文記事の切り捨てを防ぐため増量
});
const content = extractXmlContent(raw, 'REWRITTEN_CONTENT'); // ✅ 安定稼働
移行後の変化は数字で確認できた。JSON.parse のクラッシュ率はゼロになり、generateJson失敗による再試行コストも消えた。スキーマ定義ファイルを削除したことでコードベースが約40行削減され、型定義のメンテナンス負担も下がった。LLM JSON パース エラー 対策としてXMLデリミタを採用した結果、安定性・コスト・保守性の3軸すべてでプラスが出ている。
3方式をコスト・安定性・メンテナンス性で比較する
applyDirect() 関数に { rewrittenContent: string } スキーマを組み込んだ段階で、日本語の引用符や複数行コードブロックを含む記事に対して JSON.parse が position 1727 付近でクラッシュする問題が繰り返し発生した。記事生成パイプライン実装における LLM JSON パース エラー 対策として3方式を実際に検証し、コスト・安定性・保守性の3軸で比較した結果を以下にまとめた。
方式1:JSONエスケープ処理
LLMの出力に含まれるダブルクオートや改行を replace() で事前エスケープし、JSON.parse 後にアンエスケープして戻す方法。エスケープとアンエスケープの両ロジックが必要になるうえ、GeminiがコードブロックやMarkdown記法を出力に混入させるパターンにはカバーが追いつかない。実装量が3方式中で最大になるわりに、非JSON modeのGeminiでの根本的なクラッシュは防ぎ切れなかった。
- コスト:エスケープ処理・アンエスケープ・テストコードで実装行数が大きく増加し、エッジケース発見のたびに修正が発生する
- 安定性:LLMの出力パターンが増えるたびに新たな失敗ケースが出現し、カバレッジが永続的に追いかける構造になる
- 保守性:エスケープロジックが複雑化し続けるにもかかわらず問題は根本解決せず、技術的負債が積み上がる
方式2:Structured Output(generateObject)
Vercel AI SDK の generateObject のようにZodスキーマを渡し、LLMに構造化出力を強制する方式。型安全な開発体験が得られる点は利点だが、GeminiはネイティブのJSON modeを持たないためプロンプト経由のフォールバック変換になる。generateJson失敗時のリトライ処理を別途実装しなければならず、スキーマ管理コストとリトライAPIコストの二重負荷が継続的に発生した。
- コスト:Zodスキーマの定義・維持に加え、generateJson失敗のリトライでAPIコストが上乗せされる
- 安定性:Geminiではスキーマ強制が内部でプロンプト変換になるため、モデルバージョンが変わると失敗率が上昇する
- 保守性:スキーマ変更がルーター・サービス・型定義など複数箇所に波及し、変更コストが乗数的に増える
方式3:XMLデリミタ+正規表現(採用)
システムプロンプトで 形式の出力を指定し、Gemini テキスト生成の結果を generateText で受け取ったあと、LLM 出力 抽出 正規表現でパースする方式だ。JSON.parse を完全に排除できるため、日本語引用符・コードブロック・複数行テキストがどれだけ含まれてもクラッシュしない。maxTokens を 8192 から 16384 に拡張して長文記事の切り捨てを防いだことも、安定性の向上に直結した。
const match = result.text.match(/([\s\S]*?)<\/REWRITTEN_CONTENT>/);
const rewrittenContent = match ? match[1].trim() : '';
- コスト:抽出ロジックは正規表現2行で完結。スキーマ定義ファイルを削除したことでコードベースが約40行削減された
- 安定性:タグで囲まれた文字列を取り出すだけの操作なのでモデル非依存で動作し、
JSON.parseのクラッシュ率はゼロになった - 保守性:型定義の更新コストはゼロ。出力項目を追加するときは新しいタグをプロンプトに追加するだけで済む
3方式を定量で見ると、JSONエスケープ処理は実装コストが最大で根本的なクラッシュを防げず脱落し、Structured OutputはスキーマコストとリトライAPIコストの二重負荷がボトルネックになる。XMLデリミタ方式はコスト・安定性・保守性の3軸すべてで他方式を上回り、記事生成パイプラインにおける LLM JSON パース エラー 対策として定量的な根拠を持つ最適解と判断できる。
まとめ:LLMパイプラインを壊さないための実装チェックリスト
記事リライトシステムで applyDirect() を実装した際、Gemini に { rewrittenContent: string } という JSON スキーマを返させる設計が繰り返し崩壊した。日本語引用符や複数行コードブロックが含まれる本文で JSON.parse が position 1727 付近でクラッシュし、generateJson 失敗が断続的に発生した。根本原因は Gemini が JSON mode を持たない点にあり、エスケープ処理を積み上げるほど実装コストが膨らむ構造だった。XMLデリミタへの切り替えは「エラーハンドリングの強化」ではなく「設計の置き換え」として機能した経験から、以下の3ステップが LLM JSON パース エラー 対策 XMLデリミタ実装の最短経路だと判断している。
- プロンプト設計:出力ブロックを
、など大文字スネークケースのタグで囲むよう明示的に指示する。Few-shot でサンプル出力をプロンプト内に含めると、タグ欠落率が大幅に下がる - generateText の選択:
generateJsonや Structured Output を使わず、Gemini テキスト生成 API でレスポンス全体をテキストとして受け取る。maxTokensは想定最大出力の 1.5 倍以上を確保する(8192 → 16384 に変更後、長文記事の途中切り捨てがゼロになった) - 正規表現抽出:
/のパターンで LLM 出力 抽出 正規表現を実装し、マッチ失敗時はログを残してリトライ制御に委ねる([\s\S]*?)<\/REWRITTEN_CONTENT>/
記事生成パイプライン 実装を本番に上げる前に、下記のチェックリストを一通り確認する。特に正規表現の非貪欲マッチと日本語含有のテストケースは見落としやすく、開発環境では再現しない本番障害の温床になりやすい。
- □
maxTokensを想定最大出力文字数 × 1.5 以上に設定しているか - □ XMLタグのマッチ失敗をキャッチし、ログ出力とリトライ分岐が実装されているか
- □ プロンプトに Few-shot のサンプル出力(デリミタ付き)が含まれているか
- □ 複数フィールドを返す場合、タグ名が重複しない命名になっているか
- □ 正規表現が
[\s\S]*?(非貪欲・マルチライン対応)で記述されているか - □ 統合テストに日本語引用符・コードブロック・改行含有のケースが含まれているか
- □ リトライ上限(推奨: 3回)と失敗時のフォールバック処理が定義されているか
LLMモデルが更新されても崩れない設計原則は「出力形式の責任をLLMに持たせない」一点に集約される。JSONスキーマを強制しようとするほど、モデルのバージョン差異や出力トークンの揺れでパイプラインが壊れやすくなる。XMLデリミタ方式はタグの有無だけでLLM出力の異常を検知でき、モデル差し替え時の回帰コストが最小になる。generateJson 失敗の根本解決は、エラーハンドリングの積み上げではなくこの設計原則への移行によって達成できる。


コメント