이전 글에서는 실행 루프, 도구 라우팅, 컨텍스트 압축, 충돌에도 안전한 세션을 통해 에이전트 하나의 신뢰성을 높이는 방법을 다뤘습니다. 그 계층은 "에이전트 하나가 어떻게 중간에 무너지지 않고 작업을 마칠 수 있는가?"에 답합니다. 이 글은 그 위의 계층, 즉 에이전트 하나로 부족해 작업을 팀에 나눠 맡겨야 할 때 무슨 일이 일어나는지를 다룹니다.
보통 멀티 에이전트 오케스트레이션이라고 부르는 것이 이것입니다. 대화를 담당하는 리드 에이전트가 요청을 여러 부분으로 나누고, 각 부분을 전문 서브 에이전트에 맡기고, 필요한 작업이 끝나기를 기다렸다가 다음 작업을 시작하며, 결과를 전달하고, 한 단계가 실패해도 전체 흐름이 유지되도록 합니다. Orkas는 이 모든 과정을 사용자 자신의 기기에서 실행합니다. 아래에서는 이 오케스트레이션 계층의 구성을 설명합니다. 코드는 민감한 내용을 제거하고 일반화했지만 구조는 실제와 같습니다.
리드 에이전트와 서브 에이전트
명확한 지휘 체계를 갖춘 작은 팀을 떠올리면 됩니다. 리드 에이전트(저희는 Commander라고 부릅니다)가 대화와 전체 맥락을 담당합니다. 모든 일을 직접 하는 것이 아니라 무엇을, 어떤 순서로, 누가 해야 하는지 결정합니다. 서브 에이전트는 전문가로, 각자 고유한 시스템 프롬프트, 허용된 도구, 스킬 세트로 설정됩니다. 서브 에이전트는 작업의 특정 부분에 능숙하며 그 부분이 필요할 때 호출됩니다.
이것이 단순한 유행어에 그치지 않는 이유는 두 가지입니다. 첫째, 서브 에이전트와 스킬은 프롬프트 기법이 아니라 독립적인 핵심 단위입니다. 서브 에이전트는 별도로 설정된 실제 에이전트이며, 작업 배정은 고유한 맥락을 가진 실제 인계입니다. 둘째, 조율을 모델의 선의에 맡기지 않습니다. 모델이 아닌 시스템이 정확성을 유지하는 명시적 산출물이 이를 이끕니다. 그 산출물이 바로 계획입니다.
계획은 스크립트가 아니라 그래프입니다
리드 에이전트는 요청에 여러 단계가 필요하다고 판단하면 계획을 작성합니다. 계획은 자유 형식의 글도, 선형 체크리스트도 아닙니다. 작은 의존성 그래프(DAG)입니다. 각 노드는 하나의 단계이며, 단계에는 오케스트레이터가 이를 배정하는 데 필요한 모든 정보가 담깁니다.
interface PlanStep {
index: number; // 1-based, stable, never renumbered
title: string; // human-readable, shown in the UI
assignee: string; // who runs it: "user" | "commander" | a sub-agent
input?: string; // the dispatch payload — a template (see below)
wait_for?: number[]; // upstream step indexes; defaults to [index - 1]
on_failure?: "abort_plan" | "continue" | "ask_commander";
// --- runtime state, owned by the orchestrator, NOT written by the model ---
status: "pending" | "in_progress" | "done" | "failed" | "skipped" | "blocked";
output_summary?: string; // short summary of what the step produced
output_files?: string[]; // files the step produced
failure_reason?: string;
}목록을 그래프로 바꾸는 것은 wait_for 필드입니다. 기본적으로 각 단계는 바로 앞 단계를 기다립니다(단순한 연결 구조). 하지만 여러 앞선 단계에 의존한다고 선언할 수도 있습니다. 예를 들어 "요약"은 "시장 조사"와 "경쟁사 조사"를 모두 기다릴 수 있습니다. 이는 일직선이 아닌 다이아몬드 구조이며, 오케스트레이터도 그렇게 처리합니다.
사실을 관리하는 주체: 모델이 아닌 오케스트레이터
이 구조체의 구분을 다시 보세요. 모델은 계획을 처음 작성할 때 의도, 즉 제목, 담당자, 입력, 의존성을 채웁니다. 하지만 실행 상태에 관한 모든 것, 즉 status, output_summary, failure_reason은 오케스트레이터만 관리합니다. 모델은 계획을 한 번 제안할 뿐, 자신의 단계를 "완료"로 표시할 수는 없습니다.
이 구분은 의도적이며 전체 설계에서 가장 중요한 결정입니다. 언어 모델은 3단계에서 오류가 났는데도 "3단계 완료"라고 밝게 선언하거나, 긴 대화 도중 어떤 단계가 아직 남아 있는지 놓칠 수 있습니다. 상태가 모델의 머릿속에만 있다면 계획은 현실과 어긋날 것입니다. 상태를 실행기만 작성하는 구조화된 산출물로 만들고, 실제로 단계가 끝났을 때만 기록하도록 하면 계획은 실제로 일어난 일을 정확하게 반영합니다. 모델은 작업의 구성을 결정하고, 런타임은 진행 상황에 관한 사실을 결정합니다.
준비된 단계 배정하기
계획이 생기면 실행기라는 작은 엔진이 이를 진행합니다. 핵심 동작은 "준비된 단계를 찾아 배정하기"입니다. 단계가 준비된 상태가 되려면 아직 pending 상태이며 기다리는 모든 단계가 충분히 성공적인 종료 상태에 도달해야 합니다.
function findReadySteps(plan): PlanStep[] {
return plan.steps.filter((s) => {
if (s.status !== "pending") return false;
const deps = s.wait_for ?? (s.index > 1 ? [s.index - 1] : []);
return deps.every((d) => isTerminal(plan.step(d).status)); // done or skipped
});
}이 동작은 타이머가 아니라 이벤트에 반응해 실행됩니다. 서브 에이전트가 반환하거나 Commander가 종합 턴을 마치는 등 단계 하나가 끝날 때마다 실행기는 상태를 조정합니다. 방금 끝난 단계의 결과를 기록한 뒤, 그 결과 준비된 단계가 생겼는지 다시 살펴보고 배정합니다. 오케스트레이션은 남은 단계가 없을 때까지 반복되는 이 상태 조정 루프입니다.
별도 채널이 아닌 같은 채팅으로 배정합니다
시스템이 실제 상황을 그대로 드러내도록 하는 선택이 있습니다. 단계 배정에 숨겨진 RPC 채널을 사용하지 않습니다. 사용자가 보고 있는 바로 그 그룹 대화에 리드 에이전트가 서브 에이전트를 @멘션하는 메시지를 게시합니다. 서브 에이전트 입장에서는 작업을 배정받는 것이 채팅에서 말을 건네받는 것과 같습니다. 그저 평소처럼 자신의 턴을 실행합니다. 첫 번째 실행 경로와 동기화해야 할 두 번째 실행 경로가 없습니다.
담당자는 세 가지 유형 중 하나이며 배정 방식이 조금씩 다릅니다.
- 서브 에이전트 — 일반적인 경우입니다. 실행기는 에이전트 이름을 ID로 변환하고 Commander 명의로
@<agent> <rendered input>를 게시합니다. 서브 에이전트는 이를 받아 완전한 에이전트 턴을 실행합니다. - 사용자 — 실제로 사람의 입력이 필요한 단계는 양식으로 바뀌며 계획이 일시 중지됩니다(아래에서 자세히 설명합니다). 사용자가 답변할 때까지 이후 단계는 진행되지 않습니다.
- Commander 자신 — 종합하거나 결정하는 단계입니다("위의 내용을 모두 읽고 요약을 작성하라"). 이는 비공개 호출이므로 사용자에게 불필요한 메시지를 추가로 표시하지 않습니다. 리드 에이전트는 모인 맥락을 가지고 자신의 턴을 수행합니다.
단계 사이에 맥락 전달하기
구성원 사이에 작업이 이어져야 팀이 유용합니다. 이를 위한 장치가 input 템플릿입니다. 리드 에이전트가 단계를 작성할 때 입력은 고정된 문자열이 아닙니다. 이전 결과를 참조할 수 있고, 실행기는 배정 시점에 그 참조를 실제 값으로 채웁니다.
// step 3.input, as written by the lead agent
"Using the findings below, draft the launch note.\n\n{{step_1.output_summary}}"
// what the sub-agent actually receives at dispatch time
"Using the findings below, draft the launch note.\n\n- Market is growing ~20% YoY; two incumbents…"다음 단계로 전달되는 내용을 주목하세요. output_summary, 즉 완료된 각 단계의 짧은 요약이며 전체 대화 기록이 아닙니다. 이는 토큰 예산에 관한 결정이며 하네스의 컨텍스트 압축과 같은 취지입니다. 이후의 모든 단계가 앞선 전체 이력을 토큰 하나까지 물려받는다면, 몇 번만 거쳐도 맥락과 비용이 급격히 늘어날 것입니다. 요약은 각 인계 비용을 낮추고, 각 서브 에이전트가 앞선 단계의 모든 과정을 헤집는 대신 실제로 필요한 결과에 집중하게 합니다. 최초 사용자 메시지와 첨부 파일도 함께 전달되므로 연결된 흐름에서 세 단계 뒤에 있는 단계도 원래 요청을 알 수 있습니다.
기본은 순차 실행, 그 이유
다이아몬드 구조의 두 분기처럼 여러 단계가 동시에 준비되면 오케스트레이터가 모두 병렬 실행하리라고 예상할 수 있습니다. 가능은 합니다. 하지만 현재는 인덱스가 가장 앞선 준비된 단계 하나씩 배정하고 나머지는 다음 상태 조정까지 기다리게 합니다. 팀 전체를 엄격히 한 번에 하나씩 실행하는 것은 의도적이고 보수적인 선택이며, 그 이유를 솔직히 설명할 필요가 있습니다.
이유는 동시 실행 상황에서의 정확성입니다. 두 서브 에이전트가 거의 같은 순간에 끝나는 장면을 떠올려 보세요. 두 완료 이벤트가 모두 상태 조정을 유발하고, 두 조정 작업이 모두 계획을 읽고, 같은 후속 단계가 아직 pending인 것을 보고 둘 다 이를 배정합니다. 그러면 같은 단계가 두 번 실행됩니다. 이를 막기 위해 해당 대화의 모든 읽기-수정-배정 주기는 대화별 잠금 아래에서 직렬화됩니다.
// all state-changing paths for one conversation run under one mutex
planLock(uid, cid).runExclusive(async () => {
const plan = await readPlan(uid, cid);
applyOutcomeOfFinishedStep(plan); // mark done / failed / skipped
await dispatchReady(plan); // dispatch the next ready step
});잠금은 "끝난 작업 기록하기"와 "다음 작업 결정하기"가 나눌 수 없는 하나의 단위로 이루어지도록 보장해 후속 단계가 두 번 배정되는 일을 막습니다. 이런 잠금이 있는 상태에서 한 번에 한 단계씩 배정하는 것이 정확성이 명확한 가장 단순한 방식입니다. 실제 병렬 분산 실행은 이 기반 위에서 구현 가능한 확장입니다. 하지만 기반은 경쟁 조건이 없는 직렬 실행기이며, 먼저 정확하게 만들고 나중에 빠르게 한다는 우선순위가 핵심입니다.
단계에 문제가 생겼을 때
실제 기기에서 외부 모델 API를 사용하면 실패는 흔한 일입니다. 오케스트레이터는 모든 오류를 똑같이 취급하지 않고 몇 가지 경우로 나눕니다.
먼저 연결 끊김, 요청 제한, 일시적 이상 같은 단순한 일시적 실패인지 확인합니다. 그렇고 해당 단계가 소수의 재시도 한도를 아직 소진하지 않았다면, 조용히 pending으로 되돌려 다음 상태 조정에서 다시 배정되게 합니다. (이는 하네스 자체의 실행 중 재시도보다 상위에 있습니다. 계획은 에이전트 자체의 시도가 모두 소진된 뒤에만 다시 배정하며, 실제로 고장 난 단계가 무한 반복되지 않도록 엄격한 상한을 둡니다.)
실질적인 실패라면 단계에 선언된 on_failure 정책이 팀의 다음 행동을 결정합니다.
abort_plan— 이 단계가 없으면 이후 단계가 의미 없을 만큼 중요합니다. 실패로 표시하고, 아직 대기 중인 모든 단계를 연쇄적으로skipped로 표시합니다. 빠진 토대 위에 작업을 쌓는 대신 계획을 깔끔히 중단합니다.continue— 선택적인 단계였습니다.skipped로 표시하고 아무 결과도 내지 않은 것처럼 이후 단계를 진행합니다.ask_commander(기본값) — 무조건 중단하거나 계속하지 않습니다. 실패로 표시한 뒤 리드 에이전트를 호출해 상황을 살펴보고 결정하게 합니다. 다른 방식으로 재시도하거나, 우회하거나, 멈추고 사용자에게 물을 수 있습니다.
별도로 설명할 여섯 번째 상태가 있습니다. blocked입니다. 서브 에이전트는 단계 진행 중 사용자만 제공할 수 있는 무언가가 필요하다는 것을 깨닫고 양식이나 질문을 표시할 수 있습니다. 단계는 실패하는 것이 아니라 blocked 상태가 되며 전체 계획이 일시 중지됩니다. 사용자가 답하는 즉시 실행기가 상태를 조정하고 팀은 멈춘 지점에서 정확히 이어 갑니다. 차단된 계획은 망가진 계획이 아니라 일시 중지된 계획입니다.
모든 단계는 완전한 에이전트 실행입니다
이전 글과 다시 연결해 볼 만합니다. 오케스트레이터가 서브 에이전트에 단계를 배정하면 그 서브 에이전트는 축소된 루틴을 실행하지 않습니다. 완전한 하네스 루프를 실행합니다. 자체 스트리밍 실행 루프, 자체 도구 호출, 압축 기능을 갖춘 자체 컨텍스트 창, 충돌에도 안전한 자체 세션이 있습니다. 오케스트레이션은 단일 에이전트 런타임의 위에 명확히 자리하며 그 내부에 개입하지 않습니다. 리드 에이전트는 작업의 구성과 인계 순서를 결정하고, 각 서브 에이전트는 자신의 몫을 전달받으면 독립적으로 완전한 에이전트가 됩니다.
이 계층 구조 때문에 두 글의 내용이 맞물립니다. 하네스는 하나의 작업을 맡길 수 있는 신뢰할 만한 에이전트 하나를 만듭니다. 오케스트레이터는 그런 에이전트 여럿을 팀으로 구성해 어느 하나가 혼자 하기에는 너무 크거나 다양한 작업을 맡을 수 있게 합니다.
중요했던 몇 가지 결정
오케스트레이터는 실행 상태를, 모델은 의도를 관리합니다. 모델은 계획을 제안합니다. 단계를 완료, 실패, 건너뜀으로 표시하는 것은 런타임뿐이며, 실제로 무언가가 일어났을 때만 그렇게 합니다. 이 경계 하나가 계획을 모델의 낙관적인 추측이 아니라 현실을 정확히 비추는 기록으로 유지합니다.
별도 채널이 아니라 같은 대화로 배정합니다. 배정된 단계는 그저 리드 에이전트가 서브 에이전트에 보낸 메시지입니다. 실행 경로는 하나이고, 동기화가 어긋날 숨겨진 경로도 없으며, 사용자는 이미 읽고 있는 같은 스레드에서 팀이 일하는 모습을 볼 수 있습니다.
단계 사이에는 전체 기록이 아닌 요약을 전달합니다. 각 인계에는 앞선 출력의 짧은 요약이 담깁니다. 긴 연결 흐름에서도 컨텍스트 예산을 적절히 유지하고, 각 서브 에이전트가 이전 에이전트의 모든 과정이 아니라 자신에게 필요한 것에 집중하도록 합니다.
병렬화보다 정확성이 먼저입니다. 대화별 잠금이 모든 읽기-수정-배정 주기를 직렬화하고, 단계는 한 번에 하나씩 배정됩니다. 엄격한 순차 실행은 중복 배정 경쟁 조건이 없다는 것이 명확한 버전입니다. 병렬 분산 실행은 이미 정확한 기반 위에 덧붙일 최적화입니다.
마무리
Orkas의 오케스트레이션 계층 중심에는 특별한 알고리즘이 없습니다. 그 가치는 몇 가지 경계를 단단히 지키는 데 있습니다. 스크립트가 아닌 의존성 그래프 형태의 계획, 모델이 아닌 런타임이 관리하는 실행 상태, 사용자가 보는 같은 채팅을 통한 배정, 요약으로 전달되는 단계 간 맥락, 동시성보다 정확성을 우선하는 실행기입니다. 각각은 단순합니다. 이들이 함께 모이면 신뢰할 수 있는 에이전트 하나가 일을 나누고, 다음 구성원에게 전달하며, 일부가 실패해도 복구하는 팀이 됩니다.
이 아래 계층이 궁금하다면 단일 에이전트를 안정적으로 실행하도록 설계하는 방법을 읽어 보세요. 각 에이전트가 사용할수록 나아지게 하는 계층이 궁금하다면 Orkas 에이전트가 자신의 작업에서 배우는 방법을 읽어 보세요. 이 계층을 직접 구축하기보다 지휘하고 싶다면, Orkas는 사용자 자신의 기기에서 실행되는 오픈 소스 AI 에이전트 오케스트레이션으로 이를 제공합니다.