ADK-Rust を用いたマルチエージェントシステムの構築
Rust の型安全性とパフォーマンスを活用し、単純な協調から複雑なグラフベースのオーケストレーションまで、さまざまなマルチエージェントパターンをいつ、どのように使用するかを学びます。
1. はじめに
問題点: 壁にぶつかるAI
初めてのAIエージェントを構築しました。質問に答えたり、ドキュメントを要約したり、もしかしたらコードを書いたりもできる、素晴らしいものです。しかし、現実はこうです:
"前回の請求書を確認し、API の設定も手伝ってもらえますか?"
エージェントは苦戦します。請求システム、開発者向けドキュメント、トラブルシューティングのワークフローのすべてについてトレーニングを受けていません。指示プロンプトは、すべてを網羅しようとしてすでに2000トークンに達しています。応答の品質が低下します。
これがシングルエージェントの限界です。アプリケーションが成長するにつれて、次のような困難なトレードオフに直面します:
- 肥大化したプロンプト: 新しい機能を追加するたびに、指示が長くなり、レイテンシーが増加し、モデルの混乱を招きます
- 器用貧乏: 請求、サポート、営業のすべてを1つのエージェントが担当すると、どれも中途半端になります
- 不可能なメンテナンス: 請求ロジックの変更がサポートフローを壊すリスクを冒すべきではありません
- 専門化の欠如: 数学エージェントが電卓ツールを持ち、リサーチエージェントがウェブ検索を持つことはできません。すべてを共有しているからです
解決策: 専門化されたエージェントの連携
マルチエージェントシステムは、複雑なタスクを専門的な役割に分解することでこれを解決します。圧倒された一人のジェネラリストではなく、焦点を絞ったスペシャリストを作成します:
- カスタマーサービス: coordinator は、ユーザーを請求、テクニカルサポート、または営業のスペシャリストにルーティングします。それぞれが専門的なトレーニングとツールを備えています
- コンテンツ作成: リサーチエージェントが事実を収集し、writer が物語を作成し、エディターが磨きをかけます。各エージェントは一つのスキルを習得しています
- コード生成: プランナーがアーキテクチャを設計し、coder が実装し、レビュー担当者がバグを発見する—異なる視点が品質を向上させます
その結果は?各エージェントは集中し続け、プロンプトは管理しやすくなり、サポートに触れることなく請求ロジックを更新できます。これはAIのためのマイクロサービスです。
学習内容
ADK-Rust は、マルチエージェントオーケストレーションのための3つの段階的に強力なパターンを提供します。このチュートリアルでは、以下を学習します。
- 要件に基づいて各パターンをいつ使用するか
- 本番環境に対応した Rust コードでそれらを実装する方法
- 特定のユースケースにおいてアーキテクチャ上のトレードオフがなぜ重要なのか
2. 適切なパターンの選択
コードに入る前に、各パターンが何を提供するかを理解しましょう。
| パターン | 最適な用途 | 制御レベル | 複雑さ |
|---|---|---|---|
| コーディネーター | 会話の引き継ぎ | LLM が決定 | 低 |
| AgentTool | 応答処理 | コーディネーターが処理 | 中 |
| スーパーバイザーグラフ | 複雑なワークフロー | 完全な状態管理 | 高 |
3. パターン1: コーディネーター (サブエージェント)
🎯 ユースケース: カスタマーサービスルーティング
カスタマーサービスボットを構築しています。ユーザーは請求について質問したり、技術的なサポートを要求したり、新機能について問い合わせたりするかもしれません。各ドメインには専門知識が必要ですが、ユーザーはどの部署に連絡すべきかを知る必要はありません。
コーディネーターパターンは、エージェントの自動転送を使用します。.sub_agent() を介してサブエージェントを追加すると、ADK-Rust は transfer_to_agent ツールを注入します。LLM は会話に基づいていつ引き渡すかを決定します。
主な特徴
- シームレスな引き渡し: ユーザーは専門家と自然に会話を継続できます
- LLM駆動ルーティング: coordinator は会話のコンテキストに基づいて決定します
- 会話の継続性: セッション履歴は転送後も維持されます
- 応答処理なし: 転送後、専門家がユーザーと直接会話します
実装
use adk_rust::prelude::*;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let api_key = std::env::var("GOOGLE_API_KEY")?;
let model = Arc::new(GeminiModel::new(&api_key, "gemini-2.0-flash")?);
// Specialist: Billing Agent
// Clear description helps the coordinator know when to transfer
let billing_agent = LlmAgentBuilder::new("billing_agent")
.description(
"Handles all billing questions: invoices, payments, refunds, subscription plans, and account charges. Transfer here for any money-related questions."
)
.instruction(
"You are a billing specialist. Answer questions about invoices, payments, and subscription plans. Be concise and accurate. If asked about technical issues, suggest transferring to support."
)
.model(model.clone())
.build()?;
// Specialist: Technical Support Agent
let support_agent = LlmAgentBuilder::new("support_agent")
.description(
"Provides technical support: troubleshooting, bug reports, feature questions, and integration help. Transfer here for any technical problems."
)
.instruction(
"You are a technical support specialist. Help users troubleshoot issues step by step. Ask clarifying questions when needed. Be patient and thorough."
)
.model(model.clone())
.build()?;
// Coordinator: Routes to specialists
let coordinator = LlmAgentBuilder::new("coordinator")
.description("Customer service coordinator")
.instruction(
"You are a friendly customer service coordinator. Your job is to:\n 1. Greet users warmly\n 2. Understand their needs\n 3. Route to the right specialist:\n - Billing questions → transfer to billing_agent\n - Technical issues → transfer to support_agent\n 4. Handle general questions yourself\n\n Always explain who you're connecting them with."
)
.model(model.clone())
.sub_agent(Arc::new(billing_agent))
.sub_agent(Arc::new(support_agent))
.build()?;
// Run with the built-in launcher
Launcher::new(Arc::new(coordinator))
.run()
.await?;
Ok(())
}会話例
ユーザー: こんにちは、請求書について質問があります
[coordinator]: こんにちは!請求に関するご質問を喜んでお手伝いさせていただきます。担当の請求スペシャリストにお繋ぎします。
システム: 🔄 転送先: billing_agent
[billing_agent]: こんにちは!私は請求スペシャリストです。請求書、支払い、サブスクリプションに関するご質問をお手伝いできます。請求書について何を知りたいですか?
ユーザー: 今月、なぜ2回請求されたのですか?
[billing_agent]: 重複請求について確認いたします...
✅ コーディネーターを使用するタイミング
- ユーザーは専門家と直接やり取りすべきである
- ルーティングの決定は簡単である
- 専門家の応答を処理する必要がない
- 会話の流れは線形である(一度に一人の専門家)
4. パターン2: ツールとしてのエージェント (AgentTool)
🎯 ユースケース: 知識の集約
複数のドメインにわたる質問に答えるスマートアシスタントを構築しています。ユーザーが「250の15%は何ですか?また、その数字は歴史的に見てなぜ重要ですか?」と尋ねます。数学の専門家、次に雑学の専門家を呼び出し、彼らの回答を組み合わせる必要があります。
AgentTool パターンは、エージェントを呼び出し可能なツールとしてラップします。サブエージェントとは異なり、coordinator は専門家をプログラム的に呼び出し、ユーザーに返信する前にその応答を処理または結合するために受け取ります。
コーディネーターとの主な違い
コーディネーター(サブエージェント)
- • 専門家がユーザーと直接対話する
- • 一度に一人の専門家
- • 応答処理なし
AgentTool
- • コーディネーターが応答を受け取る
- • 複数の専門家を呼び出すことができる
- • 集約して要約する
実装
use adk_agent::LlmAgentBuilder;
use adk_tool::{AgentTool, FunctionTool};
use adk_core::ToolContext;
use serde_json::{json, Value};
use std::sync::Arc;
// Calculator tool for the math agent
async fn calculator(
_ctx: Arc<dyn ToolContext>,
args: Value
) -> Result<Value, adk_core::AdkError> {
let operation = args["operation"].as_str().unwrap_or("add");
let a = args["a"].as_f64().unwrap_or(0.0);
let b = args["b"].as_f64().unwrap_or(0.0);
let result = match operation {
"add" => a + b,
"multiply" => a * b,
"percent" => a * (b / 100.0),
_ => return Err(adk_core::AdkError::Tool(
format!("Unknown operation: {}", operation)
)),
};
Ok(json!({ "result": result }))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let api_key = std::env::var("GOOGLE_API_KEY")?;
let model = Arc::new(GeminiModel::new(&api_key, "gemini-2.5-flash")?);
// Create calculator tool
let calc_tool = FunctionTool::new(
"calculator",
"Performs arithmetic: add, multiply, percent. Args: operation (string), a (number), b (number)",
calculator,
);
// Math Expert agent - has its own tools
let math_agent = LlmAgentBuilder::new("math_expert")
.description(
"A math expert that performs calculations. Use for any math-related questions, percentages, or numerical analysis."
)
.instruction(
"You are a math expert. Use the calculator tool for calculations. Show your work step by step. Be precise."
)
.model(model.clone())
.tool(Arc::new(calc_tool))
.build()?;
// Trivia Expert agent - uses LLM knowledge
let trivia_agent = LlmAgentBuilder::new("trivia_expert")
.description(
"A trivia and history expert. Use for questions about historical facts, pop culture, science facts, and trivia."
)
.instruction(
"You are a trivia expert with vast knowledge across domains. Answer questions accurately and include interesting related facts."
)
.model(model.clone())
.build()?;
// Wrap agents as tools with configuration
let math_tool = AgentTool::new(Arc::new(math_agent))
.skip_summarization(false) // Summarize lengthy responses
.forward_artifacts(true); // Pass through any generated files
let trivia_tool = AgentTool::new(Arc::new(trivia_agent))
.skip_summarization(false);
// Coordinator uses agents as tools
let coordinator = LlmAgentBuilder::new("coordinator")
.description("Smart assistant that combines expert knowledge")
.instruction(
"You are a helpful assistant with access to expert agents:\n - math_expert: For calculations and math problems\n - trivia_expert: For facts, history, and trivia\n\n When questions span multiple domains, call multiple experts and synthesize their responses into a cohesive answer."
)
.model(model)
.tool(Arc::new(math_tool))
.tool(Arc::new(trivia_tool))
.build()?;
Launcher::new(Arc::new(coordinator)).run().await?;
Ok(())
}例: 複数ドメインの質問
ユーザー: 250の15%は何ですか?また、その数字は歴史的に見て重要ですか?
システム: // コーディネーターがmath_expertツールを呼び出す
[math_expertが応答]: 250の15%は37.5です
システム: // コーディネーターがtrivia_expertツールを呼び出す
[trivia_expertが応答]: 37と38は歴史的にそれほど注目されていませんが、37.5°Cは人間の体温です...
システム: // コーディネーターが統合する
[coordinator]: 250の15%は37.5です。興味深いことに、37.5°C (99.5°F) は平均的な人間の体温である37°Cに近く、医学的に重要な数値となっています!
✅ AgentToolを使用するタイミング
- 複数のエキスパートからの応答を組み合わせる必要がある場合
- コーディネーターが専門家の出力を要約またはフィルタリングする必要がある場合
- 専門家が独自のツール(ネストされた機能)を持っている場合
- エージェントの呼び出しをプログラムで制御したい場合
5. パターン3: スーパバイザーグラフ
🎯 ユースケース: コンテンツ作成パイプライン
コンテンツ作成システムを構築しています。トピックが与えられたら、(1)調査し、(2)記事を書き、(3)コード例を追加する必要があります。supervisorはタスクに基づいて順序を動的に決定し、ワーカーは修正のためにサイクルバックできます。
スーパバイザーグラフパターンは、ADK-Rustのグラフベースのワークフローシステムを使用します。supervisorエージェントは、完全な状態管理と循環実行サポートを備え、ワーカーに動的にルーティングします。
なぜグラフを使用するのか?
- 動的ルーティング: スーパバイザーが現在の状態に基づいて次のワーカーを決定
- 循環実行: ワーカーは反復のためにループバック可能
- 共有状態: すべてのノードが共通の状態オブジェクトを読み書き
- 条件付きエッジ: LLMの決定に基づく異なるパス
- 再帰制限: 無限ループを防止
実装
use adk_agent::LlmAgentBuilder;
use adk_graph::{
StateGraph,
edge::{START, END},
node::{AgentNode, ExecutionConfig, NodeOutput},
state::State,
};
use adk_model::GeminiModel;
use serde_json::json;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let api_key = std::env::var("GOOGLE_API_KEY")?;
let model = Arc::new(GeminiModel::new(&api_key, "gemini-2.0-flash")?);
// Supervisor: Decides which worker should act next
let supervisor = LlmAgentBuilder::new("supervisor")
.description("Routes tasks to specialized workers")
.instruction(
"You are a task supervisor. Based on the task and work done so far, decide who should work next.\n\n Workers available:\n - researcher: Gathers information and facts\n - writer: Writes content based on research\n - coder: Creates code examples\n\n Respond with ONLY one word: 'researcher', 'writer', 'coder', or 'done'."
)
.model(model.clone())
.build()?;
// Workers with specialized roles
let researcher = LlmAgentBuilder::new("researcher")
.instruction("Research the topic. Provide key facts as bullet points.")
.model(model.clone())
.build()?;
let writer = LlmAgentBuilder::new("writer")
.instruction("Write engaging content based on the research provided.")
.model(model.clone())
.build()?;
let coder = LlmAgentBuilder::new("coder")
.instruction("Write clean, documented code examples for the topic.")
.model(model.clone())
.build()?;
// Create AgentNodes with input/output mappers
let supervisor_node = AgentNode::new(Arc::new(supervisor))
.with_input_mapper(|state| {
let task = state.get("task").and_then(|v| v.as_str()).unwrap_or("");
let history = state.get("history")
.and_then(|v| v.as_array())
.map(|arr| arr.iter()
.filter_map(|h| h.get("agent").and_then(|a| a.as_str()))
.map(|s| format!("- {} completed", s))
.collect::<Vec<_>>()
.join("\n"))
.unwrap_or_default();
adk_core::Content::new("user").with_text(format!(
"Task: {}\n\nWork completed:\n{}\n\nWho next?",
task,
if history.is_empty() { "None yet" } else { &history }
))
})
.with_output_mapper(|events| {
let mut updates = std::collections::HashMap::new();
for event in events {
if let Some(content) = event.content() {
let text: String = content.parts.iter()
.filter_map(|p| p.text())
.collect();
let next = if text.to_lowercase().contains("researcher") {
"researcher"
} else if text.to_lowercase().contains("writer") {
"writer"
} else if text.to_lowercase().contains("coder") {
"coder"
} else {
"done"
};
updates.insert("next_agent".to_string(), json!(next));
}
}
updates
});
// Build the graph
let graph = StateGraph::with_channels(&[
"task", "next_agent", "history",
"research_output", "written_content", "code_output"
])
.add_node(supervisor_node)
.add_node(AgentNode::new(Arc::new(researcher)))
.add_node(AgentNode::new(Arc::new(writer)))
.add_node(AgentNode::new(Arc::new(coder)))
// Finalize node compiles all outputs
.add_node_fn("finalize", |ctx| async move {
let research = ctx.get("research_output").and_then(|v| v.as_str());
let content = ctx.get("written_content").and_then(|v| v.as_str());
let code = ctx.get("code_output").and_then(|v| v.as_str());
let result = format!(
"=== FINAL OUTPUT ===\n\n{}\n\n{}\n\n{}",
research.unwrap_or("No research"),
content.unwrap_or("No content"),
code.unwrap_or("No code")
);
Ok(NodeOutput::new().with_update("final_result", json!(result)))
})
// Graph structure
.add_edge(START, "supervisor")
.add_conditional_edges(
"supervisor",
|state| state.get("next_agent")
.and_then(|v| v.as_str())
.unwrap_or("done")
.to_string(),
[
("researcher", "researcher"),
("writer", "writer"),
("coder", "coder"),
("done", "finalize"),
],
)
// Workers cycle back to supervisor
.add_edge("researcher", "supervisor")
.add_edge("writer", "supervisor")
.add_edge("coder", "supervisor")
.add_edge("finalize", END)
.compile()?
.with_recursion_limit(15); // Prevent infinite loops
// Execute
let mut input = State::new();
input.insert("task".to_string(), json!("Create a guide about Rust error handling"));
input.insert("history".to_string(), json!([]));
let result = graph.invoke(input, ExecutionConfig::new("content-thread")).await?;
println!("{}", result.get("final_result").and_then(|v| v.as_str()).unwrap_or(""));
Ok(())
}✅ スーパバイザーグラフを使用するタイミング
- ワークフローの順序は動的で、LLMによって決定されます
- ワーカーは反復したり、前のステップに戻ったりする必要がある場合があります
- 複雑な状態をエージェント間で共有する必要があります
- チェックポイントまたは再開可能なワークフローが必要です
- タスクの分解には複数の連続したステップが必要です
6. パターン比較
| 機能 | コーディネーター | AgentTool | スーパーバイザーグラフ |
|---|---|---|---|
| ユーザーが話す相手 | スペシャリストに直接 | コーディネーターのみ | 最終出力 |
| マルチエージェント呼び出し | ❌ 一度に1つ | ✅ 並列可能 | ✅ オーケストレーション済み |
| 応答処理 | ❌ | ✅ | ✅ |
| 循環ワークフロー | ❌ | ❌ | ✅ |
| 共有状態 | セッションのみ | セッションのみ | 完全なグラフ状態 |
| セットアップの複雑さ | 🟢 低 | 🟡 中 | 🔴 高 |
7. 結論
マルチエージェントシステムは、専門のエージェントを組み合わせることで、洗練されたAIアプリケーションを構築できます。ニーズに基づいてパターンを選択してください:
- コーディネーター: セットアップが迅速で、カスタマーサービスルーティングに最適
- AgentTool: 応答を処理または結合する必要がある場合
- スーパーバイザーグラフ: 複雑で動的な多段階ワークフロー