13장 — 에이전트 오케스트레이션

소프트웨어 개발에서 큰 시스템을 작은 단위로 나누어 관리하는 방식은 오랫동안 효과적인 전략으로 인정받아 왔습니다. 마이크로서비스 아키텍처(MSA) 가 그 대표적인 예입니다.

AI 에이전트 시스템도 이와 매우 유사합니다. 하나의 큰 AI 시스템은 여러 개의 작은 에이전트로 구성되며, 각 에이전트는 특정 작업에 특화되어 있습니다. 고객 서비스 시스템이라면 고객의 의도를 파악하는 에이전트, 제품 정보를 검색하는 에이전트, 답변을 생성하는 에이전트, 고객 만족도를 평가하는 에이전트가 필요할 수 있습니다. 이들을 관리하고 조율하는 것이 오케스트레이션(orchestration) 입니다.

두 세계의 공통된 목적이 같습니다.

  • 상태 관리: 여러 컴포넌트가 공유하는 정보를 일관성 있게 관리해야 한다.
  • 작업 순서: 컴포넌트들의 실행 순서와 의존성을 명확히 정의해야 한다.
  • 오류 처리: 한 컴포넌트의 실패가 전체 시스템에 미치는 영향을 최소화해야 한다.
  • 확장성: 새로운 컴포넌트를 쉽게 추가하고 제거할 수 있어야 한다.

LangGraph는 마이크로서비스의 오케스트레이션 패턴을 AI 에이전트 시스템에 맞게 발전시킨 도구입니다. 검증된 패턴에 시맨틱 라우팅(규칙이 아니라 콘텍스트를 이해하는 작업 분배), 동적 상태 관리(비정형 데이터와 중간 결과물 관리), 적응형 워크플로(이전 단계 결과에 따라 다음 단계가 결정)를 더했습니다.

두 가지 패턴, 그리고 선택

오케스트레이터-워커 패턴

        ┌→ Worker ─┐
In → Orchestrator → Worker → Synthesizer → Out
        └→ Worker ─┘

복잡한 작업을 작은 하위 작업으로 분해해 여러 워커에게 분배합니다. 하위 작업의 수와 성격을 미리 예측하기 어려운 작업에서 큰 강점을 보입니다. 코드베이스 전체에 특정 기능을 추가할 때 어떤 파일을 수정해야 하는지가 상황에 따라 달라지는 경우가 좋은 예입니다.

장점은 동적인 작업 분배, 병렬처리 효율성, 확장성입니다.

그런데 책은 이 패턴을 쓰지 않습니다. 세금 계산 시스템에 맞지 않는 이유가 셋입니다.

  1. 질문 형태가 매우 다양하다. “연봉 5천만 원인데 세금이 얼마야?”, “아파트를 두 채 가지고 있는데 세금이 어떻게 돼?”, “작년에 주식 투자로 2천만 원 벌었는데 세금을 계산해줘” — 질문마다 필요한 계산이 크게 달라진다.
  2. 세금 계산은 단순히 병렬 처리하기 어려운 상호의존관계에 있다. 종합소득세를 계산할 때 부동산 임대소득을 고려해야 할 수 있고, 특정 공제 항목은 여러 세금에 동시에 영향을 미칠 수 있다.
  3. 사용자와의 대화형 상호작용이 필요하다. 추가 정보가 필요할 때는 사용자에게 질문하고, 답변에 따라 계산 방식을 조정해야 한다. 이러한 동적인 상호작용은 미리 정의된 워크플로로는 처리하기 어렵다.

멀티 에이전트 슈퍼바이저 패턴

        User
         ↕
     Supervisor
    ↙    ↓    ↘      (route)
Agent1 Agent2 Agent3

중앙의 슈퍼바이저가 여러 전문 에이전트를 관리하고 조율하는 구조로, 숙련된 프로젝트 매니저가 각 분야의 전문가와 협력해 프로젝트를 이끌어가는 것과 유사합니다. 다이어그램에서 보듯 슈퍼바이저는 사용자와 직접 상호작용을 하면서 동시에 여러 전문 에이전트와 연결되어 있어, 단순한 작업 분배를 넘어 지속적인 소통과 조정이 가능합니다.

