Last active
June 6, 2026 17:50
-
-
Save Taehun/5dad8fe8d06e09f0544fc0aa05ddc684 to your computer and use it in GitHub Desktop.
LangGraph 단계별 학습 예제
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| ═══════════════════════════════════════════════════════════════════════════ | |
| LangGraph 단계별 학습 예제 — 기본 개념부터 심화까지 | |
| ═══════════════════════════════════════════════════════════════════════════ | |
| 이 파일은 위에서 아래로 읽으면 LangGraph의 핵심 개념이 순서대로 쌓이도록 | |
| 설계되었습니다. 각 PART는 독립 실행 가능하며, 앞 PART의 개념 위에 다음 개념을 | |
| 하나씩 얹습니다. | |
| PART 1. State — 그래프가 들고 다니는 "공유 상태" | |
| PART 2. Node & Edge — 상태를 변환하는 함수와 그 연결 | |
| PART 3. Reducer — 상태 필드를 "어떻게 합칠지" 정의 | |
| PART 4. 조건부 엣지 — 런타임 데이터로 분기 | |
| PART 5. 순환(loop) — 그래프가 되돌아가며 반복 (에이전트의 핵심) | |
| PART 6. 체크포인터 — 상태 영속화 + 중단/재개 | |
| PART 7. 병렬 실행 — fan-out / fan-in | |
| PART 8. HITL — 사람이 중간에 개입 (interrupt) | |
| ★ 의도적으로 외부 LLM API를 쓰지 않습니다. | |
| 대신 결정론적인 "가짜 모델 함수"로 로직을 대체해, API 키 없이도 그래프의 | |
| '구조와 흐름'에만 집중할 수 있게 했습니다. 실제로는 이 자리에 LLM 호출이 들어갑니다. | |
| 실행: python langgraph_tutorial.py | |
| 필요: pip install langgraph | |
| """ | |
| from typing import Annotated, TypedDict, Literal | |
| import operator | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # PART 1. State — 그래프의 심장 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # | |
| # LangGraph의 모든 것은 "State(상태)"를 중심으로 돕니다. | |
| # State는 그래프가 실행되는 동안 노드들 사이를 흘러다니는 공유 데이터입니다. | |
| # | |
| # 일반 함수 체인과의 결정적 차이: | |
| # - 함수 체인: A의 리턴값이 B의 인자로 전달 (단방향, 단일 값) | |
| # - LangGraph: 모든 노드가 같은 State 객체를 읽고/부분 수정 (구조적, 다중 필드) | |
| # | |
| # State는 보통 TypedDict로 정의합니다. (타입 안전 + 가독성) | |
| class BasicState(TypedDict): | |
| topic: str # 입력 | |
| draft: str # 중간 산출물 | |
| result: str # 최종 산출물 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # PART 2. Node & Edge — 상태를 변환하고 연결하기 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # | |
| # Node(노드): State를 받아서, "바뀐 부분만" dict로 리턴하는 함수. | |
| # - 전체 State를 리턴할 필요 없음. 바꾼 키만 리턴하면 LangGraph가 병합. | |
| # Edge(엣지): 노드 실행 순서를 정의하는 연결선. | |
| # | |
| # 아래는 가장 단순한 선형 그래프: write_draft → polish | |
| from langgraph.graph import StateGraph, START, END | |
| def write_draft(state: BasicState) -> dict: | |
| # state["topic"]을 읽어서 draft를 만든다 (여기선 가짜 로직) | |
| topic = state["topic"] | |
| return {"draft": f"[초안] {topic}에 대한 글"} # ← 바뀐 키만 리턴 | |
| def polish(state: BasicState) -> dict: | |
| return {"result": state["draft"].replace("[초안]", "[완성]")} | |
| def build_basic_graph(): | |
| # 1) State 스키마를 지정해 그래프 빌더 생성 | |
| g = StateGraph(BasicState) | |
| # 2) 노드 등록 (이름, 함수) | |
| g.add_node("write", write_draft) | |
| g.add_node("polish", polish) | |
| # 3) 엣지로 흐름 정의: START → write → polish → END | |
| g.add_edge(START, "write") # START: 그래프 진입점 (특수 노드) | |
| g.add_edge("write", "polish") | |
| g.add_edge("polish", END) # END: 그래프 종료점 (특수 노드) | |
| # 4) 컴파일 → 실행 가능한 그래프 객체 | |
| return g.compile() | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # PART 3. Reducer — "상태를 어떻게 합칠 것인가" | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # | |
| # 기본 동작: 노드가 어떤 키를 리턴하면, 그 키는 "덮어쓰기"됩니다. | |
| # {"draft": "A"} 다음에 {"draft": "B"} → draft는 "B" (이전 값 소멸) | |
| # | |
| # 하지만 "누적"하고 싶을 때가 있습니다. 대표적으로 메시지 히스토리. | |
| # 이때 Reducer를 씁니다. Annotated[타입, reducer함수]로 선언하면, | |
| # 그 필드는 덮어쓰기 대신 reducer 로직으로 병합됩니다. | |
| # | |
| # operator.add 를 reducer로 주면: 리스트가 이어붙여집니다(append). | |
| class ReducerState(TypedDict): | |
| # logs 필드는 노드가 리턴할 때마다 "덮어쓰기"가 아니라 "이어붙이기" | |
| logs: Annotated[list[str], operator.add] | |
| counter: int # reducer 없음 → 덮어쓰기 | |
| def step_a(state: ReducerState) -> dict: | |
| return {"logs": ["A 실행됨"], "counter": 1} | |
| def step_b(state: ReducerState) -> dict: | |
| # logs는 누적되고(["A 실행됨", "B 실행됨"]), counter는 덮어써짐(1→2) | |
| return {"logs": ["B 실행됨"], "counter": 2} | |
| def build_reducer_graph(): | |
| g = StateGraph(ReducerState) | |
| g.add_node("a", step_a) | |
| g.add_node("b", step_b) | |
| g.add_edge(START, "a") | |
| g.add_edge("a", "b") | |
| g.add_edge("b", END) | |
| return g.compile() | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # PART 4. 조건부 엣지 — 런타임 데이터로 분기 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # | |
| # 지금까지는 흐름이 고정(static)이었습니다. 하지만 진짜 에이전트는 | |
| # "상태를 보고 어디로 갈지 결정"해야 합니다. 이것이 조건부 엣지입니다. | |
| # | |
| # add_conditional_edges(출발노드, 라우팅함수, 매핑) | |
| # - 라우팅함수: State를 받아 "다음 노드 이름(문자열)"을 리턴 | |
| # - 매핑: 리턴된 문자열 → 실제 노드 이름 | |
| class RouteState(TypedDict): | |
| text: str | |
| sentiment: str | |
| response: str | |
| def classify(state: RouteState) -> dict: | |
| # 가짜 감성 분류 (실제로는 LLM) | |
| s = "positive" if "좋" in state["text"] else "negative" | |
| return {"sentiment": s} | |
| def handle_positive(state: RouteState) -> dict: | |
| return {"response": "기쁜 소식이네요!"} | |
| def handle_negative(state: RouteState) -> dict: | |
| return {"response": "도움을 드리겠습니다."} | |
| def route_by_sentiment(state: RouteState) -> Literal["pos", "neg"]: | |
| # State를 보고 분기 결정. 리턴 문자열은 아래 매핑의 키. | |
| return "pos" if state["sentiment"] == "positive" else "neg" | |
| def build_conditional_graph(): | |
| g = StateGraph(RouteState) | |
| g.add_node("classify", classify) | |
| g.add_node("pos_handler", handle_positive) | |
| g.add_node("neg_handler", handle_negative) | |
| g.add_edge(START, "classify") | |
| # 조건부 엣지: classify 후, route_by_sentiment의 리턴값으로 분기 | |
| g.add_conditional_edges( | |
| "classify", | |
| route_by_sentiment, | |
| {"pos": "pos_handler", "neg": "neg_handler"}, # 라우팅키 → 노드 | |
| ) | |
| g.add_edge("pos_handler", END) | |
| g.add_edge("neg_handler", END) | |
| return g.compile() | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # PART 5. 순환(Loop) — 에이전트의 본질 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # | |
| # 조건부 엣지가 "이전 노드"로 돌아가게 만들면 순환이 생깁니다. | |
| # 이것이 에이전트 루프의 핵심입니다: | |
| # 생성 → 평가 → (불충분하면) 다시 생성 → 평가 → ... → (충분하면) 종료 | |
| # | |
| # 아래는 Generator/Evaluator 루프. Evaluator가 통과시킬 때까지 반복. | |
| # (태훈님의 Planner/Implementer/Evaluator 패턴의 축소판) | |
| # | |
| # ★ 무한 루프 방지: 시도 횟수를 State에 기록하고 최대치에서 강제 종료. | |
| class LoopState(TypedDict): | |
| task: str | |
| attempt: int | |
| quality: int # 0~100, 가짜 품질 점수 | |
| output: str | |
| def generate(state: LoopState) -> dict: | |
| attempt = state.get("attempt", 0) + 1 | |
| # 시도할수록 품질이 오른다고 가정 (실제로는 LLM 재생성) | |
| quality = min(40 + attempt * 25, 100) | |
| return { | |
| "attempt": attempt, | |
| "quality": quality, | |
| "output": f"{state['task']} 결과물 (시도 {attempt}, 품질 {quality})", | |
| } | |
| def should_retry(state: LoopState) -> Literal["retry", "done"]: | |
| # 평가(Evaluator) 역할: 품질 80 미만이고 5회 미만이면 재시도 | |
| if state["quality"] < 80 and state["attempt"] < 5: | |
| return "retry" | |
| return "done" | |
| def build_loop_graph(): | |
| g = StateGraph(LoopState) | |
| g.add_node("generate", generate) | |
| g.add_edge(START, "generate") | |
| # generate 후 평가 → retry면 generate로 되돌아감(순환!), done이면 END | |
| g.add_conditional_edges( | |
| "generate", | |
| should_retry, | |
| {"retry": "generate", "done": END}, | |
| ) | |
| return g.compile() | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # PART 6. 체크포인터 — 상태 영속화 + 중단/재개 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # | |
| # 지금까지 그래프는 실행이 끝나면 상태가 사라졌습니다(메모리상). | |
| # 체크포인터를 붙이면 "매 스텝마다 상태가 저장"되어: | |
| # - 서버가 죽어도 마지막 지점부터 재개 (durable execution) | |
| # - 같은 thread_id로 이어서 대화 (멀티턴) | |
| # - 과거 시점으로 되돌아가기 (time-travel) | |
| # | |
| # 핵심 개념: thread_id = 대화/세션의 경계. | |
| # 같은 thread_id로 invoke하면 이전 상태가 이어집니다. | |
| # | |
| # 여기선 학습용으로 InMemorySaver를 씁니다. | |
| # 프로덕션에선 PostgresSaver(영속) 사용 — 앞서 논의한 그것. | |
| from langgraph.checkpoint.memory import InMemorySaver | |
| class ChatState(TypedDict): | |
| messages: Annotated[list[str], operator.add] # 대화 누적 (reducer!) | |
| def chat_turn(state: ChatState) -> dict: | |
| # 직전 사용자 메시지에 답한다 (가짜) | |
| last = state["messages"][-1] | |
| return {"messages": [f"답변: '{last}'에 대한 응답"]} | |
| def build_checkpointed_graph(): | |
| g = StateGraph(ChatState) | |
| g.add_node("chat", chat_turn) | |
| g.add_edge(START, "chat") | |
| g.add_edge("chat", END) | |
| # ★ compile에 checkpointer를 넘기면 상태가 thread별로 저장됨 | |
| return g.compile(checkpointer=InMemorySaver()) | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # PART 7. 병렬 실행 — fan-out / fan-in | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # | |
| # 한 노드에서 여러 노드로 엣지를 그으면, 그 노드들이 "병렬"로 실행됩니다. | |
| # (LangGraph의 superstep 모델: 같은 단계의 노드들은 동시에 돈다) | |
| # 그 후 결과를 모으는 노드로 다시 합칩니다(fan-in). | |
| # | |
| # ★ 병렬 노드들이 같은 필드를 쓰면 충돌하므로, reducer로 안전하게 병합해야 함. | |
| # 여기선 operator.add로 결과를 리스트에 누적. | |
| class ParallelState(TypedDict): | |
| query: str | |
| findings: Annotated[list[str], operator.add] # 병렬 결과 누적 | |
| summary: str | |
| def search_web(state: ParallelState) -> dict: | |
| return {"findings": [f"웹: {state['query']} 검색결과"]} | |
| def search_docs(state: ParallelState) -> dict: | |
| return {"findings": [f"문서: {state['query']} 검색결과"]} | |
| def search_db(state: ParallelState) -> dict: | |
| return {"findings": [f"DB: {state['query']} 검색결과"]} | |
| def aggregate(state: ParallelState) -> dict: | |
| return {"summary": " / ".join(state["findings"])} | |
| def build_parallel_graph(): | |
| g = StateGraph(ParallelState) | |
| g.add_node("web", search_web) | |
| g.add_node("docs", search_docs) | |
| g.add_node("db", search_db) | |
| g.add_node("aggregate", aggregate) | |
| # fan-out: START에서 세 노드로 동시 분기 | |
| g.add_edge(START, "web") | |
| g.add_edge(START, "docs") | |
| g.add_edge(START, "db") | |
| # fan-in: 세 노드가 모두 끝나면 aggregate 실행 | |
| g.add_edge("web", "aggregate") | |
| g.add_edge("docs", "aggregate") | |
| g.add_edge("db", "aggregate") | |
| g.add_edge("aggregate", END) | |
| return g.compile() | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # PART 8. Human-in-the-Loop (HITL) — 사람이 중간에 개입 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # | |
| # 심화의 정점. 그래프 실행을 특정 지점에서 "일시정지"하고, 사람의 입력을 | |
| # 기다린 뒤 이어서 실행합니다. 승인 워크플로, 검토 단계 등에 필수. | |
| # | |
| # 핵심 도구: interrupt() — 노드 안에서 호출하면 그래프가 멈추고 제어를 반환. | |
| # 사람이 값을 주면 Command(resume=값)로 재개하면, interrupt()가 그 값을 리턴. | |
| # | |
| # ★ HITL은 체크포인터가 반드시 필요합니다 (멈춘 상태를 저장해야 하니까). | |
| from langgraph.types import interrupt, Command | |
| class ApprovalState(TypedDict): | |
| amount: int | |
| decision: str | |
| def request_approval(state: ApprovalState) -> dict: | |
| if state["amount"] >= 1000: | |
| # 1000 이상이면 사람 승인 필요 → 여기서 그래프가 멈춘다 | |
| human_decision = interrupt( | |
| {"question": f"{state['amount']}원 지출을 승인하시겠습니까?"} | |
| ) | |
| # 사람이 Command(resume=...)로 값을 주면 여기로 돌아와 이어짐 | |
| return {"decision": human_decision} | |
| else: | |
| # 소액은 자동 승인 | |
| return {"decision": "auto-approved"} | |
| def build_hitl_graph(): | |
| g = StateGraph(ApprovalState) | |
| g.add_node("approval", request_approval) | |
| g.add_edge(START, "approval") | |
| g.add_edge("approval", END) | |
| return g.compile(checkpointer=InMemorySaver()) # HITL엔 체크포인터 필수 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # 실행 데모 — 각 PART를 차례로 돌려보며 개념 확인 | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| def demo(): | |
| line = "─" * 70 | |
| print(line, "\nPART 1-2. 기본 그래프 (State + Node + Edge)") | |
| out = build_basic_graph().invoke({"topic": "LangGraph"}) | |
| print(" 결과:", out["result"]) | |
| print(line, "\nPART 3. Reducer (누적 vs 덮어쓰기)") | |
| out = build_reducer_graph().invoke({"logs": [], "counter": 0}) | |
| print(" logs(누적):", out["logs"]) # ['A 실행됨', 'B 실행됨'] | |
| print(" counter(덮어쓰기):", out["counter"]) # 2 | |
| print(line, "\nPART 4. 조건부 엣지 (분기)") | |
| out = build_conditional_graph().invoke({"text": "오늘 기분이 좋아"}) | |
| print(" 긍정 입력 →", out["response"]) | |
| out = build_conditional_graph().invoke({"text": "문제가 생겼어"}) | |
| print(" 부정 입력 →", out["response"]) | |
| print(line, "\nPART 5. 순환 루프 (Generate→Evaluate→재시도)") | |
| out = build_loop_graph().invoke({"task": "보고서", "attempt": 0}) | |
| print(f" {out['attempt']}회 시도 후 종료, 최종 품질 {out['quality']}") | |
| print(" 최종:", out["output"]) | |
| print(line, "\nPART 6. 체크포인터 (멀티턴 — 같은 thread_id로 상태 유지)") | |
| graph = build_checkpointed_graph() | |
| cfg = {"configurable": {"thread_id": "user-123"}} | |
| graph.invoke({"messages": ["안녕"]}, cfg) | |
| out = graph.invoke({"messages": ["잘 지내?"]}, cfg) # 같은 thread → 누적 | |
| print(" 누적된 대화:", out["messages"]) # 4개 (안녕,답변,잘지내,답변) | |
| print(line, "\nPART 7. 병렬 실행 (fan-out/fan-in)") | |
| out = build_parallel_graph().invoke({"query": "LangGraph", "findings": []}) | |
| print(" 병렬 수집 결과 수:", len(out["findings"])) | |
| print(" 요약:", out["summary"]) | |
| print(line, "\nPART 8. Human-in-the-Loop (interrupt/resume)") | |
| graph = build_hitl_graph() | |
| cfg = {"configurable": {"thread_id": "approval-1"}} | |
| # 1000 이상 → interrupt로 멈춤 | |
| result = graph.invoke({"amount": 5000}, cfg) | |
| print(" 실행 멈춤. 대기 중인 질문:", result["__interrupt__"][0].value["question"]) | |
| # 사람이 승인 → Command(resume)로 재개 | |
| final = graph.invoke(Command(resume="manager-approved"), cfg) | |
| print(" 사람 입력 후 재개 →", final["decision"]) | |
| print(line) | |
| if __name__ == "__main__": | |
| demo() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment