Python & AI Tutorials Logo
LangChain & LangGraph

18. 멀티 에이전트 시스템 — 슈퍼바이저 패턴

16장에서 만든 고객 문의 에이전트를 떠올려 보겠습니다. 도구는 주문 조회 도구 하나뿐이었고, 주문에 대해 문의하면 배송 상태를 알려주었습니다. 도구가 하나뿐이었으므로 엉뚱한 도구를 고를 가능성 자체가 없었습니다.

이 에이전트를 실제 서비스 수준으로 키운다고 해봅시다. 주문 조회만으로는 기능이 부족합니다. 주문 취소, 배송 추적, 배송지 변경, 교환 접수, 반품 접수, 환불 대상 확인, 환불 처리, 재고 확인, 쿠폰 발급, 포인트 조회, 상담 티켓 생성 등 많은 기능이 필요합니다. 방법 자체는 간단합니다. 도구를 계속 추가하고, 시스템 프롬프트에 업무 규칙을 덧붙이면 됩니다.

그런데 이렇게 도구와 규칙이 늘어나면 세 가지 문제가 발생합니다.

  • 도구 선택 정확도가 떨어집니다. 모델은 매 턴마다 모든 도구 설명을 읽고 어느 도구를 부를지 판단합니다. 교환 접수반품 접수처럼 입력이 같고 쓰임이 겹치는 도구가 늘어날수록 잘못 고를 확률이 높아집니다.

  • 컨텍스트가 현재 필요 없는 정보로 채워집니다. 환불을 처리하는 중에도 재고 확인 도구의 스키마, 쿠폰 발급 조건, 교환 접수 절차 등이 매 호출마다 함께 전달됩니다. 도구 전체의 스키마와 모든 도메인의 업무 규칙을 매번 실어 보내는 셈입니다. 현재 업무에 별로 중요하지 않은 다른 내용들이 많이 섞여 있으면, 핵심 내용이 거기에 묻히게 되어 작업의 정확도가 떨어지게 됩니다.

  • 고치기가 어려워집니다. 환불 규칙 하나를 바꾸려면 모든 도메인 규칙이 뒤섞인 프롬프트를 건드려야 하고, 그 수정이 교환이나 배송 처리에 영향을 주지 않는다고 장담할 수 없습니다. 도메인마다 담당자가 다르다면 문제는 더 커집니다.

이번 장에서는 이 문제를 다루는 방법 하나를 배웁니다. 도구와 규칙을 에이전트 하나에 계속 쌓는 대신, 도메인별로 에이전트를 나누는 것입니다. 16장의 고객 문의 시나리오를 이어받아, 주문 조회만 담당하는 에이전트와 환불만 담당하는 에이전트로 구성된 팀을 만들겠습니다. 각 에이전트는 자기만의 도구와 프롬프트를 갖습니다. 그리고 이들을 지휘할 에이전트를 하나 더 두는데, 이 지휘자를 슈퍼바이저(supervisor)라고 합니다. 이렇게 에이전트를 여러 개 두는 구조를 멀티 에이전트 시스템이라고 하고, 슈퍼바이저가 나머지를 통솔하는 이 방식을 슈퍼바이저 패턴이라고 합니다.

다만 멀티 에이전트에는 대가가 따릅니다. 슈퍼바이저가 매번 어느 워커 에이전트에게 맡길지 판단해야 하므로 LLM 호출이 늘고, 그만큼 응답 시간과 비용이 늘어납니다. 따라서 도구와 규칙이 그리 많지 않다면 굳이 멀티 에이전트 시스템으로 구축할 필요는 없습니다.

이 장은 다음 순서로 진행됩니다. 18.1에서는 멀티 에이전트 시스템이 무엇이고 어떻게 동작하는지 살펴봅니다. 18.2에서는 워커 에이전트를 만듭니다. 18.3에서는 LangGraph의 Command 객체로 슈퍼바이저를 직접 만듭니다. 18.4에서는 워커 에이전트를 도구로 감싸 같은 팀을 훨씬 짧은 코드로 다시 만듭니다.

18.1) 멀티 에이전트 시스템 이해하기

18.1.1) 멀티 에이전트 시스템이란

"주문이 안 왔는데 문제가 있으면 환불해 달라"는 요청을 처리할 수 있는 에이전트를 설계해 보겠습니다. 지금까지 배운 내용들로 만들어 보겠습니다. 주문 조회 도구와 환불 처리 도구를 한 에이전트에 붙이고, 처리가 끝날 때까지 에이전트 루프를 돌게 하면 됩니다. 하나의 에이전트가 주문을 조회하고, 배송이 실패한 것을 확인하고, 그 결과를 보고 환불 처리를 요청하고, 최종 답변을 만듭니다.

