Multi-agent: AI agent들이 서로 일을 나눠주는 방식

1. 개요

LLM 응용이 ‘한 질문, 한 답변’ 수준을 넘어서면, 여러 단계의 작업을 맡겨야 합니다. 예를 들어 주문 조회, 정책 확인, 응답 작성 같은 작업들이 그렇습니다. 모든 것을 하나의 agent에 집중시키면 곧 한계에 부닥칩니다. Multi-agent는 이 일을 여러 전문화된 agent에게 나누는 방식이며, 하나의 orchestrator agent가 작업을 분배합니다.

이 글에서는 multi-agent가 무엇인지, 왜 주목받는지, 그리고 일반적인 모델들을 설명합니다. 그 다음 Java 21로 간단한 orchestrator를 직접 만들어보겠습니다. 테스트와 Spring Boot 설정이 포함됩니다. 마지막으로 이 접근 방식을 사용하면 안 되는 경우를 명확히 설명합니다.

2. Multi-agent란?

agent는 세 부분으로 이루어진 루프입니다: LLM이 목표를 받고, 행동을 결정하고(tool 호출, 응답, 재질의), 결과를 관찰합니다. Multi-agent는 여러 agent로 이루어진 시스템입니다. 각 agent는 고유한 system prompt, tool, 책임 범위를 가지며, 서로 작업을 넘깁니다.

몇 가지 일반적인 조정 모델이 있습니다:

  • Orchestrator–worker (supervisor): 하나의 orchestrator agent가 요청을 분석하고 부작업으로 나누며, worker들에게 할당하고 결과를 종합합니다.
  • Pipeline: agent들이 순차적으로 연결되며, 한 agent의 출력이 다음 agent의 입력이 됩니다. 예: ‘조사 → 초안 작성 → 편집’.
  • Handoff: 처리 중인 agent가 작업이 자신의 범위를 벗어났다는 것을 인식하고 전체 대화를 다른 agent로 이관합니다. 콜센터에서 통화를 넘기는 것처럼.
  • Generator–critic: 한 agent가 결과를 생성하고, 다른 agent가 평가하고 수정을 요청하며, 달성하거나 최대 반복 횟수에 도달할 때까지 반복합니다.

이 글은 orchestrator–worker에 중점을 둡니다. 가장 이해하기 쉽고 많은 문제에 적용할 수 있는 모델입니다.

3. Multi-agent가 주목받는 이유는?

몇 가지 기술적인 이유가 있습니다:

  1. Context의 한계. 하나의 agent가 너무 많은 tool과 지침을 가지면 prompt가 길어지고 잘못된 tool을 선택하기 쉽습니다. 전문화된 agent는 자신의 부분만 알면 됩니다.
  2. 전문화. 각 agent는 고유한 prompt, model, 매개변수를 사용할 수 있습니다. 간단한 분류는 작고 저렴한 model을 사용할 수 있고, 복잡한 추론은 더 강력한 model을 사용합니다.
  3. 병렬 실행. 독립적인 부작업들을 동시에 실행할 수 있어 전체 응답 시간을 줄입니다.
  4. 명확한 권한 분리. 조회 agent는 읽기 권한만 있습니다. 환불 agent만 결제 API를 호출할 수 있습니다. 이 경계는 서비스 간 권한 분리와 같습니다.
  5. 테스트와 평가가 용이. 거대한 prompt 블록 대신 각 agent를 개별적으로 테스트하고 품질을 측정할 수 있습니다.

요약하면, 이는 친숙한 ’분할 정복’과 ‘단일 책임’ 원칙을 LLM에 적용한 것입니다.

4. Java 21로 orchestrator–worker 구축하기

예제 문제: 고객이 *“주문 #1001은 어디에? 늦으면 환불받을 수 있나요?”*라고 묻습니다. 두 개의 worker가 있습니다:

  • order: 주문 상태 조회.
  • refund: 환불 조건 확인.

Orchestrator는 계획을 세우고, 두 worker에게 작업을 병렬로 할당하고, 응답을 종합합니다.

4.1. 의존성

예제에는 기본 Spring Boot starter와 test 도구만 필요합니다. 버전은 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과 agent 추상화

특정 SDK에 얽매이지 않습니다. 모든 LLM 호출은 interface를 통해 진행됩니다:

@FunctionalInterface
public interface LlmClient {
    String complete(String systemPrompt, String userMessage);
}

