Gemini API を使ったコンテンツ生成エージェントをそこそこの規模で運用していると、ある日いきなり finishReason: "RECITATION" がログを埋め始めます。一見成功レスポンスのように見えるのに、candidates[0].content.parts は空。リトライしても同じ結果が返り、ユーザー側からは「画面に何も出ない」「途中で切れた」というクレームになります。2014年からアプリ事業をやってきた身として、Dolice ブランドで個人開発・運営している6サイト(Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab・Lacrima・Mystery)のコンテンツ自動投稿パイプラインで、ある月に Recitation block が原因で月 20 回近く記事生成が止まり、夜間バッチを朝確認するたびに記事数が足りないという状況に陥っていました。
Recitation block は、Gemini が「いま生成しようとしている応答が、著作権保護されたコンテンツの長文逐語引用に該当しそうだ」と判定したときに発動する安全機構です。safetySettings で OFF にできる類のものではなく、内部のリサイテーション検出器が独立して動いているので、プロンプトとポストプロセスの両方の設計で発火率を下げる 必要があります。
ここでは、6サイトのパイプラインで実装した発火検知・回避プロンプト・ポストプロセス・最終リトライまでの一連のコードと、半年運用してわかった発火条件の温度感を共有します。
発火する5つの典型条件
ログを半年分集計した結果、Recitation block が発火するのはほぼ次の5パターンです。
既存記事・ニュース・論文の本文を長文引用する依頼 (発生率 約 45%): 「次の記事の本文を踏まえて」「以下の論文の3章を要約して」のような依頼で、与えた原文の一部をそのまま再出力しようとすると発火しやすい
歌詞・詩・有名な文学テキストの再現 (約 20%): 著作権保護期間中の歌詞・詩・小説の一節を、たとえ少量でも生成しようとすると発火する
公開コードベースの長文コピー (約 15%): 既存 OSS のコードを大きな塊で再出力しようとすると発火する(特定のフレームワークの公式チュートリアルの完全コピーなど)
ニュース速報の見出しと第一段落の連続生成 (約 12%): ニュース要約エージェントで、見出し・リード・本文の冒頭を続けて再現しようとすると発火する
長い JSON データの逐語再生 (約 8%): 与えられたデータセットの全フィールドをそのまま JSON で出力しようとする処理で発火することがある
リトライで直る確率は、1番目で 5%、2番目で 0%、3番目で 30%、4番目で 10%、5番目で 60% でした。1〜4 はプロンプト設計の問題なので、リトライではなく 書き方を変えないと根治しません 。
API レスポンスからの検出ロジック
最初にやるべきは確実な検出です。Gemini API は Recitation block で停止すると、candidates[0].finishReason を "RECITATION" にし、content.parts を空にして返します。ステータスコードは 200 OK なので、ナイーブな実装だと「空の成功レスポンス」として通り抜けてしまいます。
import { GoogleGenerativeAI } from "@google/generative-ai" ;
interface DetectionResult {
isRecitation : boolean ;
partial : string ; // RECITATION 直前まで生成された部分
citationMetadata ?: unknown ;
}
function detectRecitation ( response : any ) : DetectionResult {
const candidate = response?.candidates?.[ 0 ];
if ( ! candidate) return { isRecitation: false , partial: "" };
const finishReason = candidate.finishReason ?? "" ;
const isRecitation = finishReason === "RECITATION" ;
// ストリーミング中に発火した場合、partial にはそこまでの生成が残っている
const partial = (candidate.content?.parts ?? [])
. map (( p : { text ?: string }) => p.text ?? "" )
. join ( "" );
return {
isRecitation,
partial,
citationMetadata: candidate.citationMetadata,
};
}
citationMetadata を返すこともあり、ここに「どの公開ソースの引用と判定したか」のヒントが入ります。私のパイプラインでは、これをログに残しておき、後で「どの依頼が引っかかったか」をデバッグできるようにしています。
発火率を下げる「言い換え強制プロンプト」
発火率を最も大きく下げるのが、プロンプト側の指示変更です。逐語引用を求めない代わりに「言い換え」「抽出」「構造化」を要求する形に書き換えます。半年運用で固まったテンプレートが次のものです。
function buildParaphrasePrompt ( sourceText : string , task : string ) : string {
return [
"あなたは情報を再構成するライターです。以下の【原文】を参照しますが、" ,
"重要な制約を守ってください:" ,
"" ,
"- 原文の文章を 10 単語以上連続してそのまま使用しないこと" ,
"- 各文を必ず言い換える(語順を入れ替える・別の表現に置き換える)" ,
"- 固有名詞・数値・日付以外は逐語複製しない" ,
"- 引用が必要な箇所は「原文の要点として」「○○氏は△△と述べた」のような" ,
" 間接話法で記述する" ,
"" ,
`タスク: ${ task }` ,
"" ,
"【原文】" ,
sourceText,
]. join ( " \n " );
}
これだけで、私のパイプラインの月 20 回規模の Recitation block が 月 1〜2 回まで減りました 。発火率にすると 90% 削減です。コスト換算では、再リトライ込みの API 呼び出し回数が月およそ 350 回減り、Gemini 2.5 Pro の入出力単価で月 ¥2,800 相当の削減になりました。
段階的フォールバックの実装
それでも発火するケースには、3段階のフォールバックを噛ませています。
同じプロンプトを一度だけリトライ(5% 程度の確率で偶発的に通る)
言い換え強制プロンプトに切り替えて再依頼(さらに 7 割が通る)
入力テキストをチャンクに分割して、各チャンク要約 → 統合の2段階に切り替え
async function safeGeminiCall (
client : GoogleGenerativeAI ,
modelId : string ,
sourceText : string ,
task : string
) : Promise < string > {
const model = client. getGenerativeModel ({ model: modelId });
// Step 1: 通常プロンプト
const initial = await model. generateContent ( `${ task } \n\n ${ sourceText }` );
let det = detectRecitation (initial.response);
if ( ! det.isRecitation && det.partial) return det.partial;
// Step 2: 言い換え強制プロンプト
const paraphrasePrompt = buildParaphrasePrompt (sourceText, task);
const second = await model. generateContent (paraphrasePrompt);
det = detectRecitation (second.response);
if ( ! det.isRecitation && det.partial) return det.partial;
// Step 3: チャンク分割
return chunkAndAggregate (model, sourceText, task);
}
async function chunkAndAggregate (
model : any ,
source : string ,
task : string
) : Promise < string > {
const chunks = splitIntoChunks (source, 3000 );
const summaries : string [] = [];
for ( const chunk of chunks) {
const prompt = buildParaphrasePrompt (chunk, "この断片の主旨を3点で抽出" );
const r = await model. generateContent (prompt);
const d = detectRecitation (r.response);
if (d.partial) summaries. push (d.partial);
}
const integration = buildParaphrasePrompt (summaries. join ( " \n --- \n " ), task);
const final = await model. generateContent (integration);
return detectRecitation (final.response).partial;
}
function splitIntoChunks ( text : string , size : number ) : string [] {
const chunks : string [] = [];
for ( let i = 0 ; i < text. length ; i += size) {
chunks. push (text. slice (i, i + size));
}
return chunks;
}
3段階通しても止まる場合は、Recitation 以前にプロンプトそのものが原文の長文再生を要求する構造になっているサインです。私の経験では、ここまで来たら依頼の設計を書き直すべきで、技術的なリトライでは突破できません。
入力前のサニタイズで発火を予防する
ポストプロセスで対処するより、入力時点で発火しそうな素材を弾く方が安全です。私のパイプラインでは、原文を Gemini に渡す前に次のサニタイズを通しています。
interface SanitizationResult {
sanitized : string ;
flags : string [];
}
function sanitizeSource ( source : string ) : SanitizationResult {
const flags : string [] = [];
let s = source;
// 1. 引用ブロック("〜"、「〜」、`> 〜`)が長いと発火しやすい
// 30 単語以上の引用ブロックは中略にする
s = s. replace ( / [「『"][ ^ 」』"] {60,} [」』"] / g , ( match ) => {
flags. push ( "long-quote-truncated" );
return match. slice ( 0 , 30 ) + "…[中略]…" + match. slice ( - 30 );
});
// 2. 連続する数値・日付の羅列は要約しない(そのまま渡してOK)
// 3. URL は引用判定を誘発しやすいので、ドメイン名だけ残す
s = s. replace ( /https ? : \/\/ ( [\w.-] + ) [ ^ \s] * / g , ( _match , host ) => `[${ host }]` );
return { sanitized: s, flags };
}
私の運用では、long-quote-truncated フラグが立った原文は、その時点で「リサイテーション発火確率が高い」とログに記録し、最初から3段階フォールバックの2段階目(言い換え強制プロンプト)から呼ぶように分岐させています。
モデル切り替えでの最終的な逃げ道
それでも発火が続くケースの最終手段は、モデル ID を別系統に切り替えることです。私の手元では、Gemini 2.5 Pro と Gemini 3.2 Pro でリサイテーション検出器の感度が微妙に異なる挙動を確認しました。同じ原文・同じプロンプトで、2.5 Pro では発火するが 3.2 Pro では通る、という逆も起きます。
const MODEL_FALLBACK_CHAIN = [
"gemini-3-2-pro" ,
"gemini-2-5-pro" ,
"gemini-2-5-flash" ,
] as const ;
async function modelFallback (
client : GoogleGenerativeAI ,
sourceText : string ,
task : string
) : Promise < string > {
for ( const modelId of MODEL_FALLBACK_CHAIN ) {
try {
const result = await safeGeminiCall (client, modelId, sourceText, task);
if (result) return result;
} catch (err) {
// 次のモデルで試す
}
}
throw new Error ( "all models blocked by recitation" );
}
私のパイプラインでは、特定の業界ニュースを要約するエージェントが Gemini 2.5 Pro で発火しやすい傾向があったため、その用途だけ 3.2 Pro を一次選択にしています。発火率が体感で 8% から 0.5% まで下がりました。
半年運用してわかった追加の落とし穴
最後に、公式ドキュメントには載っていないが運用で気づいた挙動を3つ。
ひとつめは、ストリーミング応答中に Recitation が発火すると、すでに送信済みのトークンがクライアントから見えてしまう こと。途中までは普通に応答が届いているのに、突然 finishReason: RECITATION で終わる。ユーザー向けアプリで使うときは、ストリーミング表示を画面更新する前に最終 finishReason を確認してからフラッシュしたほうがいいです。
ふたつめは、Recitation block が発火した時のトークン課金は、生成済み分まで請求される こと。空のレスポンスのように見えても、API 利用料は普通に発生します。私のパイプラインでは、月の Gemini 課金の 約 4% が Recitation で消えた応答に対する支払いだった時期があり、これがフォールバック設計に投資する直接の動機になりました。
みっつめは、citationMetadata が返るときと返らないときがある こと。前者は判定根拠が比較的明確、後者は内部スコアリングだけで弾かれているサインです。後者の方が同じプロンプトで再発しやすいので、ログにフラグを残して優先的にプロンプト書き換え対象にしています。
Recitation block は、Gemini を本番運用する上で最も静かに、しかし継続的にコストを食う問題のひとつです。私自身が踏み抜いた半年の試行錯誤が、同じ症状に直面している方の参考になれば嬉しいです。