12장 — 워크플로 vs. 에이전트

워크플로와 에이전트는 AI 시스템을 구축하는 두 가지 주요 접근 방식입니다. 이 장은 개념 비교로 시작해, 8장에서 LangChain으로 만든 종합부동산세 봇을 두 방식 모두로 다시 구현합니다.

워크플로와 에이전트

워크플로(workflow) 는 미리 정의된 규칙과 순서에 따라 작업을 처리하는 방식입니다. 마치 조리법을 따르는 것처럼 정해진 단계를 순차적으로 실행하며, 각 단계는 명확한 입력과 출력을 가집니다.

워크플로의 강점 내용
예측 가능성 실행 흐름이 명확해 결과를 예측하기 쉽고, 각 단계의 책임과 역할이 구분되어 품질 관리와 테스트가 수월하다
제어 용이성 세밀한 프로세스 제어가 가능하고 디버깅이 쉬우며, 성능 최적화 시 병목 현상을 파악하기 수월하다
구조적 안정성 일관된 결과를 생산하며 규정 준수와 감사가 용이하고, 유지 보수를 체계적으로 수행할 수 있다

단점도 분명합니다. 유연성 부족(예외 상황 대처가 제한적이고 새 요구 사항 반영이 어렵다), 복잡성 증가(조건과 분기가 많아질수록 전체 흐름 관리가 어렵고, 변경 시 전체를 수정해야 하는 상황이 자주 발생한다), 재사용성의 제약입니다.

반면 에이전트(agent) 는 주어진 목표를 달성하는 과정에서 자율적으로 의사결정을 하고 행동하는 시스템입니다. 마치 비서가 업무를 처리하는 것처럼 상황을 판단하고 적절한 행동을 선택합니다.

  • 자율성: 독립적인 의사결정 능력을 갖추고, 목표에 기반해 행동을 선택하며, 상황에 맞는 전략을 수립한다.
  • 적응성: 환경 변화에 유연하게 대응하고, 경험을 바탕으로 학습·개선하며, 새로운 상황에서도 창의적인 해결 방안을 찾아낸다.
  • 상호작용성: 환경과 지속적으로 상호작용하고, 필요하면 다른 에이전트와 협업하며, 사용자와 자연스럽게 대화를 주고받는다.

에이전트의 한계는 예측 불가능성입니다. 행동 결과를 완전히 예측하기 어렵고 일관성을 보장하기도 쉽지 않아 품질 관리에 어려움이 따릅니다. 구현의 복잡성 역시 중요한 한계로, 의사결정 로직이 복잡하고 테스트와 디버깅이 까다로우며 성능 최적화도 워크플로에 비해 어렵습니다.

워크플로는 예측 가능성과 제어 용이성이 강점이며, 에이전트는 유연성과 적응성이 강점입니다. 실제 시스템을 구현할 때는 두 접근 방식의 장점을 결합한 하이브리드 접근이 효과적일 수 있습니다.

책은 더 자세한 비교로 앤트로픽의 Building effective agents를 참고 문헌으로 답니다.

12.1 LangGraph로 구현하는 워크플로

!uv add -q langgraph

LangGraph는 기본적으로 LangChain 문법을 사용하기 때문에 기존에 쓰던 패키지와 환경변수를 그대로 쓸 수 있습니다. retriever10장에서 만든 Pinecone 인덱스를 활용합니다(Chroma를 써도 무방합니다).

① 상태 선언

from typing_extensions import TypedDict

# 그래프의 각 노드가 공유할 상태를 TypedDict로 정의한다.
# 이렇게 타입을 명시하면 상태 관리가 더 안전하고 명확해진다.
class AgentState(TypedDict):
    question: str           # 사용자가 입력한 질문을 저장한다.
    answer: str             # 계산된 세율 정보를 저장한다.
    tax_base: str           # 과세표준 계산 수식을 저장한다.
    tax_deduction: str      # 적용될 공제액을 저장한다.
    market_value_rate: str  # 해당 연도의 공정시장가액비율을 저장한다.

graph_builder = StateGraph(AgentState)

에이전트가 최종 답변인 answer를 생성하는 데 필요한 정보를 미리 선언하는 것입니다.

