Cloud Ace Tech Blog
🤖

Google ADK で多役エージェントの「議論」機能を実装した話

はじめに

こんにちは。クラウドエース株式会社の永井です。

AI にプロフィールや専門性を与えて、あたかも別の人物のように振る舞わせる — いわゆる 擬似人格の AI を複数用意したとき、「これ、お互いに議論させたらどうなるんだろう?」と思いました。

そこで、ユーザーが 2 体以上の参加者 AI を選び、1 つのお題について議論させる 機能を実装しました。単一の LLM に「みんなで議論して」と頼むのではなく、会議の役割分担(参加者・司会・ファシリテーター・チェッカー・スライド生成)をそのままソフトウェア構造に落とし込んでいます。

(本プロダクトでは、こうした擬似人格の AI を社内で「パーソナル AI」と呼んでいますが、本記事では読みやすさのため 擬似人格の AI または 参加者 AI と表記します。)

本記事で紹介する実装は、ADK の ワークフローエージェントSequentialAgent / LoopAgent)を中心に組み立てています。ADK 2.0 の Graph Workflow への移行は「今後の展望」で触れます。

記事の構成

内容
作成背景 なぜ議論機能が必要だったか
なぜ ADK か 単発 chat API では足りない理由
議論機能の全体像 ユーザー操作と技術スタック
参加者をコードに埋め込まない設計 動的選択の比較と実装の流れ
アーキテクチャ エージェントの二層構造と ADK ツリー
エンドツーエンドの流れ フロント〜Firestore までのシーケンス
データ設計 会話履歴とスライドの分離
API・SSE・その他 Orchestrator、イベント、添付ファイル
制約と今後の展望 現状の課題と ADK 2.0 への方向性

作成背景

この機能を作った背景には、組織の中で立場によって見え方が違う という課題があります。

社長や役員、マネージャー、平社員といったそれぞれの立場では、お題に対する意見やバックグラウンドが自然と異なります。経営視点の判断、現場の実務感覚、専門領域の知見など、同じ会議に集まっても発言の重みや前提が揃わないことは珍しくありません。

一方で、良い意思決定やアイデア出しには、異なる視点を対等にぶつけ合い、議論を重ねながらベストなゴールを探る プロセスが欠かせません。

本機能は、そのプロセスを AI 上で擬似的に再現することを目的として設計しました。ユーザーが複数の擬似人格の AI(それぞれ異なる人格・専門性・立場を持つ)を選び、1 つのお題について議論させます。司会・ファシリテーター・チェッカーが会議の型を保ち、結論とスライドとして成果物に落とし込める — 単一 LLM への 1 プロンプトではなく多役エージェントの議論 として実現しています。

「誰かひとりの正解」ではなく、違うバックグラウンドを持つ参加者が平等に議論し、より良い答えに近づく — その体験をプロダクトに載せたい、というのが開発の出発点です。議論の進行順序は ADK エージェントツリー で詳述します。

なぜ ADK か

「複数人格が会議する」という要件は、1 回の chat 完了 API では表現しきれません。ADK を選んだ理由を表にまとめます。

要件 単発 chat API では ADK での解決
複数人格が順番に発言 1 レスポンスに押し込めない SequentialAgent
結論が出るまで繰り返す 自前で state machine が必要 LoopAgent + max_iterations
司会だけが終了判断 プロンプトだけでは不安定 司会専用 LlmAgent + exit_discussion_loop ツール
参加者ごとに RAG ツールをエージェント単位で付与 LlmAgent(tools=[search_knowledge_base])

議論機能の全体像

ユーザーが行うこと

公開されている 擬似人格の AI を 2 体以上選択し、お題(テキストや PDF などの添付)を入力して議論を開始します。社長・部長・課長・平社員・コンサルなど、立場や専門性が異なる参加者 AI をお題に合わせて組み合わせます。

技術スタック

