結論から先に: LLMに「JSON形式で返して」と伝えるだけでは、稀に説明文が混ざったり、キーの型が揺れたりします。出力例を明示し、パース失敗時のフォールバックまで含めて初めて、実務で安心して使える構造化出力になります。
課題
LLMに何かを判定・分類・要約させて、その結果をプログラムの中でそのまま扱いたい場面はよくあります。人間が読む分には自由な文章で構わないのですが、後続の処理に渡すとなると、決まった形式(JSON)で返してもらう必要があります。ニュース要約メール(第2回)でも同じ課題に直面しており、今回はその部分を一般化してまとめます。
第2回の時点では場当たり的にプロンプトを調整していましたが、他の用途でも同じ課題に何度も遭遇したため、共通の型として整理しておく価値があると判断しました。
完成形
「出力形式の例」をプロンプトに含め、パースに失敗した場合のフォールバック処理まで用意した、構造化出力の型を確立しました。この型は、ニュース要約に限らず、分類・抽出・判定など、さまざまな用途に使い回せます。
AIとの作り方
最初は「結果をJSONで返して」とだけ指示していたのですが、LLMによっては前後に説明文を付けたり、余計なコードフェンス(```json)を付けたりすることがあり、そのままではJSON.parseが失敗しました。初めてこの現象に遭遇したときは、「プロンプトが悪いのか、モデルの問題なのか」の切り分けに少し時間がかかりました。
そこで、AIエージェントに「このプロンプトの出力を安定させたい。実際に何度か試して、失敗するパターンを洗い出してから、プロンプトを直して」と依頼し、複数回のテスト実行を通じて安定させる、という進め方に変えました。1回書いて終わりではなく、試行錯誤の工程そのものをAIと一緒に回す形です。
サンプルコード
function askStructuredOutput(prompt) {
const fullPrompt = `
${prompt}
出力は次のJSON形式のみで返してください。説明文やコードフェンスは付けないでください。
{"result": "...", "confidence": "high" | "medium" | "low"}
`;
const response = callGeminiApi(fullPrompt); // 前回までの実装と同様のAPI呼び出し関数
return parseStructuredResponse(response);
}
function parseStructuredResponse(rawText) {
try {
// コードフェンスが付いていた場合に備えて除去してからパースする
const cleaned = rawText.replace(/```json|```/g, '').trim();
return JSON.parse(cleaned);
} catch (e) {
return { result: null, confidence: 'low', error: '解析に失敗しました' };
}
}
confidenceのような自己評価フィールドを含めてもらうようにしたのも、地味に効いた工夫でした。「high」評価のものだけ自動処理し、「low」評価のものは人間が確認する、という仕分けができるようになりました。
複数フィールドを扱う場合の設計
実際の用途では、1つの値だけでなく複数のフィールドを同時に返してほしい場面も多くあります。その場合も、出力例をプロンプトに明示するという基本方針は変わりません。
const fullPrompt = `
${prompt}
出力は次のJSON形式のみで返してください。
{
"category": "カテゴリ名",
"summary": "1行要約",
"confidence": "high" | "medium" | "low"
}
`;
フィールドが増えるほど、AIが一部のフィールドだけ省略して返してくることもあるため、パース後に必須フィールドが揃っているかを確認するチェックも追加しています。
function validateFields(obj, requiredFields) {
return requiredFields.every(field => obj[field] !== undefined);
}
このチェックに失敗した場合も、フォールバックとして扱い、後続処理には渡さないようにしています。
ハマりどころ
一番厄介だったのは、同じプロンプトでも実行するたびに、まれにJSON以外の説明文が混ざることがあった点です。プロンプトで「説明文は付けないでください」と明記しても、完全には防げませんでした。
対策として、コードフェンスの除去とパース失敗時のフォールバックを必ずセットで用意するようにしました。「絶対に指示通りに返ってくる」という前提を捨て、「時々失敗する」という前提で設計を組む方が、結果的に安定した仕組みになりました。
これは、以前扱った外部API呼び出しの再試行設計とも通じる考え方だと感じています。外部APIは「たまに失敗する」ことを前提に設計するのが定石ですが、LLMの出力も「たまに指示から外れる」ことを前提に設計する方が、結果的に頑丈な仕組みになります。
もう1つ、confidenceのようなフィールドをAIに書かせると、最初のうちは常に「high」ばかり返ってくることがありました。プロンプトに「判断に迷う場合は正直にmediumやlowを選んでください」と明示してから、ようやく実態に近い評価が返ってくるようになりました。
振り返ってみると、これは人に頼みごとをするときの感覚と近いのかもしれません。「自信があるかどうか正直に教えて」と一言添えるだけで、相手の答え方が変わることは、人間同士のやり取りでもよくあることです。
まとめ + 次回予告
構造化出力は便利ですが、「指示すれば必ずその通りに返ってくる」という期待を持たないことが大切です。パース失敗を前提にしたフォールバックを用意しておくことで、多少の揺れがあっても実務で安心して使える仕組みになりました。
前回は「GASトリガー設計と指数バックオフ再試行」について、次回は「外部APIのデータをスプレッドシートに自動蓄積するGASパターン」について書く予定です。
note(作った経緯・所感はこちら): https://note.com/kar8
参考・関連リンク
- Gemini API 公式ドキュメント: https://ai.google.dev/gemini-api/docs