멀티 에이전트 시스템은 이 일을 여러 에이전트가 나누어 처리하는 구조입니다. 도메인별로 에이전트를 나누고 그 위에 슈퍼바이저를 둡니다. 각 에이전트는 자기에게 필요한 도구만 갖습니다. 앞의 에이전트라면 주문 조회 에이전트와 환불 에이전트로 나눌 수 있습니다. 슈퍼바이저는 먼저 주문 조회 에이전트를 불러 배송 상태를 확인하고, 배송이 실패했다는 보고를 받으면 환불 에이전트를 불러 환불을 처리한 뒤, 두 결과를 모아 고객에게 답합니다. 에이전트 하나가 도구를 순서대로 부르던 것을, 슈퍼바이저가 에이전트를 순서대로 부르는 것으로 바뀐 셈입니다.

도구가 둘뿐이라면 이렇게 나눌 이유가 없습니다. 에이전트 하나로 충분하고, 슈퍼바이저를 두면 LLM 호출만 늘어날 뿐입니다. 그러나 도입부에서 봤듯이 도구가 많아지면 에이전트는 도구를 잘못 고를 수 있고, 컨텍스트는 처리 중인 업무와 무관한 정보로 차고, 프롬프트는 고치기 어려워집니다.

나누면 이 문제들이 해결됩니다. 주문 조회 에이전트가 보는 도구는 주문 관련 몇 개뿐이라, 몇십 개가 넘는 목록에서 고르던 것이 몇 개 중 고르는 일로 줄어듭니다. 프롬프트에는 주문 조회 규칙만 있으니 환불 정책이나 쿠폰 조건 등 주문 조회와 무관한 내용들이 컨텍스트를 채우지 않습니다. 환불 규칙을 고칠 때는 환불 에이전트만 건드리면 되므로, 그 수정이 다른 에이전트에 영향을 주지 않습니다.

여기서 주문 조회 에이전트와 환불 에이전트는 특별한 것이 아닙니다. 16장에서 만든 것과 똑같은 에이전트입니다. create_agent에 모델과 도구와 프롬프트를 넘겨 만들고, invoke로 부릅니다. 담당 범위만 좁아졌을 뿐입니다.

슈퍼바이저는 무슨 일을 하는 걸까요. 주문 조회 에이전트와 환불 에이전트는 서로의 존재를 모릅니다. 각자 자기 일만 할 뿐, 누가 먼저 나서야 하는지도 모릅니다. 위 예에서 주문 조회를 먼저 부르고 그 결과를 본 뒤에야 환불을 부른 것은 슈퍼바이저의 판단이었습니다.

누가, 언제, 누구를 부를 것인가. 이것을 조율이라고 합니다.

18.1.2) 슈퍼바이저 패턴

슈퍼바이저 패턴(supervisor pattern)은 중앙의 슈퍼바이저 하나가 여러 워커 에이전트를 조율하는 구조입니다. 다음과 같은 규칙이 있습니다.

  • 슈퍼바이저는 직접 업무를 처리하지 않습니다. 주문을 조회하지도, 환불을 처리하지도 않습니다. 누구에게 맡길지만 정하고, 돌아온 결과를 모아 최종 답변을 만듭니다.
  • 워커 에이전트끼리는 서로를 호출하지 않습니다. 주문 조회 에이전트가 환불 에이전트를 직접 부르는 일은 없습니다. 모든 흐름이 슈퍼바이저를 거칩니다.
  • 고객과 대화하는 것은 슈퍼바이저뿐입니다. 워커 에이전트는 고객이 아니라 슈퍼바이저에게 보고합니다.

그렇다면 슈퍼바이저는 어느 에이전트를 부를지 어떻게 정할까요. LLM이 정합니다. 슈퍼바이저는 지금까지의 대화를 통째로 읽고 판단합니다. 아직 주문 상태를 모르면 주문 조회 에이전트를 부르고, 배송이 실패했다는 사실이 확인되어 환불이 필요하다고 판단되면 환불 에이전트를 부릅니다.

슈퍼바이저는 이 판단을 사용자의 요청이 완료될 때까지 반복합니다. 에이전트를 부르고, 보고를 받고, 그 보고가 더해진 대화를 다시 읽고, 다음에 부를 에이전트를 정합니다.

이 루프는 14장에서 만든 것과 같은 구조입니다.

  • Think — 지금까지의 대화를 읽고 어느 에이전트를 부를지 판단합니다.
  • Act — 선택한 에이전트를 실행합니다.
  • Observe — 에이전트의 보고를 확인하고 대화 내역에 추가합니다.

14장에서는 도구를 불렀고, 여기서는 에이전트를 부릅니다. 달라지는 것은 그것뿐입니다.

위임

위임

보고

보고

완료

고객 문의

슈퍼바이저

주문 조회 에이전트

환불 에이전트

고객에게 답변