프로덕션에서는 사용 중인 SDK나 framework를 사용하는 adapter를 작성합니다. 테스트에서는 lambda를 전달합니다. 이것이 이 추상화 계층의 가장 큰 이점입니다.

Agent도 interface입니다. 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만 다르고, 실제로는 tool set도 다릅니다. Worker는 LLM을 호출하지 않는 순수 코드일 수도 있습니다. 예를 들어 OrderRepository만 호출하는 agent. Orchestrator는 worker 내부가 무엇을 하는지 알 필요가 없습니다.

4.3. Orchestrator

public class Orchestrator {

    private static final String PLANNER_PROMPT = """
            당신은 고객 서비스 조정자입니다.
            요청을 부작업으로 나누며, 각 줄의 형식: <agent>|<작업 설명>
            유효한 agent: %s. 최대 %d줄. 다른 것을 쓰지 마세요.
            """;
    private static final String SYNTH_PROMPT = """
            agent들의 결과를 고객을 위한 간단한 답변으로 종합합니다.
            결과에 있는 정보만 사용합니다.
            """;

    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 "오류: agent 시간 초과";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "오류: 중단됨";
        } catch (ExecutionException e) {
            return "오류: " + e.getCause().getMessage();
        }
    }
}

주의할 점:

  • **run**은 세 단계로 구성됩니다: 계획 수립(LLM 호출 1회), 부작업 실행, 결과 종합(LLM 호출 1회 추가).
  • **parse**은 LLM 출력을 신뢰할 수 없는 데이터로 취급합니다. 형식이 잘못된 줄은 무시됩니다. 목록에 없는 agent 이름도 무시됩니다. 부작업의 수는 maxSubtasks에서 자릅니다. LLM이 admin agent를 ‘만들어내면’ 절대 호출되지 않습니다.
  • **execute**는 Java 21의 virtual thread를 사용합니다. LLM 호출은 주로 네트워크 I/O를 대기하므로 virtual thread가 매우 적합합니다: 부작업당 하나의 thread, pool 직접 조정 불필요. try-with-resources 블록은 모든 task가 완료된 후에만 executor를 종료합니다.
  • **await**은 각 worker에 timeout을 설정합니다. 느린 worker나 오류는 전체 요청을 망치지 않습니다. 오류 결과는 오류: … 문자열로 기록되어 종합 단계가 계속 실행될 수 있으며, 고객에게 일부 정보를 사용할 수 없음을 알릴 수 있습니다.

4절 시작의 요청에 대해 LLM이 반환하는 계획은 다음과 같을 수 있습니다:

order|주문 #1001 상태 조회
refund|주문 #1001 배송 지연 시 환불 조건 확인

참고: 여기서는 가독성을 위해 agent|설명 형식을 사용합니다. 실제로는 LLM이 schema에 따라 JSON을 반환하도록 요청하거나 공급자의 structured output/tool calling 기능을 사용해야 합니다. 어느 방식이든 코드 쪽 검증 단계는 필수입니다.

5. Orchestrator 테스트

LlmClient가 functional interface이므로 실제 LLM 대신 lambda를 사용합니다. 테스트가 빠르고, 결과가 안정적이며, 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: 배송비 환불 조건 충족");
    }
}

이 테스트는 두 가지 중요한 동작을 확인합니다:

  • 계획의 admin|모든 주문 삭제 줄이 제거되므로 두 worker만 호출됩니다.
  • 두 worker의 결과 모두 종합 단계에 도달합니다. 두 worker가 병렬로 실행되더라도 응답의 순서는 계획의 순서를 유지합니다.

lambda 종합이 받는 answer 내용의 형태:

종합
요청: 주문 #1001은 어디에? 늦으면 환불받을 수 있나요?
결과:
order: 주문 #1001 배송 중, 2일 지연
refund: 배송비 환불 조건 충족

이 유형의 테스트는 조정 부분, 즉 작성한 로직만 확인합니다. 실제 LLM 응답의 품질은 별도로 샘플 질문 세트와 채점 기준으로 평가해야 합니다.

6. Spring Boot 설정으로 agent 선언하기

worker 수가 증가하면, application.yml에서 선언하면 코드를 수정하지 않고 prompt를 추가하거나 수정할 수 있습니다:

agents:
  max-subtasks: 4
  timeout: 20s
  workers:
    - name: order
      system-prompt: "주문 상태를 조회합니다. 시스템 데이터만 기반으로 답변합니다."
    - name: refund
      system-prompt: "정책에 따라 환불 조건을 확인합니다. 임의로 환불을 약속하지 마세요."