장점 내용
지능적인 작업 라우팅 요청을 분석해 가장 적합한 에이전트에게 전달하고, 협력이 필요하면 순서와 방식을 동적으로 결정한다
양방향 커뮤니케이션 에이전트가 슈퍼바이저에게 추가 정보를 요청할 수 있고, 슈퍼바이저는 사용자와 에이전트 사이의 소통을 중재한다
유연한 확장성 새 에이전트 추가가 쉽고, 각 에이전트의 전문성을 유지하면서 시스템 기능을 확장할 수 있다

노트북을 파이썬 스크립트로 꺼내기

12장에서 만든 에이전트를 슈퍼바이저가 임포트해 쓰려면 파이썬 파일이어야 합니다. 노트북 상단 […] → [Export] → [Python Script] 로 추출해 house_tax_agent.py로 저장합니다.

여기서 두 가지 함정을 짚습니다.

invoke() 호출 부분을 제거한다.

invoke()를 하는 부분을 제거하지 않으면, 슈퍼바이저에서 임포트할 때 불필요하게 테스트하는 부분이 실행되어 토큰을 낭비하게 된다.

② 도구 밖에 선언한 변수를 도구 안으로 옮긴다.

이는 주피터 노트북과 파이썬 스크립트 간의 변수 스코프 차이 때문인데, 주피터 노트북에서는 셀 간에 변수가 공유되어 함수 외부에 선언된 변수를 함수 내부에서 쉽게 참조할 수 있습니다. 반면 파이썬 스크립트에서는 이러한 외부 변수 참조가 예상치 못한 오류를 발생시킬 수 있습니다.

그래서 스크립트로 옮길 때는 함수가 필요한 모든 데이터를 파라미터로 받거나 함수 내부에서 직접 로드하는 방식으로 바꿔야 합니다. 결론은 외부 의존성을 최소화하는 것이고, 이는 코드의 유지 보수성과 확장성을 높입니다.

소득세 에이전트 만들기

두 번째 에이전트를 새로 만듭니다. 이번에는 PDF + py-zerox 조합입니다.

py-zerox로 PDF 전처리

7장에서 이름만 나왔던 도구가 여기서 실제로 쓰입니다. py-zerox는 멀티모달 LLM으로 PDF를 마크다운으로 변환해 주며, 내부적으로 LiteLLM(OpenAI·앤트로픽·Gemini 등을 하나의 통합 API로 관리해 주는 프록시)을 씁니다.

import nest_asyncio
nest_asyncio.apply()          # zerox가 async 함수라 노트북에서는 필수
from pyzerox import zerox
import asyncio

model = "gpt-4o"

async def main():
    file_path = "./income_tax.pdf"
    select_pages = None       # 전체 페이지
    output_dir = "./output"
    result = await zerox(file_path=file_path, model=model,
                         output_dir=output_dir, select_pages=select_pages)
    return result

result = asyncio.run(main())

여기서 만날 수 있는 벽이 있습니다. OpenAI는 결제 금액에 따라 티어를 부여하고 RPM·TPM을 제한하는데, 금액이 충분하지 않으면 이런 오류가 납니다.

litellm.RateLimitError: Error code: 429 - Rate limit reached for gpt-4o ...
on tokens per min (TPM): Limit 30000, Used 29780, Requested 822.

이런 상황에서는 어쩔 수 없이 Billing에서 크레딧을 추가해야 합니다.

성과는 확실합니다. LangChain의 PyPDFLoader로는 처리할 수 없는, PDF에 이미지로 첨부된 표까지 마크다운으로 변환됩니다. 소득세법 제55조 세율표가 마크다운 표로 온전히 살아납니다.

마크다운을 다시 텍스트로

그런데 또 한 번 꺾입니다. 마크다운 파일을 처리하려고 LangChain의 UnstructuredMarkdownLoader를 쓰면 표 형식이 깨집니다. 그래서 파이썬으로 직접 변환합니다.

import markdown
from bs4 import BeautifulSoup

with open(markdown_path, 'r', encoding='utf-8') as md_file:
    md_content = md_file.read()

html_content = markdown.markdown(md_content)        # 마크다운 → HTML
soup = BeautifulSoup(html_content, 'html.parser')   # HTML 파싱
text_content = soup.get_text()                      # 텍스트만 추출

with open(text_path, 'w', encoding='utf-8') as txt_file:
    txt_file.write(text_content)

