LangGraph Supervisor 패턴 프로덕션 적용기 — 여러 AI 에이전트를 오케스트레이션하고 책임을 추적하는 법
AI 에이전트 하나로는 해결하기 어려운 복잡한 문제들이 있습니다. 검색도 해야 하고, 수학 계산도 해야 하고, 코드도 실행해야 하는 상황에서 에이전트 하나에 모든 걸 몰아넣으면 어떻게 될까요? 저도 처음엔 그렇게 했다가 프롬프트가 산처럼 쌓이고, 어디서 뭘 잘못했는지 추적조차 못하는 상황을 겪었습니다. 그때 만난 게 바로 LangGraph의 Supervisor 패턴이었어요.
이 글을 다 읽고 나면, 단일 에이전트로는 감당 안 되는 복잡한 요청을 역할별 전문 에이전트로 분리하고, 그 상호작용을 코드 레벨에서 추적할 수 있게 됩니다. 구체적으로는 create_supervisor와 create_handoff_tool로 실행 가능한 멀티에이전트 시스템을 직접 만들어보고, LangSmith로 핸드오프 흐름을 눈으로 확인하는 것까지 다룹니다. LangGraph가 왜 단순 LangChain 체인이나 ReAct 에이전트와 다른지, Supervisor 패턴이 그 차이를 어떻게 활용하는지가 이 글의 핵심입니다.
사전 지식: Python 기본 문법과 LangChain 경험이 있으면 충분합니다. 비동기(
asyncio)는 병렬 처리 예시에서만 등장하고, 그 부분은 명확히 표시해두었습니다.
핵심 개념
LangChain 체인·ReAct 에이전트와 무엇이 다른가
LangGraph를 처음 접할 때 가장 먼저 드는 의문이 이겁니다. "그냥 LangChain 체인 쓰면 안 되나?"
LangChain 체인(LCEL)은 A → B → C처럼 순차 파이프라인에 최적화돼 있습니다. 각 단계의 흐름이 고정적이고, 조건 분기나 루프가 필요하면 코드가 금방 복잡해집니다. ReAct 에이전트는 하나의 에이전트가 도구를 반복 호출하는 방식인데, 역할이 여러 개로 분리돼야 하거나 다른 에이전트에게 일을 넘겨야 할 때는 구조적인 한계가 있습니다.
LangGraph는 이 두 가지를 그래프 기반 워크플로우로 해결합니다. 처리 단계를 노드(함수·에이전트)와 엣지(흐름 방향)로 표현하고, 조건 분기·루프·병렬 실행을 자연스럽게 표현할 수 있죠.
그래프 기반 워크플로우: 처리 단계를 방향성 그래프로 표현하는 방식입니다. 각 노드가 특정 작업을 수행하고, 엣지가 다음에 어떤 노드로 흐를지 결정합니다. 단순 순차 파이프라인과 달리, 이전 단계 결과에 따라 흐름이 달라질 수 있습니다.
Supervisor 패턴의 3-레이어 구조
Supervisor 패턴은 이 그래프 위에서 계층 분리를 구현한 것입니다. 마치 잘 조직된 개발팀처럼 동작해요.
Planner Layer → Supervisor (무엇을 누구에게?)
Agents Layer → Worker Agents (각 전문 도메인 처리)
Tooling Layer → Tools / APIs (실제 실행 단위)- Supervisor: 사용자 요청을 분석해서 어떤 에이전트에 위임할지 결정하는 오케스트레이터입니다. LLM이 라우팅 결정을 내립니다.
- Sub-Agent (Worker): 검색, 수학 계산, 코드 실행 등 특정 도메인에 특화된 에이전트입니다. 자기 일에만 집중합니다.
- 공유 State: 그래프 전체에 걸쳐 유지되는 중앙 상태 객체입니다. 에이전트들이 여기를 통해 서로 정보를 주고받습니다.
공유 State, 제대로 이해하기
여기서 잠깐 멈추고 싶은 게 있는데요. 공유 State가 처음에는 그냥 "딕셔너리겠지" 싶은데, 실제 구현에서 꽤 중요한 부분이 있습니다.
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list, add_messages] # reducer 지정
current_task: strAnnotated[list, add_messages] 부분이 핵심입니다. 여러 노드가 messages를 동시에 업데이트할 때 어떻게 병합할지를 add_messages라는 reducer로 지정하는 거예요. 이걸 빠뜨리고 그냥 list로 선언하면, 나중 노드의 업데이트가 이전 노드 결과를 덮어써버리는 참사가 생깁니다. 저도 이 부분에서 한참 헤맸습니다.
create_supervisor를 쓸 땐 이 State 정의를 내부에서 처리해주니까 직접 신경 쓸 일은 줄어들지만, 커스텀 그래프를 구성하거나 State에 필드를 추가할 때 이 규칙을 알고 있어야 합니다.
Handoff Tool과 책임 추적
솔직히 처음엔 "그냥 에이전트끼리 함수 호출하면 안 되나?" 싶었는데, create_handoff_tool의 진짜 가치는 **자동 감사 추적(audit trail)**에 있습니다.
from langgraph_supervisor import create_handoff_tool
handoff_to_math = create_handoff_tool(
agent_name="math_expert",
description="수학 계산이 필요할 때 사용"
)내부적으로 이 도구가 호출될 때, LangGraph는 공유 State의 messages 히스토리에 ToolMessage 형태로 이전 기록을 추가합니다. tool_call_id와 에이전트 이름이 함께 찍히기 때문에 나중에 LangSmith에서 보면 흐름이 한눈에 보입니다.
[메시지 히스토리 예시]
User → "57의 제곱근을 계산하고 결과를 한국어로 설명해줘"
Supervisor → [ToolMessage] math_expert로 이전 (tool_call_id: abc123)
math_expert → [AIMessage] "57의 제곱근은 약 7.55입니다"
Supervisor → [ToolMessage] writer_expert로 이전 (tool_call_id: def456)
writer_expert → [AIMessage] "57의 제곱근은 7.55 정도예요. 쉽게 말하면..."단순 함수 호출로 에이전트를 연결하면 이런 추적 정보가 남지 않습니다. "어떤 에이전트가 이 결과를 냈지?"를 나중에 파악하기가 힘들어지죠.
감사 추적(Audit Trail): 누가 언제 어떤 작업을 수행했는지 시간 순서대로 기록한 로그입니다. 멀티에이전트 시스템에서는 디버깅과 책임 소재 파악에 직접 활용됩니다.
실전 적용
개념을 잡았으면 이제 직접 코드로 가보겠습니다. 예시 1은 복붙 후 바로 실행 가능한 최소 완전 예시이고, 예시 2는 그 위에 에스컬레이션 로직을 추가하는 방식으로 점진적으로 발전시킵니다.
예시 1: 전문가 패널 시스템 — 실행 가능한 최소 구성
먼저 실제로 돌아가는 코드를 보는 게 제일 빠릅니다. 수학 계산과 글쓰기를 담당하는 두 전문가를 Supervisor가 조율하는 구조입니다.
import os
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
from langgraph_supervisor import create_supervisor
# 더미 도구 정의 — 실제로는 외부 API 호출 등으로 교체
@tool
def calculator(expression: str) -> str:
"""수식을 계산합니다. 예: '3 ** 2 + 1'"""
try:
return str(eval(expression)) # 실제 프로덕션에서는 안전한 파서 사용
except Exception as e:
return f"계산 오류: {e}"
@tool
def get_weather(city: str) -> str:
"""도시의 날씨 정보를 반환합니다."""
# 실제 구현에서는 외부 날씨 API 호출
return f"{city}의 현재 기온은 22°C, 맑음입니다."
llm = ChatOpenAI(model="gpt-4o", api_key=os.environ["OPENAI_API_KEY"])
# 수학 전문가 에이전트
math_expert = create_react_agent(
model=llm,
tools=[calculator],
name="math_expert", # 핸드오프 추적의 키. 공백 없이 snake_case로
prompt="당신은 수학 전문가입니다. 계산 문제만 처리합니다."
)
# 날씨 전문가 에이전트
weather_expert = create_react_agent(
model=llm,
tools=[get_weather],
name="weather_expert",
prompt="당신은 날씨 정보 전문가입니다."
)
# 글쓰기 전문가 에이전트 (도구 없이 LLM만 활용)
writer_expert = create_react_agent(
model=llm,
tools=[],
name="writer_expert",
prompt="당신은 글쓰기 전문가입니다. 명확하고 친절한 설명을 작성합니다."
)
# Supervisor 생성 및 컴파일
supervisor = create_supervisor(
agents=[math_expert, weather_expert, writer_expert],
model=llm,
prompt=(
"당신은 팀 매니저입니다. "
"요청을 분석하고 가장 적합한 전문가에게 위임하세요. "
"복합 요청은 여러 전문가를 순차적으로 활용할 수 있습니다."
)
).compile()
# 실행
result = supervisor.invoke({
"messages": [
{"role": "user", "content": "오늘 서울 날씨 알려주고, 기온의 제곱근도 계산해줘"}
]
})
print(result["messages"][-1].content)코드를 보면서 짚어둘 포인트가 있습니다.
| 구성 요소 | 역할 | 핵심 포인트 |
|---|---|---|
create_react_agent |
개별 워커 에이전트 생성 | name 파라미터가 핸드오프 추적의 키. snake_case 권장 |
create_supervisor |
Supervisor 에이전트 생성 | agents 리스트로 관리 대상 에이전트 지정 |
.compile() |
LangGraph 그래프로 컴파일 | 이 시점에 엣지와 노드가 확정됨 |
.invoke() |
동기 실행 | 스트리밍이 필요하면 .stream() 활용 |
"서울 날씨 기온의 제곱근"이라는 복합 요청을 받으면, Supervisor는 먼저 weather_expert에 위임하고, 기온 값을 받은 뒤 math_expert에 다시 위임하는 식으로 동작합니다. 이 흐름이 메시지 히스토리에 전부 기록됩니다.
예시 2: 고객 지원 시스템 — 에스컬레이션 추가
예시 1의 기본 구조를 실무 상황으로 발전시킨 버전입니다. 실무에서 자주 맞닥뜨리는 상황인데, 고객 요청을 분류해서 청구팀·기술팀·에스컬레이션팀으로 라우팅하는 시스템입니다.
예시 1과 달라진 핵심은 Supervisor의 prompt에 명시적인 라우팅 규칙이 들어간다는 겁니다. LLM이 규칙에 따라 어느 에이전트로 보낼지 결정하고, 핸드오프 이력이 남으니 "왜 이 케이스가 에스컬레이션됐지?"를 나중에 추적할 수 있습니다.
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
from langgraph_supervisor import create_supervisor
# AWS Bedrock 환경이라면 아래처럼 LLM만 교체하면 나머지 코드는 동일
# from langchain_aws import ChatBedrock
# llm = ChatBedrock(model_id="anthropic.claude-3-5-sonnet-20241022-v2:0")
@tool
def get_invoice(customer_id: str) -> str:
"""고객 청구서를 조회합니다."""
return f"고객 {customer_id}의 최근 청구액: 99,000원 (2025-04 기준)"
@tool
def process_refund(customer_id: str, amount: int) -> str:
"""환불을 처리합니다."""
return f"고객 {customer_id}에게 {amount}원 환불 처리 완료"
@tool
def search_kb(query: str) -> str:
"""지식베이스에서 기술 문서를 검색합니다."""
return f"'{query}' 관련 문서: docs.example.com/troubleshoot"
@tool
def create_priority_ticket(description: str) -> str:
"""긴급 티켓을 생성하고 인간 상담사에게 알립니다."""
return f"긴급 티켓 #9999 생성 완료. 담당자에게 알림 전송됨."
billing_agent = create_react_agent(
model=llm,
tools=[get_invoice, process_refund],
name="billing_agent",
prompt="청구 관련 문의만 처리합니다. 환불, 청구서 조회 등을 담당합니다."
)
technical_agent = create_react_agent(
model=llm,
tools=[search_kb],
name="technical_agent",
prompt="기술적 문제 해결을 담당합니다. 해결이 어렵다면 에스컬레이션을 권장합니다."
)
escalation_agent = create_react_agent(
model=llm,
tools=[create_priority_ticket],
name="escalation_agent",
prompt="긴급하거나 복잡한 케이스를 인간 상담사에게 연결합니다."
)
support_supervisor = create_supervisor(
agents=[billing_agent, technical_agent, escalation_agent],
model=llm,
prompt="""
고객 지원 매니저입니다. 요청 유형에 따라 적절한 에이전트에 위임하세요:
- 청구/결제 문의 → billing_agent
- 기술적 문제 → technical_agent
- 긴급하거나 동일 문의가 반복되는 경우 → escalation_agent
항상 고객에게 친절하게 응대하세요.
"""
).compile()여기서 주의할 점이 하나 있는데요. name 파라미터의 값이 Supervisor prompt에 쓰인 이름과 정확히 일치해야 라우팅이 제대로 됩니다. billing_agent와 billing agent(공백 포함)는 다르게 인식될 수 있습니다.
예시 3: 병렬 문서 처리 — Supervisor 없이 StateGraph 직접 구성
이 예시는 앞의 두 예시와 패턴이 다릅니다. Supervisor 없이 StateGraph를 직접 구성하는 방식으로, 더 세밀한 제어가 필요할 때 사용합니다. Supervisor 패턴과 혼동하지 않도록 구분해서 볼 필요가 있습니다.
긴 문서를 섹션으로 나누고 여러 에이전트가 동시에 처리한 뒤 결과를 합치는 scatter-gather 패턴입니다. 이 예시는 asyncio를 직접 활용하기 때문에, 비동기 프로그래밍에 익숙한 분을 대상으로 합니다.
import asyncio
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o")
class DocumentState(TypedDict):
document: str
sections: List[str]
summaries: List[str]
final_report: str
async def summarize_section(section: str) -> str:
"""섹션 하나를 요약합니다."""
response = await llm.ainvoke(f"다음 내용을 2문장으로 요약해주세요:\n\n{section}")
return response.content
def split_document(state: DocumentState) -> dict:
sections = [s.strip() for s in state["document"].split("\n\n") if s.strip()]
return {"sections": sections}
async def process_sections_parallel(state: DocumentState) -> dict:
# asyncio.gather로 여러 섹션을 병렬 요약
# LangGraph는 async def 노드를 지원하므로 그대로 await 가능
tasks = [summarize_section(section) for section in state["sections"]]
summaries = await asyncio.gather(*tasks)
return {"summaries": list(summaries)}
def merge_results(state: DocumentState) -> dict:
final = "\n\n".join(
f"[섹션 {i+1}] {summary}"
for i, summary in enumerate(state["summaries"])
)
return {"final_report": final}
# 그래프 구성
graph = StateGraph(DocumentState)
graph.add_node("split", split_document)
graph.add_node("process", process_sections_parallel)
graph.add_node("merge", merge_results)
graph.add_edge(START, "split")
graph.add_edge("split", "process")
graph.add_edge("process", "merge")
graph.add_edge("merge", END)
pipeline = graph.compile()
# 실행 (async 환경에서)
async def main():
result = await pipeline.ainvoke({
"document": "첫 번째 섹션 내용...\n\n두 번째 섹션 내용...\n\n세 번째 섹션 내용..."
})
print(result["final_report"])참고로 LangGraph의 실제 프로덕션 병렬 처리에서는
Send API(from langgraph.types import Send)를 활용하면 더 정교한 fan-out 제어가 가능합니다.asyncio.gather방식은 간단한 병렬화에 적합하고, 에이전트 수가 동적으로 변하는 경우엔 Send API가 더 유연합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 명시적 상태 관리 | 공유 State 객체로 에이전트 간 컨텍스트 일관성이 보장됩니다 |
| 감사 추적 | Handoff Tool 호출마다 메시지 히스토리에 기록되어 디버깅과 책임 추적이 쉽습니다 |
| 조건부 분기 | 복잡한 비즈니스 로직(if/else, 루프)을 그래프로 자연스럽게 표현할 수 있습니다 |
| 병렬 처리 | 독립적인 에이전트를 동시에 실행해 전체 처리 시간을 줄일 수 있습니다 |
| 생태계 지원 | LangChain 통합, AWS·Google 클라우드 연동, LangSmith 모니터링 등이 잘 갖춰져 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 가파른 학습 곡선 | 그래프·State·Handoff 개념을 동시에 이해해야 합니다 | 공식 튜토리얼부터 단일 에이전트 → 2개 에이전트 순으로 점진적으로 접근하는 것을 권장합니다 |
| LangChain 의존성 | LangChain API가 자주 변경되어 마이그레이션 부담이 있습니다 | langgraph-supervisor 공식 패키지를 사용하고, 버전을 requirements.txt에 pin해두는 게 안전합니다 |
| 분산 시스템 복잡도 | 규모가 커지면 상태 동기화·네트워크 지연·메모리 급증 문제가 생깁니다 | LangSmith로 병목을 먼저 파악하고, 꼭 필요한 곳만 분산 처리하는 방식을 권장합니다 |
| 디버깅 난이도 | 여러 에이전트가 얽히면 어디서 잘못됐는지 찾기가 힘듭니다 | LangSmith 트레이싱을 필수로 연동하고, 각 에이전트에 명확한 name을 지정해두는 것이 좋습니다 |
| 인프라 부담 | 오케스트레이션·배포·거버넌스를 직접 구축해야 합니다 | 초기엔 LangGraph Cloud나 AWS Bedrock 관리형 서비스 활용을 검토해볼 수 있습니다 |
LangSmith: LangChain이 제공하는 공식 트레이싱·평가 플랫폼입니다. 에이전트가 어떤 순서로 실행됐는지, 각 단계에서 얼마나 걸렸는지를 시각적으로 확인할 수 있습니다. 멀티에이전트 시스템에서는 사실상 필수입니다.
실무에서 가장 흔한 실수
경험상 이 세 가지에서 가장 많이 막힙니다.
-
에이전트에 너무 많은 역할을 부여하는 것 — "만능 에이전트" 하나를 만들려다가 Supervisor 패턴의 장점을 전부 날려버리는 경우가 많습니다. 에이전트는 한 가지 역할에 집중할수록 라우팅 정확도가 높아지고, 디버깅도 훨씬 수월해집니다.
-
단순한 작업에 Supervisor 패턴을 적용하는 것 — 순차적인 3단계 파이프라인이라면 그냥 체인으로 구현하는 게 훨씬 낫습니다. Supervisor는 조건 분기와 복잡한 라우팅이 필요할 때 빛을 발합니다. 작은 문제에 적용하면 유지보수 비용만 늘어납니다.
-
LangSmith 없이 프로덕션에 배포하는 것 — 에이전트 간 상호작용을 육안으로 추적하려다 결국 로그 덤프를 파헤치게 됩니다.
.env에LANGCHAIN_TRACING_V2=true와LANGCHAIN_API_KEY를 설정하는 것만으로 자동으로 실행 흐름이 기록됩니다. 개발 초기부터 세팅해두면 나중에 정말 고맙습니다.
마치며
이 패턴을 실제 프로젝트에 적용하고 나서 달라진 게 있다면, "어디서 뭐가 잘못됐지?"를 찾는 시간이 확연히 줄었다는 겁니다. 핸드오프 이력이 메시지에 그대로 남으니까요.
지금 바로 시작해볼 수 있는 3단계를 소개합니다.
-
패키지 설치부터 —
pip install langgraph langgraph-supervisor langchain-openai로 환경을 세팅하고, 예시 1 코드를 그대로 복붙해서 실행해보시면 좋습니다.OPENAI_API_KEY만 있으면 바로 됩니다. -
create_supervisor의prompt를 바꿔가며 관찰하기 — Supervisor 프롬프트에서 라우팅 규칙 문장을 지우거나 바꿔보면, 에이전트 배분이 어떻게 달라지는지 체감할 수 있습니다. 이 관찰이 패턴에 대한 감을 가장 빠르게 키워줍니다. -
LangSmith 트레이싱 연동하기 —
.env에LANGCHAIN_TRACING_V2=true와LANGCHAIN_API_KEY를 설정하면 자동으로 실행 흐름이 기록됩니다. 핸드오프가 메시지 히스토리에 어떻게 찍히는지 눈으로 확인하는 순간 구조가 훨씬 명확하게 들어옵니다.
다음 글: LangGraph에 Human-in-the-Loop를 추가하는 방법 —
interrupt_before와checkpointer로 에이전트 결정에 인간 검토 단계를 삽입하는 프로덕션 패턴
참고 자료
- GitHub - langchain-ai/langgraph-supervisor-py
- LangGraph Reference: create_supervisor API
- langgraph-supervisor · PyPI
- LangGraph: Multi-Agent Workflows | LangChain Blog
- Build multi-agent systems with LangGraph and Amazon Bedrock | AWS Blog
- How Agent Handoffs Work in Multi-Agent Systems | Towards Data Science
- Building Multi-Agent Systems with LangGraph-Supervisor | DEV Community
- How to Continuously Improve Your LangGraph Multi-Agent System | Galileo
- @langchain/langgraph-supervisor | npm