LangChain에서의 "Handoffs" 이해하기 (하나의 에이전트, 다양한 페르소나)
요약
LangChain에서 단일 에이전트의 상태(state)를 활용해 페르소나와 도구를 전환하는 'Handoffs' 패턴을 설명합니다. 여러 에이전트를 구축하는 대신 상태 변수에 따라 시스템 프롬프트와 도구 세트를 동적으로 변경하여 효율적으로 대화 단계를 관리하는 방법을 다룹니다.
핵심 포인트
- Handoff는 에이전트 교체가 아닌 동일 에이전트 내 지침과 도구의 변경임
- 상태(state) 변수를 통해 현재 대화 단계를 추적하고 제어함
- 복잡한 라우팅 로직 없이도 단계별 맞춤형 대응 가능
- 도구 호출을 통해 상태를 업데이트하여 다음 단계로 자연스럽게 전환
만약 여러분이 LangChain 에이전트를 구축해 왔다면, 아마 다음과 같은 상황을 마주했을 것입니다: 대화가 진행되는 위치에 따라 에이전트가 다르게 행동하기를 원하는 상황 말이죠. 처음에는 명확한 질문을 던지다가, 그다음에는 문제 해결로 전환하고, 마지막에는 정중하게 마무리해야 할 수도 있습니다.
유혹적인 해결책은 세 개의 별도 에이전트를 구축하고 그들 사이에서 대화를 전달하는 것입니다. 하지만 단일 에이전트로 동일한 결과를 얻을 수 있는 더 간단한 패턴이 있습니다: 바로 handoffs (핸드오프) 입니다.
이 포스트에서는 이 패턴을 사용하는 작은 고객 지원 봇 (support-bot) 예제를 살펴보고, 내부적으로 실제로 어떤 일이 일어나고 있는지 설명하겠습니다.
"handoff"란 실제로 무엇인가?
handoff는 새로운 에이전트가 업무를 인계받는 것이 아닙니다. 동일한 에이전트이지만, 상태 (state)의 일부에 따라 지침 (instructions)과 사용 가능한 도구 (tools)가 변경되는 것입니다.
마치 한 명의 고객 서비스 상담원이 티켓이 어느 "단계"에 있느냐에 따라 책상에 붙여놓은 서로 다른 스크립트를 사용하는 것과 같다고 생각하면 됩니다. 상담원은 바뀌지 않습니다. 스크립트가 바뀌는 것입니다.
코드상으로는 다음과 같은 의미를 갖습니다:
- 에이전트의 상태 (state) 내에서 현재 단계 (예:
current_step과 같은 일반 변수)를 추적합니다. - 각 모델 호출 (model call) 전에 해당 변수를 확인하고, 적절한 시스템 프롬프트 (system prompt)와 적절한 도구 세트로 교체합니다.
- 도구 호출 (tool call)이 해당 변수를 업데이트하여 대화를 다음 단계로 이동시킬 수 있습니다.
이것이 핵심 아이디어입니다. 서브 에이전트 (sub-agents)도, 오케스트레이터 (orchestrator)도, 복잡한 라우팅 로직 (routing logic)도 필요 없습니다. 오직 상태 (state)와 이를 읽는 미들웨어 함수 (middleware function)만 있으면 됩니다.
예제: 간단한 고객 지원 봇
"""
Handoffs Demo
=============
...
데모 봇은 세 가지 단계를 가집니다:
- triage (분류) — 사용자가 어떤 종류의 문제(결제, 기술적 문제 또는 기타)를 가지고 있는지 파악합니다.
- resolve (해결) — triage에서 파악된 내용을 바탕으로 실제로 문제를 해결합니다.
- done (완료) — 대화가 마무리됩니다.
각 요소가 어떻게 이를 구현하는지 자세히 살펴보겠습니다.
1. State가 "현재 단계"를 보유합니다
class SupportState(AgentState):
current_step: str = "triage"
issue_type: str = ""
이것이 전체적인 트릭입니다. current_step은 "triage"에서 시작하여 대화가 진행됨에 따라 변경됩니다. issue_type은 사용자의 문제가 실제로 무엇이었는지를 저장하여, 이후 단계에서 이를 다시 참조할 수 있게 합니다.
LangChain 에이전트(agents)가 처음이라면, AgentState는 메시지 기록(message history)과 같은 것들을 이미 추적하고 있는 기본 클래스(base class)일 뿐입니다. 여기서는 이 봇에 특화된 두 개의 추가 필드를 사용하여 이를 확장하고 있습니다.
2. 핸드오프(handoff)를 트리거하는 도구들
이 부분은 처음에 사람들이 혼란스러워하는 부분인데, 도구(tools)들이 단순히 답변만 반환하는 것이 아니라, 상태(state)를 업데이트하는 Command를 반환하는 것들이 있기 때문입니다.
@tool
def record_issue(
issue: str,
...
모델이 record_issue를 호출하면 세 가지 일이 동시에 일어납니다:
ToolMessage가 대화에 추가됩니다 (모델이 도구가 실행되었음을 인지하도록 함)issue_type이 저장됩니다current_step이"triage"에서"resolve"로 전환됩니다
마지막 줄이 실제 핸드오프(handoff)입니다. 모델이 호출되는 바로 다음 시점에, current_step이 변경되었기 때문에 모델은 완전히 다른 지침(instructions) 하에 실행될 것입니다.
이를 resolve_billing과 같은 다른 도구들과 비교해 보세요:
@tool
def resolve_billing(
runtime: ToolRuntime[None, SupportState],
...
이 도구는 단순히 일반 문자열(plain string)을 반환합니다. 자신의 할 일을 수행하지만 단계를 변경하지는 않습니다. 실제 단계 종료 동작은 end_conversation에서 발생하며, 이는 record_issue와 마찬가지로 current_step을 "done"으로 설정하는 Command를 반환합니다.
따라서 패턴은 다음과 같습니다: 단순히 무언가를 수행하는 도구는 일반 값을 반환하고, 대화를 앞으로 진행시키는 도구는 상태를 업데이트하는 Command를 반환합니다.
3. 미들웨어(Middleware): 상태를 읽고 에이전트를 재구성하는 부분
이곳이 실제로 서로 다른 "스크립트(scripts)"가 교체되는 지점입니다.
@wrap_model_call
def apply_step_config(
request: ModelRequest,
...
@wrap_model_call은 모든 모델 호출 직전에 실행되는 미들웨어(middleware)입니다. 매번 다음과 같은 작업을 수행합니다:
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
@wrap_model_call은 모든 모델 호출 직전에 실행되는 미들웨어(middleware)입니다. 매번 다음과 같은 작업을 수행합니다:
current_step을 상태(state)에서 읽어옵니다.configs딕셔너리에서 일치하는 프롬프트와 도구 목록을 조회합니다.- 요청(request)을 해당 프롬프트와 도구로 재정의(overrides)합니다.
- 수정된 요청을 실제 모델 호출로 전달합니다.
여기서 중요한 점을 주목하세요: 트리아지(triage) 단계 동안, 모델은 문자 그대로 resolve_billing, resolve_technical, 또는 end_conversation에 접근할 수 없습니다. 이들은 모델의 도구 목록에 포함되어 있지 않습니다. 아직 전달되지 않았기 때문에 물리적으로 호출할 수 없습니다. 이것은 모델이 행동을
current_step이 (기본값인)"triage"에서 시작합니다.- 미들웨어 (middleware)가 triage 프롬프트를 로드하고 모델에게
record_issue도구만 제공합니다. - 모델이 "두 번 결제되었습니다"라는 내용을 읽고
record_issue(issue="billing")를 호출합니다. - 해당 도구는
issue_type = "billing"및current_step = "resolve"를 설정하는Command를 반환합니다. - 에이전트 (agent)가 모델을 다시 호출하기 위해 루프를 돕니다. 하지만 이제 미들웨어는
current_step = "resolve"를 확인하므로, 완전히 다른 프롬프트를 로드하고 모델에게resolve_billing,resolve_technical,resolve_other, 그리고end_conversation을 제공합니다. - 모델은
resolve_billing()을 호출하여 환불 메시지를 받은 다음,end_conversation()을 호출합니다. - 마지막 호출이
current_step = "done"으로 설정합니다. - 만약 모델이 다시 호출된다면, 이제는 도구가 전혀 없는 "done" 단계에 있게 되며, 그저 작별 인사를 하라는 지시만 받게 됩니다.
이 모든 과정은 단일 agent.invoke() 호출 내에서 발생하며, 전체 과정 동안 하나의 에이전트 객체로 유지됩니다.
별도의 에이전트를 사용하는 대신 왜 굳이 이렇게 하나요?
이 패턴을 알아둘 가치가 있는 몇 가지 이유가 있습니다:
- 하나의 메시지 히스토리 (message history). 모든 것이 동일한 대화 내에 유지되므로, 모델이 "에이전트" 사이를 전환할 때 문맥 (context)을 잃어버리지 않습니다.
- 추론 비용이 저렴함. 모든 단계 로직이 여러 에이전트 정의에 흩어져 있는 대신, 하나의 딕셔너리 (
configs) 안에 존재합니다. - 엄격한 경계 (Hard boundaries). 현재 단계에 대해서만 도구가 노출되기 때문에 내장된 가드레일 (guardrails)을 얻을 수 있습니다. 모델은 문제를 식별하기 전에 앞서 나가서 환불을 처리하는 등의 행동을 물리적으로 할 수 없습니다.
- 확장이 용이함. 새로운 단계를 추가한다는 것은
configs에 항목을 하나 더 추가하고, 어쩌면 도구를 하나 더 추가하는 것을 의미합니다. 멀티 에이전트 그래프 (multi-agent graph)를 다시 배선할 필요가 없습니다.
기억해야 할 멘탈 모델 (mental model)
만약 멀티 에이전트 시스템 (multi-agent systems)이 고객을 서로 다른 부서로 인계하는 것처럼 느껴진다면, 이 핸드오프 (handoffs) 패턴은 스크립트가 담긴 바인더를 가진 한 명의 직원이 대화의 단계에 따라 새로운 페이지를 넘기는 것과 더 비슷하게 느껴집니다. 동일한 인물, 동일한 대화 기억을 유지하되, 각 페이지마다 서로 다른 지침 (instructions)과 사용 가능한 서로 다른 도구 (tools)가 제공되는 방식입니다.
이것이 바로 LangChain에서 핸드오프 (handoffs)의 핵심 아이디어입니다. 상태 (state)가 동작을 결정하고, 미들웨어 (middleware)가 상태를 읽으며, 도구 (tools)가 상태를 앞으로 진행시키는 역할을 합니다.
직접 시도해 보세요
직접 실험해보고 싶다면, 네 번째 단계인 '에스컬레이션 (escalate)' 단계를 추가해 보는 것이 좋은 다음 단계가 될 수 있습니다. 예를 들어 결제, 기술 지원 또는 기타 범주에 맞지 않는 문제들을 처리하는 단계입니다. 다음 사항만 있으면 됩니다:
- 자체적인 프롬프트 (prompt)와 도구 (tools)를 가진
configs내의 항목 하나 추가 current_step = "escalate"를 설정하는 도구 하나
그게 전부입니다. 새로운 에이전트도, 새로운 그래프 (graph)도 필요 없습니다. 그저 바인더에 페이지 하나를 더 추가하는 것뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기