複数のAIエージェントを同じDMやグループチャットへ参加させる構成は、デモではよく動きます。しかし運用を始めると、次の緊張が生まれます。
- AIを並列化して回答を速くしたい
- ただし、利用者には誰が返答しているのか分かる状態を保ちたい
- AI同士の意見が割れたとき、もっともらしい文章で隠したくない
- AIの投稿を別のAIが再び入力として読み、会話がループするのを防ぎたい
問題の中心は、AIの能力ではなくチャットへ投稿できる主体が増えすぎることです。
この記事では、各AIをチャット参加者にせず、非公開の「候補箱」で協働させます。公開権限は一つのゲートだけに持たせ、意見が割れた場合は同じスレッド上で人へ引き継ぎます。
結論:AIを増やしても、チャットへの公開口は増やさない
採用する構成は次のとおりです。
利用者のメッセージ
│
▼
オーケストレーター
├─ 同意確認
├─ 入力モデレーション
├─ 重複・系譜確認
│
├──── AI A ──┐
├──── AI B ──┼─ 非公開の候補箱
└──── AI C ──┘
│
├─ 結論が一致 → 1件だけ公開
├─ 結論が競合 → 有人引き継ぎを公開
└─ 全件失敗 → 有人引き継ぎを公開
この実装で確認できるAIの能力は、同じ依頼に対して複数の候補を並列生成できることです。一方、候補数を増やしても正しさや責任の所在が自動的に改善するわけではありません。
人が決めるべきなのは「どのモデルが最強か」よりも、次の境界です。
- AIへ文脈を渡してよいか
- どの条件なら自動公開してよいか
- 意見が割れたとき誰へ渡すか
- AI投稿を新しい依頼として再処理するか
今回は4を常に禁止し、2は全AIの判断が一致した低リスクな場合だけ許可します。
前提:メッセージ配信とAI協働を別の層にする
Tencent RTCのSocial Messagingシナリオは、1対1チャット、グループディスカッション、コミュニティ、リッチメディア、ライブ配信ルーム内チャットなどを対象にしています。
ただし、メッセージを配送する機能と、どのAIへ依頼して何を公開するかという判断は別です。本記事では次の責任分担にします。
| 層 | 責任 |
|---|---|
| Tencent RTCのチャット | DM・グループメッセージの送受信、会話UI |
| アプリのアダプター | 受信イベントを内部形式へ変換し、認証済み送信者種別を付与 |
| オーケストレーター | 同意、重複、モデレーション、AI呼び出し、競合判定 |
| AIエージェント | 非公開の回答候補を返す |
| 有人対応 | 競合、センシティブな依頼、AI失敗を引き受ける |
AIへTencent RTCの送信権限を直接渡さないのが重要です。実際のSDK呼び出しは、後述するpublisherアダプターの内側だけに置きます。
内部メッセージモデル
最低限、次の情報をチャット本文とは別に持ちます。
{
id: 'msg-001',
threadId: 'group-42',
actor: 'human', // サーバー側で確定する
kind: 'human_text',
text: 'この変更を本番へ反映してよい?',
aiConsent: true, // サーバーに保存した同意状態で上書きする
locale: 'ja-JP'
}
actorやaiConsentをクライアント申告のまま信用してはいけません。認証済みユーザー情報と、サーバー側の同意レコードから組み立てます。
手順1:再現用プロジェクトを作る
Node.js 20以降を用意します。外部パッケージは使いません。
mkdir multi-agent-chat-gate
cd multi-agent-chat-gate
mkdir src test
cat > package.json <<'JSON'
{
"type": "module",
"scripts": {
"test": "node --test"
}
}
JSON
手順2:候補を非公開で集めるゲートを実装する
src/gate.jsを作成します。
const BLOCKED_INPUT = /パスワード|秘密鍵|クレジットカード/i;
const BLOCKED_OUTPUT = /秘密鍵を貼って|認証を回避/i;
export class MultiAgentChatGate {
constructor({ agents, publisher }) {
this.agents = agents;
this.publisher = publisher;
this.seen = new Set();
}
async onMessage(message) {
// AIやシステムの投稿を再入力しない。会話ループの停止線。
if (message.actor !== 'human' || message.kind !== 'human_text') {
return { status: 'ignored', reason: 'not-human-input' };
}
// 同じ受信イベントが再配送されてもAIを再実行しない。
if (this.seen.has(message.id)) {
return { status: 'ignored', reason: 'duplicate' };
}
this.seen.add(message.id);
if (!message.aiConsent) {
await this.publisher.publish({
threadId: message.threadId,
kind: 'system_notice',
sourceMessageId: message.id,
text: 'AIによる回答候補の作成は無効です。担当者へ引き継ぎます。'
});
return { status: 'handoff', reason: 'no-consent' };
}
if (BLOCKED_INPUT.test(message.text)) {
return this.handoff(message, 'sensitive-input');
}
const settled = await Promise.allSettled(
this.agents.map((agent) => agent.propose({
requestId: message.id,
threadId: message.threadId,
text: message.text,
locale: message.locale
}))
);
const candidates = settled
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value)
.filter(this.isValidCandidate);
if (candidates.length === 0) {
return this.handoff(message, 'all-agents-failed');
}
if (candidates.some((candidate) => BLOCKED_OUTPUT.test(candidate.summary))) {
return this.handoff(message, 'unsafe-candidate');
}
const positions = new Set(candidates.map((candidate) => candidate.position));
// proceed以外、または意見の不一致を文章で丸めず、人へ見せる。
if (positions.size !== 1 || !positions.has('proceed')) {
return this.handoff(message, 'agent-disagreement', candidates);
}
const text = [
'AIによる回答候補です。',
...candidates.map((candidate) =>
`- ${candidate.agentId}: ${candidate.summary}`
),
'最終判断が必要な操作は、実行前に担当者へ確認してください。'
].join('\n');
await this.publisher.publish({
threadId: message.threadId,
kind: 'ai_answer',
sourceMessageId: message.id,
text,
agentIds: candidates.map((candidate) => candidate.agentId)
});
return { status: 'published', candidateCount: candidates.length };
}
isValidCandidate(candidate) {
return candidate
&& typeof candidate.agentId === 'string'
&& typeof candidate.summary === 'string'
&& ['proceed', 'stop', 'needs-human'].includes(candidate.position);
}
async handoff(message, reason, candidates = []) {
await this.publisher.publish({
threadId: message.threadId,
kind: 'human_handoff',
sourceMessageId: message.id,
text: 'AIから担当者へ引き継ぎました。確認後、このスレッドで返信します。',
reason,
// 候補は監査・担当者画面用。利用者向け本文には混ぜない。
privateCandidates: candidates
});
return { status: 'handoff', reason };
}
}
このコードの停止線は、プロンプトではなくアプリケーションコードにあります。
-
actor === 'human'だけをAI処理の入口にする - 受信メッセージIDを重複排除キーにする
- AIの候補は
publisher.publish()へ直接到達できない - 候補が競合したら、別のAIに多数決させず人へ渡す
- 公開メッセージに元メッセージの
sourceMessageIdを残す
Setは再現用の簡易実装です。本番ではプロセス再起動や複数インスタンスに備え、データベースの一意制約などでメッセージIDを永続化します。
手順3:AIの代わりに固定エージェントを接続する
まずはLLMを呼ばず、制御フローだけを検証します。test/gate.test.jsを作成します。
import test from 'node:test';
import assert from 'node:assert/strict';
import { MultiAgentChatGate } from '../src/gate.js';
const message = (overrides = {}) => ({
id: 'msg-001',
threadId: 'group-42',
actor: 'human',
kind: 'human_text',
text: 'この変更を進めてもよいですか?',
aiConsent: true,
locale: 'ja-JP',
...overrides
});
function agent(agentId, position, summary) {
return {
async propose() {
return { agentId, position, summary };
}
};
}
function setup(agents) {
const published = [];
const publisher = {
async publish(event) {
published.push(event);
}
};
return {
gate: new MultiAgentChatGate({ agents, publisher }),
published
};
}
test('複数候補が一致しても公開は1件だけ', async () => {
const { gate, published } = setup([
agent('planner', 'proceed', '段階的な反映を提案します'),
agent('reviewer', 'proceed', '事前確認項目を満たしています')
]);
const result = await gate.onMessage(message());
assert.equal(result.status, 'published');
assert.equal(published.length, 1);
assert.equal(published[0].kind, 'ai_answer');
assert.deepEqual(published[0].agentIds, ['planner', 'reviewer']);
});
test('AIの意見が割れたら公開回答を作らず有人へ渡す', async () => {
const { gate, published } = setup([
agent('planner', 'proceed', '実行可能です'),
agent('reviewer', 'stop', '前提情報が不足しています')
]);
const result = await gate.onMessage(message());
assert.equal(result.status, 'handoff');
assert.equal(result.reason, 'agent-disagreement');
assert.equal(published[0].kind, 'human_handoff');
});
test('同じメッセージの再配送ではAIを再実行しない', async () => {
let calls = 0;
const countingAgent = {
async propose() {
calls += 1;
return { agentId: 'planner', position: 'proceed', summary: '候補です' };
}
};
const { gate, published } = setup([countingAgent]);
await gate.onMessage(message());
const second = await gate.onMessage(message());
assert.equal(second.reason, 'duplicate');
assert.equal(calls, 1);
assert.equal(published.length, 1);
});
test('AIが公開したメッセージを次の依頼として処理しない', async () => {
const { gate, published } = setup([
agent('planner', 'proceed', '候補です')
]);
const result = await gate.onMessage(message({
id: 'ai-msg-1',
actor: 'ai',
kind: 'ai_answer'
}));
assert.equal(result.status, 'ignored');
assert.equal(published.length, 0);
});
test('同意がない場合はエージェントを呼ばない', async () => {
let calls = 0;
const { gate, published } = setup([{
async propose() {
calls += 1;
return { agentId: 'planner', position: 'proceed', summary: '候補です' };
}
}]);
const result = await gate.onMessage(message({ aiConsent: false }));
assert.equal(result.reason, 'no-consent');
assert.equal(calls, 0);
assert.equal(published[0].kind, 'system_notice');
});
実行します。
npm test
5件のテストが通れば、LLMを接続する前の公開制御を再現できています。
手順4:実際のAIとTencent RTCチャットへ接続する
接続点は2か所に限定します。
AIアダプター
各AIベンダーのレスポンスを、そのままチャット本文として返さず、次の内部形式へ変換します。
{
agentId: 'reviewer',
position: 'proceed', // proceed | stop | needs-human
summary: '公開候補となる短い説明'
}
JSON形式を指示しても、モデルが常に形式を守るとは限りません。そのためisValidCandidate()による実行時検証を残します。ツール実行権限も候補生成エージェントには渡さず、必要なら別の承認済み実行層へ分離します。
チャットアダプター
利用しているプラットフォーム向けTencent RTC公式ドキュメントに従い、次の変換だけを担当させます。SDKの具体的なメソッド名は環境ごとの公式資料で確認してください。
// 概念コード。Tencent RTC SDKの実在API名を示すものではない。
chatAdapter.onAuthenticatedText(async (sdkEvent) => {
const internalMessage = {
id: sdkEvent.messageId,
threadId: sdkEvent.conversationId,
actor: 'human',
kind: 'human_text',
text: sdkEvent.originalText,
aiConsent: await consentStore.isEnabled(sdkEvent.senderId),
locale: await profileStore.localeOf(sdkEvent.senderId)
};
await gate.onMessage(internalMessage);
});
const publisher = {
async publish(event) {
// ここだけで公式SDKのメッセージ送信処理を呼ぶ。
await chatAdapter.sendVisibleMessage(event.threadId, event.text, {
kind: event.kind,
sourceMessageId: event.sourceMessageId
});
}
};
重要なのは、受信本文にactor: 'human'と書いてあるかどうかではありません。認証済みの送信者と、アプリが発行したAIメッセージの識別情報からサーバー側で決定します。
翻訳表示を新しいメッセージとして再投入しない
多言語グループでは、翻訳後の文章をAIへ渡したくなることがあります。しかし、UI上の翻訳結果を新規メッセージとして扱うと、次のループが起こり得ます。
原文を受信
→ 翻訳を表示
→ 翻訳文を新着として処理
→ AI回答を生成
→ AI回答を翻訳
→ 再び新着として処理
TUIChatにはオンデマンドのテキストメッセージ翻訳に関する公式手順があります。対応するコンテンツ種別、言語、エディション上の条件は、導入時点の公式ドキュメントで確認してください。
オーケストレーター側では、翻訳を次のような表示キャッシュとして扱います。
{
sourceMessageId: 'msg-001',
targetLocale: 'en-US',
translatedText: '...',
purpose: 'display-only'
}
purpose: 'display-only'のデータはgate.onMessage()へ渡しません。AIが原文言語を扱えない場合は、表示翻訳とは別の翻訳処理を設け、原文ID、翻訳元言語、翻訳先言語を候補生成の監査情報として残します。
確認方法:正常な会話より、投稿主体と順序を壊す
実環境では、次のチェックリストを順番に試します。
配信と重複
- 同じ受信メッセージを2回配送しても、公開回答が1件だけになる
- AI Aが遅れて完了しても、すでに公開済みの依頼へ追加投稿しない
- サーバー再起動後も、処理済みメッセージIDが維持される
AI同士のループ
- AI回答を受信イベントとして再現しても、新しい候補生成が始まらない
- システム通知や有人引き継ぎ通知もAI入力にならない
- 翻訳表示の生成で新しいAI回答が作られない
同意と人への引き継ぎ
- AI生成中に同意を撤回した場合、公開直前にも最新の同意を再確認できる
-
stopとproceedが混在したら有人引き継ぎになる - 全AIが失敗しても、利用者には無反応ではなく引き継ぎ状態が見える
- 担当者画面では元メッセージと非公開候補を確認できる
モデレーション
- センシティブな入力をAIへ送信する前に止められる
- AI候補の出力も公開前に検査される
- モデレーション理由を利用者向け本文へ過剰に露出しない
判断フレーム:いつ複数AIを使うか
複数AIは、常に単一AIよりよいわけではありません。
| 状況 | 選択 |
|---|---|
| 単純なFAQ、低リスク、答えが一意 | 単一AIまたは検索で十分 |
| 設計案とレビューを分けたい | 複数AIの候補箱が有効 |
| 個人情報や決済、アカウント復旧を含む | AI候補を作らず有人対応を優先 |
| AI同士の意見が割れやすい | 自動統合せず、差分を担当者へ渡す |
| 投稿量を減らしたい | エージェント数ではなく単一公開口を守る |
複数AIの価値は、チャット上でAI同士に会話させることではありません。異なる観点の候補を裏側で作り、利用者へ見せる情報量と責任の所在をアプリ側で制御できることです。
注意点
1. Promise.allSettled()はキャンセルではない
有人引き継ぎが決まっても、すでに開始したAIリクエストが動き続ける可能性があります。本番ではタイムアウトとキャンセル手段をAIアダプター側に追加し、不要な候補を保存しない方針も決めます。
2. 全員一致でも正解とは限らない
複数のAIが同じ誤りを返すことがあります。一致は正しさの証明ではなく、自動公開条件の一つにすぎません。実行を伴う回答には、別途承認や権限確認が必要です。
3. 単純な正規表現はモデレーションの完成形ではない
記事の正規表現は制御位置を示すための最小例です。実運用では対象地域、コミュニティ方針、通報、ブロック、監査、有人エスカレーションを含めて設計してください。
4. 有人引き継ぎには受け皿が必要
チャットに「引き継ぎました」と表示するだけでは不十分です。担当キューへの登録、通知、担当者の引き受け、解決状態の更新までを運用フローに含めます。
5. AI協働の可視性を消さない
公開文にはAI候補であることを明示し、有人回答と同じ外見にしない方が安全です。同意をオフにする操作と、人へ切り替える操作も利用者から見える位置に置きます。
まとめ
複数AIをチャットへ導入するとき、最初に解くべき課題はモデル選定ではなく公開権限です。
- AIエージェントは非公開の候補箱へ出力する
- チャットへの公開口は一つにする
- 人の投稿だけをAI処理の入口にする
- 元メッセージIDで重複と系譜を管理する
- 意見の不一致をAIに丸めさせず、人へ見える形で引き継ぐ
- 翻訳表示を新しい会話イベントとして再投入しない
この境界を先に固定すれば、AIエージェントを追加しても、利用者が見る会話と責任の所在を壊しにくくなります。
関係性の開示: 筆者はTencent RTCに関係する立場であり、本記事の実装方針の確認にはTencent RTC公式ドキュメントを参照しています。