컨텍스트 폭발을 이기는 2026 Supervisor/Worker 멀티‑에이전트 오케스트레이션 실전 설계
들어가며
2026년 현재 “multi-agent orchestration”이 다시 뜨는 이유는 단순합니다. 단일 Agent에 너무 많은 tool schema / 역할 / 장기 히스토리를 몰아넣으면 (1) 컨텍스트가 비대해져 비용이 튀고, (2) 의사결정이 흔들리며, (3) 실패 시 복구가 어려워집니다. 그래서 Supervisor가 흐름을 통제하고, Worker가 좁은 범위의 일을 수행하는 supervisor/worker 패턴(= orchestrator/subagent, Russian-doll 패턴)이 실무에서 가장 “수습 가능한” 형태로 자리 잡았습니다. (learn.microsoft.com)
언제 쓰면 좋나
- 작업이 “도메인/기능”으로 자연스럽게 분해되고(예: 웹리서치, DB조회, 코드생성, 정책검토), 각 단계에 서로 다른 가드레일/툴/모델이 필요한 경우 (learn.microsoft.com)
- “장기 실행 + 중간 실패 + 재시도/중단/승인(HITL)”이 필요한 업무 플로우(비즈니스 프로세스, 문서처리, 컴플라이언스) (arxiv.org)
- 비용을 통제하며 품질을 올리기 위해 fan-out → merge, validator/critic 같은 “단일 목적 호출”을 넣고 싶은 경우 (preprints.org)
언제 쓰면 안 되나
- 단일 Tool-call agent로도 충분한 짧은 작업(오케스트레이션 오버헤드가 더 큼)
- Supervisor가 계속 “생각/플래닝”을 하느라 매 턴 전체 히스토리를 먹는 구조(토큰 비용이 선형/누적 증가) (preprints.org)
- Worker 간 자유로운 P2P 대화를 허용하는 “무정부형 스웜”(디버깅/재현성/비용 통제가 매우 어려움). 대신 중앙집중/계층형을 권장 (preprints.org)
🔧 핵심 개념
주요 개념 정의
- Supervisor(Orchestrator): “다음에 누가 일할지/끝낼지”를 결정하는 라우터 + 품질/비용 책임자. 대개 routing 전용 프롬프트와 상태(state) 기반 정책을 가진다.
- Worker(Subagent): 좁은 역할(예:
ResearchWorker,SQLWorker,PolicyWorker)만 수행. 입력/출력 스키마가 명확하고, 가진 tool도 최소화. - Handoff: Supervisor가 Worker에게 작업을 “넘기는” 메커니즘. OpenAI Agents SDK에서는 handoff/agent-as-tool 같은 방식으로 매니저형 구성이 가능하고, LangGraph 계열에서는 노드 간 전이로 구현합니다. (openai.github.io)
내부 작동 방식(구조/흐름)
실무에서 안정적인 supervisor/worker 흐름은 보통 아래 3층으로 정리됩니다.
1) Plan/Route(결정 층)
Supervisor는 (a) 사용자 의도, (b) 현재 state(진행도/실패/예산), (c) 필요한 증거 수준을 보고 다음 액션을 고릅니다.
중요 포인트는 Worker 출력이 채팅 텍스트로 “그냥 붙는” 게 아니라, Supervisor가 이해하기 쉬운 구조화 결과(JSON) 로 들어와야 한다는 점입니다. (그래야 라우팅이 규칙/정책화 가능)
2) Act(실행 층)
Worker는 “한 번의 실행”을 최대한 단순하게 끝냅니다.
- web search / RAG / DB / 코드 실행 등
- 실패하면 명시적 실패 상태(retryable, non-retryable)를 반환
3) Judge/Merge(검증/통합 층)
Supervisor는 Worker 결과를 합치되,
- 증거가 부족하면 추가 Worker 호출
- 정책 위반/불확실성이면
PolicyWorker또는 HITL interrupt - 결과가 충분하면 종료
이 설계가 중요한 이유: 2026년 멀티 에이전트 논의의 핵심은 “똑똑한 대화”가 아니라 컨텍스트/비용/실패를 관리하는 오케스트레이션 엔지니어링으로 이동했기 때문입니다. (openai.github.io)
다른 접근과의 차이점
- 단일 Agent + 많은 tools: 간단하지만 tool schema가 커질수록 선택이 흔들리고, 로그/재현성이 약해집니다.
- Auto speaker-selection류 그룹챗: 매 턴 전체 대화가 들어가며 토큰 비용이 누적되기 쉬움(특히 대화 길어질수록). (preprints.org)
- Supervisor/Worker: 라우팅을 중앙에서 강제하여 컨텍스트를 “필요한 만큼만” 흘려보낼 수 있고, Worker별로 가드레일/모델/툴을 분리하기 쉽습니다. (learn.microsoft.com)
💻 실전 코드
아래 예제는 “운영팀이 매일 보는 장애 리포트(로그) + KPI 테이블”을 입력으로 받아,
1) LogWorker가 로그를 요약/원인 후보 추출
2) SQLWorker가 KPI를 조회(여기선 예시로 SQLite)
3) ReviewerWorker가 결과를 검증(근거/불확실성/후속 액션)
4) Supervisor가 최종 보고서를 작성
…하는 “현실적인” supervisor/worker 파이프라인입니다.
구현은 OpenAI Agents SDK의 ‘orchestration via code’ 스타일을 기준으로, Worker를
as_tool()로 노출하고 Supervisor가 호출하는 구조로 잡았습니다. (2026년 기준 공식 문서가 이 접근을 명시적으로 안내합니다.) (openai.github.io)
1) 설치/환경
python -m venv .venv
source .venv/bin/activate
pip install openai-agents aiosqlite pydantic python-dotenv
export OPENAI_API_KEY="..."
2) 실행 코드 (Python)
# file: supervisor_worker_incident_report.py
import os
import json
import asyncio
from typing import Literal, Optional
from pydantic import BaseModel, Field
import aiosqlite
from agents import Agent, Runner # openai-agents-python
# ---------- Domain schemas ----------
class LogFinding(BaseModel):
incident_id: str
suspected_causes: list[str]
key_errors: list[str]
confidence: float = Field(ge=0, le=1)
class KpiSnapshot(BaseModel):
incident_id: str
window: str
error_rate: float
p95_latency_ms: float
notes: Optional[str] = None
class ReviewResult(BaseModel):
verdict: Literal["ok", "needs_more_data", "unsafe_or_uncertain"]
missing: list[str] = []
risk_notes: list[str] = []
next_actions: list[str] = []
# ---------- Workers as tools ----------
log_worker = Agent(
name="LogWorker",
instructions=(
"You analyze raw incident logs.\n"
"Return STRICT JSON matching LogFinding schema.\n"
"Do not propose fixes; only extract evidence, errors, and plausible causes."
),
output_type=LogFinding,
)
sql_worker = Agent(
name="SQLWorker",
instructions=(
"You query a SQLite database for KPI context.\n"
"You will be given incident_id and time window.\n"
"Return STRICT JSON matching KpiSnapshot schema.\n"
"If data is missing, set notes and keep numeric fields as -1 if unavailable."
),
output_type=KpiSnapshot,
)
review_worker = Agent(
name="ReviewerWorker",
instructions=(
"You are a production reviewer.\n"
"Check whether the evidence supports conclusions.\n"
"Flag uncertainty, missing data, and risky claims.\n"
"Return STRICT JSON matching ReviewResult."
),
output_type=ReviewResult,
)
# ---------- Supervisor ----------
class RouteDecision(BaseModel):
step: Literal["call_log_worker", "call_sql_worker", "call_reviewer", "final"]
reason: str
incident_id: str
window: str = "last_60m"
supervisor = Agent(
name="Supervisor",
instructions=(
"You orchestrate workers to produce an incident report.\n"
"Rules:\n"
"1) Always start with LogWorker.\n"
"2) Then call SQLWorker for KPIs.\n"
"3) Then call ReviewerWorker.\n"
"4) If Reviewer says needs_more_data, ask for specific missing data and stop.\n"
"Produce final report with: Summary, Evidence, KPIs, Suspected causes, Next actions.\n"
"Be concise; avoid speculation."
),
output_type=RouteDecision,
tools=[
log_worker.as_tool(tool_name="run_log_worker"),
sql_worker.as_tool(tool_name="run_sql_worker"),
review_worker.as_tool(tool_name="run_review_worker"),
],
)
# ---------- Example "realistic" data sources ----------
RAW_LOGS = """
2026-08-24T09:12:01Z api-gw ERROR upstream timeout service=checkout latency_ms=8123 trace_id=abc...
2026-08-24T09:12:02Z checkout ERROR db pool exhausted pool=primary active=200 waiting=340
2026-08-24T09:12:05Z api-gw WARN retrying request service=checkout attempt=2
2026-08-24T09:12:08Z checkout ERROR deadlock detected on orders table txn=...
"""
async def setup_db(db_path="kpi.db"):
async with aiosqlite.connect(db_path) as db:
await db.execute(
"CREATE TABLE IF NOT EXISTS kpis(incident_id TEXT, window TEXT, error_rate REAL, p95_latency_ms REAL)"
)
await db.execute("DELETE FROM kpis")
await db.execute(
"INSERT INTO kpis VALUES(?,?,?,?)",
("INC-2026-0824-001", "last_60m", 0.073, 1820.0),
)
await db.commit()
async def query_kpi(db_path, incident_id, window):
async with aiosqlite.connect(db_path) as db:
async with db.execute(
"SELECT error_rate, p95_latency_ms FROM kpis WHERE incident_id=? AND window=?",
(incident_id, window),
) as cur:
row = await cur.fetchone()
return row
async def main():
await setup_db()
incident_id = "INC-2026-0824-001"
window = "last_60m"
# Step 1: LogWorker
finding = await Runner.run(
log_worker,
input=f"incident_id={incident_id}\nlogs:\n{RAW_LOGS}"
)
finding_obj: LogFinding = finding.final_output
# Step 2: SQLWorker (we actually query DB here, then pass to agent to format/interpret)
row = await query_kpi("kpi.db", incident_id, window)
if row:
error_rate, p95 = row
kpi_payload = {"incident_id": incident_id, "window": window, "error_rate": error_rate, "p95_latency_ms": p95}
else:
kpi_payload = {"incident_id": incident_id, "window": window, "error_rate": -1, "p95_latency_ms": -1, "notes": "missing"}
kpis = await Runner.run(
sql_worker,
input=json.dumps(kpi_payload)
)
kpi_obj: KpiSnapshot = kpis.final_output
# Step 3: ReviewerWorker
review = await Runner.run(
review_worker,
input=json.dumps({
"finding": finding_obj.model_dump(),
"kpis": kpi_obj.model_dump(),
})
)
review_obj: ReviewResult = review.final_output
if review_obj.verdict != "ok":
print("REVIEW BLOCKED:", review_obj.model_dump_json(indent=2))
return
# Final: Supervisor synthesizes (here we keep it deterministic via a final supervisor call)
final_text = await Runner.run(
Agent(
name="ReportWriter",
instructions=(
"Write a production incident report.\n"
"Use provided JSON only.\n"
"Sections: Summary, Evidence, KPIs, Suspected causes, Next actions."
)
),
input=json.dumps({
"finding": finding_obj.model_dump(),
"kpis": kpi_obj.model_dump(),
"review": review_obj.model_dump(),
})
)
print(final_text.final_output)
if __name__ == "__main__":
asyncio.run(main())
예상 출력(요약)
- Summary: checkout 경로에서 timeout 급증
- Evidence: db pool exhausted, deadlock 등
- KPIs: error_rate 7.3%, p95 1820ms
- Suspected causes: DB connection pool saturation, transactional contention
- Next actions: pool/쿼리 플랜 점검, deadlock 원인 테이블/인덱스 확인, rate limit/큐잉 등
이 예제의 포인트는 “Supervisor가 만능으로 다 한다”가 아니라,
- Worker를 좁은 책임 + 구조화 출력으로 만들고
- 마지막 합성만 최소화된 컨텍스트로 수행해서 토큰/실패/검증을 통제한다는 점입니다. (openai.github.io)
⚡ 실전 팁 & 함정
Best Practice (2~3개)
1) Worker 출력은 반드시 schema로 고정(JSON)
- Supervisor 라우팅을 “프롬프트 감”이 아니라 정책 코드/조건 분기로 옮길 수 있습니다.
- 추후 리플레이/회귀테스트도 쉬워집니다(같은 입력 → 같은 구조 결과). (arxiv.org)
2) 컨텍스트를 “공유 히스토리”가 아니라 “공유 state 요약”으로 전달
- 에이전트 수/대화 길이가 늘수록 전체 히스토리 기반 오케스트레이션은 비용이 선형으로 악화됩니다. (preprints.org)
- Supervisor는 “필요한 state만” Worker에게 주고, Worker가 본문 로그/문서는 외부 저장소에서 가져오게 하세요.
3) Reviewer/Policy Worker를 ‘마지막에 한 번’이 아니라 ‘게이트’로 배치
- 특히 web-search/RAG 기반이면 근거 누락이 빈번합니다.
- “불확실하면 멈추고 무엇이 부족한지 출력”이 운영 안정성에 크게 기여합니다. (arxiv.org)
흔한 함정/안티패턴
- Worker가 서로에게 handoff 하는 자유대화: 책임 소재가 흐려지고, 루프/핑퐁으로 비용 폭발이 납니다(실무 커뮤니티에서도 “API bill 2~3배” 패턴이 반복 보고). (reddit.com)
- Supervisor가 decomposition까지 과도하게 수행: 플래닝 토큰이 커져서 “결정 비용”이 본 작업보다 비싸지는 구간이 옵니다. 가능하면 분해는 규칙화(템플릿/분류)하고 LLM은 애매한 라우팅에만 쓰세요.
- 도구 스키마를 한 Agent에 과적재: 툴 선택 오류 + 컨텍스트 혼탁. 2026년 LangGraph 쪽에서도 “tool-calling 기반 supervisor(수동 패턴)가 컨텍스트 엔지니어링 제어에 유리”하다고 강조합니다. (reference.langchain.com)
비용/성능/안정성 트레이드오프
- Supervisor/worker는 호출 수가 늘어 비용 상한이 올라갑니다. 대신
- 각 호출 목적이 좁아 품질이 안정되고
- 재시도/부분 실패 복구가 쉬우며
- 병렬 fan-out(리서치/추출) + merge로 지연을 줄일 여지가 있습니다. (thesis.unipd.it)
실무에선 “멀티 에이전트”라기보다 오케스트레이션된 단일 목적 LLM 호출들의 그래프로 보는 게 더 정확합니다. (openai.github.io)
🚀 마무리
정리하면 2026년 8월 시점의 supervisor/worker 패턴은 “똑똑한 에이전트 팀”이라기보다, 컨텍스트와 실패를 통제하는 실행 아키텍처입니다. 도입 판단 기준은 아래처럼 잡으면 실패 확률이 줄어듭니다.
- 단일 Agent로 품질/비용이 버티는가? 버틴다면 멀티로 갈 이유가 약함
- 작업이 역할로 분해되고, 각 역할의 I/O를 스키마로 고정할 수 있는가? 가능하면 supervisor/worker가 강력
- 재시도/중단/승인/감사 로그가 중요한가? 중요할수록 중앙 오케스트레이션(그래프/상태머신)이 유리 (arxiv.org)
다음 학습으로는 (1) OpenAI Agents SDK의 orchestration/manager-style 패턴(도구화/hand off), (2) LangGraph의 supervisor 패턴과 typed state/interrupt/checkpoint 설계를 같이 보길 추천합니다. (openai.github.io)