이러한 접근 방식은 후속 처리를 위해 일관된 텍스트 형식을 보장하고자 할 때나, 자동화된 로더가 최적으로 처리하지 못할 수 있는 복잡한 마크다운 구조를 다룰 때 특히 유용합니다.

임베딩을 OpenAI로 바꿔 보기

이번에는 업스테이지 대신 OpenAI 임베딩(text-embedding-3-large) 을 씁니다. 10장에서 업스테이지는 차원(4,096)을 직접 입력해야 했지만, 상용 임베딩 모델 중 자주 쓰이는 것들은 Pinecone에서 모델을 선택하면 벡터 길이가 자동으로 입력됩니다(3,072).

여기서 실무적인 주의가 하나 더 나옵니다.

데이터가 정상적으로 적재된 것을 확인한 후에는 from_documents() 대신 from_existing_index() 를 활용한다. 소득세 에이전트도 파이썬 스크립트로 추출해서 사용해야 하는데, from_documents()를 활용하면 데이터를 계속 중복해서 적재하게 되기 때문이다.

create_agent로 간단히

from langchain_core.tools.retriever import create_retriever_tool

retriever = vector_store.as_retriever()
retriever_tool = create_retriever_tool(
    retriever,
    "search_income_tax_law",
    "2025년 대한민국의 소득세법을 검색한 결과를 반환합니다",
)
from langchain.agents import create_agent

income_tax_agent = create_agent(model='gpt-4o', tools=[retriever_tool])

create_agent 는 LangGraph가 제공하는 Prebuilt 에이전트 생성 함수로, ReAct(Reason + Act) 프롬프트 방식을 기반으로 LLM과 도구를 결합해 반복적으로 추론과 행동을 수행하는 에이전트를 손쉽게 만들어 줍니다. LLM과 도구 목록만 지정하면 내부적으로 LangGraph가 대화 흐름을 관리하고, LLM이 필요에 따라 도구를 호출하며, 그 결과로 추가 추론을 반복하는 구조를 자동으로 구성합니다.

retrieval을 위한 도구 하나에 별도의 로직을 추가하지 않기 때문에 소득세 에이전트는 create_agent를 활용해 생성한다.

다이어그램을 보면 12장에서 손으로 만든 tools_condition 구조와 같은 형태입니다.

__start__ → model ⇢ tools
              ⇡  ⇣
           __end__

“연봉 5천만 원의 소득세는 얼마인가요?”에 624만 원이라는 답이 나옵니다. 이 에이전트도 같은 방식으로 income_tax_agent.py로 추출합니다.

슈퍼바이저 구현

상태와 라우터

from langgraph.graph import MessagesState

class AgentState(MessagesState):
    next: str   # 다음 에이전트를 지정하는 변수

MessagesState의 기능에 next 필드를 추가해 다음에 실행할 에이전트를 명시적으로 지정할 수 있게 합니다.

members = ["house_tax_agent", "income_tax_agent"]
options = members + ["FINISH"]

class Router(TypedDict):
    """다음 작업자를 결정하는 라우터.
    작업이 필요한 경우 다음 작업자를 지정하고,
    더 이상 작업이 필요하지 않은 경우 FINISH를 반환."""
    next: Literal[*options]

핵심은 Router 클래스입니다. 슈퍼바이저가 반환해야 할 구조화된 응답의 형식을 명확히 정의하고, next 필드는 Literal 타입으로 세 값 중 하나만 갖도록 제한됩니다.

구조화된 출력이 주는 것

with_structured_output()을 쓰면 LLM의 출력이 항상 Router 형식을 따르도록 강제할 수 있습니다.

  • 예측 가능한 응답 보장: 슈퍼바이저는 항상 미리 정의된 옵션 중 하나만 선택하므로 예상치 못한 응답이 발생할 가능성이 없다.
  • 데이터 유효성 검증: TypedDictLiteral로 응답이 반드시 유효한 옵션 중 하나임을 보장한다.
  • 자동 파싱: LLM이 생성한 텍스트가 자동으로 파이썬 객체로 변환된다.

Command 클래스

def supervisor_node(state: AgentState) -> Command[Literal[*members, "__end__"]]:
    messages = [{"role": "system", "content": system_prompt}] + state["messages"]
    response = llm.with_structured_output(Router).invoke(messages)
    goto = response["next"]

    if goto == "FINISH":
        goto = END

    return Command(goto=goto, update={"next": goto})

