AI 개발에서 워크플로와 에이전트라는 용어를 자주 들어보셨을 겁니다. 하지만 정확히 무엇이고 어떻게 다른지, 그리고 언제 어떤 것을 사용해야 하는지 헷갈리시는 분들이 많습니다. 오늘은 LangGraph를 통해 이 개념들을 쉽고 명확하게 설명드리겠습니다.
워크플로와 에이전트, 무엇이 다른가요?
먼저 핵심 차이점부터 이해해봅시다. **워크플로(Workflow)**는 미리 정해진 순서대로 일하는 직원과 같습니다. 매뉴얼이 있고, 단계별로 정확히 따라하죠. 반면 **에이전트(Agent)**는 상황을 보고 스스로 판단해서 일하는 경험 많은 직원과 같습니다.
구체적으로 설명하면, 워크플로는 LLM과 도구들이 미리 작성된 코드 경로를 따라 움직입니다. 마치 요리 레시피처럼 1단계, 2단계, 3단계를 순서대로 따라가는 거죠.
에이전트는 다릅니다. LLM이 스스로 다음에 무엇을 할지 결정하고, 어떤 도구를 사용할지도 동적으로 판단합니다. 상황에 따라 유연하게 대응할 수 있지만, 예측하기 어려운 면도 있습니다.
LangGraph 시작하기: 기본 설정
LangGraph를 사용하려면 먼저 기본 설정을 해야 합니다. LLM은 구조화된 출력과 도구 호출을 지원하는 모델이면 됩니다. 여기서는 Anthropic의 Claude를 예시로 사용하겠습니다.
import os
import getpass
from langchain_anthropic import ChatAnthropic
def _set_env(var: str):
if not os.environ.get(var):
os.environ[var] = getpass.getpass(f"{var}: ")
_set_env("ANTHROPIC_API_KEY")
llm = ChatAnthropic(model="claude-3-5-sonnet-latest")
LangGraph의 핵심은 강화된 LLM입니다. 일반 LLM에 구조화된 출력 기능과 도구 호출 기능을 추가한 것이죠. 이것이 워크플로와 에이전트를 만드는 기본 블록이 됩니다.
패턴 1: 프롬프트 체이닝 – 단계별로 차근차근
프롬프트 체이닝은 가장 기본적인 워크플로 패턴입니다. 복잡한 작업을 여러 단계로 나누어, 각 단계의 결과를 다음 단계의 입력으로 사용합니다.
예를 들어 농담을 만드는 과정을 생각해보세요. 처음에 기본 농담을 만들고, 품질을 검사한 후, 통과하지 못하면 개선하고, 마지막으로 완성도를 높이는 단계를 거칩니다.
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
topic: str
joke: str
improved_joke: str
final_joke: str
def generate_joke(state: State):
"""첫 번째 LLM 호출로 초기 농담 생성"""
msg = llm.invoke(f"Write a short joke about {state['topic']}")
return {"joke": msg.content}
def check_punchline(state: State):
"""품질 게이트 - 농담에 펀치라인이 있는지 확인"""
if "?" in state["joke"] or "!" in state["joke"]:
return "Pass"
return "Fail"
def improve_joke(state: State):
"""두 번째 LLM 호출로 농담 개선"""
msg = llm.invoke(f"Make this joke funnier by adding wordplay: {state['joke']}")
return {"improved_joke": msg.content}
def polish_joke(state: State):
"""세 번째 LLM 호출로 최종 다듬기"""
msg = llm.invoke(f"Add a surprising twist to this joke: {state['improved_joke']}")
return {"final_joke": msg.content}
이 패턴은 작업을 명확하게 분해할 수 있고, 각 단계에서 품질을 확인할 수 있을 때 사용하면 좋습니다. 지연 시간이 늘어나는 대신 정확도를 높일 수 있는 트레이드오프가 있습니다.
패턴 2: 병렬화 – 동시에 여러 작업 처리
병렬화는 여러 LLM이 동시에 다른 작업을 수행한 후, 결과를 합치는 패턴입니다. 시간을 절약하거나 다양한 관점을 얻고 싶을 때 유용합니다.
고양이에 대한 콘텐츠를 만든다고 가정해봅시다. 동시에 농담, 이야기, 시를 각각 다른 LLM이 작성하게 할 수 있습니다.
def call_llm_1(state: State):
"""농담 생성"""
msg = llm.invoke(f"Write a joke about {state['topic']}")
return {"joke": msg.content}
def call_llm_2(state: State):
"""이야기 생성"""
msg = llm.invoke(f"Write a story about {state['topic']}")
return {"story": msg.content}
def call_llm_3(state: State):
"""시 생성"""
msg = llm.invoke(f"Write a poem about {state['topic']}")
return {"poem": msg.content}
def aggregator(state: State):
"""모든 결과를 하나로 합치기"""
combined = f"Here's a story, joke, and poem about {state['topic']}!\n\n"
combined += f"STORY:\n{state['story']}\n\n"
combined += f"JOKE:\n{state['joke']}\n\n"
combined += f"POEM:\n{state['poem']}"
return {"combined_output": combined}
병렬화는 독립적인 하위 작업들을 빠르게 처리하고 싶을 때나 여러 관점이나 시도가 필요할 때 효과적입니다. 복잡한 작업에서는 각 측면을 별도의 LLM 호출로 처리하는 것이 더 나은 결과를 가져올 수 있습니다.
패턴 3: 라우팅 – 똑똑한 분기 처리
라우팅은 입력을 분류하여 적절한 전문 작업으로 연결하는 패턴입니다. 마치 병원에서 환자를 적절한 진료과로 안내하는 것과 같습니다.
from typing_extensions import Literal
from pydantic import BaseModel, Field
class Route(BaseModel):
step: Literal["poem", "story", "joke"] = Field(
None, description="라우팅 프로세스의 다음 단계"
)
router = llm.with_structured_output(Route)
def llm_call_router(state: State):
"""입력을 적절한 노드로 라우팅"""
decision = router.invoke([
SystemMessage(content="사용자 요청에 따라 story, joke, poem 중 하나로 라우팅하세요."),
HumanMessage(content=state["input"]),
])
return {"decision": decision.step}
def route_decision(state: State):
if state["decision"] == "story":
return "llm_call_1"
elif state["decision"] == "joke":
return "llm_call_2"
elif state["decision"] == "poem":
return "llm_call_3"
라우팅은 서로 다른 카테고리가 명확하게 구분되고, 각각을 별도로 처리하는 것이 더 효과적일 때 사용합니다. 한 가지 유형의 입력에 최적화하면 다른 유형의 성능이 떨어질 수 있는 상황에서 특히 유용합니다.
패턴 4: 오케스트레이터-워커 – 계획하고 분담하기
오케스트레이터-워커 패턴은 중앙 LLM이 작업을 동적으로 분해하고, 각 하위 작업을 워커 LLM들에게 위임한 후, 결과를 종합하는 방식입니다.
보고서 작성을 예로 들어보겠습니다. 오케스트레이터가 보고서의 섹션들을 계획하고, 각 섹션을 다른 워커가 작성합니다.
from langgraph.types import Send
class Section(BaseModel):
name: str = Field(description="보고서 섹션의 이름")
description: str = Field(description="이 섹션에서 다룰 주요 주제와 개념들")
def orchestrator(state: State):
"""보고서 계획을 생성하는 오케스트레이터"""
report_sections = planner.invoke([
SystemMessage(content="보고서 계획을 생성하세요."),
HumanMessage(content=f"보고서 주제: {state['topic']}"),
])
return {"sections": report_sections.sections}
def llm_call(state: WorkerState):
"""보고서의 한 섹션을 작성하는 워커"""
section = llm.invoke([
SystemMessage(content="제공된 이름과 설명에 따라 보고서 섹션을 작성하세요."),
HumanMessage(content=f"섹션 이름: {state['section'].name}, 설명: {state['section'].description}"),
])
return {"completed_sections": [section.content]}
def assign_workers(state: State):
"""계획의 각 섹션에 워커 할당"""
return [Send("llm_call", {"section": s}) for s in state["sections"]]
이 패턴은 필요한 하위 작업을 미리 예측할 수 없는 복잡한 작업에 적합합니다. 코딩 작업에서 변경해야 할 파일의 수와 각 파일에서의 변경 사항이 작업에 따라 달라지는 경우가 좋은 예입니다.
패턴 5: 평가자-최적화자 – 반복 개선하기
평가자-최적화자 패턴은 한 LLM이 응답을 생성하고, 다른 LLM이 평가와 피드백을 제공하는 루프를 반복하는 방식입니다.
class Feedback(BaseModel):
grade: Literal["funny", "not funny"] = Field(description="농담이 재미있는지 판단")
feedback: str = Field(description="재미없다면 개선 방법 제안")
evaluator = llm.with_structured_output(Feedback)
def llm_call_generator(state: State):
"""농담을 생성하는 LLM"""
if state.get("feedback"):
msg = llm.invoke(f"{state['topic']}에 대한 농담을 쓰되, 이 피드백을 고려하세요: {state['feedback']}")
else:
msg = llm.invoke(f"{state['topic']}에 대한 농담을 써주세요")
return {"joke": msg.content}
def llm_call_evaluator(state: State):
"""농담을 평가하는 LLM"""
grade = evaluator.invoke(f"이 농담을 평가하세요: {state['joke']}")
return {"funny_or_not": grade.grade, "feedback": grade.feedback}
def route_joke(state: State):
"""평가 결과에 따라 라우팅"""
if state["funny_or_not"] == "funny":
return "Accepted"
else:
return "Rejected + Feedback"
이 패턴은 명확한 평가 기준이 있고, 반복적인 개선이 측정 가능한 가치를 제공할 때 효과적입니다. 인간 작가가 문서를 다듬는 과정과 유사합니다.
에이전트: 스스로 판단하는 AI
지금까지는 워크플로였다면, 이제 에이전트를 살펴보겠습니다. 에이전트는 환경 피드백에 기반해 도구를 사용하며 행동하는 LLM 루프로 구현됩니다.
from langchain_core.tools import tool
@tool
def multiply(a: int, b: int) -> int:
"""두 수를 곱합니다."""
return a * b
@tool
def add(a: int, b: int) -> int:
"""두 수를 더합니다."""
return a + b
tools = [add, multiply]
llm_with_tools = llm.bind_tools(tools)
def llm_call(state: MessagesState):
"""LLM이 도구를 호출할지 결정"""
return {"messages": [llm_with_tools.invoke([
SystemMessage(content="당신은 산술 연산을 수행하는 도움이 되는 어시스턴트입니다.")
] + state["messages"])]}
def tool_node(state: dict):
"""도구 호출 수행"""
result = []
for tool_call in state["messages"][-1].tool_calls:
tool = tools_by_name[tool_call["name"]]
observation = tool.invoke(tool_call["args"])
result.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))
return {"messages": result}
def should_continue(state: MessagesState):
"""도구 호출 여부에 따라 계속할지 결정"""
last_message = state["messages"][-1]
if last_message.tool_calls:
return "Action"
return END
에이전트는 필요한 단계 수를 예측하기 어려운 개방형 문제에 사용합니다. 고정된 경로를 하드코딩할 수 없고, LLM이 여러 턴 동안 작동할 가능성이 있으며, 어느 정도 의사결정을 신뢰할 수 있는 상황에서 이상적입니다.
LangGraph의 추가 가치들
LangGraph로 워크플로와 에이전트를 구축하면 몇 가지 중요한 이점을 얻습니다.
**지속성(Persistence)**은 대화 기억과 인간 개입 지점을 지원합니다. 복잡한 작업 중간에 사람의 승인을 받거나, 이전 대화 내용을 기억할 수 있습니다.
**스트리밍(Streaming)**을 통해 워크플로나 에이전트의 출력을 실시간으로 볼 수 있습니다. 사용자는 전체 작업이 완료되기 전에도 중간 결과를 확인할 수 있습니다.
배포와 관찰 가능성도 쉽게 구현할 수 있습니다. 개발에서 프로덕션으로의 전환이 간단하고, 시스템 동작을 모니터링하고 평가하는 도구들이 내장되어 있습니다.
실제 프로젝트에서의 선택 기준
그렇다면 언제 어떤 패턴을 사용해야 할까요?
프롬프트 체이닝은 작업이 명확한 단계로 분해되고, 각 단계에서 품질 검증이 중요할 때 사용합니다. 문서 작성이나 데이터 분석 파이프라인에 적합합니다.
병렬화는 독립적인 작업들을 빠르게 처리하거나, 다양한 관점이 필요한 창작 작업에 유용합니다. 콘텐츠 생성이나 여러 데이터 소스 분석에 좋습니다.
라우팅은 입력 유형이 명확하게 구분되고, 각각 다른 처리 방식이 필요할 때 사용합니다. 고객 문의 분류나 멀티모달 입력 처리에 적합합니다.
오케스트레이터-워커는 복잡하고 예측 불가능한 작업에 사용합니다. 대규모 리포트 생성이나 복잡한 코드 리팩토링 작업에 이상적입니다.
평가자-최적화자는 반복 개선이 명확한 가치를 제공하고, 품질 기준이 정의되어 있을 때 사용합니다. 창작물 개선이나 코드 최적화에 유용합니다.
에이전트는 개방형 문제 해결이나 동적인 의사결정이 필요한 상황에서 사용합니다. 리서치 어시스턴트나 복잡한 문제 해결 도구에 적합합니다.
시작해보기
이론을 이해했다면 이제 실제로 시작해보세요. 간단한 프롬프트 체이닝부터 시작해서 점진적으로 복잡한 패턴들을 실험해보는 것을 추천합니다.
각 패턴의 장단점을 이해하고, 여러분의 특정 사용 사례에 맞는 것을 선택하는 것이 중요합니다. 때로는 여러 패턴을 조합해서 사용하는 것이 최적의 결과를 가져올 수도 있습니다.
LangGraph는 이 모든 패턴들을 쉽게 구현할 수 있는 도구를 제공합니다. 복잡해 보이지만 하나씩 차근차근 따라하다 보면 어느새 강력한 AI 워크플로와 에이전트를 만들 수 있을 것입니다.
가장 중요한 것은 시작하는 것입니다. 완벽한 설계를 기다리지 말고, 작은 프로토타입부터 만들어보세요. 실제로 만들어보면서 각 패턴의 특성과 한계를 체감할 수 있을 것입니다.