ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 서브 에이전트를 @tool로 감싸는 멀티 에이전트 패턴
    IT 2026. 6. 30. 22:00
    서브 에이전트를 @tool로 감싸는 멀티 에이전트 패턴

    에이전트에게 역할을 너무 많이 주면 어떻게 될까. "리서치도 하고, 데이터 분석도 하고, 코드도 작성하고, 최종 보고서도 써라" — 이렇게 하나의 에이전트에 모든 역할을 몰아넣으면 각 역할의 품질이 떨어진다. 컨텍스트 창이 무거워지고, 단계 간 집중력이 분산되고, 한 단계의 실패가 전체를 망친다. 멀티 에이전트 패턴은 이 문제를 역할 분리로 해결한다. 그리고 LangGraph에서는 그 분리를 서브 에이전트를 @tool로 감싸는 방법으로 구현한다.

    단일 에이전트의 한계

    단일 에이전트 구조에서는 모든 것이 하나의 루프 안에 들어간다. 도구 목록이 길어지고, 시스템 프롬프트가 복잡해지고, 응답마다 불필요한 맥락이 누적된다. 무엇보다 각 역할에 맞는 "전문성"을 하나의 에이전트가 동시에 유지하기 어렵다.

    diagram

    구조는 단순해 보이지만 에이전트 하나가 리서치 전문가이면서 데이터 분석가이면서 개발자이면서 작가여야 한다. 컨텍스트 창 안에 모든 도구의 설명과 이전 단계 결과가 쌓이면서 집중력이 분산된다. 각 역할이 요구하는 시스템 프롬프트도 충돌하기 시작한다.

    서브 에이전트를 @tool로 감싸는 아이디어

    해결책은 개념적으로 간단하다. 각 역할을 전담하는 서브 에이전트를 만들고, 그 서브 에이전트를 @tool로 감싸면 메인 에이전트가 "도구"로 호출할 수 있다. 메인 에이전트는 사용자 요청을 파악하고 어떤 서브 에이전트가 필요한지 판단하는 역할에 집중한다.

    diagram

    메인 에이전트 입장에서 서브 에이전트는 그냥 도구다. "리서치가 필요하면 call_movie_research_agent를 호출한다"는 것이 전부다. 서브 에이전트가 내부적으로 어떤 도구를 쓰는지, 몇 번을 재시도하는지 메인은 알 필요가 없다.

    영화 정보 리서치 서브 에이전트 구현

    from langchain_core.tools import tool
    from langgraph.prebuilt import create_react_agent
    
    # 영화 정보 리서치 전담 서브 에이전트 생성
    movie_research_agent = create_react_agent(
        model=model,
        tools=[search_tool],  # 검색 도구만 보유
        prompt="당신은 영화 정보 리서치 전문가입니다. 주어진 키워드로 영화 정보와 뉴스를 수집하고 핵심을 정리하세요.",
    )
    
    # 서브 에이전트를 @tool로 감싸기
    @tool
    def call_movie_research_agent(query: str) -> str:
        """영화 정보 리서치가 필요할 때 사용한다. 검색어를 전달하면 결과를 반환한다."""
        result = movie_research_agent.invoke({
            "messages": [{"role": "user", "content": query}]
        })
        # 마지막 메시지가 최종 응답
        return result["messages"][-1].content
    

    @tool 데코레이터가 핵심이다. 함수 시그니처(query: str)가 도구의 입력 스키마가 되고, docstring이 메인 에이전트가 "언제 이 도구를 써야 하는지" 판단하는 설명이 된다. 서브 에이전트 내부 로직은 완전히 캡슐화된다. 메인 에이전트는 함수 이름과 docstring만 본다.

    흥행 데이터 분석 서브 에이전트 구현

    from langchain_experimental.tools import PythonAstREPLTool
    
    # 데이터프레임을 컨텍스트로 주입
    python_repl = PythonAstREPLTool(locals={"df": dataframe})
    
    # 흥행 데이터 분석 전담 서브 에이전트
    data_agent = create_react_agent(
        model=model,
        tools=[python_repl],  # Python REPL만 보유
        prompt="당신은 박스오피스 데이터 분석가입니다. pandas를 활용해 df를 분석하고 인사이트를 도출하세요.",
    )
    
    @tool
    def ask_boxoffice_analyst(question: str) -> str:
        """박스오피스·평점 데이터 분석이 필요할 때 사용한다. 분석 질문을 전달하면 코드 실행 결과를 반환한다."""
        result = data_agent.invoke({
            "messages": [{"role": "user", "content": question}]
        })
        return result["messages"][-1].content
    

    흥행 데이터 분석 서브 에이전트는 PythonAstREPLTool을 통해 실제 코드를 실행할 수 있다. locals에 데이터프레임을 주입해서 서브 에이전트가 df를 직접 다루도록 한다. 메인 에이전트는 이 복잡한 설정을 전혀 모른 채 ask_boxoffice_analyst("월별 박스오피스 추이를 알려줘")처럼 자연어로 호출할 수 있다.

    메인 에이전트 조립

    # 메인 에이전트: 서브 에이전트들을 도구로 장착
    main_agent = create_react_agent(
        model=model,
        tools=[
            call_movie_research_agent,  # 영화 정보 리서치 서브 에이전트
            ask_boxoffice_analyst,      # 흥행 데이터 분석 서브 에이전트
        ],
        prompt="서브 에이전트를 활용해 사용자 요청을 처리하세요. 필요한 전문가에게 작업을 위임하고 결과를 통합해 응답하세요.",
    )
    

    메인 에이전트의 tools 목록에는 외부 도구 대신 서브 에이전트 래퍼 함수들이 들어간다. 시스템 프롬프트도 짧고 명확하다 — "위임하고 통합하라". 역할 분리 덕분에 메인 에이전트는 조율자(orchestrator) 역할에만 집중할 수 있고, 각 서브 에이전트는 자신의 전문 도구와 프롬프트를 최적화할 수 있다.

    메인-서브 에이전트 호출 흐름

    diagram

    실제 호출 흐름을 보면 패턴이 명확해진다. 메인 에이전트는 사용자 요청을 받고 두 가지 작업이 필요하다고 판단한다. 먼저 영화 정보 리서치 서브 에이전트를 호출해 경쟁 개봉작 현황을 수집하고, 그 결과를 흥행 데이터 분석 서브 에이전트에 전달해 관객 점유율을 계산한다. 마지막으로 두 결과를 합쳐 최종 응답을 작성한다. 각 서브 에이전트는 자신의 역할만 수행하고, 메인 에이전트는 흐름을 조율한다.

    설계 시 고려점

    멀티 에이전트 패턴은 강력하지만 디버깅이 어려워진다. 서브 에이전트 내부에서 실패가 나도 메인 에이전트는 반환값만 보기 때문에 어디서 무엇이 잘못됐는지 파악하기 어렵다. @tool 래퍼 함수 안에서 예외를 잡아 메인 에이전트에게 의미 있는 오류 메시지를 돌려주는 방어 코드를 반드시 추가해야 한다. 또한 서브 에이전트 호출은 LLM 호출이 중첩되므로 비용과 지연이 선형으로 증가한다. 서브 에이전트를 무분별하게 늘리지 말고, 역할 경계를 명확히 설계한 후 분리하는 것이 원칙이다.

    @tool
    def call_movie_research_agent(query: str) -> str:
        """영화 정보 리서치가 필요할 때 사용한다. 검색어를 전달하면 결과를 반환한다."""
        try:
            result = movie_research_agent.invoke({
                "messages": [{"role": "user", "content": query}]
            })
            return result["messages"][-1].content
        except Exception as e:
            # 예외를 그대로 터뜨리면 메인 에이전트 루프 전체가 중단된다.
            # 대신 의미 있는 메시지를 "도구 결과"로 반환해 메인이 판단하게 한다.
            return f"리서치 서브 에이전트 호출 실패: {type(e).__name__} - {e}. 다른 검색어로 재시도하거나 이 단계를 건너뛰세요."
    

    핵심은 예외를 메인 에이전트에게 던지지 않고, 도구의 정상 반환값(문자열)으로 변환해 돌려준다는 점이다. 래퍼 안에서 예외가 그대로 전파되면 메인 에이전트의 실행 루프 전체가 멈춰버린다. 반면 오류 내용을 문자열로 반환하면 메인 에이전트는 그것을 다른 도구 결과와 똑같이 "관찰값"으로 받아들이고, 재시도할지·다른 서브 에이전트로 우회할지· 사용자에게 실패를 알릴지 스스로 판단할 수 있다. 이때 메시지에는 무엇이 실패했는지(에러 종류)와 다음에 무엇을 할 수 있는지(재시도·건너뛰기)를 함께 담아야 한다. 단순히 "Error"만 돌려주면 메인 에이전트가 같은 호출을 무한 반복하거나 엉뚱한 방향으로 빠지기 쉽다. 좋은 오류 메시지는 사람뿐 아니라 LLM에게도 행동 가능한 신호가 되어야 한다.

    이 패턴에서 중요한 설계 원칙이 하나 더 있다. 서브 에이전트는 자신의 독립적인 context window를 가진다. 메인 에이전트가 사용자와 주고받은 대화 히스토리 전체를 서브 에이전트가 보지 않는다. 서브 에이전트는 @tool 래퍼 함수가 전달한 입력(query, question 등)만 받아서 새 대화를 시작한다. 이는 장점이기도 하다 — 서브 에이전트가 메인의 복잡한 맥락에 오염되지 않고 자신의 역할에만 집중할 수 있다. 영화 정보 리서치 서브 에이전트는 "경쟁 개봉작 현황"이라는 쿼리만 보고 검색에 집중하면 된다.

    정리

    서브 에이전트를 @tool로 감싸는 패턴은 복잡한 인프라 없이 멀티 에이전트 협업을 구현한다. 메인 에이전트는 조율에 집중하고, 서브 에이전트는 각자의 전문 도구와 컨텍스트에 최적화된다. docstring이 서브 에이전트의 "언제 나를 써야 하는가"를 결정하는 유일한 인터페이스라는 점을 기억해야 한다. 좋은 docstring이 좋은 에이전트 협업의 시작이다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.