다중 에이전트튜토리얼AI러스트v0.1.8

ADK-Rust를 사용한 다중 에이전트 시스템 구축

Rust의 타입 안전성과 성능을 활용하여 간단한 조정부터 복잡한 그래프 기반 오케스트레이션에 이르기까지 다양한 다중 에이전트 패턴을 언제, 어떻게 사용할지 알아보세요.

·읽는 데 15분·ADK-Rust v0.1.8

1. 소개

문제: 한계에 부딪히는 AI

첫 AI 에이전트를 구축했습니다. 질문에 답하고, 문서를 요약하고, 심지어 코드를 작성할 수도 있는 인상적인 에이전트입니다. 하지만 현실은 다음과 같습니다:

"제 마지막 인보이스를 확인하고 API 설정을 도와주실 수 있나요?"

에이전트가 어려움을 겪습니다. 청구 시스템, 개발자 문서, 문제 해결 워크플로에 대해 모두 훈련되지 않았기 때문입니다. 지시 프롬프트는 이미 모든 것을 다루려다 2000 토큰에 달합니다. 응답 품질이 저하됩니다.

이것이 단일 에이전트의 한계입니다. 애플리케이션이 성장함에 따라 다음과 같은 고통스러운 절충점에 직면하게 됩니다:

  • 비대해진 프롬프트: 새로운 기능이 추가될 때마다 더 긴 지시, 더 높은 지연 시간, 모델의 더 큰 혼란을 의미합니다
  • 만물박사: 청구, 지원, 판매를 모두 처리하는 하나의 에이전트는 세 가지 모두에서 평범해집니다
  • 불가능한 유지보수: 청구 로직을 변경하는 것이 지원 흐름을 망가뜨릴 위험이 있어서는 안 됩니다
  • 전문성 부족: 수학 에이전트가 계산기 도구를 가질 수 없고, 연구 에이전트가 웹 검색을 가질 수 없습니다. 모든 것을 공유하기 때문입니다

해결책: 함께 작동하는 전문 에이전트

다중 에이전트 시스템은 복잡한 작업을 전문화된 역할로 분해하여 이 문제를 해결합니다. 압도당하는 하나의 제너럴리스트 대신, 집중된 전문가를 만듭니다:

  • 고객 서비스: coordinator는 사용자를 청구, 기술 지원 또는 판매 전문가에게 라우팅하며, 각 전문가는 집중적인 훈련과 도구를 갖추고 있습니다
  • 콘텐츠 제작: 연구 에이전트는 사실을 수집하고, writer는 내러티브를 만들고, 편집자는 다듬습니다. 각 에이전트는 하나의 기술을 마스터합니다
  • 코드 생성: 기획자는 아키텍처를 설계하고, coder는 구현하며, 검토자는 버그를 찾아냅니다. 다양한 관점은 품질을 향상시킵니다.

그 결과는? 각 에이전트는 집중하고, 프롬프트는 관리하기 쉬워지며, 지원팀에 연락하지 않고도 결제 로직을 업데이트할 수 있습니다. AI를 위한 마이크로서비스입니다.

학습 내용

ADK-Rust는 다중 에이전트 오케스트레이션을 위한 세 가지 점진적으로 강력한 패턴을 제공합니다. 이 튜토리얼에서는 다음을 배울 것입니다:

  • 요구 사항에 따라 각 패턴을 언제 사용해야 하는지
  • 프로덕션 준비가 된 Rust 코드로 이를 구현하는 방법
  • 특정 사용 사례에 아키텍처 트레이드오프가 중요한 이유

2. 올바른 패턴 선택

코드를 살펴보기 전에 각 패턴이 제공하는 것을 이해해 봅시다:

패턴가장 적합한 용도제어 수준복잡성
코디네이터대화 인계LLM가 결정낮음
AgentTool응답 처리코디네이터 처리중간
감독자 그래프복잡한 워크플로전체 상태 관리높음

3. 패턴 1: 코디네이터 (하위 에이전트)

🎯 사용 사례: 고객 서비스 라우팅

고객 서비스 봇을 구축하고 있습니다. 사용자는 청구서에 대해 문의하거나, 기술 지원을 요청하거나, 새로운 기능에 대해 질문할 수 있습니다. 각 도메인에는 전문 지식이 필요하지만, 사용자가 어떤 부서에 연락해야 하는지 알 필요는 없습니다.