화살표가 슈퍼바이저로 되돌아온다는 점을 보세요. 워커 에이전트는 일을 마치면 슈퍼바이저에게 보고하고, 슈퍼바이저가 그 보고를 읽고 다음 행동을 결정합니다.

루프는 슈퍼바이저가 끝냅니다. 고객의 요청이 모두 처리되었다고 판단하면 최종 답변을 만들고 멈춥니다.

18.2) 워커 에이전트 만들기

18.1에서 설계한 두 워커 에이전트를 만듭니다. 주문 상태를 확인하는 주문 조회 워커 에이전트와 환불을 처리하는 환불 워커 에이전트입니다. 슈퍼바이저는 18.3에서 만듭니다.

워커 에이전트는 16장에서 만든 일반 에이전트와 같습니다. 여기서는 create_agent로 간단히 만들겠습니다.

먼저 두 워커 에이전트가 공유할 주문 데이터부터 준비합니다.

python
ORDERS = {
    "12345": {"item": "무선 이어폰", "amount": 89,
              "status": "in_transit", "status_text": "배송 중 (내일 도착 예정)"},
    "67890": {"item": "기계식 키보드", "amount": 129,
              "status": "delivered", "status_text": "배송 완료"},
    "24680": {"item": "노이즈캔슬링 헤드폰", "amount": 249,
              "status": "delivery_failed", "status_text": "배송 실패 (수취인 불명으로 반송됨)"},
}

주문 조회 워커 에이전트는 도구 하나만 갖습니다.

python
from langchain.tools import tool
from langchain.agents import create_agent
 
@tool
def get_order_status(order_id: str) -> str:
    """주문 번호로 상품명, 결제 금액, 배송 상태를 조회합니다."""
    order = ORDERS.get(order_id)
    if order is None:
        return f"주문 {order_id}를 찾을 수 없습니다."
 
    return (f"주문 {order_id}: {order['item']}, "
            f"${order['amount']:,}, 상태: {order['status_text']}")
 
 
order_agent = create_agent(
    name="order_expert",
    model="openai:gpt-5.4-mini",
    tools=[get_order_status],
    system_prompt=(
        "당신은 주문 조회 전문가입니다. 주문 상태를 조회하여 답변하세요.\n"
        "최종 답변에는 주문 번호, 상품명, 결제 금액, 배송 상태를 모두 담으세요.\n"
        "환불 가능 여부를 판단하거나 환불에 대해 어떠한 언급도 하지 마세요. 당신의 역할은 주문 조회와 상태 보고뿐입니다."
    ),
)

환불 워커 에이전트는 도구 두 개를 갖습니다. 환불 대상인지 판정하는 도구와 실제로 환불을 처리하는 도구입니다.

python
@tool
def check_refund_eligibility(order_id: str) -> str:
    """주문이 환불 대상인지 판정합니다. 배송 실패 건만 환불 대상입니다."""
    order = ORDERS.get(order_id)
    if order is None:
        return f"주문 {order_id}를 찾을 수 없습니다."
 
    if order["status"] == "delivery_failed":
        return f"주문 {order_id}는 환불 대상입니다 (사유: 배송 실패)."
 
    return f"주문 {order_id}는 환불 대상이 아닙니다 (현재 상태: {order['status_text']})."
 
 
@tool
def issue_refund(order_id: str) -> str:
    """환불을 처리합니다. 반드시 check_refund_eligibility로 대상 여부를 확인한 뒤에 호출하세요."""
    order = ORDERS.get(order_id)
    if order is None:
        return f"주문 {order_id}를 찾을 수 없습니다."
 
    return (f"환불 완료: 주문 {order_id}에 대해 ${order['amount']:,}가 "
            f"3~5영업일 내 환불됩니다. (승인번호: RF-{order_id})")
 
 
refund_agent = create_agent(
    name="refund_expert",
    model="openai:gpt-5.4-mini",
    tools=[check_refund_eligibility, issue_refund],
    system_prompt=(
        "당신은 환불 처리 전문가입니다.\n"
        "반드시 check_refund_eligibility로 환불 대상 여부를 먼저 확인한 뒤에만 "
        "issue_refund를 호출하세요.\n"
        "환불을 처리했다면 최종 답변에 금액과 승인번호를 담으세요.\n"
        "환불 대상이 아니면 처리하지 말고 사유를 보고하세요."
    ),
)

두 워커 에이전트에 name을 붙였습니다. 이 이름은 18.5의 create_supervisor가 노드 이름이자 핸드오프 도구 이름으로 사용합니다.

워커 에이전트 시스템 프롬프트에 들어가야 할 네 가지

워커 에이전트의 시스템 프롬프트는 네 가지 요소로 구성됩니다. Anthropic이 자사 멀티 에이전트 리서치 시스템을 구축하면서 정리한 원칙입니다. 이것들이 부실하면 워커 에이전트가 일을 중복하거나, 해야 할 일을 빠뜨리거나, 필요한 정보를 찾지 못합니다.