설정을 record로 매핑합니다:

@ConfigurationProperties(prefix = "agents")
public record AgentProperties(int maxSubtasks, Duration timeout, List<Worker> workers) {
    public record Worker(String name, String systemPrompt) {}
}

그 다음 Orchestrator bean을 생성합니다:

@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로 변환합니다. Bean LlmClient는 사용 중인 SDK 주변에 작성하는 adapter입니다. 원하면 다양한 model을 위해 여러 LlmClient bean을 만들고, 각 worker가 설정을 통해 model을 선택하게 할 수 있습니다.

7. 흔한 오류와 사용하면 안 되는 경우

7.1. 흔한 오류

  • LLM 계획을 무조건 신뢰하지 마세요. 계획은 검증되지 않은 입력입니다. agent 이름을 whitelist하고, 단계 수를 제한하고, 실행 전에 매개변수를 검증해야 합니다. 특히 쓰기 권한이 있는 agent의 경우.
  • 루프를 제한하지 마세요. Agent가 다른 agent에게 작업을 할당할 수 있으면 A → B → A 루프에 빠질 수 있습니다. 항상 각 요청에 최대 단계, timeout, token 예산을 설정하세요.
  • 추적 손실. 요청이 여러 LLM 호출을 거칩니다. 공통 요청 ID로 로깅하고 추적하지 않으면 어느 agent가 잘못된 응답을 만들었는지 알 수 없습니다. 각 단계에서 prompt, 결과, 시간, 비용을 기록해야 합니다.
  • 문맥 전달 부족. Worker는 orchestrator가 전달하는 description 부분만 봅니다. 설명이 너무 짧으면, 예를 들어 ’환불 확인’만 있고 주문 번호가 없으면, worker가 추측해야 합니다. planner가 각 부작업에 필요한 충분한 정보를 기록하도록 요청하세요.
  • 오류 증폭. 한 worker가 잘못 응답하면, 종합 단계가 자신감 있게 해석합니다. 종합 prompt는 결과에 있는 정보만 사용하고 오류가 있을 때 보고해야 한다는 점을 명확히 해야 합니다.

7.2. Multi-agent를 사용하면 안 되는 경우

참고: Multi-agent가 자동으로 single agent보다 ‘낫지는’ 않습니다. 추가된 각 agent는 LLM 호출, 지연, 비용이 증가하고 문제가 발생할 수 있는 지점이 늘어납니다.

다음 중 하나에 해당하면 다시 생각해보세요:

  • 하나의 agent와 몇 가지 tool이 잘 작동합니다. Single agent부터 시작하세요. 명확한 문제(prompt가 너무 길거나, agent가 잘못된 tool을 선택하거나, 권한 분리 필요)를 볼 때만 분리하세요.
  • 고정된 처리 흐름. 단계가 항상 A, B, C 순서라면, 일반 코드로 작성된 workflow(각 단계에서 LLM을 호출할 수 있음)가 LLM이 자체 계획을 세우는 것보다 예측 가능하고, 테스트하기 쉽고, 저렴합니다.
  • 낮은 지연 시간 필요. 위 모델만 해도 최소 두 개의 순차 호출(계획 수립 및 종합)이 필요하며, worker 시간은 제외됩니다.
  • 단계가 서로 밀접하게 의존합니다. 각 단계가 이전 단계의 전체 문맥을 필요로 하면, 여러 agent로 분산하면 정보가 단편화되고 손실될 뿐입니다.

8. 결론

Multi-agent는 분할 정복 원칙을 LLM 응용에 적용합니다. 각 agent는 좁은 범위, 고유한 prompt와 권한을 가지며, orchestrator는 계획 수립과 종합을 담당합니다. 이 글에서는 Java 21로 orchestrator–worker 모델을 구축했습니다: 테스트 용이성을 위해 LlmClient 분리, LLM이 생성한 계획 검증, timeout이 있는 virtual thread로 worker 병렬 실행, Spring Boot 설정으로 worker 선언.

가장 중요하게 기억할 점은 LLM 출력을 검증되지 않은 데이터로 취급하고, 항상 단계, 시간, 비용에 제한을 설정하는 것입니다. 단일 agent부터 시작하세요. 구체적인 이유와 측정 가능한 이점이 있을 때만 multi-agent로 전환하세요.

ai-agentmulti-agentllmjavaspring-boot