Command 는 그래프 내에서 노드 간의 상태 전환과 상태 업데이트를 제어하는 핵심 요소로, 두 가지 일을 합니다.

파라미터 역할
goto 다음 실행할 노드를 지정한다. FINISH가 반환되면 그래프의 종료를 나타내는 END 상수로 변환한다
update 그래프의 상태를 부분적으로 업데이트한다. next 필드를 갱신해 두면 그래프 내 다른 노드에서도 접근할 수 있다

에이전트 노드 — 왜 한 겹을 더 두는가

에이전트를 직접 호출하지 않고 별도의 노드 함수로 감싸는 이유가 이 장의 설계적 핵심입니다.

from house_tax_agent import graph as house_tax_agent
from income_tax_agent import income_tax_agent

def house_tax_node(state: AgentState) -> Command[Literal["supervisor"]]:
    result = house_tax_agent.invoke(state)
    return Command(
        update={
            "messages": [
                HumanMessage(content=result["messages"][-1].content, name="house_tax_agent")
            ]
        },
        goto="supervisor",
    )

임포트 방식부터 다릅니다. house_tax_agent그래프 객체를 직접 임포트하는 반면 income_tax_agent모듈 전체를 임포트합니다. 각 에이전트의 구현 방식이 다를 수 있음을 보여주는 사례이며, 노드 함수가 이러한 차이를 흡수해 일관된 인터페이스를 제공합니다.

노드 함수가 달성하는 목적은 넷입니다. 일관된 인터페이스 제공, 상태 변환 처리, 오류 처리 및 복구, 실행 흐름 제어.

어댑터 패턴 — 이 장 최고의 비유

노드 함수는 에이전트 모듈과 LangGraph 그래프 구조 사이의 어댑터 패턴을 구현합니다. 책의 각주가 탁월합니다.

가장 직관적인 예시는 해외여행용 전원 플러그(돼지코) 다.

  • 220V 콘센트(LangGraph): 그래프의 노드는 정해진 형태의 상태(state) 입력을 기대한다.
  • 110V 플러그(에이전트): 개별 에이전트 로직은 그래프의 상태 구조와 무관하게 단순히 질문을 받아 답변을 반환하도록 설계되어 있다.
  • 어댑터(노드 함수): 이 둘 사이에서 그래프의 상태를 에이전트가 처리 가능한 입력값으로 변환하고, 에이전트의 결과를 다시 그래프가 이해할 수 있는 상태 업데이트 형식으로 변환하는 ‘중개자’ 역할을 수행한다.

그래서 얻는 것이 인터페이스 안정성입니다.

  • 에이전트가 단일 LLM 호출에서 복잡한 멀티스텝 추론으로 업그레이드되어도 노드 함수는 변경되지 않는다.
  • 새로운 도구나 외부 API가 에이전트에 추가되어도 그래프의 구조는 그대로 유지된다.
  • 에이전트의 프롬프트나 모델이 변경되어도 전체 워크플로에는 영향이 없다.

나머지 설계 포인트들

책은 13.5에서 열 가지 넘는 설계 이유를 촘촘히 나열합니다. 실무에서 기억할 만한 것들만 추리면 이렇습니다.

순환 방지와 무한 루프 제어 — 슈퍼바이저 패턴은 각 에이전트가 작업 완료 후 반드시 제어권을 슈퍼바이저에게 반환하게 해서 무한 루프나 예측 불가능한 흐름을 방지합니다.

효율적인 상태 변경update가 전체 메시지 목록이 아니라 최신 메시지만 업데이트한다는 점이 중요합니다.

# 비효율적인 방식(전체 메시지 목록 교체)
return Command(update={"messages": state["messages"] + [new_message]}, goto="supervisor")

# 효율적인 방식(새 메시지만 추가)
return Command(update={"messages": [new_message]}, goto="supervisor")

대화가 길어질수록 메시지 목록의 크기는 기하급수적으로 증가할 수 있습니다. 새 메시지만 업데이트하면 메모리 사용량을 최소화하고, 상태 직렬화·역직렬화의 오버헤드를 줄이며, 분산 환경에서 네트워크 전송 비용을 감소시킵니다.

메시지 출처 추적HumanMessage(..., name="house_tax_agent")name 파라미터는 단순한 라벨링을 넘어섭니다. 출처별 필터링, 에이전트별 성능 분석, 대화 흐름 분석이 가능해집니다.

