ARCH · STATE MACHINE

State Machine 설계

🗺️ StateGraph 설계 📌 체크포인팅 & 브레이크포인트 👤 Human-in-the-Loop

LangGraph StateGraph로 복잡한 Agent 워크플로우를 명시적 상태 머신으로 표현하고, 체크포인팅·브레이크포인트·서브그래프로 프로덕션 수준의 제어를 구현합니다.

StateGraph 핵심 개념

개념역할구현
State그래프 전체에서 공유되는 데이터 구조TypedDict 또는 Pydantic BaseModel
Node상태를 변환하는 함수 (동기/비동기)async def node(state) -> dict
Edge노드 간 전이 규칙add_edge / add_conditional_edges
Checkpointer각 단계마다 상태를 영구 저장MemorySaver / AsyncPostgresSaver
Interrupt특정 노드에서 실행 일시 중단interrupt_before / interrupt_after
Subgraph복잡한 그래프를 노드로 캡슐화compiled_subgraph.compile()

상태 설계 패턴

State는 그래프의 "진실의 단일 원천"입니다. Annotated로 리듀서를 지정해 동시 노드 업데이트를 안전하게 처리합니다.

pythonstate_design.py — 상태 설계 베스트 프랙티스
from typing import TypedDict, Annotated, Literal
from operator import add
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages

class WorkflowState(TypedDict):
    # add_messages: 새 메시지를 리스트에 추가 (덮어쓰지 않음)
    messages: Annotated[list[BaseMessage], add_messages]

    # add: 각 노드 결과를 누적 (병렬 노드 안전)
    search_results: Annotated[list[str], add]
    errors: Annotated[list[str], add]

    # 단순 덮어쓰기 (최신값만 유지)
    current_plan: list[str]
    status: Literal["planning", "executing", "reviewing", "done"]
    iteration: int
    human_approved: bool

# Private 상태 (서브그래프 내부에서만 사용)
class SearchSubState(TypedDict):
    query: str
    raw_results: list        # 외부에 노출 안 됨
    filtered_results: list   # 외부에 노출 안 됨
    final_summary: str       # 이것만 WorkflowState.search_results에 병합

체크포인팅과 브레이크포인트

브레이크포인트는 특정 노드 실행 전후에 그래프를 일시 중단시킵니다. Human-in-the-Loop 승인 게이트나 디버깅에 활용합니다.

pythonbreakpoints.py — 브레이크포인트와 재개
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, END

checkpointer = MemorySaver()

# 브레이크포인트: human_review 노드 실행 전 중단
graph = builder.compile(
    checkpointer=checkpointer,
    interrupt_before=["human_review"],    # 노드 실행 전 중단
    interrupt_after=["dangerous_action"]  # 노드 실행 후 중단
)

config = {"configurable": {"thread_id": "workflow-001"}}

# 1단계: 브레이크포인트까지 실행
state = await graph.ainvoke(
    {"messages": [{"role": "user", "content": "데이터베이스 마이그레이션 실행"}]},
    config=config
)
# → human_review 노드 직전에 중단됨

# 2단계: 현재 상태 확인
current = await graph.aget_state(config)
print(f"중단 위치: {current.next}")  # ('human_review',)
print(f"현재 계획: {current.values['current_plan']}")

# 3단계: 사람이 검토 후 승인 — 상태 업데이트 후 재개
await graph.aupdate_state(
    config,
    {"human_approved": True},
    as_node="human_review"  # 어느 노드가 업데이트했는지 명시
)

# 4단계: 중단점 이후부터 재개 (None 입력으로 계속)
result = await graph.ainvoke(None, config=config)

# 거부 시: 다른 상태로 업데이트 후 재개
await graph.aupdate_state(
    config,
    {"human_approved": False, "status": "done"},
    as_node="human_review"
)

Human-in-the-Loop 패턴

interrupt() 함수를 노드 내부에서 호출해 세밀한 제어가 가능합니다. 브레이크포인트보다 유연한 방식입니다.

pythonhitl.py — 노드 내 interrupt() 활용
from langgraph.types import interrupt, Command

async def approval_node(state: WorkflowState) -> dict:
    """위험한 작업 전 사람의 승인을 요청"""
    plan_summary = "\n".join(
        f"{i+1}. {step}" for i, step in enumerate(state["current_plan"])
    )

    # 실행 중단 + 사람에게 데이터 전달
    human_decision = interrupt({
        "type": "approval_required",
        "message": f"다음 계획을 실행합니다:\n{plan_summary}\n\n승인하시겠습니까?",
        "plan": state["current_plan"]
    })

    # interrupt() 반환값: 사람이 aupdate_state()로 전달한 값
    if human_decision == "approve":
        return {"human_approved": True, "status": "executing"}
    elif human_decision == "reject":
        return {"human_approved": False, "status": "done"}
    else:
        # 수정된 계획 받기
        return {
            "current_plan": human_decision["revised_plan"],
            "status": "planning"
        }

# API에서 사람의 응답 처리
async def handle_human_response(thread_id: str, decision: str):
    config = {"configurable": {"thread_id": thread_id}}
    # interrupt()의 반환값으로 decision을 전달
    await graph.aupdate_state(config, Command(resume=decision))
    # 재개
    return await graph.ainvoke(None, config=config)

서브그래프 — 복잡성 캡슐화

pythonsubgraph.py — 서브그래프로 모듈화
# ─── 서브그래프 정의 ─────────────────────────────────
search_builder = StateGraph(SearchSubState)
search_builder.add_node("fetch",   fetch_node)
search_builder.add_node("filter",  filter_node)
search_builder.add_node("summarize", summarize_node)
search_builder.set_entry_point("fetch")
search_builder.add_edge("fetch", "filter")
search_builder.add_edge("filter", "summarize")
search_builder.add_edge("summarize", END)
search_subgraph = search_builder.compile()

# ─── 메인 그래프에서 서브그래프를 노드로 사용 ────────
async def search_adapter(state: WorkflowState) -> dict:
    """WorkflowState → SearchSubState 변환 후 서브그래프 실행"""
    sub_result = await search_subgraph.ainvoke({
        "query": state["messages"][-1].content
    })
    # 서브그래프 출력 → 메인 상태로 병합
    return {"search_results": [sub_result["final_summary"]]}

main_builder.add_node("search", search_adapter)
💡 StateGraph 설계 원칙
  • 상태를 작게 — 노드 간 공유가 필요한 데이터만 State에 포함
  • 노드는 순수 함수처럼 — 입력 상태만 보고 출력 상태 반환, 사이드이펙트 최소화
  • 리듀서 명시 — 병렬 노드가 있으면 반드시 Annotated로 리듀서 지정
  • 서브그래프 활용 — 5개 이상의 노드로 구성된 로직은 서브그래프로 분리