단일 에이전트로 처리하던 작업이 커지면, 하나의 프롬프트 안에 도구 수십 개와 상충하는 지시가 뒤섞이기 시작합니다. 컨텍스트가 길어질수록 모델은 앞쪽 지시를 잊고, 도구 선택은 흔들리며, 디버깅은 “어디서 잘못됐는지 모르겠다”로 수렴합니다. 이때 떠오르는 대안이 멀티 에이전트 오케스트레이션, 즉 큰 문제를 역할이 분명한 여러 에이전트로 쪼개 각자 좁은 컨텍스트에서 자기 일만 하게 만드는 접근입니다.
하지만 에이전트를 늘리는 것 자체는 해법이 아닙니다. 잘못 설계하면 토큰은 몇 배로 늘고, 상태가 어긋나며, 한 명이 실패하면 전체가 멈춥니다. 이 글에서는 언제 도입할지, 역할을 어떻게 나눌지, 가장 어려운 상태를 어떻게 공유할지를 실무 관점에서 정리합니다. 예시는 Anthropic Claude API 기준이지만, 오케스트레이터-워커 구조나 상태 공유 원칙은 다른 프로바이더에도 대체로 적용됩니다.
언제 멀티 에이전트가 필요한가
가장 먼저 던져야 할 질문은 “정말 여러 에이전트가 필요한가”입니다. 각 서브에이전트는 자기만의 시스템 프롬프트·도구 정의·컨텍스트를 다시 채우고 시작하므로 단일 에이전트 대비 토큰을 몇 배 소모합니다. 이 비용을 정당화할 만큼 값어치를 하는 조건은 대체로 다음과 같습니다.
- 병렬화 가능한 폭넓은 작업: 독립적인 하위 작업이 여러 개라 동시 진행 시 벽시계 시간이 줄어들 때(예: 여러 소스 조사).
- 컨텍스트 격리가 이득인 경우: 한 작업의 방대한 중간 산출물이 다른 판단을 오염시킬 때, 서브에이전트에 가둬 오케스트레이터를 깨끗이 유지한다.
- 이질적 전문성: SQL 작성·코드 리뷰·문서 요약처럼 요구 도구·검증 기준이 다른 역할이 구분될 때.
반대로 하위 작업들이 서로의 결과에 강하게 의존하거나(한 단계 출력이 다음 단계 입력) 상태를 촘촘하게 공유해야 한다면, 멀티 에이전트는 오히려 독입니다. 이런 순차 의존 작업은 단일 에이전트의 한 컨텍스트에서 처리하는 편이 정확하고 저렴합니다.
오케스트레이터-워커 패턴
가장 널리 쓰이는 구조는 오케스트레이터-워커(orchestrator-worker)입니다. 리드 에이전트가 문제를 분해해 서브에이전트에게 위임하고 결과를 취합합니다. 핵심은 오케스트레이터가 얇아야 한다는 것입니다. 세부까지 알려고 하면 모든 중간 산출물이 오케스트레이터로 역류해 격리 이점이 사라집니다. 오케스트레이터는 “무엇을, 누구에게, 어떤 기준으로” 위임할지만 정하고, 워커의 방대한 중간 과정은 워커 안에 남겨 압축된 결과만 돌려받아야 합니다.
# 오케스트레이터는 서브에이전트를 '도구'로 노출한다.
# 워커의 상세 실행은 워커 내부에 갇히고, 오케스트레이터는 요약만 받는다.
tools = [{
"name": "dispatch_worker",
"description": "독립적인 하위 작업 하나를 전문 워커에게 위임한다. "
"여러 번 호출해 병렬로 띄울 수 있다.",
"input_schema": {
"type": "object",
"properties": {
"role": {"type": "string",
"enum": ["researcher", "sql_writer", "reviewer"]},
"objective": {"type": "string"}, # 한 문장으로 좁게 정의
"output_contract": {"type": "string"}, # 예: "JSON: {finding, source}"
},
"required": ["role", "objective", "output_contract"],
},
}]
가장 중요한 필드는 objective와 output_contract입니다. 목표가 모호하면 워커는 스코프를 넓혀 토큰을 낭비하고, 출력 형식이 없으면 종합 단계에서 매번 다른 모양을 파싱하느라 흔들립니다. 위임은 함수 호출처럼 계약을 명시해야 합니다.
역할 분담: 겹치지 않게, 검증자를 분리하라
역할을 나눌 때 가장 흔한 실수는 책임이 겹치는 것입니다. 두 에이전트가 모두 “데이터를 정리한다”라고 하면, 같은 일을 두 번 하거나 서로 미루며 공백이 생깁니다. 데이터베이스 정규화처럼 역할은 입력·출력·성공 기준이 배타적이도록, 한 책임은 한 곳에만 두도록 자릅니다.
특히 효과가 큰 패턴은 생성자와 검증자를 다른 에이전트로 분리하는 것입니다. 같은 에이전트에게 “만들고 스스로 검토하라”라고 하면 자기 결과에 관대해집니다. 검증을 독립 컨텍스트에 맡기면 생성 과정의 잡음 없이 최종 산출물만 냉정하게 평가합니다.
- 생성자(generator): 답을 만든다. 도구 접근권이 넓고 탐색적이다.
- 검증자(critic): 생성물이 계약(형식·사실·제약)을 지켰는지만 판단한다. 읽기 전용만.
- 오케스트레이터: 검증 실패 시 재위임할지 사람에게 넘길지 결정한다.
# 역할별 시스템 프롬프트는 '좁게' 쓴다. 검증자에게는 생성 권한을 주지 않는다.
ROLE_PROMPTS = {
"sql_writer": "당신은 SQL 작성 전문가다. 주어진 스키마로 단 하나의 "
"SELECT 쿼리만 작성하고 DDL/DML은 생성하지 않는다.",
"reviewer": "당신은 검증자다. 쿼리를 실행하지 말고 스키마에 없는 컬럼 참조·"
"카티전 곱만 지적한다. "
"판정은 {verdict: pass|fail, reasons: [...]} 로 응답한다.",
}
상태 공유의 세 가지 모델
멀티 에이전트 설계에서 가장 어려운 부분은 역할 분담이 아니라 상태 공유입니다. 에이전트들이 무엇을 어떻게 주고받는지가 정확성과 비용을 좌우합니다. 크게 세 가지 모델이 있습니다.
- 메시지 전달: 오케스트레이터가 필요한 정보만 넘기고 워커는 결과만 돌려준다. 격리가 강하지만 넘길 정보를 미리 알아야 한다.
- 공유 저장소(blackboard): 모든 에이전트가 읽고 쓰는 외부 상태(키-값 저장소, DB)를 둔다. 유연하지만 쓰기 충돌 관리가 필요하다.
- 공유 대화 이력: 모든 에이전트가 하나의 누적 로그를 본다. 맥락 공유는 완벽하나 컨텍스트가 폭발한다.
실무의 기본값은 메시지 전달 + 공유 저장소의 조합입니다. 방대한 산출물(문서, 생성 코드)은 저장소에 넣고 참조와 짧은 요약만 주고받으며, 원문은 필요한 워커만 당겨옵니다. 원문 전체를 메시지에 실어 나르면 컨텍스트가 매 홉마다 부풀어 오릅니다.
# 무거운 산출물은 저장소에, 메시지에는 참조(ref)와 요약만 싣는다.
def put_artifact(content: str) -> dict:
ref = "artifact:" + uuid.uuid4().hex[:12]
store[ref] = content # 원문은 저장소(Redis/S3)에
return {"ref": ref, "summary": content[:280]} # 요약만
공유 저장소의 동시성: 충돌을 예방하라
워커를 병렬로 띄우면 공유 저장소에 동시에 쓰는 상황이 생깁니다. 두 에이전트가 같은 키를 읽고 각자 수정한 뒤 쓰면, 나중에 쓴 쪽이 앞의 변경을 덮어쓰는 lost update가 발생합니다. LLM 에이전트는 재시도가 잦고 멱등하지 않기 쉬워 더 위험합니다. 가장 실용적인 대비책은 각 레코드에 버전을 두고 읽은 버전과 현재 버전이 같을 때만 쓰기를 허용하는 낙관적 동시성 제어입니다.
더 근본적으로는 가능한 한 append-only로 설계하는 것이 안전합니다. 각 에이전트가 자기 소유의 새 항목만 추가하도록 파티셔닝하면 충돌 자체가 사라집니다. 오케스트레이터는 이 항목들을 결정적 순서(예: 워커 인덱스순)로 병합합니다. 순서가 비결정적이면 같은 입력에도 결과가 달라집니다.
병렬 위임과 결과 취합
병렬화의 이점을 실제로 얻으려면 오케스트레이터가 서브에이전트를 동시에 띄워야 합니다. 순차 호출하면 격리는 얻어도 지연은 그대로입니다. 아래는 여러 워커를 병렬 실행하는 최소 골격입니다.
import asyncio
async def run_worker(role: str, objective: str, contract: str) -> dict:
resp = await client.messages.create( # 워커는 깨끗한 컨텍스트에서 시작
model="claude-opus-4-8", max_tokens=2048,
system=[{"type": "text", "text": ROLE_PROMPTS[role],
"cache_control": {"type": "ephemeral"}}], # 역할 프롬프트 캐싱
messages=[{"role": "user",
"content": f"목표: {objective}n출력형식: {contract}"}],
)
text = next(b.text for b in resp.content if b.type == "text")
return put_artifact(text) # 원문은 저장소로, 참조만 반환
# 독립 하위 작업들을 동시에 실행 → 벽시계 시간 단축
async def orchestrate(subtasks: list[dict]) -> list[dict]:
return await asyncio.gather(*[run_worker(**t) for t in subtasks])
다만 병렬성을 무한정 늘리면 안 됩니다. 워커 수에 비례해 토큰·비용·레이트 리밋 압박이 커집니다. 오케스트레이터가 복잡도에 따라 워커 수를 스스로 조절하도록, 시스템 프롬프트에 “간단한 확인은 1개, 폭넓은 비교는 3~5개” 같은 기준을 명시합니다. 기준이 없으면 사소한 질문에도 과도하게 워커를 띄웁니다.
실패 격리와 재시도
에이전트 하나가 실패하는 것은 예외가 아니라 정상 시나리오입니다. 워커는 도구 호출에 실패하고, 형식 계약을 어기고, 때로는 무한 루프에 가깝게 헤맵니다. 한 워커의 실패가 전체를 무너뜨리게 두면 멀티 에이전트가 오히려 취약해집니다. 오케스트레이터는 다음으로 부분 실패를 흡수해야 합니다.
- 실패 격리: 각 워커 호출을 개별적으로 감싸, 한 워커의 예외가 다른 결과를 버리지 않게 한다.
- 계약 검증: 결과가
output_contract를 지켰는지 확인하고, 어기면 1~2회 재위임한다. - 부분 성공 허용: 5개 중 4개만 성공해도 실패한 하나를 명시하고 나머지로 종합한다.
# 실패는 예외가 아니라 '값'으로 반환해 전체 gather를 죽이지 않는다.
async def run_worker_safe(t: dict, retries: int = 1) -> dict:
err = None
for _ in range(retries + 1):
try:
r = await run_worker(**t)
if validate_contract(r, t["output_contract"]):
return {"status": "ok", **r}
except Exception as e: # 도구 실패 등
err = str(e)
return {"status": "failed", "task": t["objective"], "error": err}
관찰 가능성: 흩어진 실패를 하나로 묶기
멀티 에이전트의 디버깅이 어려운 이유는 실패가 여러 컨텍스트에 흩어져 있기 때문입니다. 최종 응답이 이상할 때 위임이 잘못됐는지, 워커가 헛다리를 짚었는지, 취합에서 뭉갰는지 구분할 수 없다면 개선이 불가능합니다. 그래서 오케스트레이터의 위임 근거, 각 워커의 목표·산출물·소비 토큰, 계약 검증 결과를 하나의 상관관계 ID(trace_id)로 묶어 구조화 로그로 남깁니다. 특히 토큰 소비를 에이전트별로 남기면 어느 역할이 비용을 대부분 먹는지 드러납니다.
마무리
멀티 에이전트 오케스트레이션은 만능이 아니라 특정 조건에서만 값어치를 하는 트레이드오프입니다. 병렬화·컨텍스트 격리·이질적 전문성의 이득이 몇 배의 토큰 비용을 넘어설 때만 도입하고, 순차 의존이 강한 작업은 단일 에이전트에 둡니다. 도입한다면 오케스트레이터를 얇게 유지하고 역할을 배타적으로 나누며 생성자와 검증자를 분리하는 것이 토대입니다.
그리고 진짜 승부처는 상태 공유입니다. 무거운 산출물은 저장소에 두고 참조와 요약만 주고받아 컨텍스트 폭발을 막고, 공유 저장소는 낙관적 락이나 append-only로 충돌을 예방하며 병합은 결정적 순서로 합니다. 부분 실패를 값으로 흡수하고 모든 이벤트를 trace_id로 묶어 관찰 가능하게 만들 때, 비로소 여럿이 하나보다 나은 결과를 냅니다.