② 노드 — 상태를 받아 상태를 돌려준다

# AgentState를 입력받아 계산된 공제액을 상태에 추가한다.
def get_tax_deduction(state: AgentState) -> AgentState:
    question = state['question']
    user_deduction = user_deduction_chain.invoke({
        'tax_deductible_response': tax_deductible_response,
        'question': question
    })
    return {'tax_deduction': user_deduction}

노드의 구조가 명확합니다.

노드는 상태를 인수로 받는 함수이고, 실행 결과를 상태로 반환합니다. 노드가 수행한 작업은 모두 state에 기록되어야 하기 때문입니다. 노드 간의 소통, 즉 작업 결과 전달 또한 상태를 이용해 이루어집니다.

다만 예외가 있습니다. 무조건 상태에서 값을 가져와서 작업을 할 필요는 없습니다. 과세표준 계산 공식을 찾는 과정은 사용자 질문과 무관하게 항상 같은 결과를 내야 하므로 상태에 접근하지 않아도 됩니다. 하지만 노드의 작업 수행 결과는 항상 상태로 반환되어야 합니다.

8장에서 만든 네 가지 작업이 그대로 네 개의 노드가 됩니다.

노드 하는 일
get_tax_deduction 공제액을 검색하고 사용자 상황에 맞게 계산
get_tax_base 과세표준 계산 수식을 검색 (고정 질문이라 상태 미접근)
get_market_value_rate Tavily 검색으로 올해 공정시장가액비율 추출
get_house_tax 앞의 세 값과 세율표를 종합해 최종 세액 계산

③ 노드 등록과 이름 짓기

graph_builder.add_node('종합부동산세 계산', get_house_tax)
graph_builder.add_node('과세표준 계산', get_tax_base)

add_node()의 첫 번째 인수는 노드 이름, 두 번째는 함수입니다. 노드 이름을 우리말로 작성해도 되지만 오타 가능성이 높기 때문에 영어로 작성하는 것을 추천합니다. 함수 이름과 노드 이름을 같게 쓴다면 함수만 넣어도 됩니다.

graph_builder.add_node(get_house_tax)
graph_builder.add_node(get_tax_deduction)

④ 에지 — 병렬 처리는 연결 방식으로 표현된다

from langgraph.graph import START, END

graph_builder.add_edge(START, 'get_tax_deduction')
graph_builder.add_edge(START, 'get_tax_base')
graph_builder.add_edge(START, 'get_market_value_rate')
graph_builder.add_edge('get_tax_deduction', 'get_house_tax')
graph_builder.add_edge('get_tax_base', 'get_house_tax')
graph_builder.add_edge('get_market_value_rate', 'get_house_tax')
graph_builder.add_edge('get_house_tax', END)

여기가 핵심입니다. START 노드 다음에 세 개의 노드가 연결되는 것은 해당 작업이 병렬로 처리됨을 의미합니다. 하나의 노드 다음에 또 다른 노드를 연결해 순차적으로 호출할 수도 있지만, 그건 기존에 LangChain으로 작업했던 것과 크게 다르지 않습니다.

8장에서 LangChain의 RunnableParallel이 “LangGraph에서 병렬처리가 가능해지면서 사용 빈도가 크게 줄었다”고 한 대목이 여기서 확인됩니다.

⑤ 컴파일과 실행

graph = graph_builder.compile()

from IPython.display import Image, display
display(Image(graph.get_graph().draw_mermaid_png()))

그래프를 컴파일한 후에는 의도한 대로 그래프가 잘 형성됐는지 꼭 다이어그램을 보며 확인해야 합니다.

                    __start__
                   ╱    │    ╲
get_market_value_rate  get_tax_base  get_tax_deduction
                   ╲    │    ╱
                   get_house_tax
                        │
                     __end__

LangGraph는 LangChain의 문법을 따르기 때문에 컴파일된 그래프도 retriever, llm과 같은 Runnable입니다. 따라서 invoke()로 실행합니다.