코디네이터 패턴은 자동 에이전트 전송을 사용합니다. .sub_agent()을(를) 통해 하위 에이전트를 추가하면, ADK-Rust가(이) transfer_to_agent 도구를 주입합니다. LLM은(는) 대화 내용을 기반으로 언제 인계할지 결정합니다.

사용자코디네이터요청 라우팅전문가에게청구하위 에이전트지원하위 에이전트영업하위 에이전트transfer_to_agenttransfer_to_agenttransfer_to_agent

주요 특징

  • 원활한 인계: 사용자는 전문가와 자연스럽게 대화를 이어갑니다
  • 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]: 안녕하세요! 저는 청구 전문가입니다. 송장, 결제, 구독 관련 질문에 도움을 드릴 수 있습니다. 청구서에 대해 무엇을 알고 싶으신가요?

사용자: 이번 달에 왜 두 번 청구되었나요?

[billing_agent]: 해당 중복 청구에 대해 확인해 드리겠습니다...

✅ 코디네이터 사용 시점

  • 사용자는 전문가와 직접 상호작용해야 합니다.
  • 라우팅 결정이 간단합니다.
  • 전문가 응답을 처리할 필요가 없습니다.
  • 대화 흐름이 선형적입니다 (한 번에 한 명의 전문가).

4. 패턴 2: 도구로서의 에이전트 (AgentTool)

🎯 사용 사례: 지식 통합

여러 도메인에 걸친 질문에 답하는 스마트 어시스턴트를 구축하고 있습니다. 사용자가 "250의 15%는 얼마이며, 그 숫자가 역사적으로 왜 중요한가요?"라고 묻습니다. 수학 전문가를 호출한 다음, 퀴즈 전문가를 호출하여 그들의 답변을 결합해야 합니다.

AgentTool 패턴은 에이전트를 호출 가능한 도구로 래핑합니다. 서브 에이전트와 달리, coordinator는 전문가를 프로그래밍 방식으로 호출하고 그들의 응답을 받아 사용자에게 회신하기 전에 처리하거나 결합합니다.

사용자코디네이터도구로 에이전트 호출응답 처리결과 집계사용자에게 반환수학 전문가AgentTool+ 계산기상식 전문가AgentToolLLM 지식연구원AgentTool+ web_search호출 →← 응답

코디네이터와의 주요 차이점

코디네이터 (서브 에이전트)

  • • 전문가가 사용자와 직접 대화
  • • 한 번에 한 명의 전문가
  • • 응답 처리 없음

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 에이전트는 완전한 상태 관리 및 순환 실행 지원을 통해 작업자에게 동적으로 라우팅합니다.

START감독관다음 결정작업자 에이전트연구원작업자작가작업자코더작업자END다시 순환"완료" → 마무리 → END공유 상태• research_output• written_content• code_output

그래프를 사용하는 이유?

  • 동적 라우팅: 감독자가 현재 상태에 따라 다음 작업자를 결정
  • 순환 실행: 작업자가 반복을 위해 다시 돌아올 수 있음
  • 공유 상태: 모든 노드가 공통 상태 객체에 읽기/쓰기
  • 조건부 엣지: 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감독자 그래프
사용자 대화 대상전문가에게 직접코디네이터만최종 출력
다중 에이전트 호출❌ 한 번에 하나씩✅ 병렬 가능✅ 오케스트레이션됨
응답 처리
순환 워크플로
공유 상태세션 전용세션 전용전체 그래프 상태
설정 복잡성🟢 낮음🟡 중간🔴 높음

7. 결론

다중 에이전트 시스템을 사용하면 특수 에이전트를 결합하여 정교한 AI 애플리케이션을 구축할 수 있습니다. 필요에 따라 패턴을 선택하세요:

  • 코디네이터: 빠른 설정, 고객 서비스 라우팅에 적합
  • AgentTool: 응답을 처리하거나 결합해야 할 때
  • 감독자 그래프: 복잡하고 동적이며 다단계 워크플로

🦀 시작하기

나만의 다중 에이전트 시스템을 구축할 준비가 되셨나요?