요소order_agentrefund_agent
역할당신은 주문 조회 전문가입니다당신은 환불 처리 전문가입니다
도구 사용 지침반드시 check_refund_eligibility로 먼저 확인한 뒤에만 issue_refund를 호출하세요
출력 형식최종 답변에는 주문 번호, 상품명, 결제 금액, 배송 상태를 모두 담으세요환불을 처리했다면 최종 답변에 금액과 승인번호를 담으세요
작업 경계환불 가능 여부를 판단하거나 환불에 대해 어떠한 언급도 하지 마세요. 당신의 역할은 주문 조회와 상태 보고뿐입니다.환불 대상이 아니면 처리하지 말고 사유를 보고하세요

역할은 이 워커 에이전트가 누구인지 한 문장으로 정합니다. "당신은 주문 조회 전문가입니다"라고 정체성을 못박으면 모델이 자기 역할에 더 집중하고 남의 일에 덜 끼어듭니다.

도구 사용 지침. 도구를 어떤 순서와 조건으로 쓸지 등 도구 스키마로는 알지 못하는 내용이 있을 때 작성합니다. 만일 도구 스키마 정보 외에 언급할 내용이 없으면 작성하지 않아도 됩니다.

출력 형식과 작업 경계는 멀티 에이전트에서 매우 중요합니다.

출력 형식. 워커 에이전트의 최종 답변은 고객에게 보내는 답변이 아니라 슈퍼바이저에게 제출하는 보고서입니다. 거기 적히지 않은 것은 슈퍼바이저에게 전달되지 않습니다. 워커 에이전트가 도구로 금액을 조회해 놓고 최종 답변에 적지 않으면 그 금액은 슈퍼바이저가 알 수 없습니다.

작업 경계. 워커 에이전트가 어디까지 해도 되고 무엇을 하면 안 되는지를 정한 업무 범위입니다. 주문 조회 워커 에이전트는 조회만 하고 환불은 하지 말아야 합니다. 그래서 환불 도구를 주지 않았습니다. 그런데 도구를 주지 않는 것만으로는 부족합니다. 모델은 도구 없이도 "배송이 실패했으니 환불해 드리겠습니다"라고 답할 수 있기 때문입니다. 이 말이 슈퍼바이저에게 전달되면 슈퍼바이저는 환불이 처리 중인 것으로 오해하여 환불 워커 에이전트를 부르지 않을 수 있습니다. 그래서 프롬프트에도 "환불 가능 여부를 판단하거나 환불에 대해 어떠한 언급도 하지 마세요"라고 적어, 말로도 환불을 언급하지 못하게 막았습니다.

모델 선택

워커 에이전트에는 gpt-5.4-mini, 슈퍼바이저에는 gpt-5.4를 쓰고 있습니다. 워커 에이전트는 도구 몇 개를 정해진 순서로 부르는 단순한 일을 하므로 작은 모델로 충분합니다. 슈퍼바이저는 대화 전체를 읽고 다음에 누구를 부를지 판단해야 하므로 더 큰 모델이 필요합니다. 이렇게 에이전트마다 난이도에 맞춰 모델을 따로 고를 수 있다는 것도 나누기의 이득입니다.

이제 슈퍼바이저를 붙일 수 있습니다.

18.3) 슈퍼바이저 직접 만들기

슈퍼바이저를 직접 만들어 보겠습니다. 실무에서는 프레임워크가 대신 처리해 주는 방식을 주로 쓰지만(다음 절에서 다룹니다), 내부 동작 방식을 이해하려면 직접 만들어 봐야 합니다.

18.1에서 봤듯이 슈퍼바이저가 하는 일은 하나의 루프입니다. 워커 에이전트를 부르고, 보고를 읽고, 다음에 누구를 부를지 또는 끝낼지 정하는 일을 반복합니다.

18.3.1) 핸드오프와 Command

이 루프가 돌려면 제어가 슈퍼바이저와 워커 에이전트 사이를 왔다갔다 해야 합니다. 슈퍼바이저가 "다음은 이 워커 에이전트가 해"라며 제어를 넘기고, 워커 에이전트가 일을 마치면 제어를 슈퍼바이저에게 돌려줍니다. 이렇게 한 노드가 다른 노드로 제어를 넘기는 것을 핸드오프라고 합니다.

핸드오프에는 두 가지 정보가 필요합니다. 어디로 갈지(목적지)와 무엇을 넘길지(정보)입니다. 목적지는 반드시 있어야 하고, 정보는 넘길 것이 있을 때만 담습니다. LangGraph에서는 노드가 Command를 반환해 이 둘을 지정합니다.

python
from typing import Literal
from langgraph.graph import MessagesState
from langgraph.types import Command
 
