ミニマムな生成AIの対話環境を構築してハルシネーションを軽減させる試み
はじめに
近年、ChatGPTをはじめとする生成AIの台頭により、Google検索を使う代わりに生成AIに問い合わせする機会が増えてきました。 しかし、レスポンスは断定口調で返却されるにもかかわらず、実際にはハルシネーションを起こしていた、ということが依然として発生しており、情報の信頼性を担保する難しさを感じています。
ハルシネーションを軽減するための施策は様々ありますが、今回は異なる言語モデルを用いて、それぞれを回答生成役・検証役に分けロールプレイをさせることによって、質問に対する検証を行うミニマムな対話環境を構築しました。
目的
- シンプルかつミニマムに構築する
- 1をキープした上で精度を上げる工夫をする
ミニマムにやってみる
筆者は普段Claudeを利用しているため、実装のシンプルさの観点からSonnetとOpusの議論モデルを利用することを検討しました。しかし、単一ベンダのモデル同士では、訓練データや学習手法の傾向が似ているため、同じ誤りを見逃す可能性があります。異なるベンダのモデルを組み合わせることで、片方が持つバイアスや知識の欠落をもう片方が補完できると考え、以下の構成を採用しました。
- 回答生成モデル:claude-sonnet-4-5-20250929
- 検証・修正提案モデル:gemini-2.5-flash-lite
実際にLLMに渡している箇所は以下になります。
import Anthropic from '@anthropic-ai/sdk';
import { GoogleGenerativeAI } from '@google/generative-ai';
const anthropic = new Anthropic({ apiKey: ANTHROPIC_API_KEY });
const genAI = new GoogleGenerativeAI(GOOGLE_API_KEY);
async function callClaude(prompt: string, conversationHistory: Message[]): Promise<string> {
const messages = conversationHistory
.map(msg => ({
role: msg.role === 'claude' ? 'assistant' as const : 'user' as const,
content: msg.content
}))
.concat([{ role: 'user' as const, content: prompt }]);
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-5-20250929',
max_tokens: 4096,
messages
});
const content = response.content[0];
return content.type === 'text' ? content.text : '';
}
async function callGemini(prompt: string, conversationHistory: Message[]): Promise<string> {
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash-lite',
generationConfig: {
maxOutputTokens: 4096
}
});
const history = conversationHistory.map(msg => ({
role: msg.role === 'gemini' ? 'model' as const : 'user' as const,
parts: [{ text: msg.content }]
}));
const chat = model.startChat({ history });
const result = await chat.sendMessage(prompt);
return result.response.text();
}
こちらの構成では、ClaudeとGeminiのAPI Keyが必要です。 2025年12月現在では、以下の方法で取得することができます。
Claude APIキーの取得手順 Gemini APIキーの取得手順
実際に動作させる際は、ANTHROPIC_API_KEYとGOOGLE_API_KEYを.envファイルなどに定義し、環境変数から値を取得するようにコードを記述してください。
検証
質問
firebase経由で10000件のPUSH通知を送信するとき、おおまかに何秒くらいかかるか。手法別に検討してください。
回答1
最終回答: Firebase経由で10,000件のプッシュ通知を送信する際の所要時間は、手法によって大きく異なります。
1. **Topic Messaging:**
* **所要時間:** 1-3秒(FCMサーバー側での送信リクエスト完了時間)
* **特徴:** 全デバイスに同一メッセージを送信する場合に最も高速。ただし、これはサーバー処理時間であり、デバイスへの通知到達時間ではありません。
2. **HTTP v1 API (並列送信・バッチ処理):**
* **所要時間:** 10秒未満(1秒あたり数千件の送信レートが期待できる)
* **特徴:** HTTP/2を基盤とし、リクエストごとに複数のメッセージを送信できるバッチ処理により、高いスループットを発揮します。個別のメッセージ送信に最も実用的で高速な方法です。
3. **Admin SDK (sendAllまたはsendMulticastによるバッチ送信):**
* **所要時間:** 20-40秒
* **特徴:** 1リクエストあたり最大500件の送信が可能。10,000件の場合は約20バッチ必要となり、この時間が見込まれます。個別のメッセージ送信に実用的です。
4. **Legacy HTTP API (並列送信):**
* **所要時間:** 10-20秒
* **特徴:** 同時接続数を管理して送信する場合。HTTP v1 APIほどの効率はありません。
5. **Legacy HTTP API (単一送信):**
* **所要時間:** 500-1000秒 (8-17分)
* **特徴:** 1リクエストにつき1通知のため、最も時間がかかります。実用的ではありません。
**注記:**
* 上記所要時間は、ネットワーク遅延、サーバーリソース、FCMのレート制限などの要因によって変動する可能性があります。
* Topic Messagingの1-3秒は、あくまでFCMサーバーが送信リクエストを処理するまでの時間であり、デバイスへの通知到達までの時間ではありません。
* HTTP v1 APIは、レガシーAPIよりも効率的で高いスループットが期待できます。
回答2
最終回答:Firebase経由で10,000件のプッシュ通知を送信する際のおおよその所要時間は、手法によって以下のようになります。
1. **Admin SDK (Node.js) の `sendAll`**:
* **所要時間**: 10-20秒 (5-10並列実行時)、20-80秒 (直列実行時)
* **解説**: `sendAll` は最大1000件を1バッチで送信します。10,000件には最低10バッチが必要です。Node.jsの非同期処理 (`Promise.all`) を用いた並列実行により、大幅な時間短縮が可能です。
2. **HTTP v1 API の `batchSend`**:
* **所要時間**: 10-30秒 (並列最適化時)、20-100秒 (直列実行時)
* **解説**: `batchSend` エンドポイントは最大500件を1リクエストで送信します。10,000件には最低20リクエストが必要です。Admin SDKと同様に、並列実行で高速化されます。
3. **Legacy FCM API (非推奨)**:
* **所要時間**: 20-40秒
* **解説**: 最大1000件を1バッチで送信可能でした。10,000件には10リクエストが必要です。このAPIは2024年6月に廃止されています。
4. **Cloud Functions for Firebase経由**:
* **所要時間**: 60-300秒
* **解説**: コールドスタートの遅延、関数の並列実行数、タイムアウト設定に依存します。Admin SDKを直接使用する場合に比べて、関数起動などのオーバーヘッドが追加されるため、一般的に時間がかかります。
**最速の実現方法**:
Admin SDKの `sendAll` またはHTTP v1 APIの `batchSend` を、10-20並列で実行することです。これにより、**10-20秒程度** で完了する可能性があります。
**注意点**:
上記時間はあくまで目安であり、実際の所要時間はネットワーク環境、Firebaseバックエンドの負荷、実行環境のスペック、メッセージの複雑さなど、多くの要因によって変動します。並列度を上げすぎると、コネクション管理のオーバーヘッドやFirebase側のレート制限に抵触する可能性があるため、適切な調整が必要です。
起きた問題
検証している中で、以下の問題が発生しました。
-
途中から会話が意味のない文章で構成されるようになり、議論が飽和する
- お礼を言い合ってお互いを褒め称える事象が観測された
-
検証に用いるGeminiのトークンが枯渇してエラーになってしまった
- 検証の方がレスポンスサイズが大きいので、その分でリソースを食っているのかも
- Outputのトークンサイズを調整することで解決
-
同一の質問を複数回投げかけた時、回答に一意性がない
- ハルシネーションを排除できているなら一意性が発生するはず
- サンプルとして「firebase経由で10000件のPUSH通知を送信するとき、おおまかに何秒くらいかかるか。手法別に検討してください。」と5回リクエストしたところ、それぞれ想定されるレスポンスタイムが異なった
- ただし大枠は一致しており、「どの手法が最速か」という点においては一致した
まとめ
異なるモデルの組み合わせの有効性
サンプルの質問例では、具体的な数値にばらつきはあったものの、「どの手法が最速か」という大枠の結論においては一貫性が見られました。 この結果は、異なる言語モデルのLLMにロールプレイ対話をさせることで、検証役がハルシネーションの発生を抑制している可能性を示唆しています。 が、細かい数値を見るとレスポンスごとに若干の差異があることから、より信頼性を担保するには、さらなるプロンプトの追加や、質問をしたい領域に絞ったRAGの導入などのアプローチが必要そうです。
初手レスポンスの重要性
同一の質問を複数回投げかけた際に回答の一意性が損なわれるという問題は、おそらく初手のレスポンス形式が議論の土台となり、検証の効率と精度を大きく左右していると考えられます。 初手のレスポンスをベースにしてそれ以降の議論が検証されるため、初手のレスポンスの形式をいかに固定するかが鍵になりそうです。