レイヤ 技術
フロント Next.js / React / TypeScript
API FastAPI + Server-Sent Events (SSE)
マルチエージェント Google ADK(LlmAgent, SequentialAgent, LoopAgent, InMemoryRunner
LLM Gemini(Vertex AI 経由)
RAG Vertex AI Search
永続化 Firestore
添付ファイル GCS 上の URL を Gemini の Part(file_data=...) に渡す

参加者をコードに埋め込まない設計

マルチエージェントの議論を初めて組むとき、よくあるのは プロンプトやコードに参加者を固定で書き込む やり方です。本実装では コードに固定しない → 実行時にツリーを組む 方針を取っています。

従来型との対比

観点 従来型(固定参加者) 本実装(動的参加者)
参加者定義 Python / プロンプトにハードコード Firestore personal_agents + prompts
メンバー変更 コード修正 → デプロイ 管理画面でプロンプト編集、または公開設定変更
人数 2〜3 体固定が多い 2 体以上なら N 体可
ADK ツリー 起動時に固定構造 create_discussion_system(selected_agents) で都度生成
人格の中身 リポジトリに散在 agentName / title / role / prompts.personal を DB から注入
ユーザー操作 なし(開発者が決める) 議論画面でカード選択 → URL に ID を列挙

従来型では「部長を足したい」が開発対応になります。本実装では カタログ化した擬似人格の AI を UI で自由に選び、議論開始のたびに ADK エージェントツリーを組み立て直します。

比較コード

# 従来型: 参加者がコードに固定
CEO = LlmAgent(name="CEO", instruction="あなたは社長です。経営視点で…")
ENGINEER = LlmAgent(name="Engineer", instruction="あなたはエンジニアです。技術視点で…")

discussion_round = SequentialAgent(
    name="DiscussionRound",
    sub_agents=[CEO, ENGINEER, facilitator],  # メンバー固定
)
# 本実装: リクエストの ID リストから実行時に N 体生成
class DiscussionRequest(BaseModel):
    topic: str
    selected_agents: List[str]   # ["uuid-a", "uuid-b", ...]  ← ID のみ
    user_id: str

discussion_agents = []
for agent_config in selected_agents:
    discussion_agents.append(create_personal_discussion_agent(agent_config, ...))

discussion_round = SequentialAgent(
    name="DiscussionRound",
    sub_agents=[*discussion_agents, create_facilitator_agent(...)],
)

参加者のプロンプトは リクエスト Body ではなく Firestore から読み込みます。管理画面で更新した内容は、同じ ID を送るだけで次回の議論に反映されます。

動的選択のフロー

フロントは公開エージェント一覧を取得し、2 体以上を選択して POST /discussion/start に ID リストだけ送ります。バックエンドは get_agent_config() で人格を引き、create_discussion_system() でツリーを生成します。

アーキテクチャ

エージェントの二層構造

議論に登場するエージェントは、データソースが異なる 2 層 に分かれています。

種別 データソース 役割
参加者 personal_agents(議論に公開設定されたもの) ユーザーが選ぶ議論メンバー。人格プロンプト + RAG
システム役 specialized_agents(固定 ID) moderator / facilitator / log_collector / answer_checker / slide_generator など

参加者は議論開始時に 動的に N 体LlmAgent として組み立てられます。人格プロンプトは Firestore の personal_agentsprompts に格納し、管理画面から更新できます。システム役も specialized_agents 上で同様に管理しています。

ADK エージェントツリー

create_discussion_system() が返す構造が、議論フロー全体の心臓部です。

RootSequence (SequentialAgent)
├── MainLoop (LoopAgent, max_iterations=9)
│   └── Moderator (LlmAgent) … tools=[exit_discussion_loop]
│       └── RoundPhase (SequentialAgent)
│           ├── DiscussionRound (SequentialAgent)
│           │   ├── DiscussionAgent_{uuid1} (LlmAgent) … tools=[search_knowledge_base]
│           │   ├── DiscussionAgent_{uuid2} (LlmAgent)
│           │   └── Facilitator (LlmAgent)
│           ├── LogCollector (LlmAgent)
│           └── AnswerChecker (LlmAgent)
└── SlideGenerator (LlmAgent)  … ループ終了後に 1 回実行

1 ラウンドの流れ

1 イテレーション(LoopAgent の 1 周)の中身は次の通りです。RoundPhase 内の実行順に沿って整理しています。

  1. Moderator(オープニング) — ラウンド開始の宣言・参加者の紹介(各周の入口。終了判断だけではない)
  2. DiscussionRound — 選ばれた参加者 AI が順番に発言し、Facilitator が脱線をチェック
  3. LogCollector(ADK / LlmAgent — 議論要約を 生成(SSE では event_type: summary
  4. AnswerChecker — 結論の妥当性(✅ / ⚠️ をテキストで返す)
  5. Moderator(終了判断) — チェッカー結果を見て承認なら exit_discussion_loop、却下なら LoopAgent が次イテレーションへ

ループ終了後、SlideGenerator が議論履歴を参照してスライド JSON を生成します。結果は SSE の slides_generated イベントとしてフロントに届き、Firestore にも保存します。

なぜ司会だけがループ終了ツールを持つのか

ループの終了判断を「全エージェントのプロンプトに書く」だけにすると、参加者が勝手に「議論終了」と宣言したり、チェッカーの評価と司会の判断が混ざって不安定になります。

そのため、司会(Moderator)だけexit_discussion_loop ツールを持ち、ADK の escalate 機構で LoopAgent を抜けます。

def exit_discussion_loop(tool_context: ToolContext) -> dict:
    tool_context.actions.escalate = True
    return {"status": "loop_terminated", "message": "ループを終了しました"}

moderator = LlmAgent(
    name="Moderator",
    model=GEMINI_MODEL_DEFAULT,
    instruction=instruction,
    tools=[exit_discussion_loop],
    sub_agents=[round_phase],
)

「誰が会議を終わらせる権限を持つか」を コードとツールの境界 で明示することで、マルチエージェントの挙動を予測しやすくしています。

RAG は参加者だけ

RAG ツールは参加者 AI だけ に付与しています。司会・チェッカー・スライド生成に検索を持たせると、役割が曖昧になるためです。

エンドツーエンドの流れ

フロントからバックエンド、ADK、Firestore までの流れです。

  • 議論の進行は SSE でイベント単位に UI へ流す(長いループを 1 レスポンスで待たない)
  • 参加者の発言は Firestore の messages に逐次保存する
  • スライドだけ 別コレクション に保存する

データ設計 — 会話と成果物の分離

議論機能のデータは、意図的に 二重構造 にしています。

保存先 内容 理由
users/{uid}/sessions/{sid}/messages お題・全発言・metadata アプリ共通の users / sessions / messages モデルに格納。議論専用のトップコレクションを増やさない
discussions/{sid}/slides/data 生成スライド JSON のみ 会話と成果物を分離。スライド画面の再表示用

Discussion 用に別スキーマを新設せず、会話履歴は messages に一元化しています。metadata には type: "discussion"agent_nameround_number などを載せ、UI 側で議論用の表示に変換します。

RAG の sources について — 参加者 AI が検索した引用元は、議論中の SSE 表示agent_message)では UI に渡します。一方、Orchestrator の save_discussion_message() では現状 sources を引数に渡していないため、履歴を再読込したときに引用元が残らない 可能性があります(実装ギャップ)。ライブ表示と永続化の差分として認識しています。

metadata = {
    "type": "discussion_topic",
    "selected_agents": selected_agents,
}
await save_message(
    user_id=user_id,
    session_id=session_id,
    role="user",
    content=topic,
    uploaded_files=uploaded_files,
    metadata=metadata,
    agent_type="discussion",
)

API・SSE・その他

Orchestrator

DiscussionOrchestrator は ADK とフロントの 境界 です。Firestore への発言の永続化は、ADK ツリー内の LogCollector(LlmAgent)ではなく、Orchestrator 配下の LogCollectorAgent(Python クラス)が担います。

  1. 選択された agent ID から Firestore で設定を取得
  2. お題を messages に保存し、セッション title を更新(LogCollectorAgent.save_user_topic() など)
  3. adk_discussion.run() を async for で購読
  4. agent_messageLogCollectorAgent.save_discussion_message() で Firestore 保存 + SSE 転送
  5. slides_generated → スライドコレクション保存 + SSE 転送
  6. 完了時に done / conclusion イベント
async for event in self.adk_discussion.run(
    topic=request.topic,
    selected_agents=selected_configs,
    user_id=user_id,
    uploaded_files=uploaded_files,
):
    if event_type == "agent_message":
        await self.log_collector.save_discussion_message(...)
        yield {"event_type": "agent_message", "data": message_dict}
    elif event_type == "slides_generated":
        await self._save_slides_to_firestore(session_id, data)
        yield event

SSE

FastAPI 側は StreamingResponse でイベントを逐次返します。

@router.post("/start")
async def start_discussion(request: DiscussionRequest):
    orchestrator = DiscussionOrchestrator()

    async def event_generator():
        async for event in orchestrator.start_discussion(request):
            yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
    )
event_type 用途
status ステータスバー表示
round_start ラウンド番号更新
agent_message 参加者・司会などの発言
facilitator ファシリテーター吹き出し
conclusion_feedback チェッカーの ✅ / ⚠️
slides_generated スライド JSON
summary 要約
conclusion 議論終了
done セッション完了
error エラー

添付ファイル(マルチモーダル)

お題に PDF や画像を添付できるように、GCS 上の URL を Gemini の Part(file_data=...) に変換して最初の user メッセージに含めています。

制約と今後の展望

現状の制約

項目 内容
max_rounds リクエスト API にはあるが、ADK 側は max_iterations=9 固定
discussions トップ 現行フローでは書き込まず。読み取り API がレガシーとして残存
ADK セッション ID Runner 内で別 ID を生成(Firestore の sessionId とは別)
ラウンド番号 Orchestrator 側でヒューリスティックに算出
RAG sources の永続化 SSE 表示では付与するが、save_discussion_message() 未渡しのため履歴再読込では欠落する可能性
人間の途中参加 ループ実行中に割り込むには いったん実行を止める 必要がある

今後の展望 — 議論を止めずに人間が参加する

現状、AI 同士の議論の最中に人間が口を挟みたい場合は、一度 思考・ループの実行を強制終了 してから参加する形になっています。LoopAgent が回っている間は SSE でイベントが流れ続けるため、「司会に一言足す」「参加者の発言に反論する」といった割り込みを、実行中の Runner に自然に差し込む仕組みはまだありません。

今後は ADK 2.0 のワークフロー機能を活用し、議論を止めずにユーザーが参加できる 体験へ寄せたいと考えています。

  • RequestInput — ワークフローを一時停止し、ユーザー入力を待ってから 同じセッション上で再開 する
  • Resumability(チェックポイント & 再開) — 中断・再開時にコンテキストを失わず、中断したノードから続行する
  • Graph / Dynamic Workflow — 現行の SequentialAgent / LoopAgent ツリーから、段階的に移行しやすい

想定する拡張は次の通りです。

  1. ラウンド途中で RequestInput を挟み、ユーザーからの追い質問・補足を受け取る
  2. 発言を messages に保存し、ループ全体を破棄せず 次の参加者発言へ引き渡す
  3. 司会・チェッカーの判断は継続し、人間の入力も議論の一発言として扱う

役割分担の設計思想はそのままに、実行基盤を ADK 2.0 の pause / resume モデルへ載せ替える — その方向で検討を進めたいと考えています。

まとめ

本記事では、Google ADK のワークフローエージェント(SequentialAgent / LoopAgent)を組み合わせ、会議の役割分担をそのままエージェントツリーに写した議論機能 を紹介しました。

  • 参加者をコードに固定せず、Firestore から動的に LlmAgent を組み立てる
  • 司会の exit_discussion_loop でループ終了を明示する
  • 会話は messages に、スライドは別コレクションに分離する
  • SSE で発言単位に UI を更新する

マルチエージェントを「プロンプト芸」だけで組むのではなく、誰が何の権限を持つか をエージェント構造とツールで明示する — その一点が、今回の実装でいちばん学びが大きかったポイントです。

人間が議論の途中から自然に参加できる体験は、現行実装では制約がありますが、ADK 2.0 の HITL 機能で次のステップを狙っています。同様の「複数 AI が協調する」機能を検討している方の参考になれば幸いです。

参考リンク

Cloud Ace Tech Blog
Cloud Ace Tech Blog

Discussion