def some_node(state: MessagesState) -> Command[Literal["refund_expert_proxy"]]:
    return Command(
        goto="refund_expert_proxy",            # 어디로: 다음에 실행할 노드
        update={"messages": [워커_에이전트의_보고]},  # 무엇을: 상태(State)에 넘길 정보
    )

goto가 목적지, update가 넘길 정보입니다. 반환 타입 힌트의 Command[Literal["refund_expert_proxy"]]는 이 노드가 갈 수 있는 목적지들을 미리 나열해 놓은 것입니다. 이 Command가 실제로 어떻게 쓰이는지는 다음 절에서 슈퍼바이저 노드와 워커 프록시를 만들며 보겠습니다.

18.3.2) 슈퍼바이저 루프 만들기

구조 자체는 단순합니다. 슈퍼바이저 노드 하나와 필요한 만큼의 워커 프록시를 만듭니다. 워커 프록시는 자신이 맡은 워커 에이전트를 대신 호출해 주는 노드입니다. 시작점은 슈퍼바이저로 두고, 각 워커 프록시는 일을 마치면 다시 슈퍼바이저로 이동해 루프를 이룹니다. 슈퍼바이저와 워커 프록시 간의 이동은 각 노드가 반환하는 Command로 정해집니다.

goto=order_expert_proxy

goto=refund_expert_proxy

invoke

invoke

goto

goto

goto=END

START

supervisor

order_expert_proxy

refund_expert_proxy

order_agent

refund_agent

END

실선은 노드 사이의 핸드오프(goto), 점선은 워커 프록시가 워커 에이전트를 호출하는 것(invoke)입니다. 슈퍼바이저는 워커 프록시로 핸드오프하고, 워커 프록시는 일을 마치면 슈퍼바이저로 돌아옵니다. 슈퍼바이저가 FINISH로 판단하면 END로 나갑니다.

자, 이제 이 그림을 코드로 만들어 보겠습니다. 먼저 Route 클래스입니다. Route는 슈퍼바이저가 다음에 어느 워커 프록시를 호출할지 LLM에게 물었을 때, 그 답을 구조화된 응답으로 받기 위한 스키마입니다. LLM이 자연어로 답하면 어느 워커 프록시를 실행해야 할지 알아내기 어렵기 때문입니다. Route에는 다음에 어느 워커 프록시를 호출할지(next)와 왜 그렇게 정했는지 이유(reason)를 담습니다.

python
from typing import Literal
from pydantic import BaseModel, Field
 
class Route(BaseModel):
    reason: str = Field(description="이 결정을 내린 이유.")
    next: Literal["order_expert_proxy", "refund_expert_proxy", "FINISH"] = Field(
        description="다음에 실행할 워커 노드. 요청이 완전히 처리되었으면 FINISH."
    )

reasonnext보다 먼저 선언한 데에는 이유가 있습니다. 구조화된 출력은 스키마에 적은 순서대로 생성되므로, reason이 앞에 있으면 LLM은 이유를 먼저 쓰고 나서 워커를 고릅니다. 근거를 세운 뒤 결정하므로 더 나은 선택을 하게 됩니다. 순서를 뒤집어 next를 먼저 두면 LLM은 워커부터 정하고 그 뒤에 이유를 붙이는데, 잘못 결정한 후 그에 맞게 이유를 지어낼 가능성이 있습니다.

다음은 슈퍼바이저 노드입니다.

python
from typing import Literal
from langgraph.graph import MessagesState, StateGraph, START, END
from langgraph.types import Command
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
 
supervisor_llm = ChatOpenAI(model="gpt-5.4")
 
SUPERVISOR_PROMPT = (
    "당신은 고객 서비스 팀의 슈퍼바이저입니다. 두 워커를 관리합니다:\n"
    "- order_expert_proxy: 주문 상태를 조회합니다.\n"
    "- refund_expert_proxy: 환불 가능 여부를 확인하고 환불을 처리합니다.\n"
    "환불이 필요한지 판단하려면 먼저 주문 상태를 알아야 합니다.\n"
    "한 번에 한 워커에게만 맡기고, 요청이 완전히 처리되었으면 FINISH로 응답하세요."
)
 
 
def supervisor(
    state: MessagesState,
) -> Command[Literal["order_expert_proxy", "refund_expert_proxy", "__end__"]]:
    messages = [{"role": "system", "content": SUPERVISOR_PROMPT}, *state["messages"]]
    decision = supervisor_llm.with_structured_output(Route).invoke(messages)
    print(f"[supervisor] → {decision.next} ({decision.reason})")
 
    if decision.next == "FINISH":
        final = supervisor_llm.invoke(
            [{"role": "system", "content": "지금까지의 대화를 바탕으로 고객에게 답변하세요."},
             *state["messages"]]
        )
        return Command(goto=END, update={"messages": [final]})   # 종료로 핸드오프
 
    return Command(goto=decision.next)                            # 워커 프록시로 핸드오프

