서브에이전트
서브에이전트 (Subagents)
여러 개의 에이전트를 한꺼번에 다뤄야 하는 일이 많아지면서, 자연스럽게 드는 질문이 있어요. "하나의 큰 에이전트로 모든 걸 처리할까, 아니면 여러 에이전트를 나눠서 조율할까?" 이 페이지에서는 그중에서도 가장 흔한 구조인 서브에이전트(subagents) 아키텍처를 설명할게요. 중심이 되는 메인 에이전트(흔히 슈퍼바이저(supervisor) 라고 불러요)가 서브에이전트를 도구(tools)처럼 호출하면서 전체를 조율하는 방식이죠.
핵심 특징 (Key characteristics)
서브에이전트 아키텍처는 몇 가지 특징으로 요약할 수 있어요.
- 중앙집중식 제어 (Centralized control): 모든 라우팅이 메인 에이전트를 거쳐요.
- 사용자와의 직접 대화 없음 (No direct user interaction): 서브에이전트는 사용자에게가 아니라 메인 에이전트에게 결과를 돌려줘요. 다만 서브에이전트 안에서 interrupt를 써서 사용자와 상호작용하게 만들 수도 있어요.
- 도구를 통한 서브에이전트 호출 (Subagents via tools): 서브에이전트는 도구로 감싸져 호출돼요.
- 병렬 실행 (Parallel execution): 메인 에이전트는 한 턴 안에 여러 서브에이전트를 동시에 호출할 수 있어요.
슈퍼바이저 vs. 라우터: 이 패턴의 슈퍼바이저는 라우터(router)와 달라요. 슈퍼바이저는 대화 컨텍스트를 유지하면서 여러 턴에 걸쳐 어떤 서브에이전트를 부를지 동적으로 결정하는 완전한 에이전트예요. 반면 라우터는 보통 대화 상태를 유지하지 않고 단일 분류 단계로 작업을 에이전트에 배분하는 역할만 해요.
메인 에이전트가 어느 서브에이전트를 호출할지, 어떤 입력을 줄지, 그리고 결과를 어떻게 조합할지를 정해요. 서브에이전트는 무상태(stateless) 라서 과거 상호작용을 기억하지 못하고, 모든 대화 메모리는 메인 에이전트가 관리해요. 이 덕분에 컨텍스트 공학(context engineering) 관점에서 격리(isolation)가 생겨요. 서브에이전트를 호출할 때마다 깨끗한 컨텍스트 윈도우에서 작업이 진행되니까, 메인 대화의 컨텍스트가 부풀어 오르는 걸 막아주죠. 내장된 서브에이전트 지원이 필요하다면 Deep Agents를 살펴보세요.
언제 사용할까 (When to use)
여러 개의 뚜렷한 도메인(예: 캘린더, 이메일, CRM, 데이터베이스)이 있고, 서브에이전트가 사용자와 직접 대화할 필요가 없거나, 중앙에서 워크플로를 통제하고 싶을 때 서브에이전트 패턴을 쓰면 좋아요. 반대로 도구가 몇 개 없는 단순한 경우라면 단일 에이전트(single agent)를 쓰는 게 더 낫습니다.
서브에이전트 안에서 사용자 상호작용이 필요한가요? 서브에이전트는 보통 사용자와 직접 대화하기보다 메인 에이전트에게 결과를 돌려주지만, 서브에이전트 안에 interrupt를 걸어 실행을 멈추고 사용자 입력을 모을 수도 있어요. 진행하기 전에 명확히 하거나 승인을 받아야 할 때 유용하죠. 메인 에이전트는 여전히 오케스트레이터 역할을 하지만, 서브에이전트가 작업 중간에 사용자로부터 정보를 모을 수 있어요.
기본 구현 (Basic implementation)
핵심 메커니즘은 서브에이전트를 메인 에이전트가 부를 수 있는 도구로 감싸는 거예요.
from langchain.tools import tool
from langchain.agents import create_agent
# Create a subagent
subagent = create_agent(model="google_genai:gemini-3.6-flash", tools=[...])
# Wrap it as a tool
@tool("research", description="Research a topic and return findings")
def call_research_agent(query: str):
result = subagent.invoke({"messages": [{"role": "user", "content": query}]})
return result["messages"][-1].content
# Main agent with subagent as a tool
main_agent = create_agent(model="google_genai:gemini-3.6-flash", tools=[call_research_agent])
튜토리얼: 서브에이전트로 개인 비서 만들기에서 중심 메인 에이전트(슈퍼바이저)가 전문화된 워커 에이전트들을 조율하는 개인 비서를 만드는 법을 배울 수 있어요.
설계 결정 (Design decisions)
서브에이전트 패턴을 구현할 때 내려야 할 주요 설계 선택지를 아래 표로 정리했어요. 각 항목은 아래 섹션에서 자세히 다룹니다.
| 결정 | 선택지 |
|---|---|
| 동기 vs. 비동기 (Sync vs. async) | 동기(블로킹) vs. 비동기(백그라운드) |
| 도구 패턴 (Tool patterns) | 에이전트별 도구 vs. 단일 디스패치 도구 |
| 서브에이전트 명세 (Subagent specs) | 시스템 프롬프트 vs. Enum 제약 vs. 도구 기반 탐색(단일 디스패치 도구 전용) |
| 서브에이전트 입력 (Subagent inputs) | 쿼리만 vs. 전체 컨텍스트 |
| 서브에이전트 출력 (Subagent outputs) | 서브에이전트 결과 vs. 전체 대화 기록 |
동기 vs. 비동기 (Sync vs. async)
서브에이전트 실행은 동기(블로킹) 또는 비동기(백그라운드)로 할 수 있어요. 메인 에이전트가 결과를 기다려야 계속 진행할 수 있느냐에 따라 선택이 달라집니다.
| 모드 | 메인 에이전트 동작 | 언제 좋은가 | 트레이드오프 |
|---|---|---|---|
| Sync | 서브에이전트가 끝날 때까지 대기 | 메인 에이전트가 결과가 있어야 계속 진행할 때 | 단순하지만 대화가 블로킹됨 |
| Async | 서브에이전트가 백그라운드에서 도는 동안 계속 진행 | 독립적인 작업, 사용자가 기다리지 않아도 될 때 | 응답성은 좋지만 더 복잡함 |
여기서 말하는 async는 파이썬의 async/await와 혼동하면 안 돼요. 여기서 '비동기'란 메인 에이전트가 백그라운드 작업(보통 별도 프로세스나 서비스)을 시작하고 블로킹하지 않고 계속 진행하는 걸 뜻해요.
동기 (Synchronous, 기본값)
기본적으로 서브에이전트 호출은 동기예요. 메인 에이전트는 각 서브에이전트가 끝날 때까지 기다렸다가 계속 진행합니다. 메인 에이전트의 다음 동작이 서브에이전트 결과에 의존할 때 동기를 쓰세요.
동기를 쓸 때:
- 메인 에이전트가 응답을 만들기 위해 서브에이전트 결과가 필요할 때
- 작업에 순서 의존성이 있을 때 (예: 데이터 가져오기 → 분석 → 응답)
- 서브에이전트 실패가 메인 에이전트 응답을 막아야 할 때
트레이드오프:
- 구현이 단순해요. 그냥 호출하고 기다리면 되죠.
- 모든 서브에이전트가 끝날 때까지 사용자는 응답을 못 봐요.
- 오래 걸리는 작업은 대화를 얼어붙게 만들어요.
비동기 (Asynchronous)
서브에이전트의 작업이 독립적일 때, 즉 메인 에이전트가 결과 없이도 사용자와 계속 대화할 수 있을 때 비동기 실행을 써요. 메인 에이전트는 백그라운드 작업을 시작하고 응답성을 유지합니다.
비동기를 쓸 때:
- 서브에이전트 작업이 메인 대화 흐름과 독립적일 때
- 작업이 진행되는 동안 사용자가 계속 채팅할 수 있어야 할 때
- 여러 독립 작업을 병렬로 실행하고 싶을 때
세 가지 도구 패턴 (Three-tool pattern):
- 작업 시작 (Start job): 백그라운드 작업을 시작하고 job ID를 돌려줘요.
- 상태 확인 (Check status): 현재 상태(pending, running, completed, failed)를 돌려줘요.
- 결과 가져오기 (Get result): 완료된 결과를 가져와요.
작업 완료 처리 (Handling job completion): 작업이 끝나면 애플리케이션이 사용자에게 알려야 해요. 한 가지 방법은 알림을 띄우고, 그걸 클릭하면 "Check job_123 and summarize the results" 같은 HumanMessage를 보내는 거예요.
도구 패턴 (Tool patterns)
서브에이전트를 도구로 노출하는 방법은 두 가지가 있어요.
| 패턴 | 언제 좋은가 | 트레이드오프 |
|---|---|---|
| 에이전트별 도구 (Tool per agent) | 각 서브에이전트의 입력/출력을 세밀하게 제어할 때 | 설정은 더 많지만 커스터마이즈가 자유로움 |
| 단일 디스패치 도구 (Single dispatch tool) | 에이전트가 많고, 팀이 분산돼 있으며, 관례 기반 설정을 선호할 때 | 조합은 단순하지만 에이전트별 커스터마이즈는 적음 |
에이전트별 도구 (Tool per agent)
핵심 아이디어는 서브에이전트를 메인 에이전트가 부를 수 있는 도구로 감싸는 거예요.
from langchain.tools import tool
from langchain.agents import create_agent
# Create a sub-agent
subagent = create_agent(model="...", tools=[...])
# Wrap it as a tool
@tool("subagent_name", description="subagent_description")
def call_subagent(query: str):
result = subagent.invoke({"messages": [{"role": "user", "content": query}]})
return result["messages"][-1].content
# Main agent with subagent as a tool
main_agent = create_agent(model="...", tools=[call_subagent])
메인 에이전트는 작업이 서브에이전트의 description과 맞는다고 판단하면 서브에이전트 도구를 호출하고, 결과를 받아 오케스트레이션을 계속해요. 세밀한 제어가 필요하면 컨텍스트 공학을 참고하세요.
단일 디스패치 도구 (Single dispatch tool)
또 다른 접근 방식은 단일 파라미터화된 도구를 써서 독립 작업을 위한 임시(ephemeral) 서브에이전트를 호출하는 거예요. 에이전트별 도구 방식이 각 서브에이전트를 별도의 도구로 감싸는 것과 달리, 이 방식은 관례 기반(convention-based)으로 단일 task 도구를 사용해요. 작업 설명을 서브에이전트에 휴먼 메시지로 전달하고, 서브에이전트의 마지막 메시지를 도구 결과로 돌려받죠.
이 방식을 쓰면 여러 팀이 에이전트 개발을 분산해서 할 수 있고, 복잡한 작업을 별도 컨텍스트 윈도우로 격리하며, 코디네이터를 수정하지 않고도 확장 가능하게 새 에이전트를 추가할 수 있어요. 컨텍스트 공학의 유연성보다는 에이전트 조합의 단순함과 강한 컨텍스트 격리를 우선할 때 적합하죠.
Agent registry with task dispatcher
from langchain.tools import tool
from langchain.agents import create_agent
# Sub-agents developed by different teams
research_agent = create_agent(
model="gpt-5.5",
prompt="You are a research specialist..."
)
writer_agent = create_agent(
model="gpt-5.5",
prompt="You are a writing specialist..."
)
# Registry of available sub-agents
SUBAGENTS = {
"research": research_agent,
"writer": writer_agent,
}
@tool
def task(
agent_name: str,
description: str
) -> str:
"""Launch an ephemeral subagent for a task.
Available agents:
- research: Research and fact-finding
- writer: Content creation and editing
"""
agent = SUBAGENTS[agent_name]
result = agent.invoke({
"messages": [\
{"role": "user", "content": description}\
]
})
return result["messages"][-1].content
# Main coordinator agent
main_agent = create_agent(
model="gpt-5.5",
tools=[task],
system_prompt=(
"You coordinate specialized sub-agents. "
"Available: research (fact-finding), "
"writer (content creation). "
"Use the task tool to delegate work."
),
)
핵심 특징:
- 단일 task 도구: 이름으로 등록된 어떤 서브에이전트든 호출할 수 있는 파라미터화된 도구 하나
- 관례 기반 호출: 이름으로 에이전트를 선택하고, 작업을 휴먼 메시지로 전달하며, 마지막 메시지를 도구 결과로 반환
- 팀 분산: 서로 다른 팀이 독립적으로 에이전트를 개발·배포할 수 있어요
- 에이전트 탐색: 시스템 프롬프트로 이용 가능한 에이전트를 나열하거나, 점진적 공개(progressive disclosure)를 통해 도구로 온디맨드 로드할 수 있어요
이 방식의 흥미로운 점은 서브에이전트가 메인 에이전트와 완전히 동일한 능력을 가질 수도 있다는 거예요. 그런 경우 서브에이전트 호출의 주된 이유는 사실상 컨텍스트 격리예요. 복잡하고 여러 단계로 이루어진 작업을 메인 에이전트의 대화 기록을 부풀리지 않고 격리된 컨텍스트 윈도우에서 실행할 수 있죠. 서브에이전트는 자율적으로 작업을 완료하고 간결한 요약만 돌려주므로, 메인 스레드를 집중적이고 효율적으로 유지해줘요.
컨텍스트 공학 (Context engineering)
메인 에이전트와 서브에이전트 사이의 컨텍스트 흐름을 제어하는 방법을 정리했어요.
| 분류 | 목적 | 영향 |
|---|---|---|
| 서브에이전트 명세 | 서브에이전트가 호출되어야 할 때 호출되도록 보장 | 메인 에이전트 라우팅 결정 |
| 서브에이전트 입력 | 최적화된 컨텍스트로 서브에이전트가 잘 실행되도록 보장 | 서브에이전트 성능 |
| 서브에이전트 출력 | 슈퍼바이저가 서브에이전트 결과를 바탕으로 행동할 수 있도록 보장 | 메인 에이전트 성능 |
에이전트를 위한 컨텍스트 공학 종합 가이드도 참고하세요.
서브에이전트 명세 (Subagent specs)
서브에이전트에 붙는 이름(name) 과 설명(description) 은 메인 에이전트가 어떤 서브에이전트를 호출할지 아는 가장 기본적인 수단이에요. 이건 프롬프팅 레버이므로 신중히 골라야 합니다.
- 이름 (Name): 메인 에이전트가 서브에이전트를 부르는 이름. 명확하고 행동 지향적으로 지으세요. (예:
research_agent,code_reviewer) - 설명 (Description): 메인 에이전트가 알게 되는 서브에이전트의 능력. 어떤 작업을 처리하고 언제 쓰는지 구체적으로 적으세요.
단일 디스패치 도구 설계에서는 메인 에이전트에 호출 가능한 서브에이전트 정보를 추가로 제공해야 해요. 에이전트 수와 레지스트리가 정적/동적인지에 따라 여러 방식으로 제공할 수 있습니다.
| 방법 | 언제 좋은가 | 트레이드오프 |
|---|---|---|
| 시스템 프롬프트 나열 (System prompt enumeration) | 작고 정적인 에이전트 목록(< 10개) | 단순하지만 에이전트가 바뀌면 프롬프트를 업데이트해야 함 |
| Enum 제약 (Enum constraint) | 작고 정적인 에이전트 목록(< 10개) | 타입 안전하고 명시적이지만 에이전트가 바뀌면 코드를 바꿔야 함 |
| 도구 기반 탐색 (Tool-based discovery) | 크거나 동적인 에이전트 레지스트리 | 유연하고 확장 가능하지만 복잡도가 늘어남 |
시스템 프롬프트 나열 (System prompt enumeration)
메인 에이전트의 시스템 프롬프트에 이용 가능한 에이전트를 직접 나열해요. 메인 에이전트는 지시의 일부로 에이전트 목록과 설명을 보게 됩니다.
언제 쓸까:
- 작고 고정된 에이전트 집합(< 10)일 때
- 에이전트 레지스트리가 거의 바뀌지 않을 때
- 가장 단순한 구현을 원할 때
예시:
main_agent = create_agent(
model="...",
tools=[task],
system_prompt=(
"You coordinate specialized sub-agents. "
"Available agents:\n"
"- research: Research and fact-finding\n"
"- writer: Content creation and editing\n"
"- reviewer: Code and document review\n"
"Use the task tool to delegate work."
),
)
디스패치 도구에 Enum 제약 (Enum constraint on dispatch tool)
디스패치 도구의 agent_name 파라미터에 Enum 제약을 추가해요. 이렇게 하면 타입 안전성이 생기고, 이용 가능한 에이전트가 도구 스키마에 명시적으로 드러납니다.
언제 쓸까:
- 작고 고정된 에이전트 집합(< 10)일 때
- 타입 안전성과 명시적인 에이전트 이름을 원할 때
- 프롬프트 기반 안내보다 스키마 기반 검증을 선호할 때
예시:
from enum import Enum
class AgentName(str, Enum):
RESEARCH = "research"
WRITER = "writer"
REVIEWER = "reviewer"
@tool
def task(
agent_name: AgentName, # Enum constraint
description: str
) -> str:
"""Launch an ephemeral subagent for a task."""
# ...
도구 기반 탐색 (Tool-based discovery)
메인 에이전트가 온디맨드로 이용 가능한 에이전트를 발견할 수 있게 별도의 도구(예: list_agents 또는 search_agents)를 제공해요. 이 방식은 점진적 공개(progressive disclosure)를 가능하게 하고 동적 레지스트리를 지원합니다.
언제 쓸까:
- 에이전트가 많거나(> 10) 레지스트리가 계속 늘어날 때
- 에이전트 레지스트리가 자주 바뀌거나 동적일 때
- 프롬프트 크기와 토큰 사용량을 줄이고 싶을 때
- 서로 다른 팀이 다른 에이전트를 독립적으로 관리할 때
예시:
@tool
def list_agents(query: str = "") -> str:
"""List available subagents, optionally filtered by query."""
agents = search_agent_registry(query)
return format_agent_list(agents)
@tool
def task(agent_name: str, description: str) -> str:
"""Launch an ephemeral subagent for a task."""
# ...
main_agent = create_agent(
model="...",
tools=[task, list_agents],
system_prompt="Use list_agents to discover available subagents, then use task to invoke them."
)
서브에이전트 입력 (Subagent inputs)
서브에이전트가 작업을 수행하기 위해 받는 컨텍스트를 커스터마이즈할 수 있어요. 정적 프롬프트로 담기 어려운 입력(전체 메시지 기록, 이전 결과, 작업 메타데이터 등)은 에이전트의 state에서 끌어와 추가할 수 있습니다.
from langchain.agents import AgentState
from langchain.tools import tool, ToolRuntime
class CustomState(AgentState):
example_state_key: str
@tool(
"subagent1_name",
description="subagent1_description"
)
def call_subagent1(query: str, runtime: ToolRuntime[None, CustomState]):
# Apply any logic needed to transform the messages into a suitable input
subagent_input = some_logic(query, runtime.state["messages"])
result = subagent1.invoke({
"messages": subagent_input,
# You could also pass other state keys here as needed.
# Make sure to define these in both the main and subagent's
# state schemas.
"example_state_key": runtime.state["example_state_key"]
})
return result["messages"][-1].content
서브에이전트 출력 (Subagent outputs)
메인 에이전트가 좋은 결정을 내릴 수 있도록 되돌려받는 내용을 커스터마이즈할 수 있어요. 두 가지 전략이 있습니다.
- 서브에이전트를 프롬프트: 무엇을 돌려줘야 하는지 정확히 지정하세요. 흔한 실패 모드는 서브에이전트가 도구 호출이나 추론을 수행한 뒤 결과를 최종 메시지에 담지 않는 거예요. 슈퍼바이저는 최종 출력만 본다는 점을 상기시켜 주세요.
- 코드에서 포맷: 반환 전에 응답을 조정하거나 풍부하게 만들 수 있어요. 예를 들어 최종 텍스트 외에 특정 state 키를
Command로 함께 전달할 수 있습니다.
from typing import Annotated
from langchain.agents import AgentState
from langchain.tools import InjectedToolCallId
from langgraph.types import Command
@tool(
"subagent1_name",
description="subagent1_description"
)
def call_subagent1(
query: str,
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
result = subagent1.invoke({
"messages": [{"role": "user", "content": query}]
})
return Command(update={
# Pass back additional state from the subagent
"example_state_key": result["example_state_key"],
"messages": [\
ToolMessage(\
content=result["messages"][-1].content,\
tool_call_id=tool_call_id\
)\
]
})
체크포인팅과 상태 검사 (Checkpointing and state inspection)
기본적으로 서브에이전트는 상속된 체크포인터(inherited checkpointer) 모드를 사용해요. 각 호출이 깨끗한 상태로 시작하고, interrupt를 지원하며, 병렬로 안전하게 실행됩니다. 서브에이전트가 호출 간에 자체 영구 대화 기록을 유지해야 한다면 checkpointer=True(continuations 모드)로 컴파일하세요. 각 모드의 전체 비교는 서브그래프 지속성(subgraph persistence)을 참고하세요.
서브에이전트는 도구 함수 안에서 호출되기 때문에 LangGraph가 서브에이전트를 정적으로 발견할 수 없어요. 즉 subgraphs와 함께 get_state를 쓰면 서브에이전트 상태가 반환되지 않아요. 중첩 그래프 상태를 읽어야 한다면(예: interrupt 중), 커스텀 그래프의 노드 함수에서 서브에이전트를 호출하세요. 각 모드가 상태 가시성에 미치는 영향은 서브그래프 지속성에서 자세히 확인할 수 있어요.
langgraph-supervisor에서 마이그레이션 — langgraph-supervisor 패키지는 더 이상 적극적으로 유지보수되지 않아요. create_supervisor에서 서브에이전트 패턴으로, 외부 API 콜백을 이용한 interrupt·resume 흐름까지 어떻게 마이그레이션하는지 배울 수 있습니다.