graph.invoke({'question': question})
{'question': '내가 10억짜리 집을 2채 가지고 있다면 세금을 얼마나 내나요?',
 'answer': '먼저 과세표준을 계산해야 합니다. ...생략... 납부해야 할 종합부동산세액은 420만 원입니다.',
 'tax_base': '주택에 대한 종합부동산세 과세표준은 다음과 같이 계산됩니다: 과세표준 = (주택의 공시가격 합산 - 공제금액) × 공정시장가액비율.',
 'tax_deductible': '9억 원',
 'market_value_rate': '공정시장가액비율 60%'}

LangChain을 활용할 때와는 다르게 LangGraph에 invoke를 호출하면 상태를 확인할 수 있습니다.

11장에서 말한 “LangChain의 중간 데이터는 휘발성”과 “LangGraph는 모든 과정이 명시적으로 저장·추적된다”의 차이가 이 출력 하나로 드러납니다.

12.2 LangGraph로 구현하는 에이전트

구조가 근본적으로 달라집니다.

노드를 생성하고 각각의 노드를 모두 에지로 연결해주는 워크플로와 달리, 에이전트는 노드 대신 도구를 선언하고, LLM이 스스로 어떤 도구를 사용할지 판단하게 하는 구조입니다.

도구(tool) 는 LLM이 외부 세계와 상호작용할 수 있게 해주는 인터페이스입니다. LLM은 기본적으로 텍스트만 주고받을 수 있지만, 도구를 쓰면 웹 검색, 계산, DB 쿼리, 파일 입출력이 가능해집니다. 앞서 쓴 TavilySearch도 LangChain이 제공하는 도구 중 하나입니다.

Message 네 종류

에이전트를 이해하려면 LangChain의 Message를 알아야 합니다.

Message 역할
SystemMessage AI의 행동과 응답 방식을 정의하는 지침이나 콘텍스트. 대화 시작 시 페르소나, 규칙, 제약 사항을 설정한다
HumanMessage 사용자가 AI에게 보내는 입력이나 질문
AIMessage LLM이 생성한 응답. 도구를 활용하려면 이 안의 tool_calls 기능을 써야 한다
ToolMessage 도구의 실행 결과가 나타나는 형태

그래서 에이전트의 상태는 워크플로와 달라야 합니다. LangGraph가 제공하는 내장 클래스 MessagesState 를 씁니다.

class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

MessagesState타입스크립트의 any 타입처럼 LangChain의 모든 메시지를 처리할 수 있는 AnyMessage들의 리스트이고, add_messages가 새 Message를 추가하는 역할을 합니다.

# 이전 방식은 각 노드가 직접 상태를 수정했지만,
# 이제는 도구들이 메시지를 주고받는 방식으로 동작한다.
graph_builder = StateGraph(MessagesState)

노드를 도구로 바꾸기

from langchain_core.tools import tool

@tool
def get_tax_deductible(question: str) -> str:   # 함수 이름이 달라진다.
    """사용자의 부동산 소유 현황에 대한 질문을 기반으로 세금 공제액을 계산합니다.

    이 도구는 다음 두 단계로 작동합니다:
    1. tax_deductible_chain을 사용하여 일반적인 세금 공제 규칙을 검색
    2. user_deduction_chain을 사용하여 사용자의 특정 상황에 규칙을 적용

    Args:
        question (str): 부동산 소유에 대한 사용자의 질문

    Returns:
        str: 세금 공제액 (예: '9억 원', '12억 원')
    """
    ...
    return tax_deductible

노드와 비교한 주요 변경 사항은 셋입니다.

  • AgentState 타입 대신 직접 문자열을 입력받고 반환한다.
  • 상세한 도구 설명(docstring) 을 포함해 LLM이 도구의 용도를 이해하게 한다.
  • @tool 데커레이터로 LLM이 이 함수를 도구로 인식하게 한다.

동작 방식의 차이가 결정적입니다.

노드를 활용할 때는 상태에 접근해서 사용자의 질문을 개발자가 직접 LLM을 호출해야 했습니다. 반면 도구를 활용하면 LLM이 해당 도구를 활용할 때 필요한 문장으로 변경해서 도구를 호출합니다.

즉 에이전트가 docstring으로 선언된 설명을 기반으로 question을 직접 생성합니다.

에이전트에서는 가독성이 곧 성능이다

이 장에서 가장 실무적인 통찰입니다.