지금까지 노드는 바뀐 State만 돌려줬습니다. 그런데 슈퍼바이저 노드는 Command를 반환하고 있습니다. 노드가 Command를 반환하면 LangGraph는 두 가지를 합니다. update에 담긴 내용을 State에 반영하고, goto에 적힌 노드를 다음에 실행합니다. 위 코드에서는 decision.next로 꺼낸 워커 프록시 이름을 goto에 넣었기 때문에 LLM이 응답한 decision.next 노드가 실행됩니다.

다음은 워커 프록시입니다. 워커 프록시는 자기 일을 하고, 제어를 슈퍼바이저로 돌려줍니다. 그래서 goto="supervisor"로 핸드오프합니다.

워커 프록시가 하는 일은 단순합니다. 자기 워커 에이전트를 invoke로 부르는 것입니다.

python
def order_expert_proxy(state: MessagesState) -> Command[Literal["supervisor"]]:
    result = order_agent.invoke(state)
    last = result["messages"][-1]
    return Command(
        goto="supervisor",
        update={"messages": [HumanMessage(content=last.content, name="order_expert")]},
    )
 
 
def refund_expert_proxy(state: MessagesState) -> Command[Literal["supervisor"]]:
    result = refund_agent.invoke(state)
    last = result["messages"][-1]
    return Command(
        goto="supervisor",
        update={"messages": [HumanMessage(content=last.content, name="refund_expert")]},
    )

워커 에이전트의 최종 메시지만 꺼내 슈퍼바이저에게 넘기는 것에 주목하기 바랍니다. 슈퍼바이저는 결론만 알면 되고, 워커 에이전트가 내부에서 도구를 몇 번 불렀는지는 알 필요가 없기 때문입니다.

18.3.3) 그래프 연결과 실행

노드 셋을 등록하고, 시작점만 슈퍼바이저로 연결합니다. 나머지 이동은 각 노드의 Command가 정하므로 엣지가 더 필요 없습니다.

python
builder = StateGraph(MessagesState)
builder.add_node("supervisor", supervisor)
builder.add_node("order_expert_proxy", order_expert_proxy)
builder.add_node("refund_expert_proxy", refund_expert_proxy)
builder.add_edge(START, "supervisor")
 
team = builder.compile()
 
result = team.invoke(
    {"messages": [HumanMessage(
        content="주문 24680이 아직도 안 왔어요. 문제가 있으면 환불해 주세요."
    )]},
    config={"recursion_limit": 15},
)

출력:

[supervisor] → order_expert_proxy (환불 여부를 판단하려면 먼저 주문 상태를 확인해야 합니다.)
[supervisor] → refund_expert_proxy (배송 실패로 확인되었으니 환불 대상인지 확인하고 처리합니다.)
[supervisor] → FINISH (주문 확인과 환불 처리가 모두 끝났습니다.)

슈퍼바이저는 처음엔 주문 조회로 보냈습니다. 배송 실패라는 보고가 오자 환불로 보냈고, 환불 완료 보고가 오자 종료했습니다. 앞선 에이전트의 보고를 보고 다음 목적지를 정한 것입니다.

워커 프록시는 invoke(state)로 공유되는 State 전체를 워커 에이전트에 넘기고 있습니다. 그래서 워커 에이전트는 지금까지의 대화를 전부 보게 됩니다. 워커 에이전트가 둘일 때는 문제가 없지만, 워커 에이전트와 대화가 늘면 각 워커 에이전트가 자기 일과 무관한 메시지까지 읽게 됩니다. 이 문제를 다음 절에서 다르게 풀어 봅니다.

18.4) 워커 에이전트를 도구로 위임하기

18.3에서 슈퍼바이저의 내부를 직접 만들어 봤으니, 이제 실제 프로젝트에서 권장되는 방식으로 같은 팀을 다시 만들어 보겠습니다. 이 방식은 새로운 API를 필요로 하지 않습니다. @tool로 워커 에이전트를 도구화하고, 그 도구를 가진 슈퍼바이저 에이전트를 두면 됩니다. 슈퍼바이저 에이전트는 create_agent로 간단히 만들겠습니다.

핵심은 한 문장입니다. 슈퍼바이저도 하나의 에이전트이고, 각 워커 에이전트는 슈퍼바이저가 부르는 도구가 됩니다.

lookup_order 도구 호출

handle_refund 도구 호출

결과 문자열

결과 문자열

고객

슈퍼바이저 에이전트

order_agent

refund_agent

고객에게 답변

이렇게 보면 슈퍼바이저는 16장에서 만든 도구 호출 에이전트와 구조가 같습니다. 다만 get_order_status 같은 저수준 도구 대신, 에이전트를 부르는 고수준 도구를 갖고 있을 뿐입니다.

18.4.1) 워커 에이전트를 도구로 감싸기

