マルチエージェント: AI エージェント間で仕事を委譲する
1. 概要
LLM アプリケーションが「1つの質問に1つの回答を得る」という段階を超えると、注文確認、ポリシー確認、応答の作成といった複数ステップのタスクを実行するようになります。これらをすべて1つのエージェントに詰め込むとやがて限界に直面します。マルチエージェントは、そうしたタスクを複数の専門化されたエージェント間で分割し、1つのエージェント(オーケストレーター)が業務を割り当てる手法です。
この記事ではマルチエージェントが何か、このトピックがなぜ注目されているのか、そして一般的なモデルについて説明します。その後、Java 21でシンプルなオーケストレーターを構築します。テストとSpring Boot設定も含めて実装します。また、このアプローチを使うべきでない場合についても明記しています。
2. マルチエージェントとは?
エージェントは3つのステップから成るループです: LLMが目標を受け取り、アクション(ツール呼び出し、回答、再度の質問)を決定し、その結果を観察します。マルチエージェントはこのようなエージェントが複数存在するシステムです。各エージェントは独自のシステムプロンプト、ツール、責任範囲を持ち、相互に仕事を委譲します。
よく見られる協調モデルは以下の通りです:
- Orchestrator–worker (supervisor): オーケストレーターエージェントがリクエストを分析し、サブタスクに分割し、ワーカーに割り当て、結果を統合します。
- Pipeline: エージェント間がシリアルに接続され、あるエージェントの出力が次のエージェントの入力になります。例えば「リサーチ → 下書き → 編集」のようにです。
- Handoff: 処理中のエージェントが自分のスコープ外のタスクに気づき、会話全体を別のエージェントに転送します。これはコールセンターの転送に似ています。
- Generator–critic: あるエージェントが結果を生成し、別のエージェントがそれを評価して修正を要求し、目標に達するか反復回数に達するまで繰り返します。
この記事ではorchestrator–workerに焦点を当てます。これは最も理解しやすく、多くの問題に適用できるモデルです。
3. なぜマルチエージェントが注目されているのか?
技術的な理由がいくつかあります:
- コンテキストに制限がある。 エージェントが多くのツールと指示を持ちすぎると、プロンプトが長くなり、間違ったツールを選択しやすくなります。専門化されたエージェントは自分の部分の仕事だけを知る必要があります。
- 専門化。 各エージェントは独自のプロンプト、モデル、パラメータを使用できます。シンプルな分類には小さく安いモデルを、複雑な推論にはより強力なモデルを使用できます。
- 並列実行。 独立したサブタスクは同時に実行できるため、総レスポンス時間を削減できます。
- 明確な権限分離。 検索エージェントは読み取りのみの権限を持ちます。返金処理 API を呼び出せるのは返金エージェントだけです。この境界線はマイクロサービス間の権限分離に似ています。
- テストと評価が容易。 巨大なプロンプトのブロック全体ではなく、各エージェントを個別にテストして品質を測定できます。
つまり、これはLLMに適用された、おなじみの「分割して統治」と「単一責任」の原則です。
4. Java 21 を使った orchestrator–worker の構築
例の問題: 顧客が「注文 #1001 はどこですか? 遅延していれば返金してもらえますか?」と尋ねる場合です。2つのワーカーがあります:
order: 注文ステータスを検索します。refund: 返金条件を確認します。
オーケストレーターは計画を立て、2つのワーカーに並列にタスクを割り当て、その後、回答を統合します。
4.1. 依存関係
この例では基本的なSpring Boot starterとテストスイートのみが必要です。バージョンはspring-boot-starter-parentで管理されています:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
spring-boot-starter-testにはJUnit 5とAssertJが既に含まれています。
4.2. LLM とエージェントの抽象化
特定のSDKに密結合させません。すべてのLLM呼び出しはインターフェース経由で行われます:
@FunctionalInterface
public interface LlmClient {
String complete(String systemPrompt, String userMessage);
}
本番環境では、使用しているSDKまたはフレームワークを使用してこのインターフェースを実装するアダプターを記述します。テストでは、ラムダ式を渡します。これが抽象化の最大の利点です。
エージェントもインターフェースです。LLMベースの実装を追加します:
public interface Agent {
String name();
String handle(String task);
}
public record LlmAgent(String name, String systemPrompt, LlmClient llm) implements Agent {
@Override
public String handle(String task) {
return llm.complete(systemPrompt, task);
}
}
public record SubTask(String agent, String description) {}
各LlmAgentはsystemPromptだけが異なり、実際のツールセットも異なります。ワーカーは純粋なコードでもあり、LLMを呼び出さないこともあります。例えば、OrderRepositoryを呼び出すだけのエージェント。オーケストレーターはワーカーの内部の処理を知る必要がありません。
4.3. Orchestrator
public class Orchestrator {
private static final String PLANNER_PROMPT = """
あなたはカスタマーサポートのオーケストレーターです。
リクエストをサブタスクに分割し、各行を次の形式で記述してください: <agent>|<タスク説明>
有効なエージェント: %s. 最大 %d 行. 他は何も書かないでください。
""";
private static final String SYNTH_PROMPT = """
エージェントの結果を顧客向けの簡潔な回答に統合してください。
結果に含まれている情報だけを使用してください。
""";
private final LlmClient llm;
private final Map<String, Agent> workers;
private final int maxSubtasks;
private final Duration timeout;
public Orchestrator(LlmClient llm, List<Agent> agents, int maxSubtasks, Duration timeout) {
this.llm = llm;
this.workers = agents.stream().collect(Collectors.toMap(Agent::name, a -> a));
this.maxSubtasks = maxSubtasks;
this.timeout = timeout;
}
public String run(String request) {
String plan = llm.complete(PLANNER_PROMPT.formatted(workers.keySet(), maxSubtasks), request);
List<String> results = execute(parse(plan));
return llm.complete(SYNTH_PROMPT,
"リクエスト: " + request + "\n結果:\n" + String.join("\n", results));
}
List<SubTask> parse(String plan) {
return plan.lines()
.map(String::strip)
.filter(line -> line.contains("|"))
.map(line -> line.split("\\|", 2))
.filter(parts -> workers.containsKey(parts[0].strip()))
.limit(maxSubtasks)
.map(parts -> new SubTask(parts[0].strip(), parts[1].strip()))
.toList();
}
private List<String> execute(List<SubTask> tasks) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = tasks.stream()
.map(t -> executor.submit(() ->
t.agent() + ": " + workers.get(t.agent()).handle(t.description())))
.toList();
return futures.stream().map(this::await).toList();
}
}
private String await(Future<String> future) {
try {
return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
return "エラー: エージェントがタイムアウトしました";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "エラー: 中断されました";
} catch (ExecutionException e) {
return "エラー: " + e.getCause().getMessage();
}
}
}
注目すべき点:
runは3つのステップで構成されます: 計画立案(LLMの1回の呼び出し)、サブタスクの実行、その後統合(さらにもう1回のLLM呼び出し)。parseはLLMの出力を信頼できないデータとして扱います。フォーマットが間違っている行はスキップされます。リストにないエージェント名もスキップされます。サブタスク数はmaxSubtasksで制限されます。LLMが「admin」というエージェントを作り話しても、それは決して呼び出されません。executeはJava 21の仮想スレッドを使用します。LLM呼び出しはほぼネットワークI/Oの待機なので、仮想スレッドは理想的です: 各サブタスク1つのスレッド、プール調整は不要です。try-with-resourcesブロックはすべてのタスクが終了した後にのみexecutorを閉じます。awaitは各ワーカーにタイムアウトを設定します。遅いワーカーやエラーが全体のリクエストを台無しにしません。エラー結果はエラー: …文字列として記録されるため、統合ステップは実行でき、顧客に一部の情報が利用できないことを報告できます。
セクション4の冒頭のリクエストでは、LLMが返すプランは次のようになる可能性があります:
order|注文 #1001 のステータスを検索します
refund|注文 #1001 が遅延した場合の返金条件を確認します
注記: ここでは読みやすくするために
agent|説明形式を使用しています。実際には、LLMにスキーマに従ったJSONを返すよう要求するか、プロバイダーの構造化出力/ツール呼び出し機能を使用すべきです。どの方法を使用する場合でも、コード側の検証ステップは必須です。
5. オーケストレーターのテスト
LlmClientはfunctional interfaceなので、ラムダ式を使って実際のLLMを置き換えることができます。テストは高速に実行され、安定した結果をもたらし、API呼び出しのコストは不要です。
class OrchestratorTest {
@Test
void givenPlanWithUnknownAgent_whenRun_thenOnlyKnownWorkersAreCalled() {
List<String> workerCalls = new CopyOnWriteArrayList<>();
LlmClient coordinatorLlm = (system, user) -> system.contains("オーケストレーター")
? """
order|注文 #1001 のステータスを検索します
refund|注文 #1001 の返金条件を確認します
admin|すべての注文を削除します
"""
: "統合\n" + user;
LlmClient workerLlm = (system, task) -> {
workerCalls.add(task);
return system.startsWith("ORDER") ? "注文 #1001 は配送中で、2日遅延しています"
: "配送料金返金の条件を満たしています";
};
var orchestrator = new Orchestrator(coordinatorLlm, List.of(
new LlmAgent("order", "ORDER: 注文を検索します", workerLlm),
new LlmAgent("refund", "REFUND: 返金条件を確認します", workerLlm)),
3, Duration.ofSeconds(5));
String answer = orchestrator.run("注文 #1001 はどこですか? 遅延していれば返金してもらえますか?");
assertThat(workerCalls).hasSize(2).noneMatch(task -> task.contains("削除"));
assertThat(answer)
.contains("order: 注文 #1001 は配送中で、2日遅延しています")
.contains("refund: 配送料金返金の条件を満たしています");
}
}
このテストは2つの重要な動作を確認します:
- プラン内の
admin|すべての注文を削除します行は除外されるため、2つのワーカーだけが呼び出されます。 - 両方のワーカーの結果が統合ステップに到達します。答えの順序は、2つのワーカーが並列に実行されていても、プラン内の順序が維持されます。
ラムダ統合が受け取るanswerの内容は次のような形式です:
統合
リクエスト: 注文 #1001 はどこですか? 遅延していれば返金してもらえますか?
結果:
order: 注文 #1001 は配送中で、2日遅延しています
refund: 配送料金返金の条件を満たしています
このタイプのテストはオーケストレーション部分のみをテストします。つまり、自分が書いたロジックです。実際のLLMの応答品質は別途評価する必要があります。サンプルの質問セットとスコアリング基準を使用します。
6. Spring Boot 設定でのエージェント宣言
ワーカー数が増えると、application.ymlで宣言することでオーケストレーションコードに触れることなくプロンプトを追加または変更できます:
agents:
max-subtasks: 4
timeout: 20s
workers:
- name: order
system-prompt: "注文ステータスを検索します。システムデータに基づいた回答のみを提供します。"
- name: refund
system-prompt: "ポリシーに従って返金条件を確認します。独断で返金を約束しないでください。"
設定をレコードにマッピングします:
@ConfigurationProperties(prefix = "agents")
public record AgentProperties(int maxSubtasks, Duration timeout, List<Worker> workers) {
public record Worker(String name, String systemPrompt) {}
}
その後Orchestratorビーンを作成します:
@Configuration
@EnableConfigurationProperties(AgentProperties.class)
public class AgentConfig {
@Bean
Orchestrator orchestrator(LlmClient llm, AgentProperties props) {
List<Agent> agents = props.workers().stream()
.<Agent>map(w -> new LlmAgent(w.name(), w.systemPrompt(), llm))
.toList();
return new Orchestrator(llm, agents, props.maxSubtasks(), props.timeout());
}
}
Spring Bootは自動的に20sをDurationに、max-subtasksをmaxSubtasksに変換します。LlmClientビーンは使用しているSDKの周りに書くアダプターです。必要に応じて、異なるモデルの複数のLlmClientビーンを作成し、各ワーカーが設定を通じてモデルを選択できるようにできます。
7. よくあるエラーと使用すべきでない場合
7.1. よくあるエラー
- LLMの計画を無条件に信じる。 計画は検証されていない入力です。エージェント名のホワイトリスト、ステップ数の制限、実行前のパラメーター検証が必要です。特に書き込み権限を持つエージェントの場合。
- ループの制限がない。 エージェントが別のエージェントにタスクを割り当てることが許可されている場合、システムはA→B→Aループに陥る可能性があります。常に各リクエストの最大ステップ数、タイムアウト、トークン予算を設定します。
- トレーサビリティの喪失。 1つのリクエストが複数のLLM呼び出しを経由します。共通のリクエストIDでログとトレースを行わない場合、どのエージェントが間違った応答を引き起こしたかわかりません。各ステップはプロンプト、結果、時間、コストをログに記録すべきです。
- 不十分なコンテキストの転送。 ワーカーはオーケストレーターが渡す
description部分だけを見ます。説明が短すぎる場合、例えば注文コードなしで「返金を確認」とだけ書かれていると、ワーカーは推測します。プランナーに各サブタスクに必要な情報をすべて記入するよう要求します。 - エラー増幅。 1つのワーカーが間違った応答を返し、統合ステップがそれを自信を持って言い換えます。統合プロンプトは、結果に含まれている情報のみを使用し、エラーが発生した場合は報告することを明確に指示する必要があります。
7.2. マルチエージェント を使用すべきでない場合
注記: マルチエージェントが必ずしもシングルエージェントより「優れた」わけではありません。追加する各エージェントは追加のLLM呼び出し、追加のレイテンシ、追加のコスト、追加の障害ポイントです。
以下のいずれかに当てはまる場合は再検討してください:
- 数個のツールを持つ1つのエージェントが十分である。 シングルエージェントから始めてください。プロンプトが長すぎる、エージェントが誤ったツールを選択している、権限分離が必要など、明確な問題が見えた場合にのみ分割します。
- 固定処理フロー。 ステップが常にA→B→Cであれば、通常のコード(各ステップでLLMを呼び出すことができる)で書かれたワークフローの方が、予測可能で、テストしやすく、LLMに自動計画させるより安価です。
- 低レイテンシ要件。 上記のモデルだけで少なくとも2つの順序付きLLM呼び出し(計画と統合)が必要であり、これまたワーカー時間を考慮していません。
- ステップが密接に依存している。 各ステップが前のステップの完全なコンテキストを必要とする場合、複数のエージェントに分割するとコンテキストが断片化され、失われるだけです。
8. 結論
マルチエージェントはLLMアプリケーションに分割統治の原則を適用します。各エージェントは狭いスコープ、独自のプロンプトと権限を持ち、オーケストレーターが計画と統合を管理します。この記事では、Java 21でorchestrator–workerモデルを構築しました: テストのためLlmClientを分離し、LLMが生成したプランを検証し、タイムアウト付きの仮想スレッドでワーカーを並列実行し、Spring Boot設定を通じてワーカーを宣言します。
最も重要なことは、LLMの出力を未検証データとして扱い、常にステップ数、時間、コストに制限を設けることです。シングルエージェントから始めてください。具体的な理由があり、利益が測定できる場合にのみマルチエージェントに移行します。