멀티 에이전트 오케스트레이션 — 팀과 위임을 코드로 설계하기
에이전트 하나에 도구 40개를 물리고 시스템 프롬프트를 3000줄로 불리면, 어느 순간부터 더 넣을수록 더 못합니다. 도구 선택이 흔들리고, 지시를 빼먹고, 컨텍스트가 길어질수록 앞부분을 잊습니다. 이때 나오는 답이 "에이전트를 나누자"입니다.
그런데 나누는 순간 새 문제가 생깁니다. 누가 누구에게 일을 시키나? 결과는 어떻게 돌아오나? 무한 위임은 어떻게 막나? 이 글은 그 배관 설계에 관한 것입니다.
먼저 — 언제 나누면 안 되는가
순서를 뒤집어 시작하겠습니다. 멀티 에이전트는 공짜가 아닙니다. 위임 한 번마다 LLM 호출이 최소 두 번 더 늘고, 컨텍스트가 프로세스 경계를 넘으면서 정보가 샙니다. 아래에 해당하면 그냥 하나로 두세요.
- 도구 몇 개로 끝나는 일 — 나누면 지연만 늘어납니다
- 맥락을 잘게 공유해야 하는 일 — 위임 메시지에 담을 수 있는 맥락은 생각보다 적습니다
- 단순히 프롬프트가 긴 경우 — 이건 프롬프트를 다듬을 문제지 에이전트를 늘릴 문제가 아닙니다
나눌 값어치가 있는 건 대체로 이런 경우입니다. 전문성이 실제로 다르거나(리서치 vs 코드 리뷰), 병렬로 돌릴 수 있거나(파일 10개를 동시에 분석), 권한을 갈라야 할 때(주문 권한이 있는 에이전트와 없는 에이전트).
권한 계층 — 무엇을 할 수 있는가
모든 에이전트에게 "아무나 호출해도 된다"를 주면 통제가 불가능해집니다. 그래서 에이전트가 가질 수 있는 협업 능력을 계층으로 나눕니다.
type OrchestrationMode string
const (
// 자기 자신의 복제본만 띄울 수 있다
ModeSpawn OrchestrationMode = "spawn"
// 명시적으로 연결된 다른 에이전트에게 위임할 수 있다
ModeDelegate OrchestrationMode = "delegate"
// 위 전부 + 공유 태스크 보드를 쓸 수 있다
ModeTeam OrchestrationMode = "team"
)
중요한 건 이 모드를 사람이 설정하지 않는다는 점입니다. 관계를 보고 자동으로 정합니다.
// 우선순위: team > delegate > spawn
func resolveMode(ctx context.Context, agentID uuid.UUID) OrchestrationMode {
if team, _ := teamStore.GetTeamForAgent(ctx, agentID); team != nil {
return ModeTeam // 팀에 속해 있으면 팀 모드
}
if links, _ := linkStore.ListOutbound(ctx, agentID); len(links) > 0 {
return ModeDelegate // 나가는 위임 링크가 있으면 위임 모드
}
return ModeSpawn // 그 외에는 자기 복제만
}
그리고 모드에 따라 도구 자체를 숨깁니다. 권한 없는 도구를 노출한 뒤 실행 시점에 거부하면, LLM은 그 도구를 계속 시도하며 턴을 낭비합니다. 애초에 안 보이게 하는 게 훨씬 깔끔합니다.
func denyTools(mode OrchestrationMode) map[string]bool {
switch mode {
case ModeSpawn:
return map[string]bool{"delegate": true, "team_tasks": true}
case ModeDelegate:
return map[string]bool{"team_tasks": true}
default: // ModeTeam
return nil
}
}
위임 — 동기냐 비동기냐
위임 도구의 인터페이스는 의외로 단순합니다. 누구에게, 무슨 일을, 기다릴 것인가.
{
"name": "delegate",
"parameters": {
"agent_key": { "type": "string", "description": "위임 대상" },
"task": { "type": "string", "description": "맡길 일" },
"mode": { "type": "string", "enum": ["async", "sync"] }
}
}
- async(기본) — 던지고 잊습니다. 위임한 에이전트는 즉시 다음 일을 하고, 결과는 나중에 알림으로 받습니다. 병렬 처리에 맞습니다
- sync — 결과가 올 때까지 기다립니다. 다음 단계가 그 결과에 의존할 때만 쓰세요
기본값이 async여야 하는 이유가 있습니다. sync 위임은 호출 스택처럼 쌓입니다. A가 B를 기다리고 B가 C를 기다리면, 셋 다 LLM 컨텍스트를 물고 있는 채로 멈춰 있습니다. 비용도 지연도 곱셈으로 늘죠. 기다릴 이유가 명확할 때만 sync를 쓰는 게 맞습니다.
공유 태스크 보드 — 위임을 추적 가능하게
위임만 있으면 "지금 무슨 일이 돌아가고 있는지"를 아무도 모릅니다. 그래서 보드를 둡니다. 리더는 일을 만들고, 멤버는 가져가고, 끝나면 결과가 붙습니다.
type Task struct {
ID uuid.UUID
TeamID uuid.UUID
Subject string
Assignee string // 담당 에이전트 키
Status string // pending / in_progress / done / failed
Result string // 완료 시 요약
BlockedBy []uuid.UUID // 선행 태스크
}
여기서 강하게 추천하는 규칙이 하나 있습니다. 보드에 태스크 없이 이뤄지는 위임을 거부하는 것입니다.
func (d *DelegateTool) Execute(ctx context.Context, args map[string]any) *Result {
taskID, _ := args["team_task_id"].(string)
if isTeamMode(ctx) && taskID == "" {
return Fail("팀 모드에서는 team_task_id 없이 위임할 수 없습니다")
}
// ... 위임 실행, 완료 시 해당 태스크 자동 종료
}
강제하지 않으면 LLM은 편한 쪽으로 갑니다 — 보드를 건너뛰고 바로 위임하죠. 그러면 며칠 뒤 "이 결과는 어디서 나온 거지"를 추적할 방법이 없습니다. 위임이 끝나면 연결된 태스크를 자동 완료 처리하면, 기록은 남으면서 수동 관리 부담은 사라집니다.
폭주 방지 — 깊이와 동시성
멀티 에이전트에서 가장 비싼 사고는 재귀 폭주입니다. A가 B에게 위임하고, B가 다시 A에게 위임하면 무한 루프가 돌면서 청구서가 올라갑니다. 방어는 두 겹입니다.
const maxDepth = 3
func (o *Orchestrator) Delegate(ctx context.Context, req Request) error {
// ① 깊이 제한 — 위임 체인의 길이
if req.Depth >= maxDepth {
return fmt.Errorf("위임 깊이 초과 (max=%d)", maxDepth)
}
// ② 동시성 제한 — 링크별 동시 실행 수
if o.running(req.LinkID) >= req.MaxConcurrent {
return errors.New("동시 위임 한도 초과")
}
req.Depth++
return o.dispatch(ctx, req)
}
깊이는 위임 요청에 실려 전파되어야 합니다. 각 에이전트가 자기 깊이만 알면 체인 전체를 못 봅니다. 그리고 순환 링크(A→B→A) 자체를 링크 생성 시점에 막아두면 더 안전합니다.
결과 모으기 — 배치로 돌려주기
멤버 다섯이 병렬로 끝나면 리더에게 알림이 다섯 번 갑니다. 그때마다 LLM 턴이 한 번씩 돌면 낭비죠. 짧은 창을 두고 모아서 한 번에 전달하는 편이 훨씬 낫습니다.
type BatchQueue[T any] struct {
mu sync.Mutex
items []T
window time.Duration // 예: 2초
flushFn func([]T)
}
func (q *BatchQueue[T]) Add(item T) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
if len(q.items) == 1 {
time.AfterFunc(q.window, q.flush) // 첫 아이템에만 타이머 시작
}
}
2초 창 하나로 리더의 LLM 호출이 5회에서 1회로 줄고, 리더는 결과 다섯 개를 한 컨텍스트 안에서 비교할 수 있게 됩니다. 종합 품질까지 같이 올라갑니다.
정리
- 나누기 전에 나눌 값어치가 있는지 먼저 확인 — 전문성·병렬성·권한 분리가 없으면 그냥 하나로
- 협업 능력은 계층(spawn / delegate / team)으로 나누고, 관계를 보고 자동 판정
- 권한 없는 도구는 거부하지 말고 아예 숨긴다
- 위임 기본값은 async — sync는 스택처럼 쌓여 비용이 곱해진다
- 보드 없는 위임을 거부하고, 완료 시 태스크를 자동 종료해 추적성을 공짜로 얻는다
- 깊이 제한 + 동시성 제한으로 재귀 폭주를 막고, 깊이는 요청에 실어 전파
- 병렬 결과는 짧은 창으로 배치해 한 번에 — 호출 수도 줄고 종합 품질도 오른다
멀티 에이전트에서 어려운 건 LLM이 아니라 배관입니다. 누가 무엇을 할 수 있는지, 실패하면 어디로 돌아가는지, 언제 멈추는지 — 이걸 코드로 못 박아두지 않으면, 똑똑한 모델을 붙여도 시스템은 예측 불가능해집니다.
댓글
댓글 쓰기