18.2에서 만든 order_agentrefund_agent를 수정 없이 그대로 씁니다. 각각을 @tool 함수로 감싸기만 하면 됩니다.

python
from langchain.tools import tool
 
# order_agent, refund_agent는 18.2의 워커 에이전트들입니다
 
@tool
def lookup_order(request: str) -> str:
    """주문의 상품, 결제 금액, 배송 상태를 조회합니다. 주문 상태를 알아야 할 때 사용하세요.
 
    입력: 자연어 조회 요청 (예: '주문 24680의 배송 상태를 알려줘')
    """
    
    print("[도구 호출] lookup_order")
    print(f"    request: {request}")
            
    result = order_agent.invoke({"messages": [{"role": "user", "content": request}]})
    return result["messages"][-1].content
 
 
@tool
def handle_refund(request: str) -> str:
    """환불 대상 여부를 확인하고 환불을 처리합니다. 고객이 환불을 원하고, 주문 상태를 이미 확인했을 때 사용하세요.
 
    입력: 자연어 환불 요청. 주문 번호와 조회로 확인된 배송 상태를 함께 담으세요.
    (예: '주문 24680은 배송 실패 상태입니다. 환불 가능하면 처리해 주세요.')
    """
    
    print("[도구 호출] handle_refund")
    print(f"    request: {request}")
 
    result = refund_agent.invoke({"messages": [{"role": "user", "content": request}]})
    return result["messages"][-1].content

세 가지가 달라졌습니다.

  • 도구 설명이 라우팅 로직을 대신합니다. 18.3에서는 SUPERVISOR_PROMPTRoute 스키마를 직접 써서 슈퍼바이저에게 워커 목록과 선택지를 알려줬습니다. 여기서는 도구의 docstring이 그 역할을 합니다. 슈퍼바이저 LLM은 도구 설명을 읽고 언제 무엇을 부를지 판단합니다.

  • 워커 에이전트가 깨끗한 컨텍스트에서 시작합니다. 18.3과 나란히 놓고 보면 차이가 분명합니다.

    python
    # 18.3 (수동 그래프): 공유 State 전체를 넘김
    result = refund_agent.invoke(state)
     
    # 18.4 (도구 위임): 슈퍼바이저가 작성한 작업 지시만 넘김
    result = refund_agent.invoke({"messages": [{"role": "user", "content": request}]})

    18.4의 환불 워커 에이전트는 자기가 할 일이 적힌 문장 하나만 받습니다. 고객의 원문도, 슈퍼바이저의 추론도, 다른 워커 에이전트의 도구 호출 기록도 보지 않습니다. 워커 에이전트가 열 개가 되고 대화가 백 턴이 되어도 각 워커 에이전트의 컨텍스트는 작업 지시 하나로 유지됩니다.

  • 대신 슈퍼바이저가 정보 전달 책임을 집니다. 워커 에이전트는 대화 히스토리를 보지 못하므로, 워커 에이전트에게 필요한 정보는 슈퍼바이저가 request 문자열에 담아 넘겨야 합니다. handle_refund의 docstring에 "주문 번호와 조회로 확인된 배송 상태를 함께 담으세요"라고 명시한 이유입니다. 이 지시가 없으면 슈퍼바이저는 "환불해 주세요"라고만 넘기고, 환불 워커 에이전트는 어느 주문인지 몰라 헤맬 수 있습니다.

18.4.2) 슈퍼바이저 조립과 실행

이 방식에서는 슈퍼바이저도 그냥 에이전트입니다. Command도, Route 스키마도 필요 없습니다.

python
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
 
TOOL_SUPERVISOR_PROMPT = (
    "당신은 고객 서비스 팀의 슈퍼바이저입니다.\n"
    "환불이 필요한지 판단하려면 먼저 주문 상태를 확인해야 합니다.\n"
    "직접 작업하지 말고, 워커에게 위임하세요.\n"
    "하위 워커는 이 대화를 볼 수 없습니다. 위임할 때 필요한 정보를 요청 문장에 모두 담으세요.\n"
    "모든 작업이 끝나면 워커의 결과를 종합해 고객에게 답변하세요."
)
 
supervisor_agent = create_agent(
    model="openai:gpt-5.4",
    tools=[lookup_order, handle_refund],
    system_prompt=TOOL_SUPERVISOR_PROMPT,
)
 
result = supervisor_agent.invoke(
    {"messages": [HumanMessage(
        content="주문 24680이 아직도 안 왔어요. 문제가 있으면 환불해 주세요."
    )]}
)
 
print("\n\n[최종 응답]")
print(result["messages"][-1].content)

최종 처리 결과는 18.3과 같습니다.

[도구 호출] lookup_order
    request: 고객이 주문 24680이 아직 도착하지 않았다고 합니다.
    환불 필요 여부를 판단하려면 주문 24680의 상품, 결제 금액, 현재 배송 상태를 알려주세요.
