「ユーザーから『嘘の電話番号を案内されました』というクレームが届きました」――昨年、私が運用していた問い合わせ対応エージェントで実際に起きた事故です。プロンプトには明確に「知らない情報は推測しないでください」と書いてありました。Claude Sonnet 4.5、temperature は 0.3、ツールも複数連携した、当時としては丁寧に作ったエージェントでした。
それでもハルシネーションは起きます。本番で起きます。
この記事は「プロンプトを工夫すれば防げる」という前提を捨てたところから始まります。プロンプト・推論・出力・事後検証の4つのレイヤーで多層防御を組むと、致命的な事実誤認はどこまで抑えられるのか。実際に私が運用しているシステムで効いた7つの実装パターンを、TypeScript の動くコードと一緒に整理しました。
なぜ単一のプロンプトでは負けるのか
ハルシネーションを「Claude が嘘をつく問題」と単純化すると、対策を間違えます。私の経験では、本番で起きるハルシネーションは少なくとも3種類に分類できます。
事実捏造型 : 学習データにない固有名詞・数値・URL を、それらしく作ってしまうケース。電話番号・人名・価格表などで起きやすく、ビジネスインパクトが最も大きい。
推論誤り型 : 与えられた情報の解釈を間違えるケース。コンテキストには「キャンセル料は 30 日前まで無料」と書かれているのに、「いつでも無料」と回答してしまう。
指示無視型 : 「JSON で返してください」と指定したのに散文で返す、「ですます調で」と書いたのに突然丁寧語を崩す。これも広い意味でハルシネーションです。
この3つは原因が異なります。事実捏造型は「知らないことを正直に認めない」問題、推論誤り型は「コンテキストを正しく読まない」問題、指示無視型は「フォーマットを守る注意力が切れる」問題です。
プロンプトの書き方だけで全部に対処しようとすると、プロンプトが肥大化し、皮肉なことに全体の精度が落ちます。私が出した結論は、層を分けて、それぞれ別の技術で対処するというものでした。
レイヤー1: 入力グラウンディングで「答える材料」を制約する
最も効くのは、Claude に「自分の知識から答えてはいけない」状況を作ることです。RAG(Retrieval-Augmented Generation)の基本ですが、グラウンディングを「強い」契約として設計するのが本番品質の鍵になります。
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic ();
interface RetrievedDoc {
id : string ;
title : string ;
content : string ;
source_url : string ;
}
async function answerWithGrounding (
question : string ,
docs : RetrievedDoc []
) : Promise < string > {
// ❌ よくある失敗例: docs を「参考にしてください」と書く
// ✅ 本番では「これら以外を根拠にしたら違反」という強い契約にする
const groundedSystem = `あなたは社内ナレッジボットです。
絶対ルール:
1. 以下の <documents> セクション内の情報のみを根拠に回答してください
2. <documents> に書かれていない事実は、たとえ一般常識でも回答に含めないでください
3. 質問に答えるための情報が不足している場合は「提供された情報からは判断できません」と答えてください
4. 推測・補完は禁止です。固有名詞・数値・日付・URL は <documents> から逐字引用してください
回答フォーマット: 必ず最後に "根拠ID: [doc_id1, doc_id2]" の形式で参照したドキュメントIDを列挙してください` ;
const docsBlock = docs
. map (( d ) => `<document id="${ d . id }" title="${ d . title }"> \n ${ d . content } \n </document>` )
. join ( " \n\n " );
const response = await client.messages. create ({
model: "claude-sonnet-4-5" ,
max_tokens: 1024 ,
system: groundedSystem,
messages: [
{
role: "user" ,
content: `<documents> \n ${ docsBlock } \n </documents> \n\n 質問: ${ question }` ,
},
],
});
const text = response.content
. filter (( b ) : b is Anthropic . TextBlock => b.type === "text" )
. map (( b ) => b.text)
. join ( " \n " );
return text;
}
// 使用例:
// const answer = await answerWithGrounding("返金ポリシーは?", retrievedDocs);
// console.log(answer);
// 期待される出力例:
// 「キャンセルは購入日から30日以内であれば全額返金されます。
// 根拠ID: [doc_42]」
ここで重要なのは「絶対ルール」を3つ以上重ねて書くことです。ルールが1つだと曖昧な状況で破られますが、3〜5個を箇条書きにすると、互いに補強し合って違反率が体感で半分以下になります。これは私が AB テストで何度か確認した結果で、Claude の応答パターンに対する非自明な事実の一つです。
なお、<documents> タグで囲むのは Anthropic 推奨のプロンプト構造です。--- のような Markdown 区切りより、XML タグの方が Claude の attention が確実にロックされる傾向があります。
レイヤー2: Tool Use で「分からないときの逃げ道」を作る
グラウンディングだけでは「Claude がドキュメントを誤読する」推論誤り型を防げません。ここで効くのが、Claude に「答えられない」と宣言する手段を Tool Use として与えることです。
const escapeHatchTools : Anthropic . Tool [] = [
{
name: "answer_with_citation" ,
description:
"ユーザーの質問に、提供されたドキュメントを根拠として回答する。" +
"回答内容と、根拠となるドキュメントIDを必ず指定すること。" ,
input_schema: {
type: "object" as const ,
properties: {
answer: { type: "string" , description: "ユーザー向けの回答本文" },
citations: {
type: "array" ,
items: { type: "string" },
description: "根拠としたドキュメントIDのリスト(最低1件必須)" ,
},
confidence: {
type: "string" ,
enum: [ "high" , "medium" , "low" ],
description: "回答の確信度" ,
},
},
required: [ "answer" , "citations" , "confidence" ],
},
},
{
name: "decline_to_answer" ,
description:
"提供されたドキュメントから回答できない場合、または質問が範囲外の場合に使用する。" +
"推測で答える代わりに必ずこのツールを呼び出すこと。" ,
input_schema: {
type: "object" as const ,
properties: {
reason: {
type: "string" ,
enum: [ "insufficient_information" , "out_of_scope" , "ambiguous_question" ],
},
suggested_action: {
type: "string" ,
description: "ユーザーへの代替案(例: 担当部署の問い合わせ先、追加情報の依頼)" ,
},
},
required: [ "reason" , "suggested_action" ],
},
},
];
async function answerWithEscapeHatch ( question : string , docs : RetrievedDoc []) {
const docsBlock = docs
. map (( d ) => `<document id="${ d . id }">${ d . content }</document>` )
. join ( " \n " );
const res = await client.messages. create ({
model: "claude-sonnet-4-5" ,
max_tokens: 1024 ,
tools: escapeHatchTools,
tool_choice: { type: "any" }, // 必ずツールを使わせる
system:
"回答可能なら answer_with_citation を、不可能なら decline_to_answer を必ず呼び出してください。" +
"通常のテキスト応答は禁止です。" ,
messages: [
{ role: "user" , content: `<documents> \n ${ docsBlock } \n </documents> \n\n ${ question }` },
],
});
const toolUse = res.content. find (
( b ) : b is Anthropic . ToolUseBlock => b.type === "tool_use"
);
if ( ! toolUse) {
throw new Error ( "Claude returned no tool call - check tool_choice config" );
}
return { tool: toolUse.name, input: toolUse.input };
}
なぜこれが効くのか。テキストで「答えられないと言ってください」とお願いするのと、decline_to_answer ツールを呼ばせるのとでは、Claude の内部処理が変わります。ツールはスキーマ検証付きの「行動」なので、Claude は「曖昧に答えてしまう」誘惑に負けにくくなります。
実装上のコツは tool_choice: { type: "any" } を必ず指定することです。これがないと Claude は「ツールを使うかテキストで返すか」を勝手に判断し、結果テキスト応答が漏れます。本番ではこの一行の有無で違反率が大きく変わりました。
confidence フィールドを必須にしたのも意図的です。Claude に自分の確信度を申告させると、low のときに後段でユーザーに「念のため確認します」と添える分岐が組めます。
レイヤー3: Citation API で出典を機械的に強制する
Anthropic の Citations 機能 を使うと、回答の各文に「引用元のテキストの正確な位置」を Claude が自動で付与してくれます。これは入力ドキュメントに含まれていない文字列を生成しにくくする、強力な事実捏造対策になります。
async function answerWithCitations ( question : string , docs : RetrievedDoc []) {
const documentBlocks = docs. map (( d ) => ({
type: "document" as const ,
source: {
type: "text" as const ,
media_type: "text/plain" as const ,
data: d.content,
},
title: d.title,
citations: { enabled: true },
}));
const res = await client.messages. create ({
model: "claude-sonnet-4-5" ,
max_tokens: 1024 ,
messages: [
{
role: "user" ,
content: [ ... documentBlocks, { type: "text" , text: question }],
},
],
});
// 各 text block に citations 配列が付く
const annotated = res.content. flatMap (( block ) => {
if (block.type !== "text" ) return [];
return [
{
text: block.text,
citations: block.citations ?? [],
},
];
});
// citations が空のセグメントは「出典がない=ハルシネーションリスク高」として警告
for ( const seg of annotated) {
if (seg.text. trim (). length > 30 && seg.citations. length === 0 ) {
console. warn ( `⚠️ 出典なしセグメント検出: "${ seg . text . slice ( 0 , 50 ) }..."` );
}
}
return annotated;
}
Citation を有効にすると、Claude は「引用できない文章を書きにくくなる」という認知的な制約を受けます。出力の各文に cited_text(引用元の正確な文字列)が紐付くので、後段で「引用が空のセグメントは要レビュー」「引用元に存在しない数字は除外」といった機械的な事後フィルタが組めます。
レイヤー4: Self-consistency check で不安定な回答を弾く
ここからが本番品質の領域です。同じ質問を Claude に複数回(典型的には 3〜5 回)投げ、回答が安定しているかを確認します。事実捏造は確率的に発生するため、複数回試すと内容が揺らぎます。安定して同じ事実を返すなら信頼度が高い、という前提です。
import crypto from "node:crypto" ;
interface SelfConsistencyResult {
consensus_answer : string | null ;
agreement_rate : number ;
variations : string [];
}
async function selfConsistencyAnswer (
question : string ,
docs : RetrievedDoc [],
samples = 5
) : Promise < SelfConsistencyResult > {
const docsBlock = docs. map (( d ) => `<doc id="${ d . id }">${ d . content }</doc>` ). join ( " \n " );
// temperature を上げて意図的に多様性を出し、それでも一致するかを見る
const responses = await Promise . all (
Array. from ({ length: samples }, () =>
client.messages. create ({
model: "claude-sonnet-4-5" ,
max_tokens: 256 ,
temperature: 0.7 ,
system:
"提供されたドキュメントから事実を1文で抽出してください。説明や前置きは不要です。" ,
messages: [
{ role: "user" , content: `<docs>${ docsBlock }</docs> \n\n 質問: ${ question }` },
],
})
)
);
const variations = responses. map (( r ) =>
r.content
. filter (( b ) : b is Anthropic . TextBlock => b.type === "text" )
. map (( b ) => b.text. trim ())
. join ( "" )
);
// 完全一致でグループ化(より高度には embedding でクラスタリング)
const counts = new Map < string , number >();
for ( const v of variations) {
const key = normalize (v); // 表記揺れを正規化
counts. set (key, (counts. get (key) ?? 0 ) + 1 );
}
const sorted = Array. from (counts. entries ()). sort (( a , b ) => b[ 1 ] - a[ 1 ]);
const top = sorted[ 0 ];
return {
consensus_answer: top && top[ 1 ] >= Math. ceil (samples / 2 ) ? top[ 0 ] : null ,
agreement_rate: top ? top[ 1 ] / samples : 0 ,
variations,
};
}
function normalize ( s : string ) : string {
return s
. replace ( / \s + / g , " " )
. replace ( / [「」『』。、,.\s] / g , "" )
. toLowerCase ();
}
実運用では agreement_rate >= 0.6 を信頼閾値にしています。それ未満なら「回答が不安定なので人間に確認してください」と返すか、Opus にエスカレーションする分岐を入れます。
トレードオフは明らかで、API コールが 5 倍になりレイテンシも遅くなります。本番では「重要な決済額の表示」「医療・法律情報の回答」など、誤りの代償が大きい処理にだけ self-consistency を適用するのが現実的です。私のシステムでは、全リクエストの 5% 程度に絞って適用しています。
レイヤー5: LLM-as-judge で生成後に独立検証する
これが多層防御の最も強力な一手です。生成した回答を、別の Claude セッション(system プロンプトを分けた完全な独立判定)に「事実かどうか」を判定させます。
interface JudgeVerdict {
verdict : "supported" | "unsupported" | "partially_supported" ;
reasoning : string ;
problematic_claims : string [];
}
async function judgeAnswer (
question : string ,
answer : string ,
source_docs : RetrievedDoc []
) : Promise < JudgeVerdict > {
const docsBlock = source_docs. map (( d ) => `[${ d . id }] ${ d . content }` ). join ( " \n\n " );
// ❗ judge モデルには「厳しく評価する」役割を明示
// 甘い judge は意味がないため、敵対的な視点を強要する
const judgeSystem = `あなたは事実検証の監査官です。生成AIの回答が、提供された参照ドキュメントから本当に支持されているかを厳格に判定します。
判定基準:
- 回答中の固有名詞・数値・日付・URL は、参照ドキュメントに「逐字レベル」で存在する場合のみ supported
- 「常識的に正しい」「推測でこうだろう」は全て unsupported
- 部分的に正しいが、一部にドキュメントに無い情報が混入している場合は partially_supported
- 回答が問題ない場合でも、潜在的な不正確さがあれば必ず指摘すること
問題のある主張は problematic_claims に「主張: 問題点」の形式で1件ずつ列挙してください。` ;
const res = await client.messages. create ({
model: "claude-opus-4-6" , // judge は強いモデルを使う
max_tokens: 1024 ,
system: judgeSystem,
tools: [
{
name: "submit_verdict" ,
description: "事実検証の結果を提出する" ,
input_schema: {
type: "object" as const ,
properties: {
verdict: {
type: "string" ,
enum: [ "supported" , "unsupported" , "partially_supported" ],
},
reasoning: { type: "string" },
problematic_claims: { type: "array" , items: { type: "string" } },
},
required: [ "verdict" , "reasoning" , "problematic_claims" ],
},
},
],
tool_choice: { type: "tool" , name: "submit_verdict" },
messages: [
{
role: "user" ,
content:
`参照ドキュメント: \n ${ docsBlock } \n\n ` +
`ユーザーの質問: ${ question } \n\n ` +
`AI の回答: ${ answer } \n\n ` +
`この回答は参照ドキュメントから支持されているか判定してください。` ,
},
],
});
const tool = res.content. find (
( b ) : b is Anthropic . ToolUseBlock => b.type === "tool_use"
);
if ( ! tool) throw new Error ( "Judge did not return verdict" );
return tool.input as unknown as JudgeVerdict ;
}
// パイプライン全体の合成例
async function safeAnswer ( question : string , docs : RetrievedDoc []) {
const draft = await answerWithGrounding (question, docs);
const verdict = await judgeAnswer (question, draft, docs);
if (verdict.verdict === "unsupported" ) {
return {
shown_to_user: "申し訳ありません、確実に回答できる情報がありませんでした。" ,
internal_log: { draft, verdict },
};
}
if (verdict.verdict === "partially_supported" ) {
// 問題のある主張を Opus に修正させる二段階生成
const refined = await refineWithFeedback (draft, verdict.problematic_claims, docs);
return { shown_to_user: refined, internal_log: { draft, verdict, refined } };
}
return { shown_to_user: draft, internal_log: { draft, verdict } };
}
async function refineWithFeedback (
draft : string ,
issues : string [],
docs : RetrievedDoc []
) : Promise < string > {
const docsBlock = docs. map (( d ) => `[${ d . id }] ${ d . content }` ). join ( " \n " );
const res = await client.messages. create ({
model: "claude-opus-4-6" ,
max_tokens: 1024 ,
system: "事前ドラフトと指摘された問題点を踏まえ、参照ドキュメントから支持される表現に書き直してください。書き直した本文のみを返してください。" ,
messages: [
{
role: "user" ,
content:
`参照: ${ docsBlock } \n\n ドラフト: ${ draft } \n\n 問題点: \n ${ issues . join ( " \n " ) }` ,
},
],
});
return res.content
. filter (( b ) : b is Anthropic . TextBlock => b.type === "text" )
. map (( b ) => b.text)
. join ( "" );
}
このパターンには明確な理由があります。生成側 Claude が「自分の出力を自分で評価する」と、確信バイアスがかかって甘い判定になります。だから judge は別セッション、できれば異なるモデル(生成は Sonnet、judge は Opus)を使うのが効果的です。
私はこのパイプラインを通すことで、ハルシネーション起因のクレームをほぼゼロにできました。コストは2〜3倍になりましたが、判定 → 拒否 → ユーザーへの謝罪、という事故対応コストを考えれば安価です。
レイヤー6: スキーマと型システムで「形」を強制する
指示無視型のハルシネーション、つまり「JSON で返してと言ったのに散文で返す」「型が合わない」問題には、Tool Use のスキーマ検証と Zod のような実行時バリデーションを組み合わせます。
import { z } from "zod" ;
const InvoiceExtractionSchema = z. object ({
invoice_number: z. string (). regex ( / ^ [A-Z0-9-] +$ / ),
total_amount: z. number (). positive (),
currency: z. enum ([ "JPY" , "USD" , "EUR" ]),
issued_date: z. string (). regex ( / ^ \d {4} - \d {2} - \d {2}$ / ),
line_items: z
. array (
z. object ({
description: z. string (). min ( 1 ),
quantity: z. number (). int (). positive (),
unit_price: z. number (). nonnegative (),
})
)
. min ( 1 ),
});
type Invoice = z . infer < typeof InvoiceExtractionSchema>;
async function extractInvoice ( rawText : string , maxRetries = 2 ) : Promise < Invoice > {
let attempts = 0 ;
let lastError : string | undefined ;
while (attempts <= maxRetries) {
const res = await client.messages. create ({
model: "claude-sonnet-4-5" ,
max_tokens: 2048 ,
tools: [
{
name: "submit_invoice" ,
description: "請求書から構造化データを抽出する" ,
input_schema: {
type: "object" as const ,
properties: {
invoice_number: { type: "string" },
total_amount: { type: "number" },
currency: { type: "string" , enum: [ "JPY" , "USD" , "EUR" ] },
issued_date: { type: "string" },
line_items: {
type: "array" ,
items: {
type: "object" ,
properties: {
description: { type: "string" },
quantity: { type: "number" },
unit_price: { type: "number" },
},
required: [ "description" , "quantity" , "unit_price" ],
},
},
},
required: [
"invoice_number" ,
"total_amount" ,
"currency" ,
"issued_date" ,
"line_items" ,
],
},
},
],
tool_choice: { type: "tool" , name: "submit_invoice" },
messages: [
{
role: "user" ,
content:
(lastError
? `前回のエラー: ${ lastError } \n 修正して再送してください。 \n\n `
: "" ) + `請求書テキスト: \n ${ rawText }` ,
},
],
});
const tool = res.content. find (
( b ) : b is Anthropic . ToolUseBlock => b.type === "tool_use"
);
if ( ! tool) {
lastError = "ツール呼び出しがありませんでした" ;
attempts ++ ;
continue ;
}
const parsed = InvoiceExtractionSchema. safeParse (tool.input);
if (parsed.success) return parsed.data;
lastError = parsed.error.issues
. map (( i ) => `${ i . path . join ( "." ) }: ${ i . message }` )
. join ( "; " );
attempts ++ ;
}
throw new Error ( `Schema validation failed after ${ maxRetries + 1 } attempts: ${ lastError }` );
}
ここで重要なのは、エラー時に「前回のエラー内容」を次の試行のプロンプトに含めることです。Claude は自分のミスを伝えられると、次の試行で精度が大きく上がります。これは私が実測した結果で、リトライ回数を 3 回まで許容するとほぼ 100% 成功するようになりました。
JSON Schema レベルの enum・required・regex に加えて、Zod の regex(/^\d{4}-\d{2}-\d{2}$/) のような追加検証を入れると、Claude が 2026-13-45 のような形式は合っているが値が不正な日付を返してきても弾けます。
本番運用で実際に踏んだ落とし穴
理論を組んだ後、実際に運用すると以下のような問題に必ず遭遇します。事前に知っておくと数日分の調査時間を節約できます。
Judge モデルが甘くなる問題 : judge の system プロンプトに「厳格に判定してください」と書くだけでは効果が薄いです。「敵対的な視点で粗探しをしてください」「partially_supported を積極的に使ってください」といった、判定の方向性を明示する文言を入れると改善します。私は judge の精度を週次で人間レビュー(10サンプルだけ)と比較して、甘くなり始めたらプロンプトを見直しています。
Citation API で cited_text が空文字を返すケース : ドキュメントが極端に長い場合や、参照箇所が文の途中の場合に発生します。citations 配列が空のセグメントは必ず警告ログに残し、人間レビューに回す設計にしておくのが安全です。
Self-consistency の結果が「全て違う」: 質問が曖昧すぎるシグナル : 5 回の応答が全部違う場合、ハルシネーションよりも「質問が解釈の余地が大きすぎる」可能性が高いです。この場合は Claude に回答させる前に、ユーザーに質問の意図を確認する逆質問を返す分岐を入れると体験が良くなります。
Tool Use のスキーマが複雑すぎると Claude が嘘をつく : 入れ子が3階層以上、required が10個以上のスキーマだと、Claude は構造を満たすために値を捏造します。スキーマは「人間が30秒で理解できる程度」に分割し、複雑な抽出は2段階のツール呼び出しに分けるのが鉄則です。
Judge コストが想定の3倍になる問題 : judge 用に Opus を使うとコストが跳ね上がります。私は最終的に「Sonnet で生成 → Sonnet で judge → unsupported のときだけ Opus で再 judge」という3段階フィルタに落ち着きました。Opus の出番を10%以下に抑えると、品質を保ちつつコストが許容範囲に収まります。
プロンプトキャッシングと相性が悪い : judge セッションは入力が毎回異なる(生成された回答を含むため)ので、プロンプトキャッシュが効きにくいです。docs 部分だけでもキャッシュ対象に含めると改善します。詳しくはClaude API のプロンプトキャッシングで月額コストを半減させる実装手順 を参照してください。
閾値の「正解」は業務ドメインで違う : agreement_rate の閾値や、partially_supported を許容するかは、ドメインによって違います。医療系なら 0.9 でも甘いし、雑談 bot なら 0.5 で十分です。本番投入前に、自分のドメインで 100 件程度の手動評価を実施し、閾値を決めることを強く推奨します。
計測なしには「飛行中の盲目操縦」になる
防御アーキテクチャは、本番でその挙動が観測できて初めて意味を持ちます。レイヤーを組み終えたら、次の作業はその振る舞いを可視化することです。私はどの本番システムでも、3つのカテゴリの計測値を必ず取り続けています。これがないとこれらの防御は運用できません。
第1のカテゴリはレイヤーごとの拒否率です。グラウンディングが「答えられない」と返した割合、escape hatch が発火した割合、引用なしセグメントが出た割合、judge が unsupported を返した割合――これらをそれぞれ独立に記録します。これらの数値は急激に変動してはいけません。escape hatch の発火率が突然上がったら、上流の検索パイプラインが劣化したサインのことが多いですし、judge の拒否率が急増したらモデル更新でプロンプトの追従が必要になったサインです。
第2のカテゴリはレイヤーごとのレイテンシです。Self-consistency は 3〜5 倍、judge はモデルが大きい分さらに 1.5 倍ほどレイテンシを伸ばします。これをレイヤー別に分解しないと、遅いリクエストの原因究明に何時間も費やすことになります。私は OpenTelemetry でレイヤーごとに span を発行し、p50・p95・p99 をルート別に追っています。
第3のカテゴリは人間レビューサンプリングです。自動化されたレイヤーを全部入れた上で、本番回答の 200 件に 1 件程度を人間レビューに回します。コストは小さく、価値は絶大です。「judge が甘くなった」「グラウンディング自体は正しいが回答のトーンが冷たすぎる」といった、機械では検出しづらいパターンはここでしか見つかりません。サンプリングされた人間判定は回帰テストセットにも追加しておき、新しいモデル版やプロンプト変更を本番に出す前に必ずパスさせます。
3つの計測のうち1つだけ採用するなら、人間レビューサンプリングを選んでください。自動メトリクスは「何かが変わった」ことを教えてくれますが、「何をすればよいか」を教えてくれるのは人間の判定だけです。
コストの現実: 各レイヤーが買ってくれるもの
このアーキテクチャを見て最初に出る反応は「高そう」というものです。実際に高いです。私の高重要度トラフィックでは、全7レイヤーをかけると単一の Claude コールに比べて 4〜6 倍のコストになります。問うべきは「払えるか」ではなく「何を買っているか、その値段は妥当か」です。
レイヤー1(グラウンディング)は実質ゼロコストです。プロンプト設計だけなので、数リクエストで元が取れます。
レイヤー2(escape hatch)は API コストはほぼ追加されず、tool use 1 回分のレイテンシだけ増えます。Claude が捏造の代わりに撤退するようになる――この一点だけで、ユーザー信頼の改善幅はリスト全体で最大の ROI です。
レイヤー3(Citations API)は小さなトークンオーバーヘッドだけで、規模が大きくなれば実質ほぼ無料です。参照ドキュメントが自明でないリクエスト全部にかけて構いません。
レイヤー6(スキーマ)は定常状態ではほぼ無料です。リトライ経路が走るのはバリデーション失敗時だけで、スキーマ設計が良ければそれは稀です。
実コストが乗るのはレイヤー4(self-consistency)とレイヤー5(judge)です。Self-consistency はサンプル数倍のコスト、judge はそれ自体が完全な推論を1回追加します。だからこそ「誤答コストが検証コールのコストを明確に上回るルート」だけに適用するのです。
実務では「インシデント等価コスト」――顧客への謝罪・返金・人手介入の平均コスト――を基準にします。あるルートで誤答が起きたとき 1 件 5,000 円のインシデントコストが発生し、誤答率が 1% なら、リクエストあたり 40 円程度の検証コストは安い買い物です。
このアーキテクチャでも防げないもの
正直な記録を残すなら、フルスタックを組んでも解決しないカテゴリがあることも書いておきます。これを認めずに「もう大丈夫」と思い込むのが、運用初期に最もよくある罠です。
第1の弱点は過剰な保守化です。レイヤーを重ねていくと、Claude は本来答えられるはずの質問でも撤退するようになります。私が実測した例では、judge を追加してから撤退率が 4% から 11% に上がり、その何件かは人間なら迷わず答えていた質問でした。対策は、judge プロンプトに「正確性を報酬する」要素を入れること、そして「誤検出としての撤退率」を「捏造の見逃し率」と同じくらい慎重にモニタリングすることです。
第2の弱点は共有エラーモードです。検索パイプラインが間違ったドキュメントを返したら、いくらグラウンディングを強くしても助かりません。Claude は無関係なテキストに忠実にグラウンディングしてしまいます。Citations も judge も、入力そのものがズレていたら無力です。だから私は検索品質を「別の関心事」ではなく「防御の第一線」として扱います。週次で小サンプルでも検索品質の eval を回すと、ユーザーに見える誤りになる前にズレを検知できます。
第3の弱点は新規の敵対的入力です。ユーザー入力に紛れ込んだプロンプトインジェクションは、グラウンディングを迂回することがあります。ドキュメント自体に攻撃者が書き込めるシステム(コミュニティ投稿の取り込み等)では特に深刻です。レイヤー6のスキーマ・enum 制約は表面積を減らしますが、ユーザーコンテンツを取り込むシステムは常にインジェクションの攻撃面に晒されているという前提で運用すべきです。
最後に、このアーキテクチャは「事実誤認以外」の害は対象にしていません。完璧にグラウンディングされた回答が、文化的に不適切だったり、法的にリスクがあったり、ブランドボイスとずれていたりすることはあります。これらは別レビュー層――モデレーション、ペルソナ強制、コンテンツポリシーチェック――が必要で、ここで述べたハルシネーション対策とは直交した設計が要ります。
防御の組み合わせをどう選ぶか
7つすべてを全リクエストに適用するとコストが爆発します。私が運用しているシステムでは、リクエストの「重要度」によって適用する層を変えています。
低重要度(FAQ・カジュアルな雑談など)には、レイヤー1(グラウンディング)+ レイヤー2(escape hatch)だけ。これでも体感の品質は十分高まります。中重要度(製品仕様の説明・業務手順案内)にはレイヤー3(Citation)+ レイヤー6(スキーマ)を追加。高重要度(医療・法律・金融情報)にはレイヤー4(self-consistency)+ レイヤー5(judge)を全乗せします。
具体的な実装パターンや、本番投入直後に必ず作るべきモニタリングダッシュボードについては、マルチエージェントを本番投入する設計パターンの実装ガイドも合わせて読むと、防御層と監視を一体で設計するイメージが掴めます。
理論的な背景
明日からできる最初の一歩
この記事の中で1つだけ実装するなら、レイヤー2(escape hatch ツール)から始めてください。既存のシステムにツール定義を追加するだけで実装でき、効果を即日体感できます。tool_choice: { type: "any" } の指定を忘れずに。
そこから「ハルシネーションが起きる頻度を週次で計測する」習慣を作り、計測値が許容範囲を超えたタイミングで次の層を追加していくのが、過剰投資を避けつつ品質を上げていく現実的な進め方です。
完璧な防御は存在しません。残余リスクをどこで吸収するか――ユーザーへの「確証はないかもしれない」という注記、人間レビューへのエスカレーション、サービスとしての責任範囲の明示――こうした非技術的な層と組み合わせて初めて、Claude API は本番品質に到達します。技術と運用の両輪で設計してみてください。