또 하나 주목할 점은 함수의 이름과 함수 내에서 사용되는 변수명을 최대한 통일시킨 것입니다. 워크플로 방식에서는 개발자가 지정한 흐름대로 동작하기 때문에 변수명과 상관없이 개발자가 작성한 로직이 실행됩니다. 하지만 에이전트 방식에서는 LLM이 어떤 도구를 어떻게 사용할지 결정하기 때문에 최대한 가독성이 높은 상태로 코드를 작성해야 합니다. 만약 함수 이름은 deduction이라고 작성하고, 함수 내부의 변수를 deductible이라고 작성한다면, 에이전트가 도구를 의도대로 활용하지 못하는 경우가 종종 발생합니다.

나머지 도구들도 같은 방식으로 변환합니다. get_tax_base_equation사용자 질문과 관계없이 동일하기 때문에 별도의 인수를 넘겨주지 않는 방식으로 구현하고, get_house_tax는 과세표준 계산 공식·공정시장가액비율·공제액·질문 네 개의 인수를 받습니다.

여기서 LLM의 동작 방식이 흥미롭습니다. LLM은 대화 상태에 저장된 메시지들을 분석해 세금 계산에 필요한 값을 찾아냅니다. 즉 사용자와의 대화 내용을 이해하고, 그 안에서 필요한 정보를 추출해 세금 계산 도구를 실행하는 것입니다.

ToolNode와 agent 노드

LangGraph에서 이를 구현하려면 두 요소가 필요합니다. 도구를 사용할 에이전트, 그리고 도구들을 관리할 ToolNode 입니다.

from langgraph.prebuilt import ToolNode

tool_list = [get_tax_deductible, get_tax_base_equation, get_market_value_rate, get_house_tax]
llm_with_tools = llm.bind_tools(tool_list)
tool_node = ToolNode(tool_list)

마지막으로 중요한 점은 에이전트와 LLM 모두 사용 가능한 도구의 목록을 알아야 한다는 것입니다. 에이전트는 작업을 수행하는 과정에서 어떤 도구를 사용할지 결정해야 하고, LLM은 대화 문맥을 이해하고 적절한 도구를 선택하는 데 이 정보를 활용합니다. 따라서 정의한 도구들을 두 곳 모두에 전달해야 합니다.

def agent(state: MessagesState) -> MessagesState:
    system_message_content = """당신의 역할은 주택에 대한 종합부동산세를 계산하는 것입니다.
사용자의 질문이 들어오면, 사용자의 질문을 바탕으로 종합부동산세를 계산해주세요.
종합부동산세를 계산하기 위해서는 과세표준을 어떻게 계산할지 파악해야 하고,
사용자의 질문에 따른 공제액을 파악해야 하고,
사용자의 질문에 따른 공정시장가액비율을 파악해야 합니다.
이 세 가지를 파악하고 나면, 종합부동산세를 계산해주세요.
"""
    system_message = SystemMessage(content=system_message_content)
    messages = [system_message] + state['messages']
    response = llm_with_tools.invoke(messages)
    return {'messages': [response]}

조건부 에지

from langgraph.prebuilt import tools_condition

graph_builder.add_node(agent)
graph_builder.add_node('tools', tool_node)

graph_builder.add_edge(START, 'agent')
graph_builder.add_conditional_edges('agent', tools_condition)
graph_builder.add_edge('tools', 'agent')

tools_condition 은 LangGraph의 내장 함수로, llm_with_tools.invoke()의 결과에 따라 어떤 도구를 실행할지, 아니면 에이전트의 작업을 마무리할지 결정합니다. 조건에 따라 다음 노드가 달라지므로 add_edge() 대신 add_conditional_edges() 를 씁니다. 도구 호출을 감지하면 고정적으로 tools라는 문자열을 반환하도록 설계되었습니다.

__start__ → agent ⇢ __end__
             ↑  ⇣
            tools

점선이 conditional_edge이고, tools → agent로 되돌아오는 순환 구조가 생깁니다. 11장에서 말한 “LangGraph는 순환적 실행 구조를 지원한다”가 여기서 실현됩니다.

stream으로 흐름 들여다보기

from langchain_core.messages import HumanMessage

