AI-LLM을 활용하는 프로젝트 진행 시, LangGraph를 많이 사용한다고 들었다.
아는 바가 없어서, 아래 문서를 읽으며 정리하였다.
모든 노드가 공유하는 데이터 저장소
Python의 TypedDict
를 사용해서 정의한다
어떤 종류의 데이터든 저장할 수 있다
# 모든 노드가 같은 State를 보고 수정할 수 있다
class SimpleState(TypedDict):
count: int
name: str
실제 작업을 수행하는 Python 함수
현재 상태를 받아서 새로운 상태를 돌려준다
항상 state
를 첫 번째 매개변수로 받는다
딕셔너리 형태로 새로운 상태를 반환한다
반환하지 않은 필드는 기존 값을 유지한다
# 카운터를 1 증가시키는 노드
def add_one(state):
return {"count": state["count"] + 1}
# 이름을 설정하는 노드
def set_name(state):
return ("name": "Alice"}
노드들 사이의 연결을 정의
“이 작업 다음에 저 작업을 하자”라고 알려주는 역할
# 기본적인 엣지 연결
graph.add_edge("노드1", "노드2") # 노드1 → 노드2
# 시작과 끝 연결
graph.add_edge(START, "첫번째노드") # 시작 → 첫번째노드
graph.add_edge("마지막노드", END) # 마지막노트 → 끝
TypedDict
나 Pydantic BaseModel
을 사용하여 정의from typing import TypedDict
clas MyState(TypedDict):
counter: int
TypedDict
는 딕셔너리의 키와 값의 타입을 미리 정의할 수 있게 해주는 도구MyState
는 TypedDict
를 기반으로 함MyState
클래스 안에 counter
라는 항목을 만들고, 이는 정수(int) 타입LangGraph에서 상태 관리는 AI 시스템의 핵심이다.
🥨 상태(State)는 시스템이 처리하는 모든 정보를 담는 그릇이며, 노드 간에 데이터를 전달하고 축적하는 역할을 한다.
# 간단한 상태 예시
class ChatState(TypedDict):
user_message: str # 현재 사용자 메시지 -- 각 노드 실행 시 덮어쓰기된다
chat_history: list # 이전 대화 기록 -- 대화의 연속성을 위해 누적되는 리스트
user_context: dict # 사용자 정보
system_status: str # 시스템 상태
TypedDict
사용 이유dict
처럼 동작하지만, 개발 시 타입 안정성을 제공한다일관성 유지: 복잡한 대화 흐름에서 맥락을 일관되게 유지하여 사용자 경험 향상
# 대화 맥락 유지 예시
def conversation_node(state: ChatState):
# 이전 대화 내용을 참조하여 일관된 응답 생성
context = "\n".join(state["chat_history"][-3:]) # 최근 3개 대화
response = generate_contextual_response(state["user_message"], context)
return {"ai_response": response} # 변경할 필드만 반환 -- 자동으로 기존 상태와 병합
맥락 이해: 단순한 키워드 매칭을 넘어선다
복잡한 작업 처리
오류 복구 및 중단점 관리
성능 최적화: 불필요한 계산을 줄여 전반적인 시스템 성능을 향상시킨다
from typing_extensions import TypedDict
from typing import Annotated, List
from operator import add
class BasicState(TypedDict):
# 단순 값들 - 덮어쓰기 방식
current_step: str
user_id: str
# 누적되는 값들 - 추가 방식
# -- Annotated는 Python 3.9에서 도입된 타입 힌팅 기능
# -- 첫 번째 인자: 실제 타입, 두 번째 인자: 메타데이터(여기서는 리듀서 함수 add)
# -- LangGraph는 이 메타데이터를 읽어서 상태 업데이트 방식 결정
messages: Annotated[List[str], add] # “List[str] 타입 힌팅: 문자열만 들어있는 리스트다”
processing_log: Annotated[List[str], add]
current_step
, user_id
→ 새 값이 들어오면 기존 값은 완전히 사라진다messages
, processing_log
from operator import add
# 리스트의 경우
result = add([1, 2], [3, 4]) # [1, 2, 3, 4]
# 문자열의 경우
result = add("Hello", " World") # "Hello World"
# 숫자의 경우
result = add(5, 3) # 8
🥨 리듀서는 LangGraph에서 상태 업데이트 로직을 정의하는 핵심 메커니즘
왜 필요한가?
# 리듀서 없음 = 덮어쓰기 (Default Reducer)
old_state = {"counter": 5}
new_update = {"counter": 10}
result = {"counter": 10} # 기존 값이 완전히 대체됨
# 리듀서 있음 = 사용자 정의 동작
from operator import add
old_state = {"items": [1, 2, 3]}
new_update = {"items": [4, 5]}
result = {"items": [1, 2, 3, 4, 5]} # 리스트가 연결됨
def increment(state): # state라는 매개변수를 받는다 - 이는 현재 그래프 상태
return {"counter": state["counter"] + 1} # state의 counter 값을 1 증가시킨 새로운 상태를 반환
graph.add_node("increment", increment) # 정의한 함수를 그래프의 노드로 추가
# 첫 번째 인자: 노드의 이름
# 두 번째 인자: 앞서 정의한 함수
🥨 노드의 역할
→ 이렇게 정의된 노드는 그래프 내에서 하나의 작업 단위로 동작하며, 그래프의 전체 흐름에 따라 순차적으로 또는 조건에 따라 실행된다.
: 각 노드는 그래프의 한 단계를 나타내며, 데이터를 처리하고 변환하는 역할을 한다
State
클래스는 그래프가 관리할 상태의 구조를 정의한다
StateGraph(State)
로 그래프를 생성하고, add_node
로 노드를 추가한다
노드는 변경할 필드만 딕셔너리로 반환한다
LangGraph에서 노드는 애플리케이션의 핵심 구성 요소로, 그래프 내에서 특정 작업을 수행하는 독립적인 처리 단위이다.
노드의 가장 중요한 특징은 상태 중심 설계이다.
노드의 주요 책임은 크게 세 가지로 나눌 수 있다.
LangGraph의 노드는 모듈성과 재사용성을 강조한다.
TypedDict
를 활용한 상태 스키마를 통해 컴파일 시점에서 많은 오류를 방지Annotated[list, operator.add]
와 같은 어노테이션 → 리스트에 새 항목을 추가서브그래프와 Private State 개념을 통해 더욱 복잡한 아키텍처도 지원한다
노드는 또한 에러 처리와 복구에도 중요한 역할을 한다.
노드는 성능 최적화의 핵심 단위이다.
⇒ 이러한 특성들이 결합되어 LangGraph의 노드는 확장 가능하고 유지보수가 용이한 AI 애플리케이션 개발의 기반을 제공
노드의 타입은 주로 실행 방식과 처리 패턴에 따라 구분되며, 이는 애플리케이션의 성능과 구조에 큰 영향을 미친다.
def sync_node(state: State) -> Dict[str, Any]:
"""
동기 노드 - 일반적인 노드 타입
순차적으로 실행되며 결과를 즉시 반환
"""
# 동기 작업 수행
result = perform_sync_operation(state["input"])
return {"output": result}
def perform_sync_operation(data):
"""동기 작업"""
import time
time.sleep(0.1) # 시뮬레이션
return f"Sync result: {data}"
async/await
패턴을 활용하여 여러 작업을 동시에 실행asyncio.gather()
를 통해 병렬 처리 구현async def async_node(state: State) -> Dict[str, Any]: # async def로 정의된 비동기 노드
"""
비동기 노드 - I/O 작업에 효율적
동시에 여러 작업을 처리할 수 있음
"""
# 비동기 작업 수행
result = await perform_async_operation(state["input"])
# 여러 비동기 작업 동시 실행
results = await asyncio.gather( # 여러 비동기 작업을 동시에 실행하고 모든 결과를 기다림
# 독립적인 비동기 작업을 시뮬레이션 & 동시 실행
fetch_data_1(),
fetch_data_2(),
fetch_data_3()
)
return {
"output": result,
"additional_data": results
}
def conditional_node(state: State) -> Dict[str, Any]:
"""
조건부 노드 - 상태에 따라 다른 처리 수행
"""
mode = state.get("mode", "default")
if mode == "fast":
# 빠른 처리
result = quick_process(state)
execution_time = "fast"
elif mode == "thorough":
# 정밀 처리
result = thorough_process(state)
execution_time = "slow"
else:
# 기본 처리
result = default_process(state)
execution_time = "normal"
return {
"result": result,
"execution_time": execution_time,
"mode_used": mode
}
from typing import Optional
class ValidationState(TypedDict):
input_data: Any
validation_errors: list[str]
is_valid: bool
def validation_node(state: ValidationState) -> Dict[str, Any]:
"""
검증 노드 - 데이터 유효성 검사
"""
input_data = state["input_data"]
errors = []
# 다양한 검증 수행
if not input_data:
errors.append("입력 데이터가 비어있습니다.")
if isinstance(input_data, str):
if len(input_data) < 3:
errors.append("문자열이 너무 짧습니다 (최소 3자).")
if len(input_data) > 1000:
errors.append("문자열이 너무 깁니다 (최대 1000자).")
if not input_data.isascii():
errors.append("ASCII 문자만 허용됩니다.")
...
# 검증 결과 반환
return {
"validation_errors": errors,
"is_valid": len(errors) == 0
}
각 노드 타입은 특정 사용 사례와 성능 요구사항에 최적화되어 있다
노드 패턴의 조합을 통해 더욱 복잡하고 강력한 워크플로우를 구성할 수 있다
각 노드 타입은 에러 처리와 모니터링에서도 다른 접근 방식이 필요하다
타입 안전성도 중요한 고려사항이다
TypedDict
를 활용한 상태 스키마와 함께 각 노드 타입의 입출력을 명확히 정의LangGraph에서 노드의 구성은 런타임에 노드의 동작을 동적으로 조정할 수 있는 메커니즘이다.
RunnableConfig
는 LangChain의 핵심 구성 객체로, 노드가 실행될 때 런타임 설정을 전달 받게 한다.
RunnableConfig
를 매개변수로 받으면, 실행 시점에 구성 값들을 참조하여 처리 로직 조정핵심 활용 영역
구성 접근 패턴은 계층적 구조를 따른다.
동적 처리 로직의 구현에서는 구성값에 따라 다른 알고리즘이나 처리 방식을 선택할 수 있다.
사용자별 개인화도 중요한 활용 사례이다.
구성의 전파와 상속에서는 부모 그래프의 구성이 서브그래프나 하위 노드로 자동 전파되며, 필요에 따라 특정 노드에서 구성을 오버라이드할 수 있다.
성능 최적화에서도 구성이 중요한 역할을 한다.
모니터링과 관찰 가능성을 위해 구성을 활용한다.
보안 고려사항에는 적절한 암호화와 접근 제어가 필요하다.
__call__
메서드를 구현하여 호출 가능한 객체로 만들어짐패턴들의 조합과 응용 관점
성능과 메모리 고려사항
테스트와 디버깅
엣지는 LangGraph에서 노드 간의 연결을 나타낸다.
# 그래프 실행이 시작되면 바로 increment 노드로 이동
graph.add_edge(START, "increment") # START는 그래프의 시작점을 나타내는 특별한 상수
# increment 노드의 실행이 끝나면 그래프 실행을 종료
graph.add_edge("increment", END) # END는 그래프의 종료점을 나타내는 특별한 상수
엣지의 핵심 특징
엣지 타입
# 1. 일반 엣지 (Normal Edge)
graph.add_edge("node_a", "node_b")
# 2. 시작 엣지 (Start Edge)
graph.add_edge(START, "first_node")
# 3. 종료 엣지 (End Edge)
graph.add_edge("last_node", END)
# 4. 조건부 엣지 (Conditional Edge)
graph.add_conditional_edges(
"decision_node",
routing_function, # routing_function이 반환하는 값에 따라 다른 노드로 분기
{
"option_a": "node_a",
"option_b": "node_b"
}
)
add_conditional_edges
→ 상태 값, 계산 결과, 외부 조건 등에 따라 동적으로 다음 노드 결정Literal
타입 사용 → 가능한 경로들을 명시적으로 정의 → 타입 안전성 보장: 런타임 상태에 따라 동적으로 실행 경로를 결정할 수 있게 해주는 기능
기본 조건부 엣지
Literal
타입을 사용하여 가능한 모든 경로를 명시적으로 정의한다 → 타입 안전성 보장# 조건부 엣지 설정
sentiment_graph.add_edge(START, "analyze")
sentiment_graph.add_conditional_edges(
"analyze",
sentiment_router,
{
"positive": "positive",
"negative": "negative",
"neutral": "neutral"
}
)
LangGraph에서 “다음에 어디로 갈지”와 “상태를 어떻게 바꿀지”를 한 번에 결정
if stock >= quantity:
# 재고 충분 - 결제 처리로 이동
# "결제 처리로 가면서 동시에 상태 메시지 업데이트"를 한 번에 수행
return Command(
goto="process_payment",
update={
"status": "재고 확인 완료",
"messages": state["messages"] + [f"{item} {quantity}개 재고 확인됨"]
}
)
Command를 사용하면 좋은 경우:
조건부 엣지를 사용하면 좋은 경우:
graph = StateGraph(MyState)
: StateGraph 객체 생성 → MyState 상태 기반의 그래프 정의app -= graph.compile()
: 정의된 그래프를 실행 가능한 형태로 변환result = app.invoke({"counter": 0})
: invoke()
는 컴파일된 그래프 실행, 인자로 전달된 {”counter”: 0}
는 초기 상태(MyState 클래스의 구조와 일치)🥨
Annotated
란?
- Python에서
Annotated
는 타입 힌트에 “추가 메타데이터(부가 정보)”를 달아주는 문법from typing import Annotated
Annotated[원래_타입, 부가정보...]
- 첫 번째 인자: 원래 타입 (int, str, list[str], …)
- 그 뒤 인자들: 메타데이터 (아무 Python 객체나 넣을 수 있음)