生成AIのAPIレスポンスをJSONとしてそのままパースし、フロントエンドの画面に流し込む実装をしている方に向けた内容です。LLM(大規模言語モデル)に「JSON形式で返して」と指示しても、本番運用では思わぬところで壊れることがあります。dev.toで公開された「ImpactSync」という災害支援ボランティア向けのツールが、この問題への具体的な対処例を示していたので、実装の観点から掘り下げます。
ImpactSyncは、ボランティアが現場で書いた雑多なメモ(WhatsAppのメッセージや無線でのやり取りの走り書きなど)を、Google Gemini(Googleの生成AIモデル)に渡し、物資不足のタグ・寄付者向け報告書・SNS向け告知文の3種類に整形して出力するNode.js製のアプリです。バックエンドはExpress.js、AIとの通信には@google/genaiというSDKを使っています。ここで使われているのが「厳格なJSONスキーマを強制する」という設計方針です。
何が起きるか:LLMの「JSON風味の文章」問題
LLMにJSON出力を頼むと、しばしば余計な文字列が混ざります。
たとえば「```json」というコードフェンスの記法が先頭に付いたり、「以下がJSONです」という前置きの一文が混入したりします。
この状態のレスポンスをそのままJSON.parse()に渡すと、SyntaxError: Unexpected tokenという例外で処理が止まります。
ImpactSyncのようにボランティアが疲弊した状態で使うツールでは、この種のクラッシュは致命的です。エラー画面が出た瞬間、その場で直せる人はほぼいません。
影響範囲はフロントエンドだけにとどまりません。バックエンドのAPIがLLMの出力をそのまま中継している場合、パース失敗はサーバー側の500エラーとしても表面化します。
なぜ起きるか:原因を段階的に分解する
原因は大きく3段階に分けられます。
1つ目は、LLM自体が「次に来る確率が高いトークン」を予測して文章を生成する仕組みだという点です。JSONという厳密な構文を「絶対に守るべきルール」として理解しているわけではなく、学習データに含まれるMarkdown形式のコードブロック表記(``json ... ``)を模倣してしまいがちです。
2つ目は、プロンプト(LLMへの指示文)の書き方が曖昧な場合です。「JSONで返して」とだけ書くと、モデルは自由度の高い自然文っぽい応答と、構造化されたデータ出力のどちらに寄せるか判断がぶれます。
3つ目は、開発側の受け取り方の問題です。LLMのレスポンスを検証なしに信頼し、パース処理にtry-catchすら書いていないケースがあります。ImpactSyncの設計ドキュメントが「crashesやformatting bugsを起こさず動くこと」を明確な要件として掲げているのは、この3つ目の問題を強く意識しているためです。
自分のプロジェクトが該当するか確認する
以下の観点で、手元のコードを見直してみてください。
- LLM APIを呼び出しているコード内で
response.text()やresponse.candidates[0].contentなどの生テキストを、そのままJSON.parse()に渡していないか - プロンプト内に「Respond ONLY with valid JSON」のような厳密な指示があるか、それとも「JSON形式で」とだけ書かれているか
- 使用しているSDKやAPIに、構造化出力(structured output)専用のオプションがあるのに使っていないか
- パース処理の周囲にtry-catchやリトライ処理があるか
具体的な確認コマンドとしては、実際にAPIを一度叩いて生のレスポンス文字列をログ出力してみるのが手っ取り早い方法です。
node -e "
const { GoogleGenAI } = require('@google/genai');
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
async function main() {
const res = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: '緊急物資が3種類不足しています。JSONで返して',
});
console.log(JSON.stringify(res.text));
}
main();
"このように出力をJSON.stringifyで一度ラップしてログに出すと、先頭に```jsonが付いていないか、末尾に余計な改行や説明文がないかを目視で確認できます。
対策の手順
対策は難しいものではなく、Web API開発で普段からやっているバリデーションの発想を持ち込むだけです。
1. 構造化出力オプションを使う
GeminiやOpenAIのAPIには、レスポンスの形式をJSONに固定するオプションがあります。GeminiのSDKではgenerationConfigにresponseMimeType: "application/json"を指定し、さらにresponseSchemaでJSON Schema(データの型や必須項目を定義する規格)を渡すことで、モデル側の出力を強制的に構造化できます。
const result = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: userNote,
config: {
responseMimeType: 'application/json',
responseSchema: {
type: 'object',
properties: {
supplyTags: { type: 'array', items: { type: 'string' } },
donorReport: { type: 'string' },
socialPost: { type: 'string' },
},
required: ['supplyTags', 'donorReport', 'socialPost'],
},
},
});これにより、モデルが自由文を挟む余地がかなり減ります。ただし「絶対に壊れない」保証ではない点は覚えておく必要があります。
2. パース前にサニタイズ処理を挟む
万が一コードフェンスが混入しても壊れないよう、パース前に正規表現で除去する防御コードを入れます。
function safeParseJSON(rawText) {
const cleaned = rawText.replace(/```json
?|```
?/g, '').trim();
try {
return JSON.parse(cleaned);
} catch (e) {
console.error('JSON parse failed:', cleaned);
return null;
}
}safeParseJSONがnullを返した場合の後続処理(再試行するのか、ユーザーにエラーを見せるのか)も忘れず設計しておきます。
3. スキーマ違反時のフォールバックを用意する
必須フィールドが欠けていたり、配列であるべき箇所が文字列になっていたりするケースに備え、zodやajvといったスキーマバリデーションライブラリでチェックする層をもう一段挟むと安定します。
この層があると、モデルのバージョンが変わって出力傾向が変化した際にも、実害が出る前に検知できます。
4. モデルのバージョンを固定して監視する
モデル名はgemini-2.5-flashのようにバージョンを含む形で指定し、突然のモデル更新による出力形式の変化を追跡できるようにしておきます。
モデルプロバイダーの公式ドキュメントで、使用中のモデルが構造化出力(structured output)や関数呼び出し(function calling)機能に対応しているかを確認するのも有効です。対応していれば、自由文生成よりも安定したJSONを得やすくなります。
確認と対策のまとめ
LLMのJSON出力は、便利に見えて実は「壊れる前提」で設計するのが安全な領域です。
- まず生のレスポンスをログ出力し、コードフェンスや前置き文が混入していないか目視確認する
- 使用中のAPIに構造化出力オプション(responseSchemaなど)があるか公式ドキュメントで確認し、使えるなら有効化する
- パース処理にtry-catchとサニタイズ関数を必ず挟む
- zodやajv等でスキーマ検証する層を追加し、モデルバージョンを固定して変化を追跡する
災害支援の現場のように、疲弊した利用者が使うツールほど、こうした地味な防御的実装の有無が信頼性を左右します。まずは手元のプロジェクトで、LLMのレスポンスをtry-catchなしで直接パースしている箇所がないか、コードベースを検索してみてください。