[도구 호출] handle_refund
    request: 주문 24680은 상품명 노이즈캔슬링 헤드폰, 결제 금액 $249이며 배송 상태가 '배송 실패 (수취인 불명으로 반송됨)'로 확인되었습니다.
    고객이 환불을 요청하니 환불 대상 여부를 확인하고 가능하면 환불을 처리해 주세요.
 
 
[최종 응답]
확인해 보니 주문 24680은 배송 실패(수취인 불명으로 반송) 상태였습니다.
 
환불 가능 대상으로 확인되어 환불 처리까지 완료했습니다.
- 상품: 노이즈캔슬링 헤드폰
- 환불 금액: $249
- 환불 승인번호: RF-24680
 
보통 환불 반영까지는 결제수단에 따라 영업일 기준 며칠 걸릴 수 있습니다.

그런데 중간에 슈퍼바이저가 만든 도구 호출을 보면 18.3과 다른 점이 드러납니다. handle_refundrequest를 보세요. 슈퍼바이저가 앞선 조회 결과를 요약해서 작업 지시를 직접 작성했습니다. 환불 워커 에이전트는 이 한 문장만 받습니다. 18.3에서는 워커 에이전트에게 대화를 통째로 넘기고 알아서 찾아 읽게 했다면, 여기서는 필요한 것만 골라 전달합니다.

18.4.3) 슈퍼바이저에 체크포인터로 기억 붙이기

슈퍼바이저가 평범한 에이전트라는 말은 17장에서 배운 체크포인트를 그대로 사용할 수 있다는 뜻입니다.

python
from langgraph.checkpoint.memory import InMemorySaver
 
supervisor_agent = create_agent(
    model="openai:gpt-5.4",
    tools=[lookup_order, handle_refund],
    system_prompt=TOOL_SUPERVISOR_PROMPT,
    checkpointer=InMemorySaver(),      # 체크포인터는 최상위 에이전트에만
)
 
config = {"configurable": {"thread_id": "cs-1"}}
 
supervisor_agent.invoke(
    {"messages": [HumanMessage(content="주문 12345 어디까지 왔나요?")]},
    config,
)
follow_up = supervisor_agent.invoke(
    {"messages": [HumanMessage(content="그거 얼마짜리였죠?")]},
    config,
)
print(follow_up["messages"][-1].content)

출력:

주문 12345의 무선 이어폰은 $89입니다.

후속 질문의 "그거"가 앞선 질문의 "주문 12345"를 가리킨다는 것을 정확히 알고 있습니다. 체크포인터가 슈퍼바이저에도 그대로 동작하고 있습니다.

하위 워커 에이전트(order_agent, refund_agent)에는 체크포인터를 붙이지 마세요. 붙이면 워커가 앞선 호출의 작업 결과를 현재 작업까지 이어받아, 지금 업무 처리에 방해가 될 수 있습니다. 붙이지 않으면 워커는 슈퍼바이저의 요청만으로 작업을 수행합니다. 서브에이전트에는 이 방식이 권장 기본값입니다.

18.5) create_supervisor: 레거시 코드에서 만나는 형태

기존 코드베이스나 오래된 튜토리얼에서 langgraph-supervisor 패키지의 create_supervisor 헬퍼를 볼 수 있습니다. 에이전트 목록과 프롬프트만 넘기면 슈퍼바이저 그래프를 통째로 만들어 주는 함수입니다.

python
from langgraph_supervisor import create_supervisor
from langchain_openai import ChatOpenAI
 
workflow = create_supervisor(
    agents=[order_agent, refund_agent],   # 각 에이전트에 name이 지정되어 있어야 합니다
    model=ChatOpenAI(model="gpt-5.4"),
    prompt="주문 확인은 order_expert에게, 환불은 refund_expert에게 할당하세요.",
)
app = workflow.compile()

create_supervisor는 슈퍼바이저 팀을 함수 하나로 만들어 주는 헬퍼입니다(18.3의 핸드오프 방식). 다만 새 프로젝트에는 쓰지 마세요. LangChain이 더 이상 권장하지 않는 레거시이고, 내부적으로 v1에서 폐기된(deprecated) create_react_agent에 의존하기 때문입니다(제거는 v2 예정). LangChain은 앞에서 배운 도구 기반 슈퍼바이저(18.4)를 권장합니다.


이 장에서 우리는 그래프 하나에 담겨 있던 에이전트를, 도메인별로 나눈 뒤 슈퍼바이저가 조율하는 팀으로 확장했습니다. 같은 팀을 세 가지로 만들어 봤습니다 — 18.3의 수동 Command 그래프, 18.4의 도구 위임 방식, 18.5의 레거시 create_supervisor 헬퍼 함수입니다. 실무에서는 도구 위임 방식을 기본으로 사용하기 바랍니다.