はじめに
複数のAI(GPT-4、Claude、Gemini)に議論させるWebアプリを開発しました。技術スタックはCloudflare Workers + Next.jsで、ほぼ無料枠で動いています。
この記事では、LLMに「立場」を守らせるために試行錯誤した内容を共有します。
作ったもの
2~4体のAIエージェントが、あるトピックについて賛成・反対・中立などの立場から議論するシステムです。
システム仕様
- エージェント数: 2-4体(選択可能)
- 議論ラウンド数: 1-5回
- 同時実行セッション: 最大10件(Stage 1の制限)
- 処理時間: 60-180秒(エージェント数とラウンド数による)
議論の例
トピック:「リモートワークを継続すべきか」
3エージェント構成の場合:
- Agent1 (GPT-4): 賛成派
- Agent2 (Claude): 反対派
- Agent3 (Gemini): 中立派
4エージェント構成の場合:
- Agent1 (GPT-4): 賛成派
- Agent2 (Claude): 反対派
- Agent3 (Gemini): 中立派
- Agent4 (GPT-4): ユーザー視点代弁者
問題:AIが立場を守らない
現象1: Claudeの寝返り
賛成派に設定したClaudeが、2ラウンド目で突然こんな発言:
「前回の反対派の指摘は的を射ています。私も再考すると...」
賛成派なのに反対派に同調してしまう問題が頻発しました。
現象2: Geminiの哲学モード
中立派のGeminiが議論をまとめる際:
「しかし、人生とは選択の連続であり、我々は皆...」
具体的な結論を避けて抽象論に逃げる傾向がありました。
現象3: GPT-4の優等生病
反対派のGPT-4が必ず最後に:
「ただし、これは一つの視点に過ぎず、賛成派の意見にも一理あります」
と付け加えてしまい、議論が成立しません。
解決策:モデル別の対処法
1. プロンプトでの立場強制
単に「賛成の立場で」と指示するだけでは不十分でした。以下のような多重の制約が必要:
const STANCE_RULES = {
supporting: `
あなたは絶対的な賛成派です。
以下のルールを厳守してください:
1. 冒頭で必ず「私は賛成の立場から述べます」と宣言
2. 反対意見には必ず反論する
3. 結論は必ずポジティブにする
4. 「しかし」「ただし」で始まる譲歩文を使わない
5. 最後に「以上が賛成派としての主張です」で締める
`,
opposing: `
あなたは絶対的な反対派です。
以下のルールを厳守してください:
1. 冒頭で必ず「私は反対の立場から述べます」と宣言
2. 賛成意見の欠点を必ず指摘する
3. 結論は必ずネガティブにする
4. 部分的な同意も表明しない
5. 最後に「以上が反対派としての主張です」で締める
`
};
2. モデル別パラメータ調整
各LLMの「性格」に合わせた細かい調整が必要でした:
const MODEL_SETTINGS = {
'gpt-4': {
temperature: 0.3, // 低めで一貫性を保つ
top_p: 0.8, // 多様性を制限
frequency_penalty: 0.5, // 同じ表現の繰り返しを防ぐ
},
'claude-3': {
temperature: 0.4, // GPTより少し高め
top_p: 0.9,
// Claudeは特に明示的な指示が有効
system: "You must argue your position strongly. No hedging."
},
'gemini-pro': {
temperature: 0.2, // 最も低くして具体性を保つ
top_p: 0.7,
// 抽象化を防ぐ
system: "Be specific. Use numbers and facts. No metaphors."
}
};
3. レスポンスの後処理
それでも完全には防げないので、後処理で修正:
function cleanResponse(text: string, role: string, model: string): string {
let cleaned = text;
if (role === 'supporting') {
// 譲歩表現を削除
cleaned = cleaned.replace(/ただし.*?。/g, '');
cleaned = cleaned.replace(/一方で.*?。/g, '');
}
if (model.includes('gemini')) {
// 哲学的な締めを削除
cleaned = cleaned.replace(/人生とは.*$/g, '');
cleaned = cleaned.replace(/結局のところ.*$/g, '');
}
if (model.includes('claude')) {
// 過度な慎重さを削除
cleaned = cleaned.replace(/重要なのは.*?という点です/g, '');
}
return cleaned;
}
効果測定
100回の議論セッション(2-4エージェント、3-5ラウンド)で立場維持率を測定:
| モデル | 対策前 | 対策後 |
|---|---|---|
| GPT-4 | 72% | 94% |
| Claude-3 | 58% | 89% |
| Gemini Pro | 65% | 91% |
※立場維持率:設定した立場を最後まで守った割合
実装上の工夫
メモリ効率の改善
Cloudflare Workersはメモリ制限が厳しいため、議論の文脈を効率的に管理:
// 直近2ラウンドのみ保持(エージェント数に応じて動的調整)
function getRecentContext(
history: Message[],
maxRounds: number = 2,
agentCount: number = 3
): string {
const messagesToKeep = maxRounds * agentCount;
const recentMessages = history.slice(-messagesToKeep);
return recentMessages
.map(m => `${m.agent}: ${m.content.substring(0, 200)}...`)
.join('\n');
}
エラーハンドリング
LLM APIは不安定なので、リトライ処理は必須:
async function callLLMWithRetry(
params: any,
maxRetries: number = 3
): Promise<string> {
for (let i = 0; i < maxRetries; i++) {
try {
return await callLLM(params);
} catch (error) {
if (i === maxRetries - 1) throw error;
// 指数バックオフ
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
}
}
throw new Error('Max retries exceeded');
}
今後の改善点
-
コンテキスト管理の最適化
- 現在は単純な文字列結合
- ベクトル埋め込みで重要部分を抽出したい
-
動的なプロンプト調整
- 議論の流れに応じてプロンプトを変更
- 立場が弱まったら強化する
-
より多様なモデル対応
- Mistral、Llama系の追加
- 日本語特化モデルの検討
-
Stage 2への移行準備
- WebSocket対応で リアルタイム更新
- 同時実行数の制限解除
- より高度な分析機能
まとめ
LLMに「役割」を演じさせるのは予想以上に難しく、モデルごとの「性格」を理解して対処する必要がありました。
プロンプトエンジニアリングだけでなく、パラメータ調整と後処理を組み合わせることで、ようやく安定した議論が可能になりました。
現在はCloudflare無料枠の制限内で動作していますが、実用レベルのパフォーマンスを実現できています。
次回は、このシステムでキャッシュ戦略によりAPI費用を70%削減した方法について書く予定です。
※キャッシュの詳細実装(2層構造:メモリ24時間 + DB 7日間永続化)は次回記事で解説します。
βテスト実施中なの是非使ってみてください!


