왜 멀티 에이전트인가
에이전트 하나가 벽에 부딪혔다면, 더 똑똑한 선택은 더 큰 에이전트가 아니라 더 많은 에이전트입니다.
유형: Learn
언어: TypeScript
선수 조건: Phase 14(에이전트 엔지니어링)
예상 시간: 약 60분
학습 목표
- 단일 에이전트 한계(single-agent ceiling), 즉 컨텍스트 초과(context overflow), 섞인 전문성(mixed expertise), 순차 병목(sequential bottleneck)을 식별하고, 언제 여러 에이전트로 나누는 것이 올바른 선택인지 설명합니다.
- 오케스트레이션 패턴(orchestration pattern)인 파이프라인(pipeline), 병렬 팬아웃(parallel fan-out), 감독자(supervisor), 계층형(hierarchical)을 비교하고 주어진 과제 구조에 맞는 패턴을 선택합니다.
- 명확한 역할 경계(role boundary), 공유 상태(shared state), 통신 계약(communication contract)을 가진 멀티 에이전트 시스템(multi-agent system)을 설계합니다.
- 멀티 에이전트 복잡성, 즉 지연 시간(latency), 비용(cost), 디버깅 난이도와 단일 에이전트의 단순성 사이의 트레이드오프(tradeoff)를 분석합니다.
문제
Phase 14에서 단일 에이전트(single agent)를 만들었습니다. 잘 작동합니다. 파일을 읽고, 명령을 실행하고, API를 호출하고, 결과를 추론할 수 있습니다. 그런데 이제 실제 코드베이스를 맡겨봅니다. 파일 200개, 언어 세 가지, 인프라에 의존하는 테스트, 코드를 쓰기 전에 외부 API를 조사해야 한다는 요구사항까지 있습니다.
에이전트는 막힙니다. LLM이 멍청해서가 아닙니다. 과제가 에이전트 루프 하나가 감당할 수 있는 범위를 넘어섰기 때문입니다. 컨텍스트 창(context window)은 파일 내용으로 가득 찹니다. 에이전트는 도구 호출 40번 전에 읽은 내용을 잊습니다. 연구자, 코더, 리뷰어를 한꺼번에 하려다가 세 역할 모두를 어설프게 수행합니다.
이것이 단일 에이전트 한계입니다. 다음 조건이 생길 때마다 이 한계에 부딪힙니다.
- 한 창에 들어가지 않는 컨텍스트 - 파일 50개만 읽어도 20만 토큰을 넘어갑니다.
- 단계마다 다른 전문성 - 조사는 코드 생성과 다른 프롬프팅(prompting)을 필요로 합니다.
- 병렬로 할 수 있는 작업 - 파일 세 개를 동시에 읽을 수 있는데 왜 순차적으로 읽어야 할까요?
개념
단일 에이전트 한계
단일 에이전트는 루프 하나, 컨텍스트 창 하나, 시스템 프롬프트(system prompt) 하나입니다. 그림으로 보면 이렇습니다.
┌─────────────────────────────────────────┐
│ 단일 에이전트 │
│ │
│ ┌───────────────────────────────────┐ │
│ │ 컨텍스트 창 │ │
│ │ │ │
│ │ 조사 메모 │ │
│ │ + 코드 파일 │ │
│ │ + 테스트 출력 │ │
│ │ + 리뷰 피드백 │ │
│ │ + API 문서 │ │
│ │ + ... │ │
│ │ │ │
│ │ ██████████████████████ 가득 참 █ │ │
│ └───────────────────────────────────┘ │
│ │
│ 시스템 프롬프트 하나가 조사 + 코딩 + │
│ 리뷰 + 테스트를 모두 커버하려고 함 │
│ │
│ 결과: 모든 일을 그럭저럭만 수행 │
└─────────────────────────────────────────┘
세 가지가 깨집니다.
-
컨텍스트 포화(context saturation) - 도구 결과가 계속 쌓입니다. 30턴쯤 되면 에이전트는 파일 내용, 명령 출력, 이전 추론으로 15만 토큰을 이미 소비했습니다. 5턴에서 중요했던 세부사항은 사라집니다.
-
역할 혼란(role confusion) - "너는 연구자, 코더, 리뷰어, 테스터다"라고 말하는 시스템 프롬프트는 절반만 조사하고, 절반만 코딩하며, 리뷰는 끝내지 못하는 에이전트를 만듭니다.
-
순차 병목(sequential bottleneck) - 에이전트는 파일 A를 읽고, 그다음 파일 B를 읽고, 그다음 파일 C를 읽습니다. LLM 호출도 세 번 순차로 일어나고, 도구 실행도 세 번 순차로 일어납니다. 병렬성이 없습니다.
멀티 에이전트 해법
작업을 나눕니다. 각 에이전트에게 하나의 일, 하나의 컨텍스트 창, 그 일에 맞춘 하나의 시스템 프롬프트를 줍니다.
┌──────────────────────────────────────────────────────────┐
│ 오케스트레이터 │
│ │
│ "사용자 관리를 위한 REST API를 만든다" │
│ │
│ ┌──────────┬──────────┬──────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 연구자 │ │ 코더 │ │ 리뷰어 │ │ 테스터 │ │
│ │ │ │ │ │ │ │ │ │
│ │ 문서를 │ │ 조사와 │ │ 코드 │ │ 테스트를 │ │
│ │ 읽고, │ │ 명세에 │ │ 품질을 │ │ 실행하고 │ │
│ │ 패턴을 │ │ 맞춰 │ │ 확인하고 │ │ 결과를 │ │
│ │ 찾음 │ │ 코드 작성│ │ 버그 탐색│ │ 보고함 │ │
│ └─────┬────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └───────────┴────────────┴─────────────┘ │
│ │ │
│ 결과 병합 │
└──────────────────────────────────────────────────────────┘
각 에이전트는 다음을 갖습니다.
- 집중된 시스템 프롬프트: "너는 코드 리뷰어다. 네 유일한 일은 버그를 찾는 것이다."
- 자기만의 컨텍스트 창: 다른 에이전트의 작업으로 오염되지 않습니다.
- 명확한 입출력 계약: 조사 메모를 받고 코드를 출력합니다.
실제로 이렇게 하는 시스템
Claude Code 서브에이전트(subagents) - Claude Code가 Task로 서브에이전트를 만들면 범위가 제한된 일을 맡는 자식 에이전트(child agent)가 생성됩니다. 부모는 컨텍스트를 깨끗하게 유지합니다. 자식은 집중된 작업을 수행하고 요약을 반환합니다.
Devin - 계획자 에이전트(planner agent), 코더 에이전트(coder agent), 브라우저 에이전트(browser agent)를 실행합니다. 계획자는 작업을 단계로 나눕니다. 코더는 코드를 씁니다. 브라우저는 문서를 조사합니다. 각자 별도 컨텍스트를 갖습니다.
멀티 에이전트 코딩 팀(SWE-bench) - SWE-bench에서 상위권을 차지하는 시스템은 코드베이스를 읽는 연구자, 수정안을 설계하는 계획자, 구현하는 코더를 사용합니다. 단일 에이전트 시스템은 더 낮은 점수를 받습니다.
ChatGPT Deep Research - 여러 검색 에이전트를 병렬로 띄워 각자 다른 관점을 탐색하게 한 뒤, 결과를 종합합니다.
스펙트럼
멀티 에이전트는 이분법이 아닙니다. 하나의 스펙트럼입니다.
단순 ───────────────────────────────────────────── 복잡
단일 서브 파이프라인 팀 스웜
에이전트 에이전트
┌───┐ ┌───┐ ┌───┐───┐ ┌───┐───┐ ┌─┐┌─┐┌─┐
│ A │ │ A │ │ A │ B │ │ A │ B │ │ ││ ││ │
└───┘ └─┬─┘ └───┘─┬─┘ └─┬─┘─┬─┘ └┬┘└┬┘└┬┘
│ │ │ │ ┌┴──┴──┴┐
┌─┴─┐ ┌───┘───┐ │ │ │shared │
│ a │ │ C │ D │ ┌─┴───┴─┐ │ state │
└───┘ └───┘───┘ │ msg │ └───────┘
│ bus │
1 loop 부모 + 단계별 │ │ N개의 동료,
1 context 자식 작업 실행 └───────┘ 창발 행동
명시적
역할
단일 에이전트(Single agent) - 루프 하나, 프롬프트 하나입니다. 단순한 과제에 좋습니다.
서브에이전트(Subagents) - 부모가 집중된 하위 과제(subtask)를 위해 자식을 띄웁니다. 부모는 계획을 유지하고, 자식은 보고합니다. Claude Code가 이 방식을 사용합니다.
파이프라인(Pipeline) - 에이전트가 순서대로 실행됩니다. 에이전트 A의 출력이 에이전트 B의 입력이 됩니다. 조사 -> 코드 -> 리뷰 -> 테스트처럼 단계가 있는 워크플로(workflow)에 좋습니다.
팀(Team) - 에이전트가 공유 메시지 버스(message bus)를 두고 병렬 실행됩니다. 각자 역할이 있고, 오케스트레이터가 조정합니다. 서로 다른 기술이 동시에 필요할 때 좋습니다.
스웜(Swarm) - 동일하거나 거의 동일한 에이전트가 공유 상태를 사용합니다. 고정된 오케스트레이터가 없습니다. 에이전트는 큐(queue)에서 일을 가져갑니다. 처리량이 높은 병렬 과제에 좋습니다.
네 가지 멀티 에이전트 패턴
패턴 1: 파이프라인
입력 ──▶ Agent A ──▶ Agent B ──▶ Agent C ──▶ 출력
(조사) (코드) (리뷰)
각 에이전트가 데이터를 변환해 다음 단계로 넘깁니다. 이해하기 쉽습니다. 한 단계가 실패하면 나머지가 막힙니다.
패턴 2: 팬아웃 / 팬인(Fan-out / Fan-in)
┌──▶ Agent A ──┐
│ │
입력 ──▶ 분할 ──├──▶ Agent B ──├──▶ 병합 ──▶ 출력
│ │
└──▶ Agent C ──┘
작업을 병렬 에이전트에 나누고 결과를 다시 병합합니다. 독립 하위 과제로 분해되는 작업에 좋습니다.
패턴 3: 오케스트레이터-워커(Orchestrator-Worker)
┌──────────┐
│ Orch. │
└──┬───┬───┘
task │ │ task
┌─────┘ └─────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Worker A │ │ Worker B │
└──────────┘ └──────────┘
똑똑한 오케스트레이터가 할 일을 결정하고, 워커(worker)에게 위임하고, 결과를 종합합니다. 오케스트레이터 자체도 워커를 띄우는 도구를 가진 에이전트입니다.
패턴 4: 피어 스웜(Peer Swarm)
┌───┐ ◄──── msg ────▶ ┌───┐
│ A │ │ B │
└─┬─┘ └─┬─┘
│ │
msg │ ┌───────────┐ │ msg
└───▶│ Shared │◄────┘
│ State │
┌───▶│ / Queue │◄────┐
│ └───────────┘ │
msg │ │ msg
┌─┴─┐ ┌─┴─┐
│ C │ ◄──── msg ────▶ │ D │
└───┘ └───┘
중앙 오케스트레이터가 없습니다. 에이전트가 피어 투 피어(peer-to-peer)로 소통합니다. 결정은 상호작용에서 창발(emerge)합니다. 디버깅은 어렵지만 많은 에이전트로 확장할 수 있습니다.
멀티 에이전트를 쓰지 말아야 할 때
멀티 에이전트는 복잡성을 더합니다. 에이전트 사이의 모든 메시지는 잠재적 실패 지점입니다. 디버깅은 "대화 하나를 읽기"에서 "에이전트 다섯 개 사이의 메시지를 추적하기"로 바뀝니다.
단일 에이전트를 유지해야 할 때:
- 과제가 하나의 컨텍스트 창에 들어갑니다. 작업 데이터가 대략 10만 토큰 이하입니다.
- 단계마다 다른 시스템 프롬프트가 필요하지 않습니다.
- 순차 실행으로도 충분히 빠릅니다.
- 과제가 단순해서 나누는 비용이 가치보다 큽니다.
복잡성 비용:
- 모든 에이전트 경계는 손실 압축(lossy compression) 단계입니다. 에이전트 A의 전체 컨텍스트는 에이전트 B에게 가는 메시지로 요약됩니다.
- 조정 로직(coordination logic), 즉 누가 무엇을 언제 어떤 순서로 할지 정하는 로직 자체가 버그의 원천입니다.
- 지연 시간이 늘어납니다. N개의 에이전트는 최소 N번의 순차 LLM 호출을 의미하며, 서로 오가며 대화해야 하면 더 늘어납니다.
- 비용이 곱해집니다. 각 에이전트가 독립적으로 토큰을 씁니다.
경험칙은 이렇습니다. 과제가 도구 호출 20번 미만으로 끝나고 10만 토큰 안에 들어가면 단일 에이전트로 유지합니다.
만들어보기
Step 1: 과부하된 단일 에이전트
아래는 모든 일을 혼자 하려는 단일 에이전트입니다. 거대한 시스템 프롬프트 하나와 조사, 코드, 리뷰를 모두 담는 컨텍스트 창 하나를 가집니다.
type AgentResult = {
content: string;
tokensUsed: number;
toolCalls: number;
};
async function singleAgentApproach(task: string): Promise<AgentResult> {
const systemPrompt = `당신은 풀스택 개발자입니다. 반드시 다음을 수행합니다.
1. 요구사항을 조사합니다
2. 코드를 작성합니다
3. 버그를 찾기 위해 코드를 리뷰합니다
4. 테스트를 작성합니다
이 모든 일을 하나의 대화 안에서 수행합니다.`;
const contextWindow: string[] = [];
let totalTokens = 0;
let totalToolCalls = 0;
const research = await fakeLLMCall(systemPrompt, `조사: ${task}`);
contextWindow.push(research.output);
totalTokens += research.tokens;
totalToolCalls += research.calls;
const code = await fakeLLMCall(
systemPrompt,
`다음 조사 결과를 바탕으로:\n${contextWindow.join("\n")}\n\n이 과제를 위한 코드를 작성하세요: ${task}`
);
contextWindow.push(code.output);
totalTokens += code.tokens;
totalToolCalls += code.calls;
const review = await fakeLLMCall(
systemPrompt,
`이전 모든 컨텍스트:\n${contextWindow.join("\n")}\n\n코드를 리뷰하세요.`
);
contextWindow.push(review.output);
totalTokens += review.tokens;
totalToolCalls += review.calls;
return {
content: contextWindow.join("\n---\n"),
tokensUsed: totalTokens,
toolCalls: totalToolCalls,
};
}
이 접근의 문제는 다음과 같습니다.
- 컨텍스트 창이 단계마다 커집니다. 리뷰 단계가 되면 조사 메모와 코드와 이전 추론이 모두 들어 있습니다.
- 시스템 프롬프트가 너무 일반적입니다. 각 단계에 맞게 조정할 수 없습니다.
- 아무것도 병렬로 실행되지 않습니다.
Step 2: 전문 에이전트
이제 나눕니다. 각 에이전트가 하나의 일을 맡습니다.
type SpecialistAgent = {
name: string;
systemPrompt: string;
run: (input: string) => Promise<AgentResult>;
};
function createSpecialist(name: string, systemPrompt: string): SpecialistAgent {
return {
name,
systemPrompt,
run: async (input: string) => {
const result = await fakeLLMCall(systemPrompt, input);
return {
content: result.output,
tokensUsed: result.tokens,
toolCalls: result.calls,
};
},
};
}
const researcher = createSpecialist(
"researcher",
"당신은 기술 조사자입니다. 문서를 읽고, 패턴을 찾고, 발견한 내용을 요약합니다. 구현에 필요한 사실만 출력하세요."
);
const coder = createSpecialist(
"coder",
"당신은 시니어 TypeScript 개발자입니다. 요구사항과 조사 메모를 바탕으로 깔끔하고 테스트된 코드를 작성하세요. 다른 내용은 쓰지 마세요."
);
const reviewer = createSpecialist(
"reviewer",
"당신은 코드 리뷰어입니다. 버그, 보안 문제, 논리 오류를 찾으세요. 구체적으로 말하고 줄 번호를 인용하세요."
);
각 전문 에이전트는 집중된 프롬프트를 갖습니다. 각자는 필요한 입력만 담긴 깨끗한 컨텍스트 창을 받습니다.
Step 3: 메시지로 조정하기
명시적인 메시지 전달(message passing)로 전문 에이전트를 연결합니다.
type AgentMessage = {
from: string;
to: string;
content: string;
timestamp: number;
};
async function multiAgentApproach(task: string): Promise<AgentResult> {
const messages: AgentMessage[] = [];
let totalTokens = 0;
let totalToolCalls = 0;
const researchResult = await researcher.run(task);
messages.push({
from: "researcher",
to: "coder",
content: researchResult.content,
timestamp: Date.now(),
});
totalTokens += researchResult.tokensUsed;
totalToolCalls += researchResult.toolCalls;
const coderInput = messages
.filter((m) => m.to === "coder")
.map((m) => `[From ${m.from}]: ${m.content}`)
.join("\n");
const codeResult = await coder.run(coderInput);
messages.push({
from: "coder",
to: "reviewer",
content: codeResult.content,
timestamp: Date.now(),
});
totalTokens += codeResult.tokensUsed;
totalToolCalls += codeResult.toolCalls;
const reviewerInput = messages
.filter((m) => m.to === "reviewer")
.map((m) => `[From ${m.from}]: ${m.content}`)
.join("\n");
const reviewResult = await reviewer.run(reviewerInput);
messages.push({
from: "reviewer",
to: "orchestrator",
content: reviewResult.content,
timestamp: Date.now(),
});
totalTokens += reviewResult.tokensUsed;
totalToolCalls += reviewResult.toolCalls;
return {
content: messages.map((m) => `[${m.from} -> ${m.to}]: ${m.content}`).join("\n\n"),
tokensUsed: totalTokens,
toolCalls: totalToolCalls,
};
}
각 에이전트는 자기에게 주소가 지정된 메시지만 받습니다. 컨텍스트 오염이 없습니다. 연구자가 문서 읽기에 쓴 5만 토큰은 리뷰어의 컨텍스트에 들어가지 않습니다.
Step 4: 비교하기
async function compare() {
const task = "Express.js API용 레이트 리미터 미들웨어를 만들기";
console.log("=== 단일 에이전트 ===");
const single = await singleAgentApproach(task);
console.log(`사용 토큰: ${single.tokensUsed}`);
console.log(`도구 호출: ${single.toolCalls}`);
console.log("\n=== 멀티 에이전트 ===");
const multi = await multiAgentApproach(task);
console.log(`사용 토큰: ${multi.tokensUsed}`);
console.log(`도구 호출: ${multi.toolCalls}`);
}
멀티 에이전트 버전은 총 토큰을 더 많이 사용합니다. 에이전트 세 개와 별도 LLM 호출 세 번이 있기 때문입니다. 하지만 각 에이전트의 컨텍스트는 깨끗하게 유지됩니다. 시스템 프롬프트가 전문화되어 있기 때문에 각 단계의 품질이 좋아집니다.
사용해보기
이 강의는 언제 멀티 에이전트로 넘어갈지 결정하는 재사용 가능한 프롬프트를 만듭니다. outputs/prompt-multi-agent-decision.md를 확인하세요.
연습문제
- 네 번째 전문가를 추가하세요. 코더에게서 코드와 리뷰어에게서 리뷰 피드백을 받아 테스트를 작성하는 "테스터(tester)" 에이전트를 만듭니다.
- 리뷰어가 코더에게 피드백을 다시 보내 수정 루프(revision loop)를 만들 수 있게 파이프라인을 수정하세요. 최대 2라운드로 제한합니다.
- 순차 파이프라인을 팬아웃으로 바꾸세요. 연구자와 "요구사항 분석가(requirements analyzer)" 에이전트를 병렬로 실행하고, 두 출력을 병합한 뒤 코더에게 넘깁니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 스웜(Swarm) | "AI 에이전트의 집단 지성" | 공유 상태를 가지며 고정 리더가 없는 피어 에이전트 집합입니다. 행동은 지역 상호작용에서 창발합니다. |
| 오케스트레이터(Orchestrator) | "보스 에이전트" | 다른 에이전트를 만들고 관리하는 도구를 가진 에이전트입니다. 계획하고 위임하지만 실제 작업은 하지 않을 수 있습니다. |
| 코디네이터(Coordinator) | "교통정리 담당" | 규칙에 따라 에이전트 사이의 메시지를 라우팅하는 비에이전트 컴포넌트입니다. LLM이 아니라 코드인 경우가 많습니다. |
| 합의(Consensus) | "에이전트들이 동의함" | 여러 에이전트가 진행 전에 합의해야 하는 프로토콜입니다. 충돌하는 출력을 해결해야 할 때 사용합니다. |
| 창발 행동(Emergent behavior) | "에이전트들이 스스로 알아냈음" | 명시적으로 프로그래밍되지 않았지만 에이전트 상호작용에서 생기는 시스템 수준 패턴입니다. 유용할 수도 있고 해로울 수도 있습니다. |
| 팬아웃 / 팬인(Fan-out / fan-in) | "에이전트용 맵리듀스" | 작업을 병렬 에이전트로 나누고(fan-out), 결과를 결합하는(fan-in) 방식입니다. |
| 메시지 전달(Message passing) | "에이전트들이 서로 말함" | 에이전트 사이의 통신 메커니즘입니다. 공유 컨텍스트 창 대신 구조화된 데이터를 한 에이전트에서 다른 에이전트로 보냅니다. |
더 읽을거리