[LangChain] LangGraph로 Agent 워크플로우 구현하기 — State, Node, Edge, Router
by 우와한개발자
1. LangGraph
1) 개념
- 상태 기반의 그래프 구조로 복잡한 AI Agent 워크플로우를 설계하고 구현하기 위한 프레임워크
- LangChain이 Prompt, Model, Tool, Retriever 등을 연결하는 데 초점이 있다면, LangGraph는 상태 관리, 조건 분기, 반복 실행, 멀티 에이전트 흐름을 명확하게 제어하는 데 적합하다.
2) 핵심 구성요소
| 구성요소 | 설명 |
| State | 그래프 전체에서 공유되는 데이터 저장소 |
| Node | State를 입력받아 작업을 수행하고 변경할 State를 반환하는 함수 |
| Edge | 노드 간 실행 순서를 정의하는 연결 |
| Router | 조건부 엣지에서 다음 노드를 결정하는 함수 |
| Compile | 정의한 그래프를 실행 가능한 객체로 변환하는 단계 |
3) 기본 구조
- 모든 LangGraph는 START에서 시작한다.
- 하나 이상의 노드를 거친 뒤 END에 도달하면 실행이 종료된다.
- 조건부 엣지를 사용하면 분기와 루프를 구현할 수 있다.
START → Node A → Node B → END
4) 사용하는 이유
- 복잡한 워크플로우를 단계별로 제어할 수 있다.
- 조건에 따라 다른 노드로 이동하거나 반복 실행할 수 있다.
- 실행 흐름이 노드와 엣지로 나뉘어 있어 디버깅하기 쉽다.
- Tool Calling, RAG, 검증, 재시도, 사람 승인, 멀티 에이전트 같은 패턴을 구현하기 좋다.
2. State
1) 개념
- 그래프 전체에서 공유되는 데이터 저장소
- Agent가 작업을 수행하는 과정에서 발생하는 중간 결과와 상태 변화는 State를 통해 관리된다.
- 각 노드는 State를 읽고, 필요한 값만 수정하여 반환한다.
- State는 TypedDict, dataclass, Pydantic BaseModel 등으로 정의할 수 있다.
from typing import TypedDict, List
class State(TypedDict):
messages: List[str]
context: str
step: int
2) State 사용 시 주의점
- 노드는 State 전체를 반환하지 않아도 되고 변경된 필드만 반환하면 LangGraph가 기존 State에 반영한다.
- messages처럼 값이 누적되는 필드는 기존 값에 새 값을 더해 반환하거나, reducer를 사용할 수 있다.
return {
"messages": state["messages"] + [new_message]
}
3) Reducer
- 노드가 반환한 값을 기존 State에 어떻게 반영할지 정하는 함수
- reducer를 지정하지 않으면 기본적으로 새 값이 기존 값을 덮어쓴다.
- messages, logs처럼 값을 계속 누적해야 하는 필드는 reducer를 사용한다.
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
- add_messages는 기존 메시지를 유지하면서 새 메시지를 추가하는 대표적인 Reducer이다
3. Node
- State를 입력받아 특정 작업을 수행하고, 업데이트할 State를 반환하는 함수
- LLM 호출, Tool 실행, RAG 검색, 응답 검증, 데이터 변환 등을 Node로 만들 수 있다.
- 하나의 노드는 하나의 역할만 담당하는 것이 좋다.
def my_node(state: State) -> State:
result = llm.invoke(state["messages"][-1])
return {"messages": state["messages"] + [result]}
4. Edge와 Router
1) Edge
- 노드 간 실행 순서 정의
- 일반 엣지 : 항상 정해진 다음 노드로 이동한다
- 조건부 엣지 : State를 기준으로 다음 노드를 분기한다
# 일반 엣지
graph.add_edge("node_a", "node_b")
# 조건부 엣지
def route(state: State):
if state["step"] > 0:
return "node_b"
return "node_c"
graph.add_conditional_edges(
"node_a",
route,
{
"node_b": "node_b",
"node_c": "node_c"
}
)
2) Router
- 조건부 엣지에서 다음에 실행할 노드를 결정하는 함수
- State를 읽고 다음 노드 이름을 문자열로 반환한다.
- Router는 실제 작업을 수행하는 노드가 아니다.
| 구분 | 노드 | 라우팅 함수 |
| 역할 | 실제 작업 수행 | 다음 노드 결정 |
| 입력값 | State | State |
| 반환값 | 업데이트할 State 딕셔너리 | 다음 노드 이름 문자열 |
| 등록 방법 | add_node() | add_conditional_edges() |
6. Compile
- 그래프를 정의한 뒤 compile()을 호출하면 실행 가능한 런타임 객체가 생성된다.
graph = builder.compile()
- compile 단계에서 checkpointer를 연결하면 상태 복원과 메모리 기능을 사용할 수 있다.
graph = builder.compile(checkpointer=memory)
7. LangGraph 구현 흐름
1) 전체 흐름
상태 정의 → StateGraph 생성 → 노드 생성 → 엣지 연결 → 컴파일 → 실행
2) 단계별 설명
- 상태 정의: TypedDict, dataclass, BaseModel 등으로 State 구조를 정의한다.
- StateGraph 생성: State를 기반으로 그래프 builder를 만든다.
- 노드 생성: State를 입력받고 업데이트할 State를 반환하는 함수를 정의한다.
- 엣지 연결: START, 노드, END 사이의 실행 흐름을 연결한다.
- 컴파일: builder.compile()을 호출하여 실행 가능한 그래프로 변환한다.
- 실행: graph.invoke()를 호출하고 최종 State를 반환받는다.
8. LangGraph 구현 예시 - 일반 엣지
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
class State(TypedDict):
question: str
answer: str
llm = ChatOpenAI(model="gpt-4o-mini")
builder = StateGraph(State)
def chatbot_node(state: State) -> State:
response = llm.invoke([("user", state["question"])])
return {"answer": response.content}
builder.add_node("chatbot_node", chatbot_node)
builder.add_edge(START, "chatbot_node")
builder.add_edge("chatbot_node", END)
graph = builder.compile()
result = graph.invoke({"question": "Agent란 무엇인가", "answer": ""})
print(result["answer"])
9. LangGraph 구현 예시 - 조건부 엣지
1) 구조
- 질문 유형을 분류한 뒤, 날씨 질문이면 weather_node, 그 외는 chatbot_node로 분기한다
START → classify_node → weather_node 또는 chatbot_node → END
2) 전체 코드
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
class State(TypedDict):
question: str
question_type: str
answer: str
llm = ChatOpenAI(model="gpt-4o-mini")
def classify_node(state: State) -> State:
question = state["question"]
question_type = "weather" if "날씨" in question or "기온" in question else "chat"
return {"question_type": question_type}
def weather_node(state: State) -> State:
response = llm.invoke([
("system", "당신은 날씨 질문에 답변하는 assistant입니다."),
("user", state["question"])
])
return {"answer": response.content}
def chatbot_node(state: State) -> State:
response = llm.invoke([
("system", "당신은 친절한 AI assistant입니다."),
("user", state["question"])
])
return {"answer": response.content}
def route_by_type(state: State) -> Literal["weather_node", "chatbot_node"]:
return "weather_node" if state["question_type"] == "weather" else "chatbot_node"
builder = StateGraph(State)
builder.add_node("classify_node", classify_node)
builder.add_node("weather_node", weather_node)
builder.add_node("chatbot_node", chatbot_node)
builder.add_edge(START, "classify_node")
builder.add_conditional_edges("classify_node", route_by_type, {
"weather_node": "weather_node",
"chatbot_node": "chatbot_node"
})
builder.add_edge("weather_node", END)
builder.add_edge("chatbot_node", END)
graph = builder.compile()
result = graph.invoke({"question": "오늘 서울 날씨 어때?", "question_type": "", "answer": ""})
print(result["answer"])
10. 조건부 엣지와 루프
1) 개념
- 조건부 엣지를 사용하면 특정 조건을 만족할 때까지 이전 노드로 돌아가는 루프를 만들 수 있다.
- 답변 검증, 재시도, 검색 결과 보완, Tool 재실행 같은 흐름에 사용할 수 있다.
- 루프를 만들 때는 반드시 종료 조건이 있어야 한다.
generate_node → validate_node → 다시 generate_node 또는 END
2) 언제 사용하는가
- 답변이 조건을 충족하지 못하면 다시 생성해야 할 때
- 검색 결과가 부족하면 다시 검색해야 할 때
- 외부 API 호출이 실패하면 재시도해야 할 때
- 사람이 승인할 때까지 대기하는 Human-in-the-loop 패턴을 구현할 때
3) 구현 예시
- 답변 길이가 너무 짧으면 다시 생성하는 루프 예시다.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
class State(TypedDict):
question: str
answer: str
retry_count: int
llm = ChatOpenAI(model="gpt-4o-mini")
def generate_node(state: State) -> State:
response = llm.invoke([("user", state["question"])])
return {
"answer": response.content,
"retry_count": state["retry_count"] + 1
}
def validate_node(state: State) -> State:
return state
def route_validate(state: State):
# 답변이 너무 짧거나 재시도 횟수가 3회 미만이면 다시 생성
if len(state["answer"]) < 50 and state["retry_count"] < 3:
return "generate_node"
return END
builder = StateGraph(State)
builder.add_node("generate_node", generate_node)
builder.add_node("validate_node", validate_node)
builder.add_edge(START, "generate_node")
builder.add_edge("generate_node", "validate_node")
builder.add_conditional_edges("validate_node", route_validate, {
"generate_node": "generate_node",
END: END
})
graph = builder.compile()
result = graph.invoke({
"question": "LangGraph란 무엇인가?",
"answer": "",
"retry_count": 0
})
print(result["answer"])
print(f"재시도 횟수: {result['retry_count']}")
4) 주의점
- 종료 조건이 없으면 무한 루프가 발생한다
- retry_count처럼 최대 반복 횟수를 State에 저장하고 종료 조건에 포함시키는 것이 좋다
- 루프가 깊어질수록 LLM 호출 비용이 증가하므로 적절한 상한을 설정
'AI > LangChain' 카테고리의 다른 글
블로그의 정보
우와한개발자 님의 블로그
우와한개발자