안녕하세요 오랜만에 글을 쓰게 됩니다. 요즘 에이전트 개발을 하면서 Handsoff의 기능에 대해 알아볼까 합니다. 최근에 다시 Agent 개발을 하면서 멀티에이전트 패턴을 구축할 때 사용하고 있다 보니 프레임워크별로 다른 점을 발견하였고 이를 정리해보려고 합니다. 작년만 해도 이런 개념이 없었던 것 같은데, handoff 개념이 생긴 뒤 명확한 라우팅을 해주는 게 생겨서 에이전트 고도화에 유용해 보입니다.
Handoff는 멀티 에이전트 시스템에서 한 에이전트가 자신의 역할이 아니거나 더 적합한 다른 에이전트가 있다고 판단될 때, 해당 작업(제어권과 상태 정보 포함)을 다른 에이전트에게 넘기는 패턴입니다. 이 방식은 복잡한 문제를 여러 전문 에이전트가 협업해 효율적으로 해결할 수 있게 해 줍니다.
우선 먼저 에이전트 별로 예시를 한번 구현해 보겠습니다.
AutoGen
Handoffs — AutoGen
Handoffs Handoff is a multi-agent design pattern introduced by OpenAI in an experimental project called Swarm. The key idea is to let agent delegate tasks to other agents using a special tool call. We can use the AutoGen Core API to implement the handoff p
microsoft.github.io
오토젠 같은 경우는 이벤트기반 pub-sub 통신을 통해 핸드오프를 구현을 하게 됩니다.
1. 메시지 프로토콜
AutoGen에서는 세 가지 핵심 메시지 타입을 사용하게 됩니다. 일반적으로 에이전트 시스템의 멀티턴 구현체를 위한 context들을 선언을 해주게 됩니다.
class UserTask(BaseModel):
contexct: List[ChatMessage] # 대화 history
class AgentResponse(BaseModel):
reply_to_topic_type: str
context : List[ChatMessage] # 응답과 함께 컨텍스트 전달
class User Login(BaseModel):
pass
2. 핸드오프 실행 과정
1. 작업수신 : 에이전트가 UserTask 메시지를 받아서 처리 시작
2. 판단 및 분류 : LLM 이 작업 내용을 분석해서 적절한 처리 방법 결정(Routing)
3. 위임 결정 : 다른 에이전트가 더 적합하다고 판단되면 delegate_tool 호출
4. 메시지 발행 : 대상 에이전트의 토픽으로 UserTask 메시지 publish
5. 상태 전달 : 전체 대화 히스토리와 콘텍스트를 함께 전달
3. 구현 예시
랭그래프에서의 supervisor 에이전트의 개념을 예로 들어 KT Agent를 구축한다고 가정을 했을 때 상담분류 시스템 Agent를 구축해 보았습니다.
상위 Agent
# Supervisior
kt_supervisor_agent = AIAgent(
system_message="""
KT 고객센터 상담 분류 시스템입니다.
고객 문의를 분석하여 가장 적절한 전문 상담 에이전트로 연결하세요.
- 모바일/5G 관련: 요금제, 기기, 통화품질
- 인터넷 관련: KT인터넷, WiFi, 속도 문제
- 요금/결제: 청구서, 납부, 할인 혜택
- 기술지원: 설정, 장애, 설치 문의
""",
delegate_tools=[
transfer_to_mobile_agent_tool, # 모바일 서비스
transfer_to_internet_agent_tool, # 인터넷/WiFi 관련
transfer_to_billing_agent_tool, # 요금/결제 관련
transfer_to_tech_support_tool, # 기술 지원
escalate_to_human_tool # 복잡한 케이스
]
)
기존의 Tool들을 인티그레이션 해서 Agent를 붙였던 개념이 익숙하실 텐데 에이전트 자체를 하나의 툴로 호출을 해서 이쪽으로 호출하도록 지정을 하고 그다음 워크플로우가 이어지도록 delegate_tools라는 개념을 도입을 했습니다. 결국 delegate_tools는 langgraph에서의 command 역할을 해주게 됩니다. 어떻게 보면 더 자유롭게 커스터마이징이 가능하다는 점이 장점 같습니다.
하위 Agent
# 모바일 전문 상담 에이전트
kt_mobile_agent = AIAgent(
system_message="""
KT 모바일 서비스 전문 상담사입니다.
5G/LTE 요금제, 기기 변경, 로밍, 부가서비스 등을 담당합니다.
## 처리 가능한 문의:
- 모바일 요금제 변경/추천
- 기기 변경 및 구매
- 데이터/통화/문자 사용량 조회
- 로밍 서비스
- 모바일 부가서비스 (컬러링, 안심번호 등)
## 다른 에이전트로 연결해야 하는 경우:
- 인터넷/WiFi 관련 문의 → transfer_back_to_supervisor 사용
- 요금 청구서/결제 문제 → transfer_back_to_supervisor 사용
- 기술적 장애 (통화 불가, 데이터 연결 안됨) → transfer_back_to_supervisor 사용
- 복잡한 기업 계약 관련 → escalate_to_human 사용
- 고객이 직접 상담사 요청 시 → escalate_to_human 사용
모바일 서비스 범위를 벗어나는 문의는 반드시 적절한 도구를 사용해 연결하세요.
""",
tools=[
check_customer_plan_tool, # 현재 요금제 조회
recommend_plan_tool, # 맞춤 요금제 추천
process_plan_change_tool, # 요금제 변경 처리
check_data_usage_tool # 데이터 사용량 조회
],
delegate_tools=[transfer_back_to_supervisor_tool, escalate_to_human_tool]
)
하위 세부 에이전트입니다. 인터넷, 모바일 에이전트 역시 본인이 활용해야 할 tools들이 구축이 되어있고, 다시 슈퍼바이저 툴로 호출해서 판단을 다시 하도록 하는 핸즈오프 방식으로 구축을 했습니다. 기존에는 알아서 판단해서, supervisior에게 판단을 맡겼다면 프롬프트로 모바일 기기 전문 상담에이전트이기 때문에 관련한 상담만 가능하도록 처리를 할 수 있게 다른 장애문제가 오면 슈퍼바이저로 보내고, 복잡한 내용은 실제 상담사 함수로 호출하도록 하는 로직입니다. 여기서도 허점을 생각해 볼 수 있는 건 요금제 기반 기기 가격 상담이라고 하면 유연하게 둘이 핸즈오프되고 소통할 수 있도록 프롬프트를 정의를 다시 해주어야 할 것 같습니다
# 핸드오프 도구 정의
def transfer_to_mobile_agent() -> str:
return "KT_MobileAgent"
def transfer_to_internet_agent() -> str:
return "KT_InternetAgent"
def transfer_to_billing_agent() -> str:
return "KT_BillingAgent"
def transfer_back_to_triage() -> str:
return "KT_SupervisiorAgent"
def escalate_to_human() -> str:
return "KT_HumanAgent"
# 도구 등록
transfer_to_mobile_tool = FunctionTool(
transfer_to_mobile_agent,
description="5G, LTE, 요금제, 기기 변경, 로밍 등 모바일 서비스 관련 문의"
)
transfer_to_internet_tool = FunctionTool(
transfer_to_internet_agent,
description="KT 인터넷, 기가 WiFi, 공유기, 속도/연결 문제 등"
)
transfer_to_billing_tool = FunctionTool(
transfer_to_billing_agent,
description="요금 조회, 결제, 청구서, 할인 혜택 등 요금 관련 문의"
)
transfer_back_to_supervisor_tool = FunctionTool(
transfer_back_to_triage,
description="다른 업무 영역 문의나 복잡한 케이스 시 상담 분류 에이전트로 다시 연결"
)
escalate_to_human_tool = FunctionTool(
escalate_to_human,
description="AI로 해결이 어려운 복잡한 문의나 고객이 직접 요청한 경우 인간 상담사 연결"
)
LLM은 정의된 툴의 함수를 호출해서 리턴값을 받고 , FunctionTooL 기능을 통해 접근해서 툴을 사용하게 되는 구조라고 생각하시면 됩니다.
예시)
고객: "인터넷이 자꾸 끊어져요"
↓
KT SuperVisior Agent: 인터넷 관련 문의 감지 → transfer_to_internet_agent() 호출
↓
KT Internet Agent: "어떤 지역이고 언제부터 문제가 있었나요?" → 상세 정보 수집
↓
고객: "강남구인데 어제부터 계속 끊어져요"
↓
KT Internet Agent: check_network_status() → run_diagnostics() → "해당 지역 장애 복구 중, 2시간 내 정상화 예정입니다"
예시 2) 크로스 도메인 핸드오프
고객: "5G 요금제로 바꾸고 싶어요"
↓
KT supervisor Agent: 모바일 요금제 변경 → transfer_to_mobile_agent() 호출
↓
KT Mobile Agent: "현재 요금제와 월 데이터 사용량을 알려주세요" → 맞춤 요금제 추천
↓
고객: "그런데 이번 달 요금이 왜 이렇게 나왔는지도 궁금해요"
↓
KT Mobile Agent: 요금 문의 감지 → transfer_back_to_supervisor() 호출 (범위 벗어남)
↓
KT supervisor Agent: 요금 관련 문의 → transfer_to_billing_agent() 호출
↓
KT Billing Agent: "요금 명세를 확인해드리겠습니다" → check_bill_details() 실행
OpenAI Agent SDK
OpenAI에서 처음 handoff 기능을 도입하였고, 거의 SDK별로 도입이 되는 것 같다. 근데 GOAT 답게 정말 쉽게 구현이 가능하다. 이번에 사이드 프로젝트를 하면서 OpenAI SDK를 쓰고 있는데 정말 생각보다 잘 만들어 놨다. 역시 GPT를 만든 곳이라 그런지.. 가드레일부터 요물이다.
OpenAI Agents SDK
OpenAI Agents SDK The OpenAI Agents SDK enables you to build agentic AI apps in a lightweight, easy-to-use package with very few abstractions. It's a production-ready upgrade of our previous experimentation for agents, Swarm. The Agents SDK has a very smal
openai.github.io
from openai.beta.assistants import Assistant, Thread, run_sync, Handoff
# 하위 에이전트 정의
billing_assistant = Assistant.create(name="KT 요금 상담원", instructions="요금 조회 및 결제 관련 업무를 처리합니다.")
tech_support_assistant = Assistant.create(name="KT 기술 지원 상담원", instructions="인터넷, TV 등 기술 문제를 해결합니다.")
# 초기 접수 어시스턴트: 상황에 따라 핸드오프
Supervisor_assistant = Assistant.create(
name="KT 종합 상담원",
instructions="고객의 요청을 파악하여, 요금 문제는 '요금 상담원'에게, 기술 문제는 '기술 지원 상담원'에게 핸드오프하세요.",
tools=[billing_assistant, tech_support_assistant] # 다른 어시스턴트를 도구로 등록
)
thread = Thread.create()
run = run_sync(supervisor_assistant, thread, "인터넷 속도가 너무 느려요.")
print(run.final_output)
비교적 간단하게 구현체가 구현이 된다. 이 방식의 장점은 지금 현재 프로젝트에서도 구현을 해놨는데 supervisor 자체가 라우팅을 하면서 서비스하는 tools 에이전트로 쓰게 하는 게 맘에 들었다. instruction에 역할을 부여하면서 라우팅도 하도록 설정이 가능하기 때문에 유연성이 좋다는 느낌을 받았다. 또 그렇다면 할루시네이션이 발생했을 때 그냥 supervisor 때문에 할루시네이션이 생길 수도 있단 생각이 들었다 결국 프롬프트를 잘 체계화해서 넣어야 할 것 같다. 조금 더 고도화된 독스의 방식을 살펴본다면
from agents import Agent, handoff, RunContextWrapper
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX
import asyncio
# 1. 하위 에이전트 정의
# 각자의 전문 분야를 가진 에이전트들입니다.
billing_agent = Agent(
name="KT_요금_상담원",
instructions="당신은 요금 조회, 결제 내역 확인, 요금제 변경에 따른 비용 안내 등 오직 요금 관련 업무만 처리하는 전문가입니다."
)
tech_support_agent = Agent(
name="KT_기술_지원_상담원",
instructions="당신은 인터넷 연결 문제, 속도 저하, IPTV 장애 등 기술적인 문제를 진단하고 해결책을 제시하는 전문가입니다."
)
# 2. 핸드오프 정의
# on_handoff 콜백 함수: 기술 지원 핸드오프가 호출되는 순간 실행됩니다.
async def prepare_for_tech_support(ctx: RunContextWrapper[None]):
"""기술 지원을 위해 필요한 데이터를 미리 준비하는 콜백 함수입니다."""
print("\n[Callback Executed]: 기술 지원팀으로 연결합니다. 고객님의 서비스 상태를 미리 조회합니다...")
# 실제 애플리케이션에서는 여기서 DB를 조회하거나 API를 호출하여
# 고객의 가입 정보를 가져오는 등의 비동기 작업을 수행할 수 있습니다.
# UI에 뿌려주는 역할 을 할 때 사용하면 좋다.
await asyncio.sleep(1) # 비동기 작업을 시뮬레이션
# handoff() 함수를 사용해 각 핸드오프를 '도구'처럼 정의합니다.
billing_handoff = handoff(
agent=billing_agent,
tool_name_override="transfer_to_billing_specialist", # LLM이 호출할 도구 이름 지정
tool_description_override="요금, 결제, 청구서 관련 문의일 경우에만 사용하세요." # LLM에게 명확한 가이드라인 제공
)
tech_support_handoff = handoff(
agent=tech_support_agent,
tool_name_override="request_technical_support",
tool_description_override="인터넷 끊김, 속도 저하, TV 문제 등 기술적인 도움이 필요할 경우에만 사용하세요.",
on_handoff=prepare_for_tech_support # 핸드오프 시 콜백 함수 연결
)
#3. 에이전트 정의
# 에이전트들을 감독하고 작업을 분배하는 역할입니다.
supervisor_agent = Agent(
name="KT_종합_상담원_Supervisor",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
당신은 KT의 종합 상담원이자 다른 전문가 에이전트들을 관리하는 감독관입니다.
고객의 요청을 명확히 파악한 후, 당신이 가진 도구(전문가 핸드오프)를 사용하여 가장 적합한 전문가에게 작업을 위임하세요.
당신의 역할은 정확한 라우팅입니다. 직접 답변하려 하지 마세요.
""",
handoffs=[ # handoffs 파라미터에 정의된 핸드오프 객체들을 전달
billing_handoff,
tech_support_handoff,
]
)
async def run_scenario():
user_query_1 = "인터넷이 너무 자주 끊겨서 불편해요. 어떻게 해야 하나요?"
# tech_support_handoff가 호출될 것으로 예상
supervisor_agent.run(user_query_1)
# 비동기 함수 실행
if __name__ == "__main__":
asyncio.run(run_scenario())
handoff() 함수를 사용해서 명확히 핸드오프를 지정해서 사용할 수 있다. 이는 핸드오프 설정 객체를 별도로 만들고, 도구를 제어하는 측면에서 인스트럭션에 프롬프팅을 해주어서 쓰면 supervisor agent에게 도움이 된다. 위에서 지적한 문제를 어느 정도 해소할 수 있지 않을까 생각이 든다. 안에 메서드 들은 따로 agent docs를 읽어보면 쉽게 구현이 가능하다. 사실 제일 쉽게 핸즈오프를 구현가능하지 않나 생각이 든다..!
LangGraph
랭그래프는 Agent계의 Goat 점유율이 사실 압도적이다 우리나라가 java를 고집하듯이 이상하게 우리나라에서도 랭그래프를 다 쓰려고 한다. 물론 장점이 많지만 무거운 게 단점이다. AWS 람다에서 무거워서 설치가 안된다고 하니 다른 대안도 고려는 해보면 좋겠다 생각이 듭니다. 랭그래프는 기존의 핸드오프의 핵심이 되는 Command 객체를 활용해서 전달을 하게 됩니다.
- goto: 다음으로 이동할 노드(에이전트)의 이름
- update: 이동하면서 그래프의 전체 상태(State)를 어떻게 업데이트할지에 대한 정보 (예: 대화 내역 추가)
- graph: 이 명령이 적용될 그래프의 범위 (보통 부모 그래프를 의미하는 Command.PARENT)
Command = {
goto: "다음에 누구한테 갈지" (예: "KT_기술_지원팀")
update: "지금까지 대화 내용 업데이트"
graph: "어느 시스템에서 실행할지"
}
구현코드
handoff.py
from typing import Annotated
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
from langgraph.graph import MessagesState
from langgraph.types import Command
def create_handoff_tool(agent_name: str, description: str):
@tool(f"transfer_to_{agent_name}", description=description)
def handoff_tool(
state: Annotated[MessagesState, InjectedState],
) -> Command: # 다음에이전트로 핸드오프
return Command(
goto=agent_name,
)
return handoff_tool
다음 에이전트로 리턴하는 중첩함수와 함께 @tool 형태의 handoff를 만듭니다.
tools = [
tool_1,
tool_2,
create_handoff_tool(
agent_name="5G_Agent",
description="5G 요금제를 질문하는 경우 이 에이전트를 호출하게 됩니다"
),
create_handoff_tool(
agent_name="Device_Agent",
description="핸드폰기종을 물어보는 경우 호출을 하게 됩니다."
),
]
llm = ChatOpenAI(model=DEFAULT_MODEL, temperature=0, api_key=OPENAI_API_KEY)
KT_QA_AGENT = create_react_agent(llm, tools)
이렇게 기존의 툴 1, 툴 2와 함께 핸드오프 툴을 쓰게 되는 에이전트 1,2를 같이 툴에 넣어서 선언을 해서 KT_QA_AGENT를 구축합니다.
def router(state: MessagesState) -> Command:
"""사용자의 첫 질문을 받고, 어떤 전문 에이전트에게 보낼지 결정합니다."""
router_llm = ChatOpenAI(model=gpt-4o-mini, temperature=0, api_key=OPENAI_API_KEY)
messages = state["messages"]
recent_context = ""
# 10개 메시지 컨텍스트
if len(messages) > 1:
recent_messages = messages[-10:]
context_parts = []
for msg in recent_messages:
if hasattr(msg, 'content') and msg.content:
content = msg.content
context_parts.append(f"- {type(msg).__name__}: {content}")
recent_context = "\n".join(context_parts)
current_message = messages[-1].content if messages else ""
@tool
def route(destination: Literal["KT_QA_AGENT","기타에이전트", "end"]):
"""
사용자의 질문과 이전 대화 내역을 고려하여 가장 적합한 전문가에게 라우팅합니다.
라우팅 규칙:
- KT 관련 요금제 및 QA 관련 Tool1, Tool2의 내용은 'KT_QA_AGENT'
- 간단한 질문이나 관련 없는 질문은 'end'
"""
return destination
try:
# 컨텍스트를 포함한 프롬프트 생성
routing_prompt = f"""
현재 사용자 메시지: "{current_message}"
최근 대화 내역:
{recent_context}
위 대화 내역을 고려하여 라우팅을 결정하세요.
"""
decision = router_llm.bind_tools([route]).invoke([HumanMessage(content=routing_prompt)])
if not decision.tool_calls:
return Command(goto="end_handler")
# 안전한 destination 접근
tool_call = decision.tool_calls[0]
args = tool_call.get('args', {})
destination = args.get('destination', 'end')
# end 인 경우 가드레일 에이전트로 라우팅
if destination == "end":
return Command(goto="guardrail_agent")
return Command(goto=destination)
except Exception as e:
return Command(goto="guardrail_agent")
def guardrail_agent(state: MessagesState):
""""""
guard_message = """
가드레일이 발동합니다 🧠 쿼리를 제대로 입력하세요!!
"""
return {"messages": [AIMessage(content=guard_message)]}
가드레일을 적용한 Supervisor MultiAgent 형태입니다. 여기서 고민해 볼 것은 만약 사용자 쿼리가 2개의 tools를 호출해야 하는 경우가 있다면? 그렇다면 핸드오프는 어떻게 주체가 될 것인가? 였는데 그렇다면 호출된 에이전트에도 역시, 나머지 tools에 Agent를 선언해서 답변을 할 수 있도록 프롬프트 조정과 코드를 위처럼 해주면 해결이 가능하지 않을까 싶습니다. 그래서 메모리기반 프롬프팅이 좀 중요해 보인다는 생각을 했습니다.
프레임워크 비교
프레임워크 | Handoff 방식 | 구현방식 특징 | 상태 및 메시지 전달 |
OpenAI SDK | 명시적 handoff 객체를 통한 에이전트 간 제어권 위임 | 각 에이전트에 handoff 목록 등록, 비동기 함수로 제어 | 대화 흐름 및 Context 를 함께 전달 필요시 전체 메시지 히스토리 공유 |
AutoGen | pub-sub 기반 이벤트 메시지 에이전트 간 작업 위임 | delegate tool을 통해 특정 에이전트 토픽으로 메시지 publish | User Task, Agent Response 메시지 타입으로 컨텍스트와 상태 전달 |
LangGraph | Command 객체 반환 그래프 노드 간 제어권 이동 | create_handoff_tool 등 특수 도구로 handoff , 상태 / 메시지 동시 업데이트 | 전체메시지 히스토리 및 커스텀 State를 payload로 함께 전달 |
랭그래프는 역시 강력한 도구라고 생각을 했습니다. 3개를 비교해 보자면 openai sdk는 알 깔딱 잘 센! 하지만 이거 되는 거야? 의심스러운 느낌은 있었습니다. 많이 시도를 해봐야 하겠지만, AutoGen 오토젠 이놈 물건이다.. 오토젠이 최근에 Graph 개념을 도입한 만큼 랭그래프화가 되는 것인가 생각했는데 뭔가 더 쉽고 쓰기 좋은 느낌인 것 같기도 했습니다. 특히 랭그래프와 달리 Tool 대신 delegate tool 등록을 통한 구분점이 인상 깊었다. 대신 레퍼가 없다.. 랭그래프는 역시 레퍼런스만큼은 최고다 Docs도 잘 정리가 되어서 보고 따라만 쳐도 될 정도입니다. 결국 선택은 사용자 몫! Google ADK 도 있지만 따로 써보진 않아서 다음에 내용을 추가할 수 있으면 추가하겠습니다.
'NLP' 카테고리의 다른 글
[Agent Study]RAG 성능을 올리기 위한 Context Retrieval 을 적용 (0) | 2025.01.12 |
---|---|
[가짜연구소] 금융에이전트 Stockelper 개발과정 깃잔심 5기 회고 (0) | 2025.01.05 |
[Agent Study] LangGraph Human in the loop (경제 리포트 작성하기) (0) | 2024.12.29 |
[Agent Study] 에이전트를 활용하여 멀티 DB 연결 구축하기 (1) | 2024.12.20 |
[Agent Study] Multi-Agent , Multi-tool 만들기 - (1) : Custom Tool 만들기 (0) | 2024.12.18 |