question = HumanMessage(content='내가 10억짜리 집을 2채 가지고 있다면 세금을 얼마나 내나요?')
for chunk in graph.stream({'messages': [question]}, stream_mode='values'):
    chunk['messages'][-1].pretty_print()

에이전트의 상태인 messages가 LangChain의 Message 리스트이기 때문에 문자열 대신 Message들을 리스트로 넘겨야 합니다.

출력에서 드러나는 것이 흥미롭습니다.

================== Ai Message ==================
Tool Calls:
  get_tax_deductible
    question: 내가 10억짜리 집을 2채 가지고 있다면 공제액은 얼마인가요?
  get_market_value_rate
    question: 내가 10억짜리 집을 2채 가지고 있다면 공정시장가액비율은 어떻게 되나요?
  get_tax_base_equation
    (args 없음)

실행 결과를 보면 LLM이 각 도구를 호출할 때마다 스스로 질문을 변경한 것을 볼 수 있습니다. 공제액 계산 도구와 공정시장가액비율을 확인하는 도구를 호출할 때는 해당 도구에 맞는 질문을 활용했고, 과세표준을 계산하는 도구는 별도의 인수를 받지 않으므로 따로 args를 생성하지 않았습니다.

마지막으로 get_house_tax 도구를 호출할 때는 앞선 도구들의 결과를 인수로 채워 넣습니다.

  get_house_tax
    tax_base_equation: 과세표준 = (주택의 공시가격 합산 - 공제금액) × 공정시장가액비율
    market_value_rate: 60%
    tax_deductible: 9억원
    question: 내가 10억짜리 집을 2채 가지고 있다면 세금을 얼마나 내나요?

결과는 워크플로와 같은 420만 원입니다. 흐름도 닮았습니다. 워크플로에서 get_house_tax 노드를 마지막에 실행하는 것처럼, 에이전트도 최종적으로 get_house_tax라는 도구를 활용합니다. 다른 점은 그 순서를 개발자가 에지로 그렸느냐, LLM이 스스로 판단했느냐입니다.

에이전트 도입을 검토한다면

책이 12장을 닫는 조언이 실용적입니다.

LangGraph를 활용한 에이전트의 강력함은 워크플로보다 방금 작성한 에이전트를 활용할 때 더 체감할 수 있습니다. 목표를 달성하는 데 필요한 작업들을 작은 도구들로 나누고, 해당 도구들을 LLM에 전달하기만 하면 LLM이 스스로 판단해서 문제를 해결합니다. 만약 에이전트 도입을 검토하고 있다면, 현업에서 활용하는 함수들을 도구로 변환하고 간단하게 시스템 프롬프트를 작성해 에이전트에 전달하는 것을 추천합니다. 그렇게 하면 본격적인 프로젝트에 돌입하기 전에 간단하게 가능성을 검증할 수 있고, 개발 과정에서 발생하는 비용을 많이 감축할 수 있을 것입니다.

정리

  • 워크플로는 예측 가능성·제어·감사가 강점이고, 에이전트는 자율성·적응성이 강점이다. 대신 에이전트는 예측 불가능하고 디버깅이 까다롭다.
  • 워크플로의 노드는 상태를 받아 상태를 돌려주는 함수다. 상태를 읽지 않아도 되지만 쓰는 것은 항상이다.
  • 병렬 처리는 에지를 어떻게 잇느냐로 표현된다. START에 세 노드를 달면 셋이 동시에 돈다.
  • graph.invoke()의 반환값에 중간 상태가 전부 남는다. LangChain에서는 사라지던 값들이다.
  • 에이전트로 바꾸는 일은 노드를 @tool로 바꾸고 docstring을 잘 쓰는 것이다. LLM이 그 설명을 보고 인수를 스스로 만든다.
  • 그래서 에이전트에서는 코드 가독성이 곧 성능이다. 함수명과 변수명이 어긋나면 도구를 의도대로 못 쓴다.
  • 도구 목록은 bind_toolsToolNode 두 곳 모두에 전달해야 하고, 분기는 tools_condition + add_conditional_edges로 만든다.
  • 검증은 싸게 시작한다. 현업 함수를 도구로 감싸고 시스템 프롬프트 한 장이면 가능성을 확인할 수 있다.