agent_messages = [msg for msg in state["messages"] if msg.name == "house_tax_agent"]
conversation_flow = [msg.name for msg in state["messages"] if msg.name]

새로운 에이전트 추가 — 상속세 에이전트를 붙이려면 노드 함수 하나를 같은 패턴으로 만들고 members 목록에 이름만 추가하면 됩니다.

그래프 구성과 실행

builder = StateGraph(AgentState)

builder.add_edge(START, "supervisor")   # 모든 실행은 슈퍼바이저에서 시작된다.

builder.add_node("supervisor", supervisor_node)
builder.add_node("house_tax_agent", house_tax_node)
builder.add_node("income_tax_agent", income_tax_node)

graph = builder.compile()
              __start__
                  ↓
           ┌→ supervisor ─┐
           │   ↙   ↓   ↘  │
 house_tax_agent  income_tax_agent  __end__

흐름이 명확합니다. 사용자가 질의하면 가장 먼저 슈퍼바이저 노드가 실행되어 질문을 분석하고 다음에 실행할 에이전트를 결정합니다. 결정된 에이전트가 해당 영역의 계산을 수행하고 결과를 반환하면, 그 결과는 다시 슈퍼바이저에게 전달됩니다. 슈퍼바이저는 추가 작업이 필요한지 또는 완료할지 결정하고, 모든 작업이 끝나면 실행이 종료됩니다.

두 질문으로 검증합니다.

Human: 연봉 5천만 원의 소득세는 얼마인가요?
Ai (Name: income_tax_agent): ... 따라서, 연봉 5천만 원에 대한 소득세는 **624만 원**입니다.

Human: 집이 13억일 때 세금은 얼마인가요?
Ai (Name: house_tax_agent): ... 1세대 1주택자 기준으로 종합부동산세는 **30만 원**입니다.

질문의 의도에 맞는 에이전트가 각각 선택되었습니다. 각 에이전트는 자신의 전문 영역에만 집중할 수 있고, 슈퍼바이저는 전체 작업 흐름을 조율하는 역할을 담당합니다. 마치 전문가 팀을 관리하는 리더가 작업을 적절히 분배하고 조율하는 방식과 유사합니다.

사용자의 의도에 따라 필요한 에이전트를 활용하는 방법은 앞에서 언급한 분할 정복의 원리와 유사합니다. 또한 새로운 도메인이나 기능이 필요할 때, 기존 시스템을 완전히 재구성하지 않아도 새로운 에이전트를 추가하는 방식으로 시스템을 쉽게 확장할 수 있다는 장점이 있습니다.

정리

  • 에이전트 오케스트레이션은 MSA의 오케스트레이션 패턴을 AI에 맞게 발전시킨 것이다. 다른 점은 시맨틱 라우팅·동적 상태 관리·적응형 워크플로다.
  • 오케스트레이터-워커는 미리 정의된 작업을 병렬 처리할 때 좋고, 요구가 유동적이고 대화형 상호작용이 필요하면 슈퍼바이저가 맞다.
  • 노트북을 스크립트로 꺼낼 때는 invoke() 호출을 지우고(임포트 시 토큰 낭비), 셀 간에 공유되던 변수를 함수 안으로 옮긴다.
  • py-zerox는 PDF의 이미지 표까지 마크다운으로 바꿔 주지만, TPM 한도에 걸리면 크레딧 충전 말고는 방법이 없다. 마크다운 로더가 표를 깨면 markdown + BeautifulSoup으로 직접 텍스트화한다.
  • 스크립트로 쓸 벡터 저장소는 from_existing_index() 다. from_documents()는 실행할 때마다 중복 적재한다.
  • 슈퍼바이저는 with_structured_output(Router) 로 답을 세 값 중 하나로 강제하고, Command(goto, update) 로 다음 노드와 상태를 함께 넘긴다.
  • 에이전트를 노드 함수로 감싸는 이유는 어댑터 패턴 — 220V 그래프와 110V 에이전트 사이의 돼지코다. 덕분에 에이전트 내부가 바뀌어도 그래프는 그대로다.
  • 상태 업데이트는 새 메시지만 넘긴다. 전체 목록 복사는 대화가 길어질수록 비용이 커진다.
  • 에이전트 추가는 노드 함수 하나 + members에 이름 한 줄이다.