일을 맡길 때와 제어권을 넘길 때는 무엇이 다를까요?

고객 센터에서 상담원이 환불 문의를 받았다고 해 봅니다. 상담원이 환불 담당 직원에게 물어보고 답을 전해 줄 수도 있고, 전화를 환불 담당 직원에게 돌려 고객이 직접 이야기하게 할 수도 있습니다. 앞의 것을 맡기기, 뒤의 것을 넘기기라고 합니다.

Agent 프로그램에서는 이 둘이 Subagent와 Handoff입니다. 둘 다 전문 Agent를 쓰지만 고객이 누구와 말하는지가 다릅니다. 방식을 바꿔 가며 단계를 눌러 보세요.

방식

설명을 위한 예시 장면입니다. 이름과 문구는 이 글의 예제에서 따왔습니다.

하위 Agent가 도구를 쓴 횟수

맡기면 중간 과정은 하위 Agent 안에 남습니다

Subagent 방식은 하위 Agent를 도구로 감싸서 총괄 Agent에게 줍니다. 총괄 Agent가 그 도구를 부르면 하위 Agent가 자기 창에서 일하고 마지막 메시지만 돌려줍니다. 중간에 어떤 도구를 몇 번 썼는지는 총괄에게 보이지 않습니다.

덕분에 총괄의 대화가 길어지지 않습니다. 대신 하위 Agent는 사용자와 직접 말하지 못하고 기본적으로 이전 호출을 기억하지 못합니다. 그래서 하위 Agent의 프롬프트에 결과를 마지막 메시지에 쓰라고 적어 두어야 합니다.

subagent.pypython
billing = create_agent(model, tools=[lookup_refund],
    system_prompt="청구 전문 담당이다. 결과를 마지막 메시지에 쓴다.")  # 1

@tool("billing_agent",
      description="환불·결제 문의를 청구 전문 Agent에게 맡긴다")   # 2
def call_billing(query: str) -> str:
    msg = {"role": "user", "content": query}
    r = billing.invoke({"messages": [msg]})
    return r["messages"][-1].content               # 3 마지막만 돌려줌

main = create_agent(model, tools=[call_billing],
    system_prompt="지원 총괄이다. 전문 Agent에게 맡긴다.")
터미널
$ python subagent.py 총괄 메시지 4개: Human → AI(도구 호출) → Tool → AI 답: 청구 담당 확인: 환불은 영업일 3일 안에 처리됩니다.

출력은 예시입니다. 모의 서버로 확인한 결과이고 실제 모델의 답은 다릅니다. 코드는 langchain 1.4 기준이며 lookup_refund 도구 정의는 생략했습니다.

  1. 하위 Agent를 만듭니다. 앞에서 배운 create_agent로 청구 전담 Agent를 만들었습니다. 프롬프트에 결과를 마지막 메시지에 쓰라고 적었습니다. 총괄은 그 메시지만 받기 때문입니다.
  2. 도구로 감쌉니다. 총괄이 어느 하위 Agent를 부를지는 도구의 이름과 설명을 단서로 정합니다. 이름은 하는 일이 드러나게, 설명은 언제 쓰는지 구체적으로 적습니다.
  3. 마지막 메시지만 돌려줍니다. result["messages"][-1].content가 총괄이 받는 전부입니다. 하위 Agent의 도구 호출과 생각은 여기서 끊깁니다.

넘기면 같은 Agent의 모습이 단계에 따라 바뀝니다

LangChain이 권하는 Handoff는 Agent를 여러 개 만드는 대신 Agent 하나에 단계 값을 두는 방식입니다. State에 current_step 칸을 두고 도구가 이 값을 바꿉니다. 미들웨어는 모델을 부르기 직전에 이 값을 읽어 시스템 프롬프트와 쓸 수 있는 도구를 갈아 끼웁니다.

