チャットに常駐して働くAIエージェントは、負荷やテナント数が増えてくると1プロセスでは捌ききれなくなり、複数インスタンスに水平スケールさせたくなります。ここで多くの人がハマるのが「同じイベントに2つのインスタンスが同時に反応して、同じ返信を2回投げてしまう」問題です。この記事では、常駐エージェントを複数台構成にする際の排他制御をどう設計したかを整理します。
なぜ普通のSlack botでは起きにくい問題なのか
単一インスタンスで動くbotであれば、イベントを受け取るのは常に1プロセスなので二重応答は起きません。問題が顕在化するのは、可用性やスループットのために同じSlack/Teamsアプリのイベント購読を複数プロセスで受ける構成にした瞬間です。Socket Modeであれ、Events APIのWebhookを複数インスタンス背後にロードバランスする構成であれ、同じイベントが複数プロセスに配送される(または再配送される)可能性は排除できません。
しかも厄介なのは、この問題が低負荷時には再現しないことです。テナント数が少ない開発中は気づかず、本番で同時接続数が増えたタイミングで初めて「同じ質問に2つの答えが返ってくる」というクレームとして表面化します。
対策1: イベントIDベースの冪等性チェックを最前段に置く
SlackのEvents APIも、TeamsのBot Framework も、各イベントには一意のIDが振られています。これを使って「処理済みかどうか」を最初にチェックします。
async function handleEvent(event: ChatEvent) {
const key = `event:${event.eventId}`;
// SETNX相当。すでに処理中/処理済みならここで弾く
const acquired = await store.setIfNotExists(key, "processing", { ttlSeconds: 300 });
if (!acquired) {
return; // 他インスタンスが処理中、または処理済み
}
try {
await process(event);
await store.set(key, "done", { ttlSeconds: 300 });
} catch (e) {
await store.delete(key); // 失敗時は解放してリトライ可能にする
throw e;
}
}
ポイントは3つあります。
- 判定は必ず最初の1行目で行う。ビジネスロジックの途中で判定すると、その手前までの副作用(LLM呼び出しなど)が二重に走ってしまう
- ロックの取得は
SETNX(RedisならSET key val NX)のようなアトミックな操作を使う。「存在確認してから書き込む」の2ステップにすると、その間に別インスタンスが割り込むレースが残る - TTLを必ず設定する。プロセスがクラッシュしてロックを解放し損ねた場合に、永久に処理されないイベントを作らないため
対策2: 返信そのものにも冪等キーを持たせる
イベント処理の排他制御をすり抜けるケース(再配送のタイミングがずれる、複数のイベントソースが同じ事象を検知するなど)に備えて、実際にメッセージを送信する層でも二重送信を防ぐ二段構えにしておくと安全です。
async function postReply(channelId: string, dedupeKey: string, body: string) {
const key = `reply:${channelId}:${dedupeKey}`;
const acquired = await store.setIfNotExists(key, "sent", { ttlSeconds: 3600 });
if (!acquired) return; // 同じ内容の返信は送らない
await chatClient.postMessage(channelId, body);
}
dedupeKey には元イベントのIDや、対象となったメッセージのタイムスタンプなど「何に対する返信か」を一意に表す値を使います。イベント層とメッセージ送信層の両方に冪等性チェックを持たせることで、片方をすり抜けても最終的な二重投稿だけは防げます。
対策3: ロック共有ストアはボトルネックにしない
排他制御用のストア(RedisやDynamoDBなど)は、常駐エージェントのイベント処理経路すべてが通過するため、ここが単一障害点・レイテンシのボトルネックになりがちです。特に注意すべき点は次の通りです。
| 観点 | 注意点 |
|---|---|
| TTL | 短すぎると再配送で二重処理が漏れる、長すぎると障害時の再処理が遅れる。数分〜1時間程度が目安 |
| 可用性 | ロックストア自体が落ちたときにイベント処理全体を止めない(フェイルオープンかフェイルクローズかを事前に決める) |
| コスト | イベント数に比例してキー数が増える。TTL後の自動削除に任せ、手動クリーンアップは不要にする |
「二重応答は防ぎたいが、ロックストアの障害で全チャンネルが無応答になるのは避けたい」場合は、ロック取得に失敗したら処理を諦めるのではなく警告ログを出して処理を通す(フェイルオープン)という選択もあります。この判断はプロダクトの許容度次第です。
まとめ
- 複数インスタンス構成の常駐エージェントは「同じイベントへの二重応答」を前提に設計する
- イベントIDベースの冪等性チェックを処理の最前段(1行目)に置き、アトミックなロック取得を使う
- イベント層に加えて、メッセージ送信層にも
dedupeKeyで二段構えの冪等性チェックを持たせる - ロック共有ストアの障害時にフェイルオープンかフェイルクローズかを事前に決めておく
筆者は Slack / Teams / Chatwork に常駐する法人向けAIスタッフ「HACH」を開発しています。テナント数の増加に伴うスケールアウト時は、まさにこの設計で二重応答を防いでいます。同種のエージェントを運用している方の参考になれば幸いです。