사용자 눈에는 담당 직원이 바뀐 것처럼 보여도 코드에서는 같은 Agent가 옷만 바꿔 입었을 뿐입니다. 단계 값은 체크포인터에 저장되어 다음 대화에서도 이어집니다. 스크롤하며 모델 호출 틀이 바뀌는 순간을 보세요.

접수 단계로 모델을 부름

current_step이 triage이니 미들웨어가 접수용 프롬프트와 record_issue_type 도구 하나만 모델에 줍니다.

모델이 도구를 부름

모델이 "청구 문제"라고 판단해 record_issue_type("billing")을 부릅니다. 이 도구가 단계를 바꾸는 열쇠입니다.

도구가 State를 바꿈

도구는 Command로 current_step을 billing으로 고치고 모델이 부른 도구에 답이 있도록 ToolMessage도 함께 돌려줍니다.

다음 호출은 청구 단계 틀

같은 Agent가 다시 모델을 부를 때 미들웨어가 청구용 프롬프트와 refund_status 도구로 틀을 갈아 끼웁니다.

handoff.pypython
class SupportState(AgentState):                   # 1 단계를 담는 칸
    current_step: str
    issue_type: str

@tool
def record_issue_type(issue_type: str, runtime: ToolRuntime) -> Command:
    return Command(update={                       # 2 도구가 State를 고친다
        "messages": [ToolMessage(content=f"기록: {issue_type}",
                                 tool_call_id=runtime.tool_call_id)],
        "issue_type": issue_type, "current_step": "billing"})

@wrap_model_call
def by_step(request, handler):                    # 3 모델 호출 직전에 갈아 끼움
    prompt, tools = STEPS[request.state.get("current_step", "triage")]
    return handler(request.override(
        system_prompt=prompt.format(**request.state), tools=tools))
터미널
$ python handoff.py 1턴 처음 모델이 본 도구: ['record_issue_type'] 1턴 끝: current_step = billing · issue_type = billing 그다음 모델이 본 도구: ['refund_status']

출력은 예시입니다. 모의 서버가 받은 요청에서 도구 목록을 읽어 확인했습니다. STEPS는 단계별 프롬프트와 도구를 묶은 사전이며 생략했습니다.

  1. 단계를 State에 둡니다. AgentState를 이어받아 current_step을 더했습니다. 값이 바뀌면 다음 모델 호출의 모습이 달라집니다.
  2. 도구가 단계를 바꿉니다. 도구가 Command(update=…)를 돌려주면 State가 고쳐집니다. 모델이 도구를 불렀으니 그 호출의 답으로 ToolMessage를 꼭 함께 넣고 tool_call_id를 맞춥니다. 빠지면 대화 기록이 어긋납니다.
  3. 미들웨어가 틀을 갈아 끼웁니다. wrap_model_call은 모델을 부르기 직전에 실행됩니다. 단계에 맞는 프롬프트와 도구로 요청을 덮어쓰므로 모델은 그 단계에서 허용된 도구만 봅니다.

어느 쪽을 쓸지는 사용자가 누구와 말하느냐로 정합니다

여러 전문가의 결과를 모아 한 번에 답하거나 전문가를 동시에 돌려야 하면 Subagent입니다. 사용자가 접수, 청구, 배송처럼 단계를 따라 한 전문가와 이어서 대화하면 Handoff입니다. 일을 하나 골라 보세요.

예전에는 langgraph-supervisor 라이브러리로 총괄 Agent를 만들었습니다. LangChain의 이전 안내서는 이 패키지가 더 이상 적극적으로 관리되지 않는다고 밝히고 대신 Subagents 패턴을 권합니다. 총괄 Agent가 전문 일꾼을 도구로 부르는 방식입니다.

다음 글에서는 긴 일을 하는 Agent가 길을 잃지 않도록 계획을 적고 파일에 정리하는 Deep Agents를 알아보겠습니다.

참고 자료

출처 4개 펼치기