AX에이전트 실습 노트
DAY 3

되돌아갈 줄 아는 그래프, LangGraph

체인은 앞으로만 흐릅니다. 결과를 보고 갈라지고, 되돌아가고, 다시 시도하는 흐름을 적으려면 그래프가 필요하고, 그 그래프의 가장 유명한 모양이 ReAct 에이전트입니다.

StateGraphState노드와 엣지reduceradd_messages조건부 엣지사이클recursion_limitReActToolNode

이 날 배우는 것

3일차는 1일차에 while 문으로 돌렸던 도구 호출 루프를 LangGraph의 그래프로 옮기는 날입니다. 먼저 체인만으로는 '실패하면 앞으로 돌아가라'를 적을 수 없다는 것을 확인하고, 그래프를 이루는 세 가지(State, 노드, 엣지)와 값을 합치는 규칙(reducer), 조건부 엣지, 사이클을 하나씩 따로 익힙니다. 마지막에 이 부품들을 모아 '모델 노드 + 도구 노드 + 조건부 엣지 + 사이클'로 된 ReAct 에이전트를 조립합니다. 4일차 이후의 도구, 메모리, 승인, 멀티 에이전트는 전부 이 그래프에 무언가를 덧붙이는 이야기입니다.

  • 체인(LCEL)으로 표현할 수 없는 흐름이 무엇인지, 그때 판단과 반복이 어디로 밀려나는지 설명할 수 있다
  • State, 노드, 엣지로 그래프를 만들고 compile / invoke / stream 으로 실행한다
  • 노드가 돌려준 값이 State에 합쳐지는 규칙(덮어쓰기와 add_messages)을 구분한다
  • add_conditional_edges 와 라우팅 함수로 분기와 루프를 만들고, 루프에 종료 장치를 둔다
  • ReAct 루프(추론 → 도구 실행 → 관찰 → 재판단)를 while 문으로 직접 구현한 뒤, 각 줄이 그래프의 어느 부품이 되는지 대응시킨다
  • stream_mode="updates" 로 에이전트가 어떤 노드를 거쳐 답에 이르는지 한 단계씩 추적한다

오늘의 지도: 체인에서 그래프로

1~2일차에 만든 체인은 prompt | llm | parser 처럼 왼쪽에서 오른쪽으로 한 번 흐르고 끝났습니다. 그런데 1일차 도구 호출에서 이미 본 것처럼, 에이전트는 "모델을 부른다 → 도구를 실행한다 → 다시 모델을 부른다"를 반복합니다. 이 반복을 체인 바깥의 while 문이 아니라 프로그램의 구조 자체로 적게 해 주는 라이브러리가 LangGraph입니다.

LangGraph에서 프로그램은 그래프입니다. 여기서 그래프는 차트가 아니라 "점(노드)과 그 사이를 잇는 선(엣지)"이라는 자료 구조를 말합니다. 익혀야 할 것은 세 가지뿐입니다.

State

그래프가 실행되는 동안 모든 노드가 함께 읽고 쓰는 데이터 한 덩어리입니다. 어떤 칸이 있는지는 미리 클래스로 선언합니다.

노드 (Node)

일을 하는 파이썬 함수입니다. State를 받아서, 바꾸고 싶은 칸만 dict로 돌려줍니다. 모델 호출도, 도구 실행도 노드 안에서 일어납니다.

엣지 (Edge)

"이 노드가 끝나면 다음은 어디인가"를 정합니다. 고정된 엣지와, State를 보고 실행 중에 목적지를 고르는 조건부 엣지가 있습니다.

하루의 흐름은 다음과 같습니다. 앞의 여섯 칸은 부품을 하나씩 따로 익히는 연습이고, 뒤의 두 칸이 그 부품으로 에이전트를 조립하는 본론입니다.

체인의 한계chain_limit.py Statestate_schema.py 노드와 엣지hello_graph.py reducerreducer.py 분기conditional.py 루프cycle.py, branch_loop_graph.py 수동 ReActmanual_react.py 그래프 ReActreact_agent.py
체인 (LCEL)그래프 (LangGraph)
흐름의 모양한 줄. 앞 부품의 출력이 뒤 부품의 입력노드와 엣지. 갈라질 수도, 되돌아갈 수도 있음
데이터가 다니는 방식부품에서 부품으로 값 하나를 넘김모든 노드가 State 하나를 공유하고, 각자 필요한 칸만 고침
다음 단계를 정하는 것코드를 쓸 때 | 로 고정고정 엣지 또는 실행 중에 State를 읽는 라우팅 함수
반복체인 안에 적을 수 없음. 바깥에 while 문이미 지나온 노드로 가는 엣지를 그으면 그것이 반복
어울리는 일번역, 요약, RAG처럼 순서가 정해진 일결과를 보고 다음 행동이 달라지는 일. 에이전트
체인을 버리는 것이 아닙니다

그래프의 노드 안에서는 여전히 llm.invoke(...) 를 부르고 1일차의 메시지 클래스를 씁니다. LangGraph는 LangChain 부품들 사이의 흐름을 맡는 한 겹 위의 도구입니다. 순서가 고정된 일에는 체인이 여전히 더 짧고 읽기 쉽습니다.

체인에는 되돌아갈 자리가 없다

새 도구를 배우기 전에, 지금 가진 도구로 무엇이 안 되는지를 먼저 봅니다. 2일차의 RAG 체인에 요구 사항 하나를 더해 보겠습니다. "검색 결과가 없으면 질문을 바꿔서 다시 찾아라."

chain_limit.py 는 이 실험을 위해 모델을 부르지 않는 가짜 부품 세 개로 2일차와 같은 모양의 체인(검색 → 프롬프트 조립 → 생성)을 만듭니다. 모델을 쓰면 실행마다 답이 달라져 무엇 때문에 막히는지가 흐려지기 때문에, 누가 돌려도 같은 결과가 나오게 한 것입니다.

chain_limit.py 13–35줄
13def retrieve(question: str) -> dict:
14    """2일차 retriever 자리입니다.
15    진짜 벡터 검색 대신, 질문에 '재택'이라는 말이 있을 때만
16    문서를 찾았다고 치는 가짜 검색기입니다."""
17    if "재택" in question:
18        found = ["재택근무는 주 2회까지 가능하다."]   # 검색 성공
19    else:
20        found = []                                    # 검색 실패: 관련 문서를 못 찾음
21    print(f"  [retrieve] 질문={question!r} -> 문서 {len(found)}건")
22    return {"question": question, "docs": found}
23
24
25def build_prompt(data: dict) -> dict:
26    """2일차 prompt 자리입니다. 찾은 문서를 문자열 하나로 합칩니다."""
27    context = "\n".join(data["docs"]) if data["docs"] else "(근거 문서 없음)"
28    return {"question": data["question"], "context": context}
29
30
31def generate(data: dict) -> str:
32    """2일차 llm 자리입니다. 근거가 없으면 모른다고 답하도록 흉내만 냅니다."""
33    if data["context"] == "(근거 문서 없음)":
34        return "죄송합니다. 관련 규정을 찾지 못했습니다."
35    return f"답변: {data['context']}"
가짜 검색기는 질문에 '재택'이라는 말이 있을 때만 문서를 찾습니다. 검색 실패를 원하는 때에 일으키기 위한 장치입니다.
chain_limit.py 38–46줄
38# ---------- 2) 부품을 체인으로 잇습니다. 2일차와 같은 모양입니다 ----------
39# retrieve -> build_prompt -> generate 로 왼쪽에서 오른쪽으로 한 번 흐릅니다.
40chain = RunnableLambda(retrieve) | RunnableLambda(build_prompt) | RunnableLambda(generate)
41
42
43# ---------- 3) 검색이 실패하는 질문을 넣어 봅니다 ----------
44# '재택'이라는 말이 없으므로 retrieve가 빈손으로 돌아옵니다.
45print("[1] 체인을 한 번 실행합니다")
46print("  결과:", chain.invoke("집에서 일해도 되나요?"))
세 함수를 RunnableLambda 로 감싸 파이프로 잇습니다. 1일차 LCEL에서 본 그 모양입니다.

"집에서 일해도 되나요?"에는 '재택'이라는 말이 없으므로 검색이 빈손으로 돌아오고, 체인은 그대로 "찾지 못했습니다"를 냅니다. 여기까지는 체인이 잘못한 것이 없습니다. 문제는 "그러면 질문을 바꿔 다시 찾아라"를 어디에 적느냐입니다.

체인 안에 적을 수 있는 것

A | B | C 에서 | 사이에 놓을 수 있는 것은 다음에 실행할 부품뿐입니다. "C의 결과가 나쁘면 A로 돌아가라"는 문장을 넣을 칸이 문법에 없습니다.

그래서 실제로 쓰게 되는 코드

체인을 통째로 감싸는 while 문을 체인 바깥에 둡니다. 다시 찾을지 판단하는 코드와 반복하는 코드가 모두 체인 밖에 삽니다.

chain_limit.py 60–73줄
60question = "집에서 일해도 되나요?"
61alternatives = ["재택근무 규정", "재택근무 며칠"]   # 바꿔서 다시 물어볼 질문 후보
62attempt = 0
63
64answer = chain.invoke(question)                    # 첫 시도
65
66while "찾지 못했습니다" in answer and attempt < len(alternatives):
67    question = alternatives[attempt]               # 질문을 바꾸고
68    attempt += 1
69    print(f"  [바깥 루프] {attempt}번째 재시도 -> 질문을 {question!r} 로 바꿉니다")
70    answer = chain.invoke(question)                # 체인을 처음부터 다시 실행
71
72print(f"\n최종 답변: {answer}")
73print(f"재시도 횟수: {attempt}")
강조한 while 문이 '판단'과 '반복'을 전부 맡습니다. 체인은 그 안에서 불리는 함수 하나로 줄어듭니다.
실제 출력

이 파일은 모델을 부르지 않으므로 직접 실행해 확인했습니다. 첫 실행은 실패하고, 바깥 루프가 질문을 '재택근무 규정'으로 바꾼 뒤에야 문서를 찾습니다.

chain_limit.py 실행 출력
 1[1] 체인을 한 번 실행합니다
 2  [retrieve] 질문='집에서 일해도 되나요?' -> 문서 0건
 3  결과: 죄송합니다. 관련 규정을 찾지 못했습니다.
 4
 5[2] 재검색을 넣으려면 체인 바깥에 루프를 둬야 합니다
 6  [retrieve] 질문='집에서 일해도 되나요?' -> 문서 0건
 7  [바깥 루프] 1번째 재시도 -> 질문을 '재택근무 규정' 로 바꿉니다
 8  [retrieve] 질문='재택근무 규정' -> 문서 1건
 9
10최종 답변: 답변: 재택근무는 주 2회까지 가능하다.
11재시도 횟수: 1
12
13[관찰] 다시 찾을지 말지를 판단하는 코드와 반복하는 코드가
14       chain 안에 있습니까, 이 파일의 while 문 안에 있습니까?

바깥 루프가 왜 문제인가

동작은 합니다. 하지만 흐름의 핵심(언제 되돌아가고 언제 멈추는가)이 체인의 정의가 아니라 흩어진 제어문 속에 숨습니다. 요구가 하나씩 늘 때마다 이 while 문에 if가 붙고, 재시도 횟수 같은 값은 attempt, question 같은 지역 변수로 떠돕니다. 이 구조로는 다음 일들이 어렵습니다.

  • 흐름을 한눈에 보기: 체인을 그림으로 그려도 재시도는 나타나지 않습니다. 재시도는 체인이 아니라 while 문에 있기 때문입니다.
  • 중간 상태를 들여다보기: 지금 몇 번째 시도이고 질문이 무엇으로 바뀌었는지가 체인의 입출력 어디에도 없습니다.
  • 중간에 멈췄다 이어 가기: 지역 변수는 프로그램이 끝나면 사라집니다. 5일차의 체크포인터와 사람 승인은 "흐름의 현재 위치와 값"이 한곳에 모여 있어야 가능합니다.

LangGraph는 이 세 가지를 뒤집습니다. 떠돌던 지역 변수는 State가 되고, while 문의 조건은 조건부 엣지가 되고, "처음으로 돌아가기"는 되돌아가는 엣지가 됩니다. 같은 문제를 그래프로 다시 푼 답안이 뒤의 분기와 루프를 한 그래프에 절에 있습니다.

PYday3_practice/chain_limit.py체인만으로는 재검색을 표현할 수 없어 바깥에 while 문을 두게 되는 과정 확인 (LLM 미사용)77줄
모델을 부르지 않으므로 자격 증명 없이 바로 실행됩니다. 마지막 [관찰] 질문의 답은 "while 문 안"입니다.
 1# chain_limit.py - 체인에는 되돌아가기를 적을 자리가 없다는 것을 확인합니다.
 2#
 3# 이 파일은 LLM을 부르지 않습니다. 모델을 부르면 실행할 때마다 답이 달라져서
 4# 무엇 때문에 막히는지가 잘 안 보이기 때문입니다.
 5# 그래서 검색과 생성을 흉내만 내는 함수 세 개로 2일차와 같은 모양의 체인을 만듭니다.
 6# 누가 돌려도 결과가 똑같이 나옵니다.
 7
 8from langchain_core.runnables import RunnableLambda
 9
10
11# ---------- 1) 체인에 끼울 부품 세 개를 만듭니다 ----------
12
13def retrieve(question: str) -> dict:
14    """2일차 retriever 자리입니다.
15    진짜 벡터 검색 대신, 질문에 '재택'이라는 말이 있을 때만
16    문서를 찾았다고 치는 가짜 검색기입니다."""
17    if "재택" in question:
18        found = ["재택근무는 주 2회까지 가능하다."]   # 검색 성공
19    else:
20        found = []                                    # 검색 실패: 관련 문서를 못 찾음
21    print(f"  [retrieve] 질문={question!r} -> 문서 {len(found)}건")
22    return {"question": question, "docs": found}
23
24
25def build_prompt(data: dict) -> dict:
26    """2일차 prompt 자리입니다. 찾은 문서를 문자열 하나로 합칩니다."""
27    context = "\n".join(data["docs"]) if data["docs"] else "(근거 문서 없음)"
28    return {"question": data["question"], "context": context}
29
30
31def generate(data: dict) -> str:
32    """2일차 llm 자리입니다. 근거가 없으면 모른다고 답하도록 흉내만 냅니다."""
33    if data["context"] == "(근거 문서 없음)":
34        return "죄송합니다. 관련 규정을 찾지 못했습니다."
35    return f"답변: {data['context']}"
36
37
38# ---------- 2) 부품을 체인으로 잇습니다. 2일차와 같은 모양입니다 ----------
39# retrieve -> build_prompt -> generate 로 왼쪽에서 오른쪽으로 한 번 흐릅니다.
40chain = RunnableLambda(retrieve) | RunnableLambda(build_prompt) | RunnableLambda(generate)
41
42
43# ---------- 3) 검색이 실패하는 질문을 넣어 봅니다 ----------
44# '재택'이라는 말이 없으므로 retrieve가 빈손으로 돌아옵니다.
45print("[1] 체인을 한 번 실행합니다")
46print("  결과:", chain.invoke("집에서 일해도 되나요?"))
47
48
49# ---------- 4) 여기서 요구가 하나 늘어납니다 ----------
50# "검색 결과가 없으면 질문을 바꿔서 다시 찾아라."
51#
52# 이 지시를 위의 chain 안에 적을 수 있을까요? 적을 칸이 없습니다.
53# chain은 부품을 | 로 이어 붙인 파이프라서, 사이에 놓을 수 있는 것은
54# 다음에 실행할 부품뿐입니다. "실패했으면 앞으로 돌아가라"를 넣을 자리가 없습니다.
55#
56# 그래서 아래처럼 체인 바깥에 while 루프를 따로 둘 수밖에 없습니다.
57
58print("\n[2] 재검색을 넣으려면 체인 바깥에 루프를 둬야 합니다")
59
60question = "집에서 일해도 되나요?"
61alternatives = ["재택근무 규정", "재택근무 며칠"]   # 바꿔서 다시 물어볼 질문 후보
62attempt = 0
63
64answer = chain.invoke(question)                    # 첫 시도
65
66while "찾지 못했습니다" in answer and attempt < len(alternatives):
67    question = alternatives[attempt]               # 질문을 바꾸고
68    attempt += 1
69    print(f"  [바깥 루프] {attempt}번째 재시도 -> 질문을 {question!r} 로 바꿉니다")
70    answer = chain.invoke(question)                # 체인을 처음부터 다시 실행
71
72print(f"\n최종 답변: {answer}")
73print(f"재시도 횟수: {attempt}")
74
75# ---------- 5) 무엇을 봐야 하는지 ----------
76print("\n[관찰] 다시 찾을지 말지를 판단하는 코드와 반복하는 코드가")
77print("       chain 안에 있습니까, 이 파일의 while 문 안에 있습니까?")

State: 노드들이 함께 쓰는 데이터

그래프를 만들 때 가장 먼저 하는 일은 흐름을 그리는 것이 아니라 "실행 중에 어떤 데이터를 들고 다닐 것인가"를 정하는 것입니다. 이 데이터의 모양을 선언한 것이 State 스키마입니다.

체인에서는 앞 부품의 출력이 곧 뒤 부품의 입력이었습니다. 그래서 세 번째 부품이 첫 번째 부품의 결과를 쓰려면 두 번째 부품이 그 값을 일부러 실어 날라야 했습니다(chain_limit.py 의 build_prompt 가 question 을 그대로 다시 내보내는 것이 그 예입니다). 그래프에서는 모든 노드가 같은 State를 보므로 이런 전달 코드가 필요 없습니다.

state_schema.py 2–13줄
 2from typing import TypedDict
 3
 4
 5class State(TypedDict):
 6    user_input: str   # 사용자가 넣는 입력
 7    response: str     # 그래프가 채울 응답
 8    search_quality: bool
 9
10
11initial: State = {"user_input": "안녕하세요", "response": "", "search_quality": False}
12print(initial)
13print(type(initial))   # 실행 시점에는 그냥 dict입니다
State는 TypedDict 로 선언합니다. 칸 이름과 타입을 적은 것이 전부입니다.

TypedDict 는 "이 dict에는 이런 키가 이런 타입으로 들어 있다"를 적는 파이썬 표준 타입 힌트입니다. 클래스처럼 보이지만 객체를 만들어 주지는 않습니다.

state_schema.py 실행 출력
1{'user_input': '안녕하세요', 'response': '', 'search_quality': False}
2<class 'dict'>
State는 실행 시점에는 그냥 dict입니다

두 번째 줄이 요점입니다. State 라는 이름은 타입 검사기와 LangGraph에게 "어떤 칸이 있는가"를 알려 주는 설계도이고, 실제로 노드 사이를 오가는 값은 평범한 dict입니다. 그래서 노드 안에서는 state["user_input"] 처럼 dict 문법으로 읽습니다.

State에 넣는 것이 날의 예
바깥에서 들어오는 입력user_input, question
노드가 만들어 다음 노드에 넘기는 중간 결과word_count, docs, intent
분기나 종료를 판단하는 데 쓰는 값attempts, quality_score, attempt
최종 결과response, answer
대화 기록messages (에이전트의 State는 이 칸 하나뿐입니다)
TypedDict는 값을 검사해 주지 않습니다

TypedDict 는 파이썬이 실행 중에 타입을 확인하는 장치가 아닙니다. search_quality 에 문자열을 넣어도 파이썬은 오류를 내지 않습니다. 또 이 날 실습에는 State, ChatState, ClassifyState, QualityState, AgentState 처럼 파일마다 따로 선언한 State가 나옵니다. state_schema.py, hello_graph.py, branch_loop_graph.py 의 클래스는 이름이 모두 State 지만 칸이 서로 다른 별개의 클래스입니다.

PYday3_practice/state_schema.pyTypedDict로 State 스키마를 선언하고, 실행 시점에는 dict임을 확인 (LLM 미사용)13줄
그래프는 아직 없습니다. State의 모양만 선언하고 초기값 하나를 찍어 보는 준비 운동입니다.
 1# state_schema.py - State 스키마 정의
 2from typing import TypedDict
 3
 4
 5class State(TypedDict):
 6    user_input: str   # 사용자가 넣는 입력
 7    response: str     # 그래프가 채울 응답
 8    search_quality: bool
 9
10
11initial: State = {"user_input": "안녕하세요", "response": "", "search_quality": False}
12print(initial)
13print(type(initial))   # 실행 시점에는 그냥 dict입니다

첫 그래프: 노드, 엣지, compile

State를 정했으면 그 위에서 일할 노드를 만들고, 엣지로 순서를 잇고, 실행 가능한 객체로 바꿉니다. hello_graph.py 는 분기도 반복도 없는 일자형 그래프로 이 네 단계를 보여 줍니다. 모델도 부르지 않습니다.

START clean공백 정리 count단어 수 세기 respond답변 만들기 END

노드는 "State를 받아 바꿀 칸만 돌려주는 함수"

hello_graph.py 6–29줄
 6class State(TypedDict):
 7    user_input: str
 8    word_count: int
 9    response: str
10
11
12def clean(state: State) -> dict:
13    """앞뒤 공백과 연속 공백을 정리합니다. 이미 있는 user_input을 덮어씁니다."""
14    print(f"  [clean]   받은 State: {state}")
15    cleaned = " ".join(state["user_input"].split())
16    return {"user_input": cleaned}
17
18
19def count(state: State) -> dict:
20    """단어 수를 세서 새 키 word_count를 추가합니다."""
21    print(f"  [count]   받은 State: {state}")
22    return {"word_count": len(state["user_input"].split())}
23
24
25def respond(state: State) -> dict:
26    """앞 노드들이 만든 값을 모아 답변을 만듭니다."""
27    print(f"  [respond] 받은 State: {state}")
28    user = state["user_input"]
29    return {"response": f"입력하신 내용은 '{user}' 이고, 단어 {state['word_count']}개입니다."}
세 노드 모두 State 전체를 받지만, 돌려주는 dict에는 자기가 바꾼 키만 들어 있습니다.

노드 함수의 규칙은 두 가지입니다.

  1. 입력은 State 전체노드는 자기에게 필요한 칸만 골라 읽습니다. respond 는 clean 이 정리한 user_input 과 count 가 만든 word_count 를 둘 다 읽습니다. 앞 노드가 값을 실어 날라 주지 않아도 됩니다.
  2. 출력은 바꿀 칸만 담은 dictcount 는 {"word_count": ...} 만 돌려줍니다. State 전체를 복사해 돌려주는 것이 아닙니다. LangGraph가 이 dict를 기존 State에 합쳐 줍니다.

조립: 노드 등록 → 엣지 연결 → compile

hello_graph.py 32–41줄
32builder = StateGraph(State)
33builder.add_node("clean", clean)
34builder.add_node("count", count)
35builder.add_node("respond", respond)
36builder.add_edge(START, "clean")
37builder.add_edge("clean", "count")
38builder.add_edge("count", "respond")
39builder.add_edge("respond", END)
40
41graph = builder.compile()    # 그래프를 실행 가능한 객체로 변환
StateGraph(State) 는 빈 설계도이고, compile() 을 거쳐야 실행할 수 있는 graph 가 됩니다.
코드하는 일
StateGraph(State)"이 그래프의 노드들은 State 모양의 데이터를 공유한다"고 선언하며 빈 설계도(builder)를 만듭니다
add_node("clean", clean)함수에 이름을 붙여 노드로 등록합니다. 엣지와 라우팅 함수는 이 이름 문자열로 노드를 가리킵니다
add_edge("clean", "count")"clean이 끝나면 다음은 항상 count"라는 고정 엣지를 긋습니다
START, END실제 함수가 아니라 시작점과 끝점을 뜻하는 특별한 이름입니다. START 에서 나가는 엣지가 첫 노드를 정하고, END 로 들어가는 엣지가 종료를 뜻합니다
builder.compile()설계도를 실행 가능한 객체로 바꿉니다. 이 객체는 1일차의 체인과 마찬가지로 invoke, stream 으로 실행합니다
hello_graph.py 가 실행 끝에 저장하는 구조도. LangGraph가 그래프 정의를 읽어 직접 그린 것으로, 그림에서는 START와 END가 __start__, __end__ 로 표시됩니다.
hello_graph.py 가 실행 끝에 저장하는 구조도. LangGraph가 그래프 정의를 읽어 직접 그린 것으로, 그림에서는 START와 END가 __start__, __end__ 로 표시됩니다.

실행: stream의 두 가지 보기 방식

invoke 는 끝까지 실행한 뒤 마지막 State만 돌려줍니다. 중간 과정을 보려면 stream 을 쓰고, 무엇을 받을지를 stream_mode 로 고릅니다.

hello_graph.py 44–61줄
44if __name__ == "__main__":   # langgraph dev 서버가 import할 때는 실행되지 않도록 가드 아래에 둡니다
45    # invoke 전에는 State 값이 없습니다. State는 데이터 모양(타입)일 뿐이고, 값은 실행할 때 생깁니다
46    user_input = "  안녕하세요    랭그래프   "
47
48    # 1) 단계마다 State 전체 (노드 안의 print는 '받은 State', 여기는 '노드 실행 뒤 State')
49    print("[values] 단계마다 State 전체")
50    for i, step in enumerate(graph.stream({"user_input": user_input}, stream_mode="values")):
51        print(f"{i}단계 뒤 State: {step}\n")
52
53    # 2) 단계마다 노드가 돌려준 값만
54    print("[updates] 단계마다 노드가 돌려준 값만")
55    for step in graph.stream({"user_input": user_input}, stream_mode="updates"):
56        print(f"  돌려준 값: {step}")
57
58    # 3) invoke는 마지막 State만 돌려줍니다
59    print("\n[invoke] 최종 State")
60    result = graph.invoke({"user_input": user_input})
61    print(result)
같은 입력을 values 모드, updates 모드, invoke 로 세 번 실행합니다.
실행 방법받는 것어울리는 상황
graph.invoke(입력)실행이 끝난 뒤의 최종 State 전체결과만 필요할 때
graph.stream(입력, stream_mode="values")단계가 끝날 때마다 그 시점의 State 전체. 맨 처음에는 입력만 들어 있는 State가 한 번 나옵니다State가 어떻게 자라는지 통째로 보고 싶을 때
graph.stream(입력, stream_mode="updates")단계가 끝날 때마다 {노드 이름: 그 노드가 돌려준 dict}어느 노드가 무엇을 바꿨는지 추적할 때. 이 날 뒤쪽의 실행 추적은 전부 이 모드입니다
실제 출력으로 State가 자라는 과정 보기

아래는 이 파일의 실행부를 PNG 저장 줄만 빼고 그대로 돌린 출력입니다(모델을 부르지 않아 누가 돌려도 같습니다). 노드 안의 print 는 "노드가 받은 State", 바깥의 print 는 "노드가 끝난 뒤의 State"입니다.

hello_graph.py 실행 출력 (PNG 저장 부분 제외)
 1[values] 단계마다 State 전체
 20단계 뒤 State: {'user_input': '  안녕하세요    랭그래프   '}
 3
 4  [clean]   받은 State: {'user_input': '  안녕하세요    랭그래프   '}
 51단계 뒤 State: {'user_input': '안녕하세요 랭그래프'}
 6
 7  [count]   받은 State: {'user_input': '안녕하세요 랭그래프'}
 82단계 뒤 State: {'user_input': '안녕하세요 랭그래프', 'word_count': 2}
 9
10  [respond] 받은 State: {'user_input': '안녕하세요 랭그래프', 'word_count': 2}
113단계 뒤 State: {'user_input': '안녕하세요 랭그래프', 'word_count': 2, 'response': "입력하신 내용은 '안녕하세요 랭그래프' 이고, 단어 2개입니다."}
12
13[updates] 단계마다 노드가 돌려준 값만
14  [clean]   받은 State: {'user_input': '  안녕하세요    랭그래프   '}
15  돌려준 값: {'clean': {'user_input': '안녕하세요 랭그래프'}}
16  [count]   받은 State: {'user_input': '안녕하세요 랭그래프'}
17  돌려준 값: {'count': {'word_count': 2}}
18  [respond] 받은 State: {'user_input': '안녕하세요 랭그래프', 'word_count': 2}
19  돌려준 값: {'respond': {'response': "입력하신 내용은 '안녕하세요 랭그래프' 이고, 단어 2개입니다."}}
20
21[invoke] 최종 State
22  [clean]   받은 State: {'user_input': '  안녕하세요    랭그래프   '}
23  [count]   받은 State: {'user_input': '안녕하세요 랭그래프'}
24  [respond] 받은 State: {'user_input': '안녕하세요 랭그래프', 'word_count': 2}
25{'user_input': '안녕하세요 랭그래프', 'word_count': 2, 'response': "입력하신 내용은 '안녕하세요 랭그래프' 이고, 단어 2개입니다."}

출력에서 읽어 낼 것은 세 가지입니다.

  • 이미 있는 키를 돌려주면 덮어씁니다. clean 이 user_input 을 돌려주자 공백이 섞인 원래 값이 정리된 값으로 바뀌었습니다. 원래 값은 State에 남지 않습니다.
  • 없던 키를 돌려주면 추가됩니다. word_count 는 count 가 끝난 뒤에야 State에 나타납니다. 입력에 넣지 않은 칸은 누군가 써 주기 전까지 State에 존재하지 않으므로, 뒤 노드가 state["word_count"] 로 읽으려면 그 값을 쓰는 노드가 반드시 먼저 실행돼야 합니다.
  • invoke 의 반환값은 답변 문자열이 아니라 State 전체입니다. 필요한 값은 result["response"] 처럼 꺼내 씁니다.
구조도를 파일로 남기기

파일 끝의 graph.get_graph().draw_mermaid_png(output_file_path="hello_graph.png") 가 위 그림을 만듭니다. PNG를 만들 수 없는 환경에서는 예외가 나므로 try/except 로 감싸고, 실패하면 draw_mermaid() 로 같은 구조를 Mermaid 문법의 글자로 출력하게 해 두었습니다. 그림은 조립이 의도대로 됐는지 확인하는 가장 빠른 방법입니다.

hello_graph.py 63–69줄
63    # 구조 시각화: PNG 저장이 안 되는 환경도 있으므로 try/except로 감쌉니다
64    try:
65        graph.get_graph().draw_mermaid_png(output_file_path="hello_graph.png")
66        print("\nhello_graph.png 저장 완료")
67    except Exception as e:
68        print("\nPNG 저장은 건너뜁니다:", e)
69        print(graph.get_graph().draw_mermaid())   # 텍스트 출력으로 대체
이 날의 branch_loop_graph.py, react_agent.py 도 같은 방식으로 구조도를 저장합니다.
흐름도

hello_graph 흐름도

그래프 구조, State가 바뀌는 과정, 코드와 그림의 대응을 한 장에 정리한 자료입니다. 노드가 respond 하나뿐이던 초기 버전을 기준으로 만든 것이라 지금 파일(노드 3개)보다 단순하지만, 설계도 → compile → invoke 의 뼈대는 같습니다.

새 탭으로day3_practice/hello_graph_flow.html
PYday3_practice/hello_graph.py노드 3개를 일렬로 이어 State가 단계마다 어떻게 바뀌는지 관찰 (LLM 미사용)69줄
실행하면 hello_graph.png 를 새로 저장합니다. 실행부가 if __name__ == "__main__": 아래에 있는 이유는 LangGraph 개발 서버 절에서 설명합니다.
 1# hello_graph.py - 노드 3개를 이어서 State가 바뀌는 과정 보기
 2from typing import TypedDict
 3from langgraph.graph import StateGraph, START, END
 4
 5
 6class State(TypedDict):
 7    user_input: str
 8    word_count: int
 9    response: str
10
11
12def clean(state: State) -> dict:
13    """앞뒤 공백과 연속 공백을 정리합니다. 이미 있는 user_input을 덮어씁니다."""
14    print(f"  [clean]   받은 State: {state}")
15    cleaned = " ".join(state["user_input"].split())
16    return {"user_input": cleaned}
17
18
19def count(state: State) -> dict:
20    """단어 수를 세서 새 키 word_count를 추가합니다."""
21    print(f"  [count]   받은 State: {state}")
22    return {"word_count": len(state["user_input"].split())}
23
24
25def respond(state: State) -> dict:
26    """앞 노드들이 만든 값을 모아 답변을 만듭니다."""
27    print(f"  [respond] 받은 State: {state}")
28    user = state["user_input"]
29    return {"response": f"입력하신 내용은 '{user}' 이고, 단어 {state['word_count']}개입니다."}
30
31
32builder = StateGraph(State)
33builder.add_node("clean", clean)
34builder.add_node("count", count)
35builder.add_node("respond", respond)
36builder.add_edge(START, "clean")
37builder.add_edge("clean", "count")
38builder.add_edge("count", "respond")
39builder.add_edge("respond", END)
40
41graph = builder.compile()    # 그래프를 실행 가능한 객체로 변환
42
43
44if __name__ == "__main__":   # langgraph dev 서버가 import할 때는 실행되지 않도록 가드 아래에 둡니다
45    # invoke 전에는 State 값이 없습니다. State는 데이터 모양(타입)일 뿐이고, 값은 실행할 때 생깁니다
46    user_input = "  안녕하세요    랭그래프   "
47
48    # 1) 단계마다 State 전체 (노드 안의 print는 '받은 State', 여기는 '노드 실행 뒤 State')
49    print("[values] 단계마다 State 전체")
50    for i, step in enumerate(graph.stream({"user_input": user_input}, stream_mode="values")):
51        print(f"{i}단계 뒤 State: {step}\n")
52
53    # 2) 단계마다 노드가 돌려준 값만
54    print("[updates] 단계마다 노드가 돌려준 값만")
55    for step in graph.stream({"user_input": user_input}, stream_mode="updates"):
56        print(f"  돌려준 값: {step}")
57
58    # 3) invoke는 마지막 State만 돌려줍니다
59    print("\n[invoke] 최종 State")
60    result = graph.invoke({"user_input": user_input})
61    print(result)
62
63    # 구조 시각화: PNG 저장이 안 되는 환경도 있으므로 try/except로 감쌉니다
64    try:
65        graph.get_graph().draw_mermaid_png(output_file_path="hello_graph.png")
66        print("\nhello_graph.png 저장 완료")
67    except Exception as e:
68        print("\nPNG 저장은 건너뜁니다:", e)
69        print(graph.get_graph().draw_mermaid())   # 텍스트 출력으로 대체

reducer: 돌려준 값을 State에 합치는 규칙

앞 절에서 노드가 이미 있는 키를 돌려주면 값이 덮어써진다는 것을 봤습니다. 대부분의 칸은 그래도 괜찮습니다. 하지만 대화 기록은 다릅니다. 새 메시지가 올 때마다 앞의 대화가 지워지면 에이전트는 방금 무슨 질문을 받았는지도 잊습니다.

"노드가 돌려준 값을 기존 값과 어떻게 합칠 것인가"를 칸마다 정하는 함수가 reducer입니다. reducer는 (기존 값, 새 값)을 받아 합친 값을 돌려주는 함수이고, State를 선언할 때 타입 옆에 붙여 둡니다.

reducer.py 8–21줄
 8class ChatState(TypedDict):
 9    messages: Annotated[list, add_messages]
10
11
12def chat(state: ChatState) -> dict:
13    # 새 메시지를 리스트로 반환만 하면 add_messages가 알아서 누적합니다
14    return {"messages": [AIMessage(content="안녕하세요, 반갑습니다.")]}
15
16
17builder = StateGraph(ChatState)
18builder.add_node("chat", chat)
19builder.add_edge(START, "chat")
20builder.add_edge("chat", END)
21graph = builder.compile()
Annotated[list, add_messages] 는 '타입은 list, 합칠 때는 add_messages 를 쓴다'는 뜻입니다. 노드는 새 메시지 하나만 리스트에 담아 돌려줍니다.

Annotated[타입, 부가정보] 는 타입 힌트에 메모를 덧붙이는 파이썬 표준 문법입니다. LangGraph는 이 메모 자리에 놓인 함수를 그 칸의 reducer로 사용합니다. add_messages 는 LangGraph가 대화 기록용으로 미리 만들어 둔 reducer입니다.

reducer 없음: messages: list

노드가 {"messages": [AI 응답]} 을 돌려주면 리스트가 통째로 교체됩니다. 사용자의 질문이 사라지고 AI 응답 하나만 남습니다.

reducer 있음: Annotated[list, add_messages]

같은 값을 돌려줘도 기존 리스트 뒤에 붙습니다. 노드는 "새로 생긴 메시지"만 신경 쓰면 되고, 누적은 reducer가 합니다.

reducer.py 24–35줄
24if __name__ == "__main__":   # langgraph dev 서버가 import할 때는 실행되지 않도록 가드 아래에 둡니다
25    # 1차 실행
26    result = graph.invoke({"messages": [HumanMessage(content="안녕하세요?")]})
27    print("1차 실행 후 메시지 개수:", len(result["messages"]))
28
29    # 2차 실행: 이전 messages를 그대로 이어서 넘기면 계속 누적됩니다
30    result2 = graph.invoke(
31        {"messages": result["messages"] + [HumanMessage(content="잘 지내시나요?")]}
32    )
33    print("2차 실행 후 메시지 개수:", len(result2["messages"]))
34    for m in result2["messages"]:
35        print(f"  [{type(m).__name__}] {m.content}")
그래프를 두 번 실행합니다. 두 번째에는 첫 실행의 결과에 새 질문을 붙여 직접 넘겨줍니다.
reducer.py 실행 출력
11차 실행 후 메시지 개수: 2
22차 실행 후 메시지 개수: 4
3  [HumanMessage] 안녕하세요?
4  [AIMessage] 안녕하세요, 반갑습니다.
5  [HumanMessage] 잘 지내시나요?
6  [AIMessage] 안녕하세요, 반갑습니다.

1차 실행은 입력 1개에 노드의 응답 1개가 붙어 2개가 됐습니다. 2차 실행은 우리가 넣은 3개(1차 결과 2개 + 새 질문 1개)에 응답 1개가 붙어 4개입니다. 이 실습의 chat 노드는 모델을 부르지 않고 항상 같은 문장을 돌려주므로, 관찰 대상은 내용이 아니라 개수입니다.

add_messages를 빼면 정말 지워지는가

같은 구조의 작은 그래프에서 messages: list 로만 선언해 돌려 보면 결과의 메시지는 AIMessage 1개뿐입니다. 입력으로 넣은 질문이 사라집니다. 아래 설명 자료에 이 실험이 그림으로 정리돼 있습니다.

add_messages가 덧붙이기 말고 하는 일

add_messages 는 단순한 리스트 덧셈이 아닙니다. 직접 확인한 동작 두 가지를 알아 두면 뒤의 날들이 쉬워집니다.

  • 같은 id 의 메시지는 붙이지 않고 교체합니다. 기존에 id="x1" 인 메시지가 있을 때 같은 id 의 새 메시지를 돌려주면 개수는 그대로이고 내용만 바뀝니다. 기록을 고치거나 줄이는 기능들이 이 성질 위에 서 있습니다.
  • dict 형태로 넣어도 메시지 객체로 바꿔 줍니다. {"role": "user", "content": "..."} 를 넣으면 HumanMessage 가 되고, id 가 없는 메시지에는 id 가 붙습니다.
누적은 한 번의 실행 안에서만 일어납니다

1차 실행과 2차 실행은 서로 무관합니다. 그래프는 실행이 끝나면 State를 기억하지 않습니다. 2차 실행의 기록이 이어진 것은 코드 31번째 줄에서 result["messages"] 를 우리가 다시 넣어 줬기 때문입니다. 1일차의 멀티턴에서 리스트를 직접 들고 다닌 것과 같은 원리입니다. 실행이 끝나도 State를 저장해 두었다가 이어 주는 장치가 5일차의 체크포인터입니다.

정리

reducer 쉽게 이해하기

reducer가 없을 때(덮어쓰기)와 add_messages 를 붙였을 때(이어 쓰기)를 나란히 놓고, reducer.py 의 1차·2차 실행에서 메시지가 2개, 4개로 쌓이는 과정과 add_messages 를 뺐을 때 질문이 사라지는 실험을 그림으로 따라갑니다.

새 탭으로day3_practice/reducer_explained.html
PYday3_practice/reducer.pyadd_messages reducer가 메시지를 덮어쓰지 않고 누적하는 것을 확인 (LLM 미사용)35줄
chat 노드는 고정된 AIMessage 를 돌려주는 가짜 노드입니다. 모델 호출이 없어 자격 증명 없이 실행됩니다.
 1# reducer.py - add_messages reducer 동작 확인
 2from typing import TypedDict, Annotated
 3from langgraph.graph import StateGraph, START, END
 4from langgraph.graph.message import add_messages
 5from langchain_core.messages import HumanMessage, AIMessage
 6
 7
 8class ChatState(TypedDict):
 9    messages: Annotated[list, add_messages]
10
11
12def chat(state: ChatState) -> dict:
13    # 새 메시지를 리스트로 반환만 하면 add_messages가 알아서 누적합니다
14    return {"messages": [AIMessage(content="안녕하세요, 반갑습니다.")]}
15
16
17builder = StateGraph(ChatState)
18builder.add_node("chat", chat)
19builder.add_edge(START, "chat")
20builder.add_edge("chat", END)
21graph = builder.compile()
22
23
24if __name__ == "__main__":   # langgraph dev 서버가 import할 때는 실행되지 않도록 가드 아래에 둡니다
25    # 1차 실행
26    result = graph.invoke({"messages": [HumanMessage(content="안녕하세요?")]})
27    print("1차 실행 후 메시지 개수:", len(result["messages"]))
28
29    # 2차 실행: 이전 messages를 그대로 이어서 넘기면 계속 누적됩니다
30    result2 = graph.invoke(
31        {"messages": result["messages"] + [HumanMessage(content="잘 지내시나요?")]}
32    )
33    print("2차 실행 후 메시지 개수:", len(result2["messages"]))
34    for m in result2["messages"]:
35        print(f"  [{type(m).__name__}] {m.content}")

조건부 엣지: 실행 중에 다음 노드를 고르기

add_edge 로 그은 엣지는 코드를 쓸 때 목적지가 정해집니다. 조건부 엣지는 출발 노드가 끝난 뒤에 State를 보고 목적지를 정합니다. 그래프가 체인과 달라지는 첫 지점입니다.

conditional.py 는 입력 문장이 답을 원하는 질문인지 감탄이나 소감인지 가려서, 질문이면 모델로 답하고 감탄이면 모델 없이 정해진 반응을 돌려줍니다.

START classifyintent 를 State에 기록 route_by_intent answer_question모델로 답변 또는 react_exclaim고정 반응 END

판단은 노드가, 길 안내는 라우팅 함수가

분기에는 두 가지 역할이 있고, 이 파일은 둘을 나눠 놓았습니다.

분류 노드 classify

모델에게 물어 "question" 또는 "exclaim" 을 얻고 {"intent": ...} 로 돌려줍니다. 노드이므로 State를 바꿉니다. 판단 결과가 State에 기록으로 남습니다.

라우팅 함수 route_by_intent

State의 intent 를 읽고 다음 노드의 이름을 문자열로 돌려줍니다. 노드가 아니므로 State를 바꾸지 못하고, stream_mode="updates" 에도 나타나지 않습니다.

conditional.py 52–67줄
52def answer_question(state: ClassifyState) -> dict:
53    """질문에 LLM으로 답합니다."""
54    response = llm.invoke(f"다음 질문에 한 줄로 답하세요: {state['user_input']}")
55    return {"response": get_text(response)}
56
57
58def react_exclaim(state: ClassifyState) -> dict:
59    """감탄에는 LLM 없이 정해진 반응을 합니다."""
60    return {"response": f"'{state['user_input']}' 라니 멋진 일이네요."}
61
62
63def route_by_intent(state: ClassifyState) -> Literal["answer_question", "react_exclaim"]:
64    """라우팅 함수: State를 보고 다음 노드의 이름을 반환합니다."""
65    if state["intent"] == "question":
66        return "answer_question"
67    return "react_exclaim"
분기 대상 노드 둘과 라우팅 함수. 라우팅 함수가 돌려주는 문자열은 add_node 로 등록한 노드 이름과 정확히 같아야 합니다.
conditional.py 70–79줄
70builder = StateGraph(ClassifyState)
71builder.add_node("classify", classify)
72builder.add_node("answer_question", answer_question)
73builder.add_node("react_exclaim", react_exclaim)
74
75builder.add_edge(START, "classify")
76builder.add_conditional_edges("classify", route_by_intent)   # 분기 등록
77builder.add_edge("answer_question", END)
78builder.add_edge("react_exclaim", END)
79graph = builder.compile()
add_conditional_edges(출발 노드, 라우팅 함수). classify 가 끝날 때마다 route_by_intent(state) 가 불립니다.
판단을 라우팅 함수 안에서 바로 하면 안 되나

동작은 같습니다. 하지만 노드에서 intent 를 State에 적어 두면 최종 결과에 "왜 이 길로 갔는지"가 남습니다. 이 파일의 실행부가 result['intent'] 를 함께 출력할 수 있는 것도 그 덕분입니다. 또 분류 방식을 바꿀 때 고칠 곳이 classify 한 군데로 모입니다.

분류를 모델에게 맡길 때: 값의 범위를 묶는다

라우팅 함수는 state["intent"] == "question" 처럼 정확한 문자열을 기대합니다. 모델이 "질문입니다"나 "Question"처럼 답하면 분기가 틀어집니다. 그래서 분류 결과를 자유로운 글이 아니라 허용 값이 정해진 구조로 받습니다.

conditional.py 15–27줄
15# 분류 결과 스키마 - Literal로 두 값 중 하나만 나오도록 강제합니다
16class Intent(BaseModel):
17    intent: Literal["question", "exclaim"] = Field(
18        description="답을 원하는 질문이면 'question', 감탄이나 소감이면 'exclaim'"
19    )
20
21
22# 분류는 매번 같은 답이 나와야 하므로 temperature=0으로 따로 둡니다
23classifier = ChatBedrockConverse(
24    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
25    region_name="us-east-1",
26    temperature=0,
27).with_structured_output(Intent)
Literal 로 허용 값을 두 개로 제한하고, with_structured_output(Intent) 로 모델이 이 스키마에 맞는 객체를 돌려주게 합니다.
conditional.py 38–49줄
38class ClassifyState(TypedDict):
39    user_input: str
40    intent: str
41    response: str
42
43
44def classify(state: ClassifyState) -> dict:
45    """LLM이 입력을 보고 question / exclaim 중 하나로 분류합니다. 물음표 유무와 무관합니다."""
46    result = classifier.invoke(
47        f"다음 문장이 답을 원하는 질문인지, 감탄이나 소감인지 분류하세요: {state['user_input']}"
48    )
49    return {"intent": result.intent}
classifier.invoke(...) 의 결과는 문자열이 아니라 Intent 객체입니다. result.intent 로 값을 꺼내 State에 적습니다.
  • Literal["question", "exclaim"]: 이 칸에는 두 문자열 중 하나만 올 수 있다는 타입입니다. 1일차의 JsonOutputParser가 프롬프트로 형식을 "부탁"했다면, with_structured_output 은 스키마 자체를 모델에 전달해 그 모양으로 답하게 합니다.
  • Field(description=...): 모델에게 전달되는 분류 기준입니다. 기준을 바꾸려면 이 문장을 고칩니다.
  • temperature=0 인 분류 전용 모델: 답변용 llm 과 따로 만들었습니다. 같은 입력이 실행마다 다른 길로 가는 일을 줄이기 위해서입니다.
get_text 는 왜 있나

이 날의 여러 파일에 같은 get_text 함수가 반복됩니다. 주석이 설명하듯 ChatBedrockConverse 는 응답의 content 를 문자열이 아니라 블록의 리스트로 주기도 합니다. 그래서 리스트이면 각 블록의 text 만 모아 붙이고, 문자열이면 그대로 돌려줍니다. 응답을 State의 문자열 칸에 넣기 전에 이 함수를 거칩니다.

규칙에서 모델로: 무엇을 얻고 무엇을 치르나

실행부의 시험 입력 다섯 개 가운데 마지막 두 개에 붙은 주석이 이 파일의 내력을 알려 줍니다. 처음에는 "문장에 물음표가 있으면 질문"이라는 규칙으로 분류했고, 그 규칙이 틀리는 입력을 일부러 넣어 둔 것입니다.

conditional.py 82–92줄
82if __name__ == "__main__":   # langgraph dev 서버가 import할 때는 실행되지 않도록 가드 아래에 둡니다
83    for text in [
84        "오늘 사내 식당 메뉴 뭐야?",
85        "와 배포가 한 번에 성공했다",
86        "LangGraph가 뭐야?",
87        "오늘 메뉴 뭐야",         # 물음표 없는 질문 -> 이전 규칙에서는 exclaim으로 오분류
88        "이게 되네?!",            # 물음표 있는 감탄 -> 이전 규칙에서는 question으로 오분류
89    ]:
90        result = graph.invoke({"user_input": text, "intent": "", "response": ""})
91        print(f"입력: {text}")
92        print(f"분류: {result['intent']} / 응답: {result['response']}\n")
물음표 없는 질문과 물음표 있는 감탄. 물음표 규칙으로는 둘 다 반대로 분류됩니다.
항목물음표 규칙모델 분류 (지금 코드)
"오늘 메뉴 뭐야", "이게 되네?!"둘 다 오분류뜻을 보고 분류. 설명 자료에 기록된 실행에서는 다섯 입력이 모두 의도대로 분류됐습니다
입력 한 건당 모델 호출질문 1번(답변), 감탄 0번질문 2번(분류 + 답변), 감탄 1번(분류)
결과가 매번 같은가항상 같음temperature=0 이어도 완전히 보장되지는 않음
실패할 수 있는 지점없음분류 호출이 실패하면 그래프 전체가 실패

분류 방식이 규칙에서 모델로 바뀌었어도 route_by_intent 와 그래프 조립 코드는 그대로입니다. 판단을 노드에, 길 안내를 라우팅 함수에 나눠 둔 구조의 이득이 여기서 드러납니다.

갈 수 있는 목적지를 그래프에게 알려 주기

라우팅 함수는 실행해 보기 전에는 무엇을 돌려줄지 알 수 없는 평범한 함수입니다. 그래서 구조도를 정확히 그리려면 "이 함수가 갈 수 있는 곳"을 따로 알려 줘야 합니다. 이 날의 네 파일은 서로 다른 방법을 씁니다.

파일알려 주는 방법코드
conditional.py라우팅 함수의 반환 타입 힌트-> Literal["answer_question", "react_exclaim"]
branch_loop_graph.py세 번째 인자에 노드 이름 리스트["generate", "rewrite", "give_up"]
react_agent.py세 번째 인자에 dict. 라우팅 함수는 라벨을 돌려주고 dict가 라벨을 노드로 바꿉니다{"tools": "tools", "end": END}
cycle.py알려 주지 않음-> str, 세 번째 인자 없음
목적지 정보가 없으면 그림이 틀리게 나옵니다

실행에는 영향이 없습니다. 라우팅 함수가 돌려준 이름의 노드가 존재하기만 하면 그래프는 그리로 갑니다. 하지만 그림은 다릅니다. 이 저장소에 설치된 langgraph 1.2.19에서 cycle.py 와 같은 구조(반환 타입 str, 세 번째 인자 없음)의 작은 그래프를 그려 보니 되돌아가는 엣지가 그림에 빠졌습니다. 반환 타입을 Literal 로 바꾸자 두 목적지가 모두 점선으로 그려졌습니다. 강의 때 만든 설명 자료에는 "갈 수 없는 경로까지 점선으로 그려질 수 있다"고 적혀 있어, 틀리는 모양은 버전에 따라 다른 것으로 보입니다. 어느 쪽이든 구조도를 믿으려면 목적지를 명시해야 합니다.

정리

조건부 엣지 정리

고정 엣지와 조건부 엣지의 차이, 분류 노드와 라우팅 함수의 역할 분리, 구조화 출력으로 분류 값을 묶는 방법을 정리하고, conditional.py 를 실제로 실행한 결과와 물음표 규칙 대 모델 분류의 비교표를 담았습니다.

새 탭으로day3_practice/conditional_explained.html
PYday3_practice/conditional.py모델이 분류한 intent 값에 따라 답변 노드와 고정 반응 노드로 분기92줄
모델을 부릅니다(입력 다섯 건에 분류 5번, 질문으로 분류된 건마다 답변 1번). 실행하려면 자격 증명이 필요합니다.
 1# conditional.py - 조건부 엣지로 분기하기
 2from typing import TypedDict, Literal
 3from dotenv import load_dotenv
 4from pydantic import BaseModel, Field
 5from langgraph.graph import StateGraph, START, END
 6from langchain_aws import ChatBedrockConverse
 7
 8load_dotenv()
 9llm = ChatBedrockConverse(
10    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
11    region_name="us-east-1",
12)
13
14
15# 분류 결과 스키마 - Literal로 두 값 중 하나만 나오도록 강제합니다
16class Intent(BaseModel):
17    intent: Literal["question", "exclaim"] = Field(
18        description="답을 원하는 질문이면 'question', 감탄이나 소감이면 'exclaim'"
19    )
20
21
22# 분류는 매번 같은 답이 나와야 하므로 temperature=0으로 따로 둡니다
23classifier = ChatBedrockConverse(
24    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
25    region_name="us-east-1",
26    temperature=0,
27).with_structured_output(Intent)
28
29
30def get_text(message):
31    """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다."""
32    content = message.content
33    if isinstance(content, list):
34        return "".join(block.get("text", "") for block in content if isinstance(block, dict))
35    return content
36
37
38class ClassifyState(TypedDict):
39    user_input: str
40    intent: str
41    response: str
42
43
44def classify(state: ClassifyState) -> dict:
45    """LLM이 입력을 보고 question / exclaim 중 하나로 분류합니다. 물음표 유무와 무관합니다."""
46    result = classifier.invoke(
47        f"다음 문장이 답을 원하는 질문인지, 감탄이나 소감인지 분류하세요: {state['user_input']}"
48    )
49    return {"intent": result.intent}
50
51
52def answer_question(state: ClassifyState) -> dict:
53    """질문에 LLM으로 답합니다."""
54    response = llm.invoke(f"다음 질문에 한 줄로 답하세요: {state['user_input']}")
55    return {"response": get_text(response)}
56
57
58def react_exclaim(state: ClassifyState) -> dict:
59    """감탄에는 LLM 없이 정해진 반응을 합니다."""
60    return {"response": f"'{state['user_input']}' 라니 멋진 일이네요."}
61
62
63def route_by_intent(state: ClassifyState) -> Literal["answer_question", "react_exclaim"]:
64    """라우팅 함수: State를 보고 다음 노드의 이름을 반환합니다."""
65    if state["intent"] == "question":
66        return "answer_question"
67    return "react_exclaim"
68
69
70builder = StateGraph(ClassifyState)
71builder.add_node("classify", classify)
72builder.add_node("answer_question", answer_question)
73builder.add_node("react_exclaim", react_exclaim)
74
75builder.add_edge(START, "classify")
76builder.add_conditional_edges("classify", route_by_intent)   # 분기 등록
77builder.add_edge("answer_question", END)
78builder.add_edge("react_exclaim", END)
79graph = builder.compile()
80
81
82if __name__ == "__main__":   # langgraph dev 서버가 import할 때는 실행되지 않도록 가드 아래에 둡니다
83    for text in [
84        "오늘 사내 식당 메뉴 뭐야?",
85        "와 배포가 한 번에 성공했다",
86        "LangGraph가 뭐야?",
87        "오늘 메뉴 뭐야",         # 물음표 없는 질문 -> 이전 규칙에서는 exclaim으로 오분류
88        "이게 되네?!",            # 물음표 있는 감탄 -> 이전 규칙에서는 question으로 오분류
89    ]:
90        result = graph.invoke({"user_input": text, "intent": "", "response": ""})
91        print(f"입력: {text}")
92        print(f"분류: {result['intent']} / 응답: {result['response']}\n")

사이클: 조건을 만족할 때까지 되돌아가기

LangGraph에는 반복문이 따로 없습니다. 조건부 엣지의 라우팅 함수가 이미 지나온 노드의 이름을 돌려주면 그것이 곧 반복입니다. 이렇게 엣지를 따라가다 같은 노드로 돌아오는 구조를 사이클이라고 부릅니다.

cycle.py 는 답변을 생성하고, 품질 점수를 매기고, 점수가 모자라면 생성으로 돌아갑니다.

START generate답변 생성, attempts + 1 check_qualityquality_score 기록 route_quality END통과 또는 시도 한도 미달이면 generate다시 생성
cycle.py 22–43줄
22class QualityState(TypedDict):
23    question: str
24    answer: str
25    attempts: int
26    quality_score: float
27
28
29def generate(state: QualityState) -> dict:
30    """답변을 생성하고 시도 횟수를 1 올립니다."""
31    response = llm.invoke(
32        f"다음 질문에 200자 이상으로 자세하게 답하세요: {state['question']}"
33    )
34    return {
35        "answer": get_text(response),
36        "attempts": state.get("attempts", 0) + 1,
37    }
38
39
40def check_quality(state: QualityState) -> dict:
41    """품질 평가. 실습용으로 단순하게 길이 기반 점수를 씁니다 (200자면 만점)."""
42    score = min(len(state["answer"]) / 500, 1.0)
43    return {"quality_score": score}
generate 는 answer 를 덮어쓰고 attempts 를 1 올립니다. check_quality 는 답변 길이로 점수를 매깁니다.
cycle.py 46–61줄
46def route_quality(state: QualityState) -> str:
47    """품질이 좋거나 시도 한도에 도달하면 종료하고 아니면 재시도합니다."""
48    if state["quality_score"] >= 0.8:
49        return END
50    if state["attempts"] >= 10:       # 안전장치: 무한 루프 방지
51        return END
52    return "generate"                # 이전 노드로 돌아감 = 반복
53
54
55builder = StateGraph(QualityState)
56builder.add_node("generate", generate)
57builder.add_node("check_quality", check_quality)
58builder.add_edge(START, "generate")
59builder.add_edge("generate", "check_quality")
60builder.add_conditional_edges("check_quality", route_quality)
61graph = builder.compile()
라우팅 함수가 END 를 돌려주면 종료, 'generate' 를 돌려주면 한 바퀴 더 돕니다. 그래프 조립에는 generate 로 돌아가는 add_edge 가 없습니다.
  1. 반복 횟수는 State에 둔다attempts 는 while 문의 카운터 변수에 해당합니다. generate 가 state.get("attempts", 0) + 1 을 돌려줘 한 바퀴마다 1씩 오릅니다. reducer가 없는 칸이므로 새 값이 옛 값을 덮어씁니다.
  2. 판단 근거도 State에 둔다check_quality 가 quality_score 를 기록하고, route_quality 는 그 값을 읽기만 합니다. 앞 절과 같은 역할 분리입니다.
  3. 라우팅 함수의 검사 순서가 종료 규칙이다먼저 품질이 기준(0.8) 이상이면 끝내고, 아니면 시도 횟수가 한도(10)에 닿았는지 보고, 둘 다 아니면 "generate" 로 돌려보냅니다.
  4. END로 가는 길은 라우팅 함수뿐이다이 그래프에는 add_edge(..., END) 가 없습니다. 라우팅 함수가 END 를 돌려주지 않으면 그래프는 끝나지 않습니다.
주석과 코드가 어긋난 곳: 만점은 200자가 아니라 500자

check_quality 의 설명글은 "200자면 만점"이라고 적혀 있지만 실제 식은 min(len(state["answer"]) / 500, 1.0) 입니다. 500자가 만점이고, 통과 기준 0.8은 400자 이상에 해당합니다. 200자짜리 답은 0.40점으로 미달입니다. 아래 설명 자료는 기준이 200자, 시도 한도가 3이던 이전 버전을 설명하고 있으며, 그 버전에서는 모델이 첫 시도에 541자를 써서 바로 통과하는 바람에 사이클이 돌지 않았다고 기록돼 있습니다. 지금 파일은 기준을 500자로, 한도를 10으로 올리고 질문에 "한문장으로"를 넣어 두었는데, 반복이 실제로 일어나는 모습을 보려는 조정으로 보입니다.

실행부: updates를 손으로 합치기

cycle.py 63–86줄
63if __name__ == "__main__":   # 5번째 세션에서 이 파일을 import해 쓰므로 실행 부분은 가드 아래에 둡니다
64    result = {
65        "question": "LangGraph의 장점을 한문장으로 설명해 주세요",
66        "answer": "",
67        "attempts": 0,
68        "quality_score": 0.0,
69    }
70
71    # invoke 대신 stream으로 돌려 노드가 끝날 때마다 중간 과정을 출력합니다
72    # updates 모드는 노드가 돌려준 값만 주므로 result에 직접 합쳐 최종 State를 만듭니다
73    for step in graph.stream(result, stream_mode="updates"):
74        node, update = next(iter(step.items()))
75        result.update(update)
76        if node == "generate":
77            print(f"[{result['attempts']}회차] generate      -> 답변 {len(update['answer'])}자")
78        elif node == "check_quality":
79            score = update["quality_score"]
80            if score >= 0.8:
81                verdict = "통과, 종료"
82            elif route_quality(result) == END:
83                verdict = "미달이지만 시도 한도, 종료"
84            else:
85                verdict = "미달, 재시도"
86            print(f"[{result['attempts']}회차] check_quality -> 점수 {score:.2f} ({verdict})")
updates 모드는 노드가 돌려준 값만 주므로, result.update(update) 로 직접 합쳐 현재 State를 따라갑니다.

step 은 {노드 이름: 돌려준 dict} 모양이고, next(iter(step.items())) 로 그 한 쌍을 꺼냅니다. 이 State에는 reducer가 붙은 칸이 없어 모든 칸이 덮어쓰기이므로 dict.update 로 합친 결과가 그래프 안의 State와 같습니다. 82번째 줄에서는 route_quality(result) 를 직접 불러 "이번 판정이 종료인가"를 출력용으로 다시 계산합니다. 라우팅 함수가 State를 읽기만 하는 평범한 함수이기 때문에 가능한 일입니다.

cycle.py 출력 모양 (예시)
 1[1회차] generate      -> 답변 300자
 2[1회차] check_quality -> 점수 0.60 (미달, 재시도)
 3[2회차] generate      -> 답변 450자
 4[2회차] check_quality -> 점수 0.90 (통과, 종료)
 5
 6=== 최종 결과 ===
 7시도 횟수: 2
 8품질 점수: 0.90
 9답변 길이: 450자
10답변 미리보기: ...
글자 수와 회차는 모델 응답에 따라 매번 달라집니다. 300자는 0.60으로 미달, 450자는 0.90으로 통과입니다.

종료 장치는 두 겹으로

사이클이 있는 그래프는 끝나지 않을 수 있습니다. 품질 기준을 끝내 못 넘는 경우가 그렇습니다. 그래서 종료 장치를 둘 둡니다.

장치어디에 있나걸렸을 때
attempts >= 10우리 코드 (route_quality)정상 종료합니다. 결과는 기준에 못 미친 마지막 답변입니다
recursion_limitLangGraph한 번의 실행에서 밟은 단계 수가 한도를 넘으면 GraphRecursionError 예외를 던집니다. graph.invoke(입력, config={"recursion_limit": N}) 으로 정합니다

한 바퀴가 generate 와 check_quality 두 단계이므로 10회 시도는 20단계입니다. 코드 쪽 한도가 먼저 걸리도록 설계하고, recursion_limit 은 라우팅 로직에 버그가 있을 때의 마지막 방어선으로 둡니다.

recursion_limit 의 기본값에 기대지 마세요

강의 때 만든 설명 자료들은 기본값을 25단계로 적고 있습니다. 그런데 이 저장소의 .venv 에 설치된 langgraph 1.2.19에서 끝나지 않는 작은 그래프를 돌려 보니 오류 메시지는 Recursion limit of 10007 reached ... 였습니다(소스의 기본값 정의도 10007이고, 환경 변수 LANGGRAPH_DEFAULT_RECURSION_LIMIT 로 바꿀 수 있게 돼 있습니다). 기본값은 버전에 따라 다르고, 1만 단계는 모델을 부르는 그래프에서는 사실상 한도가 없는 것과 같습니다. 사이클이 있는 그래프에는 코드 쪽 한도를 반드시 두고, 프레임워크 한도도 config 로 직접 적어 주는 편이 안전합니다. config={"recursion_limit": 6} 을 주면 6단계를 실행한 뒤 예외가 나는 것까지 확인했습니다.

이 코드를 그대로 쓰면 아쉬운 점

지점지금 코드개선 방향
재시도가 같은 요청의 반복generate 는 매번 같은 프롬프트를 보냅니다. 앞선 답이 왜 떨어졌는지 모델은 모릅니다State의 이전 답변과 점수를 프롬프트에 넣어 "무엇을 보강하라"고 알려 줍니다
평가 기준이 길이뿐내용이 틀려도 길면 통과합니다모델 평가자(구조화 출력으로 점수와 이유를 받음)나 규칙 검사를 더합니다. 7일차 평가와 이어집니다
종료 이유를 구분할 수 없음통과로 끝났는지 한도로 끝났는지 호출한 쪽이 점수를 다시 비교해야 압니다State에 종료 사유 칸을 두거나, 한도 종료를 별도 노드로 보냅니다(다음 절의 give_up 노드가 그 예입니다)
앞선 답변이 사라짐answer 는 덮어쓰기라 마지막 답만 남습니다이력이 필요하면 리스트 칸에 reducer를 붙여 누적합니다
정리

사이클 그래프 정리

조건부 엣지가 이전 노드 이름을 돌려주는 것이 곧 사이클이라는 점, 코드 한도와 recursion_limit 의 두 겹 종료 장치, 가짜 generate 로 사이클을 재현한 시뮬레이션 결과를 정리했습니다. 품질 기준 200자, 시도 한도 3이던 이전 버전의 cycle.py 를 기준으로 작성된 자료입니다.

새 탭으로day3_practice/cycle_explained.html
PYday3_practice/cycle.py답변 생성 → 길이로 품질 평가 → 미달이면 재생성하는 사이클 그래프92줄
모델을 부릅니다. 반복이 돌면 호출 횟수도 그만큼 늘어나며 최대 10번입니다. langgraph.json 에 등록돼 있어 개발 서버에서도 열립니다.
 1# cycle.py - 조건을 만족할 때까지 반복하는 그래프
 2from typing import TypedDict
 3from dotenv import load_dotenv
 4from langgraph.graph import StateGraph, START, END
 5from langchain_aws import ChatBedrockConverse
 6
 7load_dotenv()
 8llm = ChatBedrockConverse(
 9    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
10    region_name="us-east-1",
11)
12
13
14def get_text(message):
15    """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다."""
16    content = message.content
17    if isinstance(content, list):
18        return "".join(block.get("text", "") for block in content if isinstance(block, dict))
19    return content
20
21
22class QualityState(TypedDict):
23    question: str
24    answer: str
25    attempts: int
26    quality_score: float
27
28
29def generate(state: QualityState) -> dict:
30    """답변을 생성하고 시도 횟수를 1 올립니다."""
31    response = llm.invoke(
32        f"다음 질문에 200자 이상으로 자세하게 답하세요: {state['question']}"
33    )
34    return {
35        "answer": get_text(response),
36        "attempts": state.get("attempts", 0) + 1,
37    }
38
39
40def check_quality(state: QualityState) -> dict:
41    """품질 평가. 실습용으로 단순하게 길이 기반 점수를 씁니다 (200자면 만점)."""
42    score = min(len(state["answer"]) / 500, 1.0)
43    return {"quality_score": score}
44
45
46def route_quality(state: QualityState) -> str:
47    """품질이 좋거나 시도 한도에 도달하면 종료하고 아니면 재시도합니다."""
48    if state["quality_score"] >= 0.8:
49        return END
50    if state["attempts"] >= 10:       # 안전장치: 무한 루프 방지
51        return END
52    return "generate"                # 이전 노드로 돌아감 = 반복
53
54
55builder = StateGraph(QualityState)
56builder.add_node("generate", generate)
57builder.add_node("check_quality", check_quality)
58builder.add_edge(START, "generate")
59builder.add_edge("generate", "check_quality")
60builder.add_conditional_edges("check_quality", route_quality)
61graph = builder.compile()
62
63if __name__ == "__main__":   # 5번째 세션에서 이 파일을 import해 쓰므로 실행 부분은 가드 아래에 둡니다
64    result = {
65        "question": "LangGraph의 장점을 한문장으로 설명해 주세요",
66        "answer": "",
67        "attempts": 0,
68        "quality_score": 0.0,
69    }
70
71    # invoke 대신 stream으로 돌려 노드가 끝날 때마다 중간 과정을 출력합니다
72    # updates 모드는 노드가 돌려준 값만 주므로 result에 직접 합쳐 최종 State를 만듭니다
73    for step in graph.stream(result, stream_mode="updates"):
74        node, update = next(iter(step.items()))
75        result.update(update)
76        if node == "generate":
77            print(f"[{result['attempts']}회차] generate      -> 답변 {len(update['answer'])}자")
78        elif node == "check_quality":
79            score = update["quality_score"]
80            if score >= 0.8:
81                verdict = "통과, 종료"
82            elif route_quality(result) == END:
83                verdict = "미달이지만 시도 한도, 종료"
84            else:
85                verdict = "미달, 재시도"
86            print(f"[{result['attempts']}회차] check_quality -> 점수 {score:.2f} ({verdict})")
87
88    print("\n=== 최종 결과 ===")
89    print(f"시도 횟수: {result['attempts']}")
90    print(f"품질 점수: {result['quality_score']:.2f}")
91    print(f"답변 길이: {len(result['answer'])}자")
92    print(f"답변 미리보기: {result['answer'][:80]}...")

분기와 루프를 한 그래프에: 재검색 문제 다시 풀기

조건부 엣지(분기)와 되돌아가는 엣지(루프)를 익혔으니, 처음의 숙제로 돌아갑니다. chain_limit.py 에서 체인 바깥 while 문에 둘 수밖에 없던 "검색 실패 시 질문을 바꿔 재검색"을 그래프 안으로 옮깁니다.

START retrieve generate give_up END rewrite docs 있음 후보를 다 씀 docs 없음, 후보 남음 질문을 바꿔 다시 검색
점선은 조건부 엣지(route_after_retrieve 가 셋 중 하나를 고름), 실선은 고정 엣지입니다. 파란 실선 rewrite → retrieve 가 루프를 만듭니다.

지역 변수가 State로

branch_loop_graph.py 14–19줄
14class State(TypedDict):
15    question: str
16    alternatives: list[str]   # 바꿔서 다시 물어볼 질문 후보
17    attempt: int              # 재시도 횟수
18    docs: list[str]
19    answer: str
chain_limit.py 에서 while 문 주변에 흩어져 있던 question, alternatives, attempt 가 모두 State의 칸이 됐습니다.

노드 넷과 라우팅 함수 하나

branch_loop_graph.py 24–57줄
24def retrieve(state: State) -> dict:
25    """가짜 검색기: 질문에 '재택'이 있을 때만 문서를 찾습니다."""
26    q = state["question"]
27    docs = ["재택근무는 주 2회까지 가능하다."] if "재택" in q else []
28    print(f"  [retrieve] 질문={q!r} -> 문서 {len(docs)}건")
29    return {"docs": docs}
30
31
32def rewrite(state: State) -> dict:
33    """질문을 다음 후보로 바꾸고 재시도 횟수를 올립니다."""
34    attempt = state["attempt"]
35    new_q = state["alternatives"][attempt]
36    print(f"  [rewrite]  {attempt + 1}번째 재시도 -> 질문을 {new_q!r} 로 바꿉니다")
37    return {"question": new_q, "attempt": attempt + 1}
38
39
40def generate(state: State) -> dict:
41    print("  [generate] 문서로 답변을 만듭니다")
42    return {"answer": f"답변: {state['docs'][0]}"}
43
44
45def give_up(state: State) -> dict:
46    print("  [give_up]  재시도를 다 써서 포기합니다")
47    return {"answer": "죄송합니다. 관련 규정을 찾지 못했습니다."}
48
49
50# ---------- 분기 함수: 다음에 갈 노드 이름을 돌려줍니다 ----------
51
52def route_after_retrieve(state: State) -> str:
53    if state["docs"]:
54        return "generate"
55    if state["attempt"] < len(state["alternatives"]):
56        return "rewrite"
57    return "give_up"
각 노드는 한 가지 일만 합니다. 어디로 갈지는 강조한 라우팅 함수가 State의 docs, attempt, alternatives 를 보고 정합니다.
branch_loop_graph.py 62–78줄
62builder = StateGraph(State)
63builder.add_node("retrieve", retrieve)
64builder.add_node("rewrite", rewrite)
65builder.add_node("generate", generate)
66builder.add_node("give_up", give_up)
67
68builder.add_edge(START, "retrieve")
69builder.add_conditional_edges(                 # 분기: State를 보고 셋 중 하나로
70    "retrieve",
71    route_after_retrieve,
72    ["generate", "rewrite", "give_up"],
73)
74builder.add_edge("rewrite", "retrieve")        # 루프: 다시 검색으로 되돌아감
75builder.add_edge("generate", END)
76builder.add_edge("give_up", END)
77
78graph = builder.compile()
조건부 엣지 하나와 되돌아가는 고정 엣지 하나. 세 번째 인자의 리스트는 갈 수 있는 목적지 목록입니다.
chain_limit.py (체인 + 바깥 루프)branch_loop_graph.py (그래프)
지역 변수 question, alternatives, attemptState의 칸
while "찾지 못했습니다" in answer and attempt < len(alternatives)라우팅 함수 route_after_retrieve
while 본문의 question = alternatives[attempt]; attempt += 1rewrite 노드
while 문 처음으로 돌아가기add_edge("rewrite", "retrieve")
답변 문자열에 특정 문구가 있는지로 실패를 판단state["docs"] 가 비었는지로 판단
실패해도 생성 단계까지 간 뒤에 되돌아감 (체인 전체를 재실행)검색이 실패하면 생성으로 가지 않고 바로 rewrite 로
포기하는 경우가 따로 드러나지 않음give_up 노드로 명시
이 루프가 반드시 끝나는 이유

rewrite 는 실행될 때마다 attempt 를 1 올리고, 라우팅 함수는 attempt < len(alternatives) 일 때만 rewrite 로 보냅니다. 후보가 유한하므로 언젠가는 generate 나 give_up 으로 빠집니다. 사이클을 만들 때는 "매 바퀴마다 변하는 값"과 "그 값을 보는 종료 조건"이 짝으로 있는지를 먼저 확인합니다.

세 가지 경우 돌려 보기

branch_loop_graph.py 84–103줄
 84    cases = [
 85        ("바로 찾음",       "재택근무 며칠 가능해?", ["재택근무 규정"]),
 86        ("두 번 돌고 찾음", "집에서 일해도 되나요?", ["원격 근무 규정", "재택근무 규정"]),
 87        ("끝내 못 찾음",    "집에서 일해도 되나요?", ["원격 근무 규정", "홈오피스 규정"]),
 88    ]
 89
 90    for title, question, alternatives in cases:
 91        print("=" * 60)
 92        print(f"[{title}] 질문={question!r}, 후보={alternatives}")
 93        path = []
 94        final = {}
 95        for step in graph.stream(
 96            {"question": question, "alternatives": alternatives, "attempt": 0, "docs": [], "answer": ""},
 97            stream_mode="updates",
 98        ):
 99            node, update = next(iter(step.items()))
100            path.append(node)
101            final.update(update)
102        print(f"  지나간 경로: START -> {' -> '.join(path)} -> END")
103        print(f"  최종 답변: {final['answer']}")
updates 모드에서 노드 이름만 모으면 '지나간 경로'가 됩니다.
실제 출력

모델을 부르지 않는 파일이므로, 실행부를 PNG 저장 줄만 빼고 돌린 출력을 그대로 싣습니다. 같은 그래프가 입력에 따라 세 가지 다른 경로를 지납니다.

branch_loop_graph.py 실행 출력 (PNG 저장 부분 제외)
 1============================================================
 2[바로 찾음] 질문='재택근무 며칠 가능해?', 후보=['재택근무 규정']
 3  [retrieve] 질문='재택근무 며칠 가능해?' -> 문서 1건
 4  [generate] 문서로 답변을 만듭니다
 5  지나간 경로: START -> retrieve -> generate -> END
 6  최종 답변: 답변: 재택근무는 주 2회까지 가능하다.
 7============================================================
 8[두 번 돌고 찾음] 질문='집에서 일해도 되나요?', 후보=['원격 근무 규정', '재택근무 규정']
 9  [retrieve] 질문='집에서 일해도 되나요?' -> 문서 0건
10  [rewrite]  1번째 재시도 -> 질문을 '원격 근무 규정' 로 바꿉니다
11  [retrieve] 질문='원격 근무 규정' -> 문서 0건
12  [rewrite]  2번째 재시도 -> 질문을 '재택근무 규정' 로 바꿉니다
13  [retrieve] 질문='재택근무 규정' -> 문서 1건
14  [generate] 문서로 답변을 만듭니다
15  지나간 경로: START -> retrieve -> rewrite -> retrieve -> rewrite -> retrieve -> generate -> END
16  최종 답변: 답변: 재택근무는 주 2회까지 가능하다.
17============================================================
18[끝내 못 찾음] 질문='집에서 일해도 되나요?', 후보=['원격 근무 규정', '홈오피스 규정']
19  [retrieve] 질문='집에서 일해도 되나요?' -> 문서 0건
20  [rewrite]  1번째 재시도 -> 질문을 '원격 근무 규정' 로 바꿉니다
21  [retrieve] 질문='원격 근무 규정' -> 문서 0건
22  [rewrite]  2번째 재시도 -> 질문을 '홈오피스 규정' 로 바꿉니다
23  [retrieve] 질문='홈오피스 규정' -> 문서 0건
24  [give_up]  재시도를 다 써서 포기합니다
25  지나간 경로: START -> retrieve -> rewrite -> retrieve -> rewrite -> retrieve -> give_up -> END
26  최종 답변: 죄송합니다. 관련 규정을 찾지 못했습니다.
branch_loop_graph.py 가 저장하는 구조도. retrieve 에서 나가는 점선 셋이 조건부 엣지이고, rewrite 에서 retrieve 로 올라가는 실선이 루프입니다. 체인의 그림에는 없던 '재시도'가 구조에 드러납니다.
branch_loop_graph.py 가 저장하는 구조도. retrieve 에서 나가는 점선 셋이 조건부 엣지이고, rewrite 에서 retrieve 로 올라가는 실선이 루프입니다. 체인의 그림에는 없던 '재시도'가 구조에 드러납니다.
판단과 반복이 프로그램의 구조가 됐습니다

chain_limit.py 의 마지막 질문("판단하는 코드와 반복하는 코드가 어디에 있습니까")에 대한 그래프의 답은 "그래프 안, 라우팅 함수와 엣지에"입니다. 흐름이 그림으로 보이고, 경로가 기록으로 남고, 현재 위치와 값이 State 한곳에 모입니다.

PYday3_practice/branch_loop_graph.py검색 실패 시 질문을 바꿔 재검색하는 분기 + 루프 그래프 (LLM 미사용)111줄
chain_limit.py 의 답안에 해당합니다. 실행하면 branch_loop_graph.png 를 새로 저장합니다.
  1# branch_loop_graph.py - 분기와 루프가 있는 그래프
  2#
  3# chain_limit.py에서 체인 바깥 while 문으로 처리했던 "검색 실패 시 질문 바꿔 재검색"을
  4# 그래프 안으로 옮깁니다. LLM은 부르지 않으므로 누가 돌려도 결과가 같습니다.
  5#
  6#   START -> retrieve -> (분기) --문서 있음-------------> generate -> END
  7#                          |  --문서 없음, 재시도 가능--> rewrite -> retrieve (루프)
  8#                          |  --문서 없음, 재시도 끝----> give_up  -> END
  9
 10from typing import TypedDict
 11from langgraph.graph import StateGraph, START, END
 12
 13
 14class State(TypedDict):
 15    question: str
 16    alternatives: list[str]   # 바꿔서 다시 물어볼 질문 후보
 17    attempt: int              # 재시도 횟수
 18    docs: list[str]
 19    answer: str
 20
 21
 22# ---------- 노드 ----------
 23
 24def retrieve(state: State) -> dict:
 25    """가짜 검색기: 질문에 '재택'이 있을 때만 문서를 찾습니다."""
 26    q = state["question"]
 27    docs = ["재택근무는 주 2회까지 가능하다."] if "재택" in q else []
 28    print(f"  [retrieve] 질문={q!r} -> 문서 {len(docs)}건")
 29    return {"docs": docs}
 30
 31
 32def rewrite(state: State) -> dict:
 33    """질문을 다음 후보로 바꾸고 재시도 횟수를 올립니다."""
 34    attempt = state["attempt"]
 35    new_q = state["alternatives"][attempt]
 36    print(f"  [rewrite]  {attempt + 1}번째 재시도 -> 질문을 {new_q!r} 로 바꿉니다")
 37    return {"question": new_q, "attempt": attempt + 1}
 38
 39
 40def generate(state: State) -> dict:
 41    print("  [generate] 문서로 답변을 만듭니다")
 42    return {"answer": f"답변: {state['docs'][0]}"}
 43
 44
 45def give_up(state: State) -> dict:
 46    print("  [give_up]  재시도를 다 써서 포기합니다")
 47    return {"answer": "죄송합니다. 관련 규정을 찾지 못했습니다."}
 48
 49
 50# ---------- 분기 함수: 다음에 갈 노드 이름을 돌려줍니다 ----------
 51
 52def route_after_retrieve(state: State) -> str:
 53    if state["docs"]:
 54        return "generate"
 55    if state["attempt"] < len(state["alternatives"]):
 56        return "rewrite"
 57    return "give_up"
 58
 59
 60# ---------- 그래프 조립 ----------
 61
 62builder = StateGraph(State)
 63builder.add_node("retrieve", retrieve)
 64builder.add_node("rewrite", rewrite)
 65builder.add_node("generate", generate)
 66builder.add_node("give_up", give_up)
 67
 68builder.add_edge(START, "retrieve")
 69builder.add_conditional_edges(                 # 분기: State를 보고 셋 중 하나로
 70    "retrieve",
 71    route_after_retrieve,
 72    ["generate", "rewrite", "give_up"],
 73)
 74builder.add_edge("rewrite", "retrieve")        # 루프: 다시 검색으로 되돌아감
 75builder.add_edge("generate", END)
 76builder.add_edge("give_up", END)
 77
 78graph = builder.compile()
 79
 80
 81if __name__ == "__main__":   # langgraph dev 서버가 import할 때는 실행되지 않도록 가드 아래에 둡니다
 82    # ---------- 세 가지 경우를 실행해 봅니다 ----------
 83
 84    cases = [
 85        ("바로 찾음",       "재택근무 며칠 가능해?", ["재택근무 규정"]),
 86        ("두 번 돌고 찾음", "집에서 일해도 되나요?", ["원격 근무 규정", "재택근무 규정"]),
 87        ("끝내 못 찾음",    "집에서 일해도 되나요?", ["원격 근무 규정", "홈오피스 규정"]),
 88    ]
 89
 90    for title, question, alternatives in cases:
 91        print("=" * 60)
 92        print(f"[{title}] 질문={question!r}, 후보={alternatives}")
 93        path = []
 94        final = {}
 95        for step in graph.stream(
 96            {"question": question, "alternatives": alternatives, "attempt": 0, "docs": [], "answer": ""},
 97            stream_mode="updates",
 98        ):
 99            node, update = next(iter(step.items()))
100            path.append(node)
101            final.update(update)
102        print(f"  지나간 경로: START -> {' -> '.join(path)} -> END")
103        print(f"  최종 답변: {final['answer']}")
104
105    # 구조 시각화: PNG 저장이 안 되는 환경도 있으므로 try/except로 감쌉니다
106    try:
107        graph.get_graph().draw_mermaid_png(output_file_path="branch_loop_graph.png")
108        print("\nbranch_loop_graph.png 저장 완료")
109    except Exception as e:
110        print("\nPNG 저장은 건너뜁니다:", e)
111        print(graph.get_graph().draw_mermaid())   # 텍스트 출력으로 대체

ReAct 루프를 while 문으로 직접 돌리기

이제 그래프에 올릴 대상을 봅니다. ReAct는 Reasoning(추론)과 Acting(행동)을 합친 말로, 모델이 무엇을 할지 판단하고 → 도구를 실행하고 → 결과를 관찰한 뒤 → 다시 판단하는 반복을 가리킵니다. 오늘날 "에이전트"라고 부르는 것의 기본 동작이 이 루프입니다.

구조는 1일차 도구 호출 맛보기의 반복문과 같습니다. 그때는 맛보기였고, 여기서는 그래프로 옮기기 직전에 루프의 각 줄이 무슨 역할인지 이름을 붙여 가며 다시 씁니다.

추론 (Reason)모델이 tool_calls 를 결정 행동 (Act)우리 코드가 도구 실행 관찰 (Observe)결과를 ToolMessage 로 기록 재판단 추론tool_calls 가 없으면 최종 답변

준비: 도구, 바인딩, 대화 기록

manual_react.py 22–53줄
22# ---------- 1) 도구 정의 ----------
23@tool
24def search_employee(name: str) -> str:
25    """임직원 이름으로 소속 팀과 이메일을 조회합니다. 임직원 정보 질문에 사용하세요."""
26    # 교육용 더미 데이터입니다. 실제 사내 데이터와 무관합니다.
27    employees = {
28        "김하늘": "김하늘 / 클라우드운영팀 / [email protected]",
29        "박도윤": "박도윤 / 물류플랫폼팀 / [email protected]",
30    }
31    return employees.get(name, f"'{name}' 님을 찾을 수 없습니다.")
32
33
34@tool
35def calculate(expression: str) -> str:
36    """수식 문자열을 계산해 정확한 결과를 반환합니다. 예: '34 * 27', '(15 - 5) / 2'"""
37    try:
38        return str(eval(expression, {"__builtins__": {}}, {}))
39    except Exception as e:
40        return f"계산 실패: {e}"
41
42
43tools = [search_employee, calculate]
44tools_by_name = {t.name: t for t in tools}   # 이름으로 도구를 찾기 위한 사전
45
46# ---------- 2) 도구 목록을 모델에 알려주기 ----------
47llm_with_tools = llm.bind_tools(tools)
48
49# ---------- 3) 대화 기록 초기화 ----------
50messages = [
51    SystemMessage(content="너는 삼성SDS 사내 어시스턴트야. 필요하면 도구를 사용해."),
52    HumanMessage(content="김하늘 님과 박도윤 님이 각각 어느 팀 소속인지 알려주고, 34 곱하기 27도 계산해줘."),
53]
도구 두 개를 정의하고, 이름으로 찾기 위한 사전을 만들고, bind_tools 로 모델에 도구 설명서를 붙입니다.
  • @tool: 함수 이름이 도구 이름, 설명글(docstring)이 모델이 읽는 설명서, 타입 힌트가 인자 스키마가 됩니다. 모델은 설명글만 보고 언제 이 도구를 쓸지 정합니다.
  • tools_by_name: 모델은 도구를 이름 문자열로 지목합니다. 그 이름으로 실제 함수를 찾기 위한 dict입니다.
  • llm.bind_tools(tools): 도구 설명서를 매 요청에 함께 보내는 새 모델 객체를 돌려줍니다. 모델은 도구를 실행하지 않고 "이 도구를 이 인자로 불러 달라"는 tool_calls 만 돌려줍니다.
  • messages: system 메시지와 사용자 질문으로 시작하는 평범한 리스트입니다. 질문은 일부러 도구가 세 번 필요한 내용(두 사람 조회 + 계산)으로 골랐습니다.

루프 본체

manual_react.py 55–77줄
55# ---------- 4) ReAct 루프: 도구 선택 -> 실행 -> 결과 관찰 -> 재판단 ----------
56MAX_TURNS = 10                                     # 안전장치
57turn = 0
58while turn < MAX_TURNS:
59    turn += 1
60    ai_message = llm_with_tools.invoke(messages)   # (추론) 모델이 도구 필요 여부를 판단
61    messages.append(ai_message)                    # 응답을 기록에 추가
62
63    if not ai_message.tool_calls:                  # 도구 요청이 없으면 최종 답변
64        print(f"\n[최종 답변] {get_text(ai_message)}")
65        break
66
67    for tool_call in ai_message.tool_calls:        # (행동) 요청된 도구를 전부 실행
68        tool_fn = tools_by_name[tool_call["name"]]
69        result = tool_fn.invoke(tool_call["args"])
70        print(f"[턴 {turn}] {tool_call['name']}({tool_call['args']}) -> {result}")
71
72        messages.append(                           # (관찰) 결과를 기록에 추가
73            ToolMessage(content=str(result), tool_call_id=tool_call["id"])
74        )
75    # while 처음으로 돌아감 -> 모델이 도구 결과를 보고 다시 판단 (재판단)
76else:
77    print("\n[중단] 최대 턴 수를 초과했습니다.")
주석의 (추론) (행동) (관찰) 이 ReAct의 세 단계입니다. 맨 아래 else 는 while 에 붙은 것입니다.
  1. 추론: 기록 전체를 보내고 응답을 기록에 붙인다모델은 상태가 없으므로 매 턴 messages 전체가 다시 전송됩니다. 응답 ai_message 를 기록에 붙이는 것을 빠뜨리면 다음 턴에서 모델이 자기가 무엇을 요청했는지 알 수 없습니다.
  2. 종료 판단: tool_calls 가 비었는가비어 있으면 모델이 "이제 답할 수 있다"고 판단한 것이므로 최종 답변을 출력하고 break 합니다. 이 한 줄이 루프의 유일한 정상 출구입니다.
  3. 행동: 요청된 도구를 전부 실행한다모델은 한 번의 응답에 도구 요청을 여러 개 담을 수 있으므로 for 문으로 돕니다. 이름으로 함수를 찾아 tool_fn.invoke(tool_call["args"]) 로 실행합니다.
  4. 관찰: 결과를 ToolMessage로 돌려준다tool_call_id=tool_call["id"] 로 어느 요청의 결과인지 짝을 맞춥니다. 요청이 셋이면 ToolMessage도 셋이어야 합니다.
  5. 재판단: while 처음으로도구 결과가 붙은 기록을 다시 모델에 보냅니다. 모델은 결과를 보고 답을 쓰거나 도구를 더 부릅니다.
  6. 안전장치: MAX_TURNS 와 while-else파이썬의 while ... else 에서 else 는 break 없이 조건이 거짓이 되어 끝났을 때만 실행됩니다. 그래서 "최종 답변 없이 턴 한도에 걸린 경우"만 따로 잡아 중단 메시지를 냅니다.
manual_react.py 출력 모양 (예시)
1[턴 1] search_employee({'name': '김하늘'}) -> 김하늘 / 클라우드운영팀 / [email protected]
2[턴 1] search_employee({'name': '박도윤'}) -> 박도윤 / 물류플랫폼팀 / [email protected]
3[턴 1] calculate({'expression': '34 * 27'}) -> 918
4
5[최종 답변] ...
도구의 반환값은 코드에서 정해지지만, 모델이 도구를 한 턴에 몇 개 부를지와 최종 답변의 문장은 실행마다 달라질 수 있습니다.

아래 설명 자료에는 강의 때의 실제 실행 기록이 메시지 번호와 함께 정리돼 있습니다. 그 실행에서 모델은 서로 독립적인 도구 요청 세 개를 한 턴에 한꺼번에 냈고, 모델 호출은 두 번(요청 한 번, 최종 답변 한 번)으로 끝났습니다. 이것은 모델이 정하는 일이라 보장되지 않습니다. 앞 도구의 결과가 다음 도구의 인자가 되는 질문이면 턴이 늘어납니다.

이 루프를 실서비스에 쓰기 전에

지점지금 코드문제와 대응
계산기eval(expression, {"__builtins__": {}}, {})모델이 만든 문자열을 eval 에 넣는 것은 내장 함수를 비워도 안전하지 않습니다. 매우 큰 거듭제곱 같은 입력으로 프로세스를 묶을 수 있습니다. 숫자와 사칙연산만 허용하는 파서로 바꿉니다(뒤의 Java 버전이 그렇게 합니다)
도구 찾기tools_by_name[tool_call["name"]]모델이 없는 이름을 내면 KeyError 로 루프 전체가 죽습니다. 오류 내용을 ToolMessage로 돌려줘 모델이 고치게 합니다
도구 예외calculate 만 try/except 로 실패를 문자열로 돌려줌외부 시스템을 부르는 도구는 실패가 흔합니다. 실패해도 같은 tool_call_id 의 ToolMessage를 반드시 넣어야 다음 호출이 성립합니다. 4일차의 장애 주입 실습이 이 주제입니다
턴 한도MAX_TURNS = 10한도에 걸리면 답 없이 끝납니다. 사용자에게 보낼 대체 응답을 정해 둡니다
토큰매 턴 messages 전체 전송턴과 도구 결과가 쌓일수록 입력 토큰이 늘어납니다. 긴 도구 결과는 줄여서 넣습니다
잘 도는 while 문을 왜 굳이 그래프로 옮기나

이 파일은 chain_limit.py 와 같은 처지입니다. 판단(if not ai_message.tool_calls)과 반복(while)이 제어문에 있고, 상태(messages, turn)가 지역 변수입니다. 여기에 "위험한 도구는 실행 전에 사람 승인을 받는다", "중간에 끊겨도 이어서 한다", "다른 에이전트에게 넘긴다"를 넣으려면 while 문 안이 조건문으로 가득 찹니다. 그래프로 옮겨 두면 이런 요구가 노드 하나, 엣지 하나를 더하는 일이 됩니다. 5~6일차가 바로 그 작업입니다.

정리

수동 ReAct 루프 정리

while 문으로 돌린 ReAct 루프의 구조와 구성 요소를 설명하고, 실제 실행 뒤의 messages 기록 7개를 번호와 함께 보여 줍니다. 실서비스 전에 고칠 점과, 루프의 각 부분이 LangGraph의 무엇에 대응되는지 표로 정리했습니다.

새 탭으로day3_practice/manual_react_explained.html
PYday3_practice/manual_react.pyLangGraph 없이 while 문으로 ReAct 루프(도구 선택 → 실행 → 관찰 → 재판단)를 직접 구현77줄
모델을 부릅니다. 가드 없이 파일 맨 아래까지 바로 실행되는 스크립트입니다.
 1# manual_react.py - ReAct 루프를 while 루프로 직접 구현
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4from langchain_core.tools import tool
 5from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
 6
 7load_dotenv()
 8llm = ChatBedrockConverse(
 9    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
10    region_name="us-east-1",
11)
12
13
14def get_text(message):
15    """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다."""
16    content = message.content
17    if isinstance(content, list):
18        return "".join(block.get("text", "") for block in content if isinstance(block, dict))
19    return content
20
21
22# ---------- 1) 도구 정의 ----------
23@tool
24def search_employee(name: str) -> str:
25    """임직원 이름으로 소속 팀과 이메일을 조회합니다. 임직원 정보 질문에 사용하세요."""
26    # 교육용 더미 데이터입니다. 실제 사내 데이터와 무관합니다.
27    employees = {
28        "김하늘": "김하늘 / 클라우드운영팀 / [email protected]",
29        "박도윤": "박도윤 / 물류플랫폼팀 / [email protected]",
30    }
31    return employees.get(name, f"'{name}' 님을 찾을 수 없습니다.")
32
33
34@tool
35def calculate(expression: str) -> str:
36    """수식 문자열을 계산해 정확한 결과를 반환합니다. 예: '34 * 27', '(15 - 5) / 2'"""
37    try:
38        return str(eval(expression, {"__builtins__": {}}, {}))
39    except Exception as e:
40        return f"계산 실패: {e}"
41
42
43tools = [search_employee, calculate]
44tools_by_name = {t.name: t for t in tools}   # 이름으로 도구를 찾기 위한 사전
45
46# ---------- 2) 도구 목록을 모델에 알려주기 ----------
47llm_with_tools = llm.bind_tools(tools)
48
49# ---------- 3) 대화 기록 초기화 ----------
50messages = [
51    SystemMessage(content="너는 삼성SDS 사내 어시스턴트야. 필요하면 도구를 사용해."),
52    HumanMessage(content="김하늘 님과 박도윤 님이 각각 어느 팀 소속인지 알려주고, 34 곱하기 27도 계산해줘."),
53]
54
55# ---------- 4) ReAct 루프: 도구 선택 -> 실행 -> 결과 관찰 -> 재판단 ----------
56MAX_TURNS = 10                                     # 안전장치
57turn = 0
58while turn < MAX_TURNS:
59    turn += 1
60    ai_message = llm_with_tools.invoke(messages)   # (추론) 모델이 도구 필요 여부를 판단
61    messages.append(ai_message)                    # 응답을 기록에 추가
62
63    if not ai_message.tool_calls:                  # 도구 요청이 없으면 최종 답변
64        print(f"\n[최종 답변] {get_text(ai_message)}")
65        break
66
67    for tool_call in ai_message.tool_calls:        # (행동) 요청된 도구를 전부 실행
68        tool_fn = tools_by_name[tool_call["name"]]
69        result = tool_fn.invoke(tool_call["args"])
70        print(f"[턴 {turn}] {tool_call['name']}({tool_call['args']}) -> {result}")
71
72        messages.append(                           # (관찰) 결과를 기록에 추가
73            ToolMessage(content=str(result), tool_call_id=tool_call["id"])
74        )
75    # while 처음으로 돌아감 -> 모델이 도구 결과를 보고 다시 판단 (재판단)
76else:
77    print("\n[중단] 최대 턴 수를 초과했습니다.")

ReAct 에이전트를 그래프로 조립하기

수동 루프의 줄 하나하나를 지금까지 익힌 부품으로 바꿉니다. 새로 배울 개념은 없습니다. State + reducer, 노드 둘, 조건부 엣지 하나, 되돌아가는 엣지 하나가 전부입니다.

START agent 노드System + messages 로 모델 호출 END tools 노드ToolNode(tools) AgentState.messages두 노드가 돌려준 메시지를add_messages 가 뒤에 붙임 tool_calls 없음 → "end" tool_calls 있음→ "tools" add_edge("tools", "agent")
점선 둘이 should_continue 가 고르는 조건부 엣지, 파란 실선이 while 문을 대신하는 사이클입니다.

수동 루프의 각 줄은 어디로 갔나

react_agent.py 의 주석에는 [수동 루프의 ...] -> ... 형식의 표시가 붙어 있습니다. 그것을 모으면 다음 표가 됩니다.

manual_react.pyreact_agent.py앞에서 익힌 곳
messages 리스트와 appendAgentState.messages: Annotated[list, add_messages]reducer
llm_with_tools.invoke(messages) 와 응답 appendagent 노드가 {"messages": [응답]} 반환노드
if not ai_message.tool_calls: breakshould_continue + add_conditional_edges조건부 엣지
for tool_call in ... 실행과 ToolMessage 포장내장 ToolNode(tools)오늘 처음 나옴
while 처음으로 돌아가기add_edge("tools", "agent")사이클
MAX_TURNS = 10코드에 없음. recursion_limit 에 맡김종료 장치

1/3 도구 준비: 도구 안에서 모델을 부르는 도구

react_agent.py 26–60줄
26@tool
27def search_employee(name: str) -> str:
28    """임직원 이름으로 소속 팀과 이메일을 조회합니다. 임직원 정보 질문에 사용하세요."""
29    # 교육용 더미 데이터입니다. 실제 사내 데이터와 무관합니다.
30    employees = {
31        "김하늘": {"team": "클라우드운영팀", "email": "[email protected]"},
32        "박도윤": {"team": "물류플랫폼팀", "email": "[email protected]"},
33    }
34    if name in employees:
35        e = employees[name]
36        return f"{name} / {e['team']} / {e['email']}"
37    return f"'{name}' 님을 찾을 수 없습니다. 조회 가능한 임직원: {list(employees.keys())}"
38
39
40@tool
41def calculate(expression: str) -> str:
42    """수식 문자열을 계산해 정확한 결과를 반환합니다. 예: '3480000 * 1.1', '48 * 1.12'"""
43    try:
44        return str(eval(expression, {"__builtins__": {}}, {}))
45    except Exception as e:
46        return f"계산 실패: {e}. 수식 형식을 확인하세요."
47
48
49@tool
50def classify_request(text: str) -> str:
51    """사내 문의 문장을 받아 담당 부서를 IT지원, 인사, 총무 중 하나로 분류합니다. 문의 접수와 부서 배정 질문에 사용하세요."""
52    # 도구 안에서 다시 LLM을 호출합니다. 도구 안의 AI입니다.
53    result = llm.invoke(
54        "다음 사내 문의의 담당 부서를 IT지원, 인사, 총무 중 하나로만 답하세요. "
55        f"다른 말은 붙이지 마세요.\n\n문의: {text}"
56    )
57    return get_text(result).strip()
58
59
60tools = [search_employee, calculate, classify_request]
도구가 셋으로 늘었습니다. 강조한 줄은 실패했을 때 모델에게 다음 행동의 단서를 주는 반환값과, 도구 안의 모델 호출입니다.

search_employee

더미 사전에서 조회합니다. 없는 이름이면 예외를 던지지 않고 조회 가능한 이름 목록을 붙인 문자열을 돌려줍니다. 모델이 사용자에게 상황을 설명하거나 다른 이름으로 다시 시도할 수 있습니다.

calculate

eval 로 계산합니다. 실패 메시지에 "수식 형식을 확인하세요"를 덧붙여 모델이 인자를 고쳐 다시 부를 여지를 줍니다. 수동 루프와 같은 eval 이므로 같은 보안 문제가 있습니다.

classify_request

문의 문장을 IT지원, 인사, 총무 중 하나로 분류합니다. 이 도구는 안에서 도구가 붙지 않은 llm 을 한 번 더 부릅니다. 에이전트가 보기에는 문자열을 돌려주는 평범한 도구입니다.

2/3 그래프 조립

react_agent.py 64–83줄
64# [수동 루프의 messages 리스트] -> State + add_messages reducer
65class AgentState(TypedDict):
66    messages: Annotated[list, add_messages]
67
68
69SYSTEM_PROMPT = """너는 삼성SDS 사내 AI 어시스턴트야.
70
71[도구 사용 전략]
721. 임직원 정보는 반드시 search_employee로 조회해. 지어내지 마.
732. 계산이 필요하면 calculate를 사용해. 암산하지 마.
743. 사내 문의의 담당 부서 판단은 classify_request를 사용해.
754. 도구가 필요 없는 인사나 일반 질문에는 도구 없이 바로 답해."""
76
77llm_with_tools = llm.bind_tools(tools)
78
79
80# [수동 루프의 invoke와 append] -> agent 노드
81def agent(state: AgentState) -> dict:
82    messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
83    return {"messages": [llm_with_tools.invoke(messages)]}
State는 messages 한 칸입니다. agent 노드는 system 메시지를 앞에 붙여 모델을 부르고, 새 응답 하나만 리스트에 담아 돌려줍니다.
system 메시지는 State에 넣지 않습니다

agent 는 호출할 때마다 [SystemMessage(...)] + state["messages"] 를 만들어 보내지만, 돌려주는 것은 모델의 응답뿐입니다. 그래서 State에는 사용자와 모델과 도구가 주고받은 대화만 쌓이고, 지침은 코드의 SYSTEM_PROMPT 한 곳에서 관리됩니다. 지침을 고쳐도 저장된 기록을 건드릴 필요가 없습니다. 수동 루프에서는 messages 리스트의 첫 칸에 SystemMessage가 들어 있던 것과 다른 점입니다.

SYSTEM_PROMPT 의 [도구 사용 전략]은 도구마다 "언제 쓰라"를 한 줄씩 적고, 마지막 줄에서 "도구가 필요 없으면 바로 답하라"고 합니다. 모델이 도구를 고르는 기준은 이 지침과 각 도구의 설명글 두 곳입니다.

react_agent.py 86–100줄
 86# [수동 루프의 if not ai_message.tool_calls] -> 라우팅 함수
 87def should_continue(state: AgentState) -> Literal["tools", "end"]:
 88    last = state["messages"][-1]
 89    return "tools" if last.tool_calls else "end"
 90
 91
 92builder = StateGraph(AgentState)
 93builder.add_node("agent", agent)
 94builder.add_node("tools", ToolNode(tools))   # [도구 실행과 ToolMessage 포장] -> 내장 ToolNode
 95
 96builder.add_edge(START, "agent")
 97builder.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
 98builder.add_edge("tools", "agent")           # [while 루프] -> Cycle
 99
100graph = builder.compile()
라우팅 함수, 노드 등록, 엣지 셋. 이 열다섯 줄이 수동 루프의 while 문 전체를 대신합니다.
  1. should_continue: 마지막 메시지만 본다agent 노드 직후에 불리므로 state["messages"][-1] 은 방금 모델이 낸 AIMessage입니다. 거기에 tool_calls 가 있으면 "tools", 없으면 "end" 를 돌려줍니다.
  2. 세 번째 인자 dict가 라벨을 목적지로 바꾼다{"tools": "tools", "end": END} 에서 왼쪽은 라우팅 함수가 돌려주는 라벨, 오른쪽은 실제 목적지입니다. 라우팅 함수는 END 를 직접 알 필요 없이 "end" 라는 뜻이 드러나는 라벨만 돌려줍니다.
  3. ToolNode: 도구 실행과 포장을 맡는 기성 노드ToolNode(tools) 는 LangGraph가 제공하는 노드입니다. 뒤의 실행 추적 코드가 tools 노드의 반환값에서 name, content, tool_call_id 를 읽는 것에서 알 수 있듯, 요청된 도구들을 실행하고 요청마다 같은 tool_call_id 를 단 ToolMessage를 messages 로 돌려줍니다. 수동 루프의 tools_by_name 조회, 실행, 포장 코드가 이 안으로 들어갔습니다.
  4. add_edge("tools", "agent"): 사이클도구가 끝나면 항상 모델로 돌아갑니다. 이 고정 엣지 하나가 while 문입니다.
react_agent.py 가 저장하는 구조도. agent 에서 나가는 점선 둘이 조건부 엣지(하나는 tools 로, 'end' 라벨이 붙은 하나는 __end__ 로), tools 에서 agent 로 돌아가는 실선이 사이클입니다. tools 상자에는 등록된 도구 세 개의 이름이 표시됩니다.
react_agent.py 가 저장하는 구조도. agent 에서 나가는 점선 둘이 조건부 엣지(하나는 tools 로, 'end' 라벨이 붙은 하나는 __end__ 로), tools 에서 agent 로 돌아가는 실선이 사이클입니다. tools 상자에는 등록된 도구 세 개의 이름이 표시됩니다.
이 그래프에는 턴 한도가 없습니다

수동 루프의 MAX_TURNS 에 해당하는 코드가 react_agent.py 에는 없습니다. 모델이 도구를 계속 부르면 멈추게 하는 것은 LangGraph의 recursion_limit 뿐인데, 앞 절에서 확인했듯 이 환경의 기본값은 10007단계입니다. agent 와 tools 한 바퀴가 2단계이므로, 한도를 20으로 두면 도구 왕복이 열 번 안쪽에서 멈춥니다. 실서비스라면 graph.invoke(입력, config={"recursion_limit": 20}) 처럼 직접 적고, GraphRecursionError 를 잡아 대체 응답을 내는 처리를 함께 둡니다.

이 파일만 모델 ID가 다릅니다

이 날의 다른 파일은 us.anthropic.claude-sonnet-4-5-20250929-v1:0 을 쓰는데 react_agent.py 는 us.anthropic.claude-sonnet-4-6 으로 적혀 있습니다. 1일차에서 본 것처럼 모델 교체는 모델 객체를 만드는 한 줄의 일이고 그래프 코드는 바뀌지 않습니다. 다만 강의 때 만든 다이어그램 자료에는 Sonnet 4.5로 표기된 곳이 있으니 자료와 코드를 맞춰 볼 때 참고하세요.

같은 그래프를 다르게 그린 자료 셋

정리

ReAct 에이전트 그래프 정리

수동 루프와 그래프의 대응표, 구성 요소 넷(State, agent, should_continue, ToolNode)과 도구 셋의 설명, 질문 세 개를 실제로 돌린 실행 기록, 주의할 점을 한 장에 정리했습니다. 실행부가 없던 이전 버전의 파일을 기준으로 작성돼, 구조도 저장 코드의 위치 같은 일부 설명은 지금 파일과 다릅니다.

새 탭으로day3_practice/react_agent_explained.html
흐름도

ReAct 에이전트 파이프라인

질문 하나가 graph.stream() 안에서 어떤 노드를 거치는지, 그리고 step마다 State의 messages 가 어떻게 한 칸씩 쌓이는지를 그린 흐름도입니다. agent 가 쓰는 llm_with_tools 와 도구 안쪽에서 쓰는 llm 을 구분해 보여 줍니다.

새 탭으로day3_practice/react_agent_pipeline.html
다이어그램

react_agent.py ReAct 파이프라인 다이어그램

같은 파이프라인을 레인별 구조도로 그린 자료입니다. 노드를 누르면 그 노드의 설명, 이어진 연결, react_agent.py 의 코드 위치가 아래 상세 카드에 나옵니다.

새 탭으로day3_practice/react_agent_archify.html

파일 전체는 다음 절의 실행 추적 설명 뒤에 싣습니다.

에이전트의 실행을 한 단계씩 읽기

그래프로 옮긴 덕에 생긴 가장 눈에 띄는 이득은 관찰입니다. 수동 루프에서는 print 문을 루프 곳곳에 심어야 했지만, 그래프는 stream 만 걸면 노드가 끝날 때마다 무슨 일이 있었는지를 내줍니다. react_agent.py 의 3/3 부분이 이 추적 코드입니다.

react_agent.py 117–153줄
117    for q in questions:
118        print("\n" + "=" * 70)
119        print(f"질문: {q}")
120        history = [HumanMessage(content=q)]   # State의 messages를 따라가며 보관 (보낸 메시지 수 계산용)
121
122        # stream(updates): 노드가 하나 끝날 때마다 그 노드가 돌려준 값을 받습니다
123        for step_no, step in enumerate(
124            graph.stream({"messages": history[:]}, stream_mode="updates"), start=1
125        ):
126            node, update = next(iter(step.items()))
127            new_messages = update["messages"]
128
129            if node == "agent":
130                ai = new_messages[0]
131                print(f"\n[step {step_no}] agent 노드 -> LLM 호출 "
132                      f"(보낸 메시지: System 1개 + 대화 {len(history)}개)")
133                print(f"  LLM 리턴 타입: {type(ai).__name__}")
134                print(f"  content (원본): {ai.content!r}")
135                if ai.tool_calls:
136                    print(f"  tool_calls {len(ai.tool_calls)}개 -> 다음은 tools 노드")
137                    for tc in ai.tool_calls:
138                        print(f"    - {tc['name']}({tc['args']})  id={tc['id']}")
139                else:
140                    print("  tool_calls 없음 -> END (이게 최종 답변)")
141                if ai.usage_metadata:
142                    u = ai.usage_metadata
143                    print(f"  토큰: 입력 {u['input_tokens']} / 출력 {u['output_tokens']}")
144
145            elif node == "tools":
146                print(f"\n[step {step_no}] tools 노드 -> 도구 {len(new_messages)}개 실행, 결과를 ToolMessage로 반환")
147                for tm in new_messages:
148                    print(f"    - {tm.name} -> {tm.content!r}  (tool_call_id={tm.tool_call_id})")
149                print("  -> 다음은 다시 agent 노드 (사이클)")
150
151            history.extend(new_messages)
152
153        print(f"\n최종 답변:\n{get_text(history[-1])}")
updates 모드로 한 단계씩 받아, agent 노드면 모델의 결정을, tools 노드면 도구 실행 결과를 출력합니다.
  1. 입력은 messages 한 칸graph.stream({"messages": history[:]}, ...) 로 질문 하나짜리 리스트를 넣습니다. history[:] 는 복사본을 넘기는 표기입니다.
  2. step은 {노드 이름: 돌려준 dict}update["messages"] 가 그 노드가 새로 만든 메시지들입니다. agent 노드면 AIMessage 하나, tools 노드면 실행한 도구 수만큼의 ToolMessage입니다.
  3. history는 State를 따라가는 사본151번째 줄의 history.extend(new_messages) 는 그래프 안에서 add_messages 가 하는 일을 바깥에서 흉내 내는 것입니다. "이번 모델 호출에 메시지가 몇 개 갔는가"를 출력하려고 둔 장부입니다.
  4. 마지막 메시지가 최종 답변스트림이 끝나면 history[-1] 은 tool_calls 가 없는 AIMessage이고, get_text 로 글만 꺼내 출력합니다.
react_agent.py 첫 번째 질문의 출력 모양 (예시)
 1======================================================================
 2질문: 김하늘 님 소속 팀 알려주고, 연봉 3480000에 10% 인상하면 얼마인지 계산해줘.
 3
 4[step 1] agent 노드 -> LLM 호출 (보낸 메시지: System 1개 + 대화 1개)
 5  LLM 리턴 타입: AIMessage
 6  content (원본): ...
 7  tool_calls 2개 -> 다음은 tools 노드
 8    - search_employee({'name': '김하늘'})  id=...
 9    - calculate({'expression': '3480000 * 1.1'})  id=...
10  토큰: 입력 ... / 출력 ...
11
12[step 2] tools 노드 -> 도구 2개 실행, 결과를 ToolMessage로 반환
13    - search_employee -> '김하늘 / 클라우드운영팀 / [email protected]'  (tool_call_id=...)
14    - calculate -> '3828000.0000000005'  (tool_call_id=...)
15  -> 다음은 다시 agent 노드 (사이클)
16
17[step 3] agent 노드 -> LLM 호출 (보낸 메시지: System 1개 + 대화 4개)
18  LLM 리턴 타입: AIMessage
19  content (원본): ...
20  tool_calls 없음 -> END (이게 최종 답변)
21  토큰: 입력 ... / 출력 ...
22
23최종 답변:
24...
모델이 두 도구를 한 번에 요청한 경우의 모양입니다. content 원본, id, 토큰 수, 최종 답변의 문장은 실행마다 다르므로 줄임표로 적었습니다.

추적에서 읽어 낼 것

출력의 어느 부분알 수 있는 것
"대화 1개" → "대화 4개"모델 호출마다 기록 전체가 다시 갑니다. 두 번째 호출에는 질문 1개, 도구를 요청한 AIMessage 1개, ToolMessage 2개가 함께 들어갑니다. 입력 토큰 수가 커지는 이유입니다
content (원본)문자열이 아니라 블록 리스트일 수 있어 !r 로 원래 모양을 그대로 찍습니다. 도구를 요청하는 응답은 글 없이 요청만 담고 있을 수도 있습니다
id=... 와 tool_call_id=...agent 단계의 요청 id와 tools 단계의 결과 id가 짝을 이룹니다. ToolNode가 이 짝 맞추기를 대신했습니다
'3828000.0000000005'파이썬에서 3480000 * 1.1 의 결과입니다. 도구는 "정확한 계산"을 약속했지만 부동소수 오차는 그대로 돌려줍니다. 금액을 다루는 도구라면 도구 안에서 반올림하거나 십진 연산을 써야 합니다. 모델이 알아서 다듬어 주기를 기대하면 안 됩니다
step 수도구를 한 번에 요청하면 agent → tools → agent 세 단계입니다. 하나씩 요청하면 왕복이 한 번 더 생깁니다
추적에 보이지 않는 모델 호출이 있습니다

두 번째 질문("노트북 화면이 안 켜져요...")에서 모델이 classify_request 를 부르면, 그 도구 안에서 모델 호출이 한 번 더 일어납니다. 하지만 이 추적은 노드 단위로만 출력하므로 화면에는 tools 노드가 문자열 하나를 돌려준 것으로만 보이고, 이 내부 호출의 토큰 수도 찍히지 않습니다. agent 노드가 두 번 돌았다면 실제 모델 호출은 세 번입니다. 비용과 지연을 따질 때 "도구 안의 모델 호출"을 따로 세어야 하는 이유이며, 이런 숨은 호출까지 보여 주는 도구가 7일차의 추적입니다.

PYday3_practice/react_agent.py수동 ReAct 루프를 StateGraph로 옮긴 에이전트: agent 노드 + ToolNode + 조건부 엣지 + 사이클153줄
파일은 주석으로 1/3 도구 준비, 2/3 그래프 조립, 3/3 실행 추적 세 부분으로 나뉩니다. 실행하면 react_agent.png 를 새로 저장하고 질문 두 개를 돌립니다. 모델을 부르므로 자격 증명이 필요합니다.
  1# react_agent.py (1/3) - 도구 준비
  2from typing import TypedDict, Annotated, Literal
  3from dotenv import load_dotenv
  4from langgraph.graph import StateGraph, START, END
  5from langgraph.graph.message import add_messages
  6from langgraph.prebuilt import ToolNode
  7from langchain_aws import ChatBedrockConverse
  8from langchain_core.tools import tool
  9from langchain_core.messages import HumanMessage, SystemMessage
 10
 11load_dotenv()
 12llm = ChatBedrockConverse(
 13    model="us.anthropic.claude-sonnet-4-6",
 14    region_name="us-east-1",
 15)
 16
 17
 18def get_text(message):
 19    """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다."""
 20    content = message.content
 21    if isinstance(content, list):
 22        return "".join(block.get("text", "") for block in content if isinstance(block, dict))
 23    return content
 24
 25
 26@tool
 27def search_employee(name: str) -> str:
 28    """임직원 이름으로 소속 팀과 이메일을 조회합니다. 임직원 정보 질문에 사용하세요."""
 29    # 교육용 더미 데이터입니다. 실제 사내 데이터와 무관합니다.
 30    employees = {
 31        "김하늘": {"team": "클라우드운영팀", "email": "[email protected]"},
 32        "박도윤": {"team": "물류플랫폼팀", "email": "[email protected]"},
 33    }
 34    if name in employees:
 35        e = employees[name]
 36        return f"{name} / {e['team']} / {e['email']}"
 37    return f"'{name}' 님을 찾을 수 없습니다. 조회 가능한 임직원: {list(employees.keys())}"
 38
 39
 40@tool
 41def calculate(expression: str) -> str:
 42    """수식 문자열을 계산해 정확한 결과를 반환합니다. 예: '3480000 * 1.1', '48 * 1.12'"""
 43    try:
 44        return str(eval(expression, {"__builtins__": {}}, {}))
 45    except Exception as e:
 46        return f"계산 실패: {e}. 수식 형식을 확인하세요."
 47
 48
 49@tool
 50def classify_request(text: str) -> str:
 51    """사내 문의 문장을 받아 담당 부서를 IT지원, 인사, 총무 중 하나로 분류합니다. 문의 접수와 부서 배정 질문에 사용하세요."""
 52    # 도구 안에서 다시 LLM을 호출합니다. 도구 안의 AI입니다.
 53    result = llm.invoke(
 54        "다음 사내 문의의 담당 부서를 IT지원, 인사, 총무 중 하나로만 답하세요. "
 55        f"다른 말은 붙이지 마세요.\n\n문의: {text}"
 56    )
 57    return get_text(result).strip()
 58
 59
 60tools = [search_employee, calculate, classify_request]
 61
 62# react_agent.py (2/3) - 그래프 조립
 63
 64# [수동 루프의 messages 리스트] -> State + add_messages reducer
 65class AgentState(TypedDict):
 66    messages: Annotated[list, add_messages]
 67
 68
 69SYSTEM_PROMPT = """너는 삼성SDS 사내 AI 어시스턴트야.
 70
 71[도구 사용 전략]
 721. 임직원 정보는 반드시 search_employee로 조회해. 지어내지 마.
 732. 계산이 필요하면 calculate를 사용해. 암산하지 마.
 743. 사내 문의의 담당 부서 판단은 classify_request를 사용해.
 754. 도구가 필요 없는 인사나 일반 질문에는 도구 없이 바로 답해."""
 76
 77llm_with_tools = llm.bind_tools(tools)
 78
 79
 80# [수동 루프의 invoke와 append] -> agent 노드
 81def agent(state: AgentState) -> dict:
 82    messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
 83    return {"messages": [llm_with_tools.invoke(messages)]}
 84
 85
 86# [수동 루프의 if not ai_message.tool_calls] -> 라우팅 함수
 87def should_continue(state: AgentState) -> Literal["tools", "end"]:
 88    last = state["messages"][-1]
 89    return "tools" if last.tool_calls else "end"
 90
 91
 92builder = StateGraph(AgentState)
 93builder.add_node("agent", agent)
 94builder.add_node("tools", ToolNode(tools))   # [도구 실행과 ToolMessage 포장] -> 내장 ToolNode
 95
 96builder.add_edge(START, "agent")
 97builder.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
 98builder.add_edge("tools", "agent")           # [while 루프] -> Cycle
 99
100graph = builder.compile()
101
102# react_agent.py (3/3) - 실행하면서 노드마다 무슨 일이 일어나는지 출력
103if __name__ == "__main__":   # langgraph dev 서버가 import할 때는 실행되지 않도록 가드 아래에 둡니다
104    # 구조 시각화 (PNG 저장이 안 되는 환경 대비 try/except)
105    try:
106        graph.get_graph().draw_mermaid_png(output_file_path="react_agent.png")
107        print("react_agent.png 저장 완료")
108    except Exception as e:
109        print("PNG 저장은 건너뜁니다:", e)
110        print(graph.get_graph().draw_mermaid())
111
112    questions = [
113        "김하늘 님 소속 팀 알려주고, 연봉 3480000에 10% 인상하면 얼마인지 계산해줘.",
114        "노트북 화면이 안 켜져요. 어느 부서에 문의해야 하나요?",
115    ]
116
117    for q in questions:
118        print("\n" + "=" * 70)
119        print(f"질문: {q}")
120        history = [HumanMessage(content=q)]   # State의 messages를 따라가며 보관 (보낸 메시지 수 계산용)
121
122        # stream(updates): 노드가 하나 끝날 때마다 그 노드가 돌려준 값을 받습니다
123        for step_no, step in enumerate(
124            graph.stream({"messages": history[:]}, stream_mode="updates"), start=1
125        ):
126            node, update = next(iter(step.items()))
127            new_messages = update["messages"]
128
129            if node == "agent":
130                ai = new_messages[0]
131                print(f"\n[step {step_no}] agent 노드 -> LLM 호출 "
132                      f"(보낸 메시지: System 1개 + 대화 {len(history)}개)")
133                print(f"  LLM 리턴 타입: {type(ai).__name__}")
134                print(f"  content (원본): {ai.content!r}")
135                if ai.tool_calls:
136                    print(f"  tool_calls {len(ai.tool_calls)}개 -> 다음은 tools 노드")
137                    for tc in ai.tool_calls:
138                        print(f"    - {tc['name']}({tc['args']})  id={tc['id']}")
139                else:
140                    print("  tool_calls 없음 -> END (이게 최종 답변)")
141                if ai.usage_metadata:
142                    u = ai.usage_metadata
143                    print(f"  토큰: 입력 {u['input_tokens']} / 출력 {u['output_tokens']}")
144
145            elif node == "tools":
146                print(f"\n[step {step_no}] tools 노드 -> 도구 {len(new_messages)}개 실행, 결과를 ToolMessage로 반환")
147                for tm in new_messages:
148                    print(f"    - {tm.name} -> {tm.content!r}  (tool_call_id={tm.tool_call_id})")
149                print("  -> 다음은 다시 agent 노드 (사이클)")
150
151            history.extend(new_messages)
152
153        print(f"\n최종 답변:\n{get_text(history[-1])}")

langgraph.json: 그래프를 개발 서버에 올리기

지금까지는 python 파일.py 로 스크립트를 직접 실행했습니다. LangGraph에는 컴파일된 그래프를 서버로 띄워 브라우저(LangGraph Studio)에서 실행해 보는 개발 도구가 있고, langgraph.json 은 그 도구가 읽는 설정 파일입니다.

langgraph.json 1–8줄
1{
2  "dependencies": ["."],
3  "graphs": {
4    "react_agent": "./react_agent.py:graph",
5    "cycle": "./cycle.py:graph"
6  },
7  "env": "../.env"
8}
graphs 의 각 값은 '파일 경로:변수 이름' 형식입니다. 그 파일을 import 해서 그 이름의 변수를 그래프로 씁니다.
항목값뜻
dependencies["."]이 폴더(day3_practice/)를 코드가 있는 곳으로 지정합니다
graphsreact_agent, cycle서버에 등록할 그래프 둘. 각각 react_agent.py 와 cycle.py 의 graph 변수입니다. 다른 파일의 그래프는 등록돼 있지 않습니다
env"../.env"환경 변수를 읽을 파일. 레포 루트의 .env 를 가리킵니다

개발 서버는 LangGraph CLI의 langgraph dev 명령으로 띄웁니다. 이 저장소의 .venv 에는 langgraph-cli 가 설치돼 있고, day3_practice/ 아래에는 개발 서버가 남기는 것으로 보이는 .langgraph_api/ 폴더가 있습니다. 서버에서 그래프를 실행하면 모델을 실제로 부르므로 스크립트 실행과 똑같이 비용이 듭니다.

실행부를 가드 아래에 둔 이유

이 날의 그래프 파일들은 실행부가 모두 if __name__ == "__main__": 아래에 있고, 대부분 그 옆에 "langgraph dev 서버가 import할 때는 실행되지 않도록"이라는 주석이 달려 있습니다.

가드가 없으면

서버가 graph 변수를 가져오려고 파일을 import하는 순간, 파일 맨 아래의 실행 코드까지 돌아갑니다. import만 했는데 모델이 불리고 PNG가 저장됩니다.

가드가 있으면

__name__ 은 파일을 직접 실행할 때만 "__main__" 입니다. import될 때는 그래프 정의(graph = builder.compile())까지만 실행되고 시험 코드는 건너뜁니다.

이 구분은 서버가 아니어도 쓸모가 있습니다. cycle.py 의 주석은 다른 세션에서 이 파일을 import해 쓴다고 적고 있고, 설명 자료의 사이클 시뮬레이션도 cycle.py 의 check_quality 와 route_quality 를 import해서 만들었습니다. 그래프를 정의하는 부분과 시험 삼아 돌려 보는 부분을 나눠 두면 같은 그래프를 스크립트, 서버, 다른 파일에서 모두 가져다 쓸 수 있습니다. manual_react.py 와 chain_limit.py 는 가드가 없는 순수한 스크립트라 import하면 곧바로 실행됩니다.

JSONday3_practice/langgraph.jsonlanggraph dev 가 읽는 설정: react_agent 와 cycle 두 그래프를 등록8줄
JSON 파일이라 주석이 없습니다. 그래프를 더 등록하려면 graphs 에 "이름": "./파일.py:변수" 를 추가하는 형식입니다.
1{
2  "dependencies": ["."],
3  "graphs": {
4    "react_agent": "./react_agent.py:graph",
5    "cycle": "./cycle.py:graph"
6  },
7  "env": "../.env"
8}

다른 언어로 옮겨 보기: Spring AI의 ReAct 루프

ReAct 루프는 LangGraph의 기능이 아니라 모델의 도구 호출 위에 얹는 제어 흐름입니다. 그래서 다른 언어, 다른 프레임워크로도 같은 구조를 만들 수 있습니다. spring_ai_react/ 는 react_agent.py 를 Java와 Spring AI로 옮겨 본 비교용 프로젝트입니다.

소스의 주석에 따르면 Spring AI에는 StateGraph에 해당하는 것이 없어서, 그래프의 각 부품을 메서드로 일대일 대응시켰습니다. 결과물은 그래프라기보다 "그래프의 노드와 엣지에 이름을 붙여 놓은 while 루프"이고, 그 점에서 manual_react.py 와 react_agent.py 의 중간쯤에 있습니다.

ReactAgent.java 68–97줄
68    public String run(String question) {
69        // AgentState.messages. SystemMessage는 여기 넣지 않고 agent()에서 매번 앞에 붙입니다.
70        List<Message> state = new ArrayList<>();
71        state.add(new UserMessage(question));
72
73        int step = 0;
74        while (step < MAX_STEPS) {
75            // ---- agent 노드 ----
76            step++;
77            System.out.printf("%n[step %d] agent 노드 -> LLM 호출 (보낸 메시지: System 1개 + 대화 %d개)%n",
78                    step, state.size());
79            ChatResponse response = agent(state);
80            AssistantMessage ai = response.getResult().getOutput();
81            state.add(ai);                                    // add_messages: append
82            printAgentStep(response, ai);
83
84            // ---- should_continue (조건부 엣지) ----
85            if (!shouldContinue(response)) {
86                return ai.getText();                          // -> END
87            }
88
89            // ---- tools 노드 ----
90            step++;
91            ToolResponseMessage toolMessage = tools(state, response);
92            state.add(toolMessage);                           // add_messages: append
93            printToolsStep(step, toolMessage);
94            // ---- add_edge("tools", "agent"): 루프 처음으로 돌아감 (사이클) ----
95        }
96        throw new IllegalStateException("MAX_STEPS(" + MAX_STEPS + ")를 넘었습니다. 사이클이 끝나지 않습니다.");
97    }
run() 한 메서드가 그래프 전체입니다. 주석의 agent 노드, should_continue, tools 노드, add_edge 가 파이썬 그래프의 부품을 가리킵니다.
ReactAgent.java 99–126줄
 99    /** agent 노드: [SystemMessage] + state 를 도구 스키마와 함께 LLM에 보냄 */
100    private ChatResponse agent(List<Message> state) {
101        return chatClient.prompt()
102                .system(SYSTEM_PROMPT)
103                .messages(state)
104                .options(chatOptions)
105                .call()
106                .chatResponse();
107    }
108
109    /** should_continue: 마지막 AI 메시지에 tool_calls가 있으면 tools, 없으면 END */
110    private boolean shouldContinue(ChatResponse response) {
111        return response.hasToolCalls();
112    }
113
114    /**
115     * tools 노드 (ToolNode): tool_calls를 실행하고 결과를 ToolResponseMessage로 돌려줌.
116     * executeToolCalls는 "프롬프트 + AI 메시지 + 도구 결과" 전체 히스토리를 돌려주므로
117     * 이번 노드가 새로 만든 마지막 메시지(도구 결과)만 꺼내 state에 append 합니다.
118     * Spring AI는 도구 여러 개의 결과를 ToolResponseMessage 하나에 담습니다
119     * (LangChain은 ToolMessage를 도구마다 하나씩 만드는 점이 다름).
120     */
121    private ToolResponseMessage tools(List<Message> state, ChatResponse response) {
122        Prompt prompt = new Prompt(List.copyOf(state), chatOptions);
123        ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, response);
124        List<Message> history = result.conversationHistory();
125        return (ToolResponseMessage) history.get(history.size() - 1);
126    }
노드와 라우팅 함수에 해당하는 세 메서드. tools() 는 ToolCallingManager 가 돌려준 전체 기록에서 마지막 메시지(도구 결과)만 꺼냅니다.
파이썬 (react_agent.py)Java (spring_ai_react)차이
AgentState.messages + add_messagesList<Message> state + state.add(...)reducer가 없으므로 누적을 코드가 직접 합니다
agent 노드agent(state): chatClient.prompt().system(...).messages(state).options(chatOptions).call()system 지침을 매번 앞에 붙이고 기록에는 넣지 않는 방식이 같습니다
llm.bind_tools(tools)ToolCallingChatOptions 에 ToolCallbacks.from(agentTools) 를 실어 요청마다 전달
should_continue + 조건부 엣지shouldContinue(response) 가 response.hasToolCalls() 를 돌려주고, run() 의 if 문이 분기라우팅 결과가 문자열이 아니라 boolean입니다
ToolNode(tools)tools(state, response) 안의 toolCallingManager.executeToolCalls(...)주석에 따르면 Spring AI는 도구 여러 개의 결과를 ToolResponseMessage 하나에 담습니다. LangChain은 도구마다 ToolMessage를 하나씩 만듭니다
add_edge("tools", "agent")run() 의 while 루프
(코드에 없음, recursion_limit 에 맡김)MAX_STEPS = 10, 넘으면 IllegalStateExceptionagent와 tools를 각각 한 step으로 세므로 agent의 모델 호출은 최대 다섯 번입니다. 한도를 코드에 직접 적어 둔 점은 수동 루프 쪽과 같습니다
@tool 함수와 설명글@Tool(description=...) 메서드와 @ToolParam도구 이름이 searchEmployee, classifyRequest 처럼 Java 표기로 바뀌어 system 지침 속 이름도 함께 바뀌었습니다
eval(expression, ...)ExpressionCalculator (사칙연산과 괄호만 받는 파서, BigDecimal)임의 코드 실행의 여지가 없습니다. 주석은 BigDecimal 이라 3480000 * 1.1 에 부동소수 오차가 생기지 않는다고 설명합니다. 파이썬 쪽이 3828000.0000000005 를 돌려주던 바로 그 계산입니다
if __name__ == "__main__":ReactAgentApplication 의 CommandLineRunner 빈같은 질문 두 개를 돌립니다
ChatBedrockConverse(model=..., region_name=...)application.properties 의 spring.ai.bedrock... 설정Java 쪽 모델은 Sonnet 4.5로 적혀 있습니다

도구: 설명글이 애너테이션으로

AgentTools.java 32–61줄
32    @Tool(description = "임직원 이름으로 소속 팀과 이메일을 조회합니다. 임직원 정보 질문에 사용하세요.")
33    public String searchEmployee(@ToolParam(description = "임직원 이름. 예: 김하늘") String name) {
34        String[] e = EMPLOYEES.get(name);
35        if (e != null) {
36            return name + " / " + e[0] + " / " + e[1];
37        }
38        return "'" + name + "' 님을 찾을 수 없습니다. 조회 가능한 임직원: " + EMPLOYEES.keySet();
39    }
40
41    @Tool(description = "수식 문자열을 계산해 정확한 결과를 반환합니다. 예: '3480000 * 1.1', '48 * 1.12'")
42    public String calculate(@ToolParam(description = "사칙연산 수식") String expression) {
43        // Python은 eval()을 썼지만 Java에는 eval이 없으므로 작은 파서로 대신합니다.
44        // BigDecimal이라 3480000 * 1.1 = 3828000.0 처럼 부동소수 오차가 없습니다.
45        try {
46            return ExpressionCalculator.evaluate(expression).toPlainString();
47        } catch (RuntimeException e) {
48            return "계산 실패: " + e.getMessage() + ". 수식 형식을 확인하세요.";
49        }
50    }
51
52    @Tool(description = "사내 문의 문장을 받아 담당 부서를 IT지원, 인사, 총무 중 하나로 분류합니다. 문의 접수와 부서 배정 질문에 사용하세요.")
53    public String classifyRequest(@ToolParam(description = "사내 문의 원문") String text) {
54        // 도구 안에서 다시 LLM을 호출합니다. 도구 안의 AI입니다.
55        String result = plainLlm.prompt()
56                .user("다음 사내 문의의 담당 부서를 IT지원, 인사, 총무 중 하나로만 답하세요. "
57                        + "다른 말은 붙이지 마세요.\n\n문의: " + text)
58                .call()
59                .content();
60        return result == null ? "" : result.strip();
61    }
@Tool 의 description 이 파이썬 도구의 설명글과 같은 문장입니다. 세 번째 도구는 여기서도 도구가 붙지 않은 모델(plainLlm)을 한 번 더 부릅니다.
프레임워크가 루프를 대신 돌려 주기도 합니다

application.properties 의 마지막 줄 spring.ai.chat.client.tool-calling.enabled=false 에는 "ChatClient가 도구 호출 루프를 알아서 돌리지 않게 끈다"는 주석이 달려 있습니다. ReactAgent.java 의 주석은 이 설정을 지우고 .tools(agentTools) 를 붙여 호출하면 Spring AI가 루프 전체를 내부에서 돌려 준다고 설명합니다. 파이썬 쪽에도 이 그래프를 한 번에 만들어 주는 기성 함수가 있습니다. 이 날 일부러 직접 조립한 것은, 5~6일차에 그 루프 사이에 승인과 가드레일과 다른 에이전트를 끼워 넣으려면 루프의 속을 알아야 하기 때문입니다.

언어가 바뀌어도 남는 것

메시지 목록을 들고, 모델을 부르고, 도구 요청이 있으면 실행해 결과를 목록에 붙이고, 없으면 끝낸다. 이 네 가지는 파이썬의 while 문에서도, LangGraph의 그래프에서도, Java의 메서드에서도 똑같습니다. 프레임워크가 달라질 때 바뀌는 것은 "누적, 분기, 도구 실행, 반복을 누가 대신 써 주느냐"뿐입니다.

JAVAday3_practice/spring_ai_react/src/main/java/com/example/react/ReactAgent.java그래프의 agent 노드, should_continue, tools 노드, 사이클을 메서드와 while 루프로 옮긴 본체154줄
클래스 설명 주석에 파이썬 그래프와의 대응표가 그대로 적혀 있습니다.
  1package com.example.react;
  2
  3import java.util.ArrayList;
  4import java.util.List;
  5
  6import org.springframework.ai.chat.client.ChatClient;
  7import org.springframework.ai.chat.messages.AssistantMessage;
  8import org.springframework.ai.chat.messages.Message;
  9import org.springframework.ai.chat.messages.ToolResponseMessage;
 10import org.springframework.ai.chat.messages.UserMessage;
 11import org.springframework.ai.chat.metadata.Usage;
 12import org.springframework.ai.chat.model.ChatResponse;
 13import org.springframework.ai.chat.prompt.ChatOptions;
 14import org.springframework.ai.chat.prompt.Prompt;
 15import org.springframework.ai.model.tool.ToolCallingChatOptions;
 16import org.springframework.ai.model.tool.ToolCallingManager;
 17import org.springframework.ai.model.tool.ToolExecutionResult;
 18import org.springframework.ai.support.ToolCallbacks;
 19import org.springframework.stereotype.Component;
 20
 21/**
 22 * react_agent.py (2/3) - "그래프" 조립.
 23 *
 24 * Spring AI에는 StateGraph가 없으므로, LangGraph 그래프의 각 부품을 메서드로 1:1 옮겼습니다.
 25 *
 26 *   START -> agent -> should_continue --(tool_calls 있음)--> tools -> agent  (사이클)
 27 *                                     --(없음)------------> END
 28 *
 29 *   AgentState.messages + add_messages  ->  List<Message> state + state.add(...)
 30 *   agent 노드                          ->  agent(state)
 31 *   should_continue                     ->  shouldContinue(ai)
 32 *   ToolNode(tools)                     ->  tools(state, response)   (ToolCallingManager 사용)
 33 *   add_edge("tools", "agent")          ->  run()의 while 루프
 34 *
 35 * 참고: 루프를 직접 보고 싶지 않다면 application.properties의 tool-calling.enabled=false를 지우고
 36 *   chatClient.prompt().system(SYSTEM_PROMPT).user(q).tools(agentTools).call().content();
 37 * 한 줄이면 Spring AI가 이 루프 전체를 내부에서 돌려 줍니다. (LangGraph의 create_react_agent 같은 것)
 38 */
 39@Component
 40public class ReactAgent {
 41
 42    static final String SYSTEM_PROMPT = """
 43            너는 삼성SDS 사내 AI 어시스턴트야.
 44
 45            [도구 사용 전략]
 46            1. 임직원 정보는 반드시 searchEmployee로 조회해. 지어내지 마.
 47            2. 계산이 필요하면 calculate를 사용해. 암산하지 마.
 48            3. 사내 문의의 담당 부서 판단은 classifyRequest를 사용해.
 49            4. 도구가 필요 없는 인사나 일반 질문에는 도구 없이 바로 답해.""";
 50
 51    /** 무한 사이클 방지. LangGraph의 recursion_limit에 해당합니다. */
 52    private static final int MAX_STEPS = 10;
 53
 54    private final ChatClient chatClient;
 55    /** llm.bind_tools(tools)에 해당: 도구 스키마를 LLM 요청에 실어 보내는 옵션 */
 56    private final ChatOptions chatOptions;
 57    /** ToolNode에 해당: tool_calls를 받아 실제 메서드를 실행하고 ToolResponseMessage로 포장 */
 58    private final ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
 59
 60    public ReactAgent(ChatClient.Builder builder, AgentTools agentTools) {
 61        this.chatClient = builder.build();
 62        this.chatOptions = ToolCallingChatOptions.builder()
 63                .toolCallbacks(ToolCallbacks.from(agentTools))
 64                .build();
 65    }
 66
 67    /** graph.stream({"messages": [HumanMessage(q)]}) 에 해당 */
 68    public String run(String question) {
 69        // AgentState.messages. SystemMessage는 여기 넣지 않고 agent()에서 매번 앞에 붙입니다.
 70        List<Message> state = new ArrayList<>();
 71        state.add(new UserMessage(question));
 72
 73        int step = 0;
 74        while (step < MAX_STEPS) {
 75            // ---- agent 노드 ----
 76            step++;
 77            System.out.printf("%n[step %d] agent 노드 -> LLM 호출 (보낸 메시지: System 1개 + 대화 %d개)%n",
 78                    step, state.size());
 79            ChatResponse response = agent(state);
 80            AssistantMessage ai = response.getResult().getOutput();
 81            state.add(ai);                                    // add_messages: append
 82            printAgentStep(response, ai);
 83
 84            // ---- should_continue (조건부 엣지) ----
 85            if (!shouldContinue(response)) {
 86                return ai.getText();                          // -> END
 87            }
 88
 89            // ---- tools 노드 ----
 90            step++;
 91            ToolResponseMessage toolMessage = tools(state, response);
 92            state.add(toolMessage);                           // add_messages: append
 93            printToolsStep(step, toolMessage);
 94            // ---- add_edge("tools", "agent"): 루프 처음으로 돌아감 (사이클) ----
 95        }
 96        throw new IllegalStateException("MAX_STEPS(" + MAX_STEPS + ")를 넘었습니다. 사이클이 끝나지 않습니다.");
 97    }
 98
 99    /** agent 노드: [SystemMessage] + state 를 도구 스키마와 함께 LLM에 보냄 */
100    private ChatResponse agent(List<Message> state) {
101        return chatClient.prompt()
102                .system(SYSTEM_PROMPT)
103                .messages(state)
104                .options(chatOptions)
105                .call()
106                .chatResponse();
107    }
108
109    /** should_continue: 마지막 AI 메시지에 tool_calls가 있으면 tools, 없으면 END */
110    private boolean shouldContinue(ChatResponse response) {
111        return response.hasToolCalls();
112    }
113
114    /**
115     * tools 노드 (ToolNode): tool_calls를 실행하고 결과를 ToolResponseMessage로 돌려줌.
116     * executeToolCalls는 "프롬프트 + AI 메시지 + 도구 결과" 전체 히스토리를 돌려주므로
117     * 이번 노드가 새로 만든 마지막 메시지(도구 결과)만 꺼내 state에 append 합니다.
118     * Spring AI는 도구 여러 개의 결과를 ToolResponseMessage 하나에 담습니다
119     * (LangChain은 ToolMessage를 도구마다 하나씩 만드는 점이 다름).
120     */
121    private ToolResponseMessage tools(List<Message> state, ChatResponse response) {
122        Prompt prompt = new Prompt(List.copyOf(state), chatOptions);
123        ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, response);
124        List<Message> history = result.conversationHistory();
125        return (ToolResponseMessage) history.get(history.size() - 1);
126    }
127
128    // ---------------- 출력용 (react_agent.py의 print 부분) ----------------
129
130    private void printAgentStep(ChatResponse response, AssistantMessage ai) {
131        System.out.println("  content: " + ai.getText());
132        if (ai.hasToolCalls()) {
133            System.out.printf("  tool_calls %d개 -> 다음은 tools 노드%n", ai.getToolCalls().size());
134            for (AssistantMessage.ToolCall tc : ai.getToolCalls()) {
135                System.out.printf("    - %s(%s)  id=%s%n", tc.name(), tc.arguments(), tc.id());
136            }
137        } else {
138            System.out.println("  tool_calls 없음 -> END (이게 최종 답변)");
139        }
140        Usage usage = response.getMetadata().getUsage();
141        if (usage != null) {
142            System.out.printf("  토큰: 입력 %d / 출력 %d%n", usage.getPromptTokens(), usage.getCompletionTokens());
143        }
144    }
145
146    private void printToolsStep(int step, ToolResponseMessage toolMessage) {
147        System.out.printf("%n[step %d] tools 노드 -> 도구 %d개 실행, 결과를 ToolResponseMessage로 반환%n",
148                step, toolMessage.getResponses().size());
149        for (ToolResponseMessage.ToolResponse r : toolMessage.getResponses()) {
150            System.out.printf("    - %s -> %s  (id=%s)%n", r.name(), r.responseData(), r.id());
151        }
152        System.out.println("  -> 다음은 다시 agent 노드 (사이클)");
153    }
154}
JAVAday3_practice/spring_ai_react/src/main/java/com/example/react/AgentTools.java파이썬의 @tool 함수 3개를 @Tool 메서드 3개로 옮긴 도구 모음62줄
 1package com.example.react;
 2
 3import java.util.Map;
 4
 5import org.springframework.ai.chat.client.ChatClient;
 6import org.springframework.ai.tool.annotation.Tool;
 7import org.springframework.ai.tool.annotation.ToolParam;
 8import org.springframework.stereotype.Component;
 9
10/**
11 * react_agent.py (1/3) - 도구 준비.
12 *
13 * Python의 @tool 함수 3개 -> @Tool 메서드 3개.
14 * description이 LLM에게 전달되는 도구 설명(= Python docstring)입니다.
15 */
16@Component
17public class AgentTools {
18
19    // 교육용 더미 데이터입니다. 실제 사내 데이터와 무관합니다.
20    private static final Map<String, String[]> EMPLOYEES = Map.of(
21            "김하늘", new String[]{"클라우드운영팀", "[email protected]"},
22            "박도윤", new String[]{"물류플랫폼팀", "[email protected]"}
23    );
24
25    /** classify_request 안에서 쓰는 '도구 없는' LLM. Python의 plain llm에 해당합니다. */
26    private final ChatClient plainLlm;
27
28    public AgentTools(ChatClient.Builder builder) {
29        this.plainLlm = builder.build();
30    }
31
32    @Tool(description = "임직원 이름으로 소속 팀과 이메일을 조회합니다. 임직원 정보 질문에 사용하세요.")
33    public String searchEmployee(@ToolParam(description = "임직원 이름. 예: 김하늘") String name) {
34        String[] e = EMPLOYEES.get(name);
35        if (e != null) {
36            return name + " / " + e[0] + " / " + e[1];
37        }
38        return "'" + name + "' 님을 찾을 수 없습니다. 조회 가능한 임직원: " + EMPLOYEES.keySet();
39    }
40
41    @Tool(description = "수식 문자열을 계산해 정확한 결과를 반환합니다. 예: '3480000 * 1.1', '48 * 1.12'")
42    public String calculate(@ToolParam(description = "사칙연산 수식") String expression) {
43        // Python은 eval()을 썼지만 Java에는 eval이 없으므로 작은 파서로 대신합니다.
44        // BigDecimal이라 3480000 * 1.1 = 3828000.0 처럼 부동소수 오차가 없습니다.
45        try {
46            return ExpressionCalculator.evaluate(expression).toPlainString();
47        } catch (RuntimeException e) {
48            return "계산 실패: " + e.getMessage() + ". 수식 형식을 확인하세요.";
49        }
50    }
51
52    @Tool(description = "사내 문의 문장을 받아 담당 부서를 IT지원, 인사, 총무 중 하나로 분류합니다. 문의 접수와 부서 배정 질문에 사용하세요.")
53    public String classifyRequest(@ToolParam(description = "사내 문의 원문") String text) {
54        // 도구 안에서 다시 LLM을 호출합니다. 도구 안의 AI입니다.
55        String result = plainLlm.prompt()
56                .user("다음 사내 문의의 담당 부서를 IT지원, 인사, 총무 중 하나로만 답하세요. "
57                        + "다른 말은 붙이지 마세요.\n\n문의: " + text)
58                .call()
59                .content();
60        return result == null ? "" : result.strip();
61    }
62}
JAVAday3_practice/spring_ai_react/src/main/java/com/example/react/ExpressionCalculator.javaeval 대신 쓰는 사칙연산 파서 (+ - * / 괄호, 단항 -, BigDecimal)71줄
문법 세 줄(expr, term, factor)을 그대로 메서드 세 개로 옮긴 재귀 하강 파서입니다. 숫자, 소수점, 연산자, 괄호 말고는 받아들이지 않습니다.
 1package com.example.react;
 2
 3import java.math.BigDecimal;
 4import java.math.MathContext;
 5
 6/**
 7 * calculate 도구용 아주 작은 사칙연산 파서 (+ - * / 괄호, 단항 -).
 8 * Python 버전의 eval(expression, {"__builtins__": {}}, {}) 자리를 대신합니다.
 9 *
10 * 문법:
11 *   expr   := term (('+' | '-') term)*
12 *   term   := factor (('*' | '/') factor)*
13 *   factor := '-' factor | '(' expr ')' | number
14 */
15final class ExpressionCalculator {
16
17    private final String s;
18    private int pos;
19
20    private ExpressionCalculator(String s) {
21        this.s = s;
22    }
23
24    static BigDecimal evaluate(String expression) {
25        ExpressionCalculator p = new ExpressionCalculator(expression.replace(" ", ""));
26        BigDecimal value = p.expr();
27        if (p.pos != p.s.length()) {
28            throw new IllegalArgumentException("알 수 없는 문자 '" + p.s.charAt(p.pos) + "'");
29        }
30        return value.stripTrailingZeros();
31    }
32
33    private BigDecimal expr() {
34        BigDecimal v = term();
35        while (pos < s.length()) {
36            char c = s.charAt(pos);
37            if (c == '+') { pos++; v = v.add(term()); }
38            else if (c == '-') { pos++; v = v.subtract(term()); }
39            else break;
40        }
41        return v;
42    }
43
44    private BigDecimal term() {
45        BigDecimal v = factor();
46        while (pos < s.length()) {
47            char c = s.charAt(pos);
48            if (c == '*') { pos++; v = v.multiply(factor()); }
49            else if (c == '/') { pos++; v = v.divide(factor(), MathContext.DECIMAL64); }
50            else break;
51        }
52        return v;
53    }
54
55    private BigDecimal factor() {
56        if (pos >= s.length()) throw new IllegalArgumentException("수식이 끝났습니다");
57        char c = s.charAt(pos);
58        if (c == '-') { pos++; return factor().negate(); }
59        if (c == '(') {
60            pos++;
61            BigDecimal v = expr();
62            if (pos >= s.length() || s.charAt(pos) != ')') throw new IllegalArgumentException("')'가 없습니다");
63            pos++;
64            return v;
65        }
66        int start = pos;
67        while (pos < s.length() && (Character.isDigit(s.charAt(pos)) || s.charAt(pos) == '.')) pos++;
68        if (start == pos) throw new IllegalArgumentException("숫자가 필요한 위치: " + start);
69        return new BigDecimal(s.substring(start, pos));
70    }
71}
JAVAday3_practice/spring_ai_react/src/main/java/com/example/react/ReactAgentApplication.java실행부: 파이썬과 같은 질문 두 개를 에이전트에 넣고 최종 답변 출력37줄
 1package com.example.react;
 2
 3import java.util.List;
 4
 5import org.springframework.boot.CommandLineRunner;
 6import org.springframework.boot.SpringApplication;
 7import org.springframework.boot.autoconfigure.SpringBootApplication;
 8import org.springframework.context.annotation.Bean;
 9
10/**
11 * react_agent.py (3/3) - 실행부.
12 * Python의 if __name__ == "__main__": 블록에 해당합니다.
13 */
14@SpringBootApplication
15public class ReactAgentApplication {
16
17    public static void main(String[] args) {
18        SpringApplication.run(ReactAgentApplication.class, args);
19    }
20
21    @Bean
22    CommandLineRunner run(ReactAgent agent) {
23        return args -> {
24            List<String> questions = List.of(
25                    "김하늘 님 소속 팀 알려주고, 연봉 3480000에 10% 인상하면 얼마인지 계산해줘.",
26                    "노트북 화면이 안 켜져요. 어느 부서에 문의해야 하나요?"
27            );
28
29            for (String q : questions) {
30                System.out.println("\n" + "=".repeat(70));
31                System.out.println("질문: " + q);
32                String answer = agent.run(q);
33                System.out.println("\n최종 답변:\n" + answer);
34            }
35        };
36    }
37}
PROPERTIESday3_practice/spring_ai_react/src/main/resources/application.propertiesBedrock 리전과 모델, 자격 증명 방식, 자동 도구 호출 루프 끄기 설정15줄
키를 파일에 적는 두 줄은 주석 처리돼 있습니다. 주석에 따르면 비워 두면 AWS 기본 자격 증명 체인(환경 변수 등)을 씁니다.
 1spring.application.name=spring-ai-react
 2spring.main.web-application-type=none
 3
 4# Python: ChatBedrockConverse(model=..., region_name="us-east-1")
 5spring.ai.bedrock.aws.region=us-east-1
 6spring.ai.bedrock.converse.chat.model=us.anthropic.claude-sonnet-4-5-20250929-v1:0
 7
 8# 자격 증명: 아래를 비워 두면 AWS 기본 자격 증명 체인(환경 변수, ~/.aws/credentials 등)을 씁니다.
 9# Python 쪽 .env 값을 환경 변수로 넣어 두면 그대로 동작합니다.
10#spring.ai.bedrock.aws.access-key=${AWS_ACCESS_KEY_ID}
11#spring.ai.bedrock.aws.secret-key=${AWS_SECRET_ACCESS_KEY}
12
13# ChatClient가 도구 호출 루프를 알아서 돌리지 않게 끕니다.
14# 루프(agent -> should_continue -> tools -> agent)는 ReactAgent에서 직접 돌립니다. (LangGraph 그래프와 같은 구조)
15spring.ai.chat.client.tool-calling.enabled=false
XMLday3_practice/spring_ai_react/pom.xmlMaven 빌드 설정: Spring Boot 4.0.0, Spring AI 2.0.2, Java 21, Bedrock Converse 스타터60줄
의존성은 기본 스타터와 spring-ai-starter-model-bedrock-converse 둘뿐입니다. 웹 서버 없이 콘솔로만 실행하는 구성입니다.
 1<?xml version="1.0" encoding="UTF-8"?>
 2<project xmlns="http://maven.apache.org/POM/4.0.0"
 3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
 5    <modelVersion>4.0.0</modelVersion>
 6
 7    <!-- Spring AI 2.0.x는 Spring Boot 4.0.x / 4.1.x 지원 -->
 8    <parent>
 9        <groupId>org.springframework.boot</groupId>
10        <artifactId>spring-boot-starter-parent</artifactId>
11        <version>4.0.0</version>
12        <relativePath/>
13    </parent>
14
15    <groupId>com.example</groupId>
16    <artifactId>spring-ai-react</artifactId>
17    <version>0.0.1-SNAPSHOT</version>
18    <name>spring-ai-react</name>
19    <description>react_agent.py를 Spring AI로 옮긴 실습</description>
20
21    <properties>
22        <java.version>21</java.version>
23        <spring-ai.version>2.0.2</spring-ai.version>
24    </properties>
25
26    <dependencyManagement>
27        <dependencies>
28            <dependency>
29                <groupId>org.springframework.ai</groupId>
30                <artifactId>spring-ai-bom</artifactId>
31                <version>${spring-ai.version}</version>
32                <type>pom</type>
33                <scope>import</scope>
34            </dependency>
35        </dependencies>
36    </dependencyManagement>
37
38    <dependencies>
39        <!-- 웹 서버 없이 콘솔 실행만 하므로 기본 starter만 -->
40        <dependency>
41            <groupId>org.springframework.boot</groupId>
42            <artifactId>spring-boot-starter</artifactId>
43        </dependency>
44
45        <!-- Python의 langchain_aws.ChatBedrockConverse에 해당 -->
46        <dependency>
47            <groupId>org.springframework.ai</groupId>
48            <artifactId>spring-ai-starter-model-bedrock-converse</artifactId>
49        </dependency>
50    </dependencies>
51
52    <build>
53        <plugins>
54            <plugin>
55                <groupId>org.springframework.boot</groupId>
56                <artifactId>spring-boot-maven-plugin</artifactId>
57            </plugin>
58        </plugins>
59    </build>
60</project>

3일차 코드를 한 장의 지도로

파일이 아홉 개, 설명 자료가 여덟 개나 되면 무엇이 무엇과 이어지는지 놓치기 쉽습니다. 강의 끝에 이 폴더 전체를 분석 도구(graphify)에 넣어 지식 그래프로 만든 자료가 있습니다. 여기서 말하는 그래프는 LangGraph의 실행 그래프가 아니라, 코드의 함수와 개념을 점으로, 그 관계를 선으로 그린 지도입니다.

지식 그래프

3일차 코드 지식 그래프

day3_practice 폴더의 함수, 클래스, 설명 자료 속 개념을 점 168개와 선 298개로 연결하고 11개 묶음으로 색을 나눈 탐색형 그래프입니다. 점을 누르면 그 항목의 정보가 보이고, 묶음별로 켜고 끌 수 있습니다.

새 탭으로day3_practice/graphify-out/graph.html

분석 보고서(graphify-out/GRAPH_REPORT.md)가 찾아낸 묶음은 이 날의 강의 순서와 거의 겹칩니다. 연결이 가장 많은 항목으로는 retrieve(), agent 노드, tools 노드, classify_request(), 그리고 "ReAct 루프(Reason → Act → Observe)"가 꼽혔습니다. 여러 실습이 결국 ReAct 에이전트 한 곳으로 모인다는 점이 연결 수로도 드러납니다.

보고서의 묶음해당 파일이 페이지의 절
체인 한계와 수동 ReActchain_limit.py, manual_react.py체인의 한계, 수동 ReAct
일렬 그래프 hello_graphhello_graph.py첫 그래프
공통 import와 메시지 State / 품질 사이클과 리듀서reducer.py, cycle.pyreducer, 사이클
조건부 엣지와 의도 분류conditional.py조건부 엣지
분기·루프 재검색 그래프branch_loop_graph.py분기와 루프
ReAct 루프 개념과 안전장치 / ReAct 파이프라인 다이어그램 / 에이전트 도구와 그래프 그림react_agent.py 와 그 설명 자료들그래프 ReAct, 실행 추적
LangGraph 서버 설정langgraph.json개발 서버
이 지도에 없는 것

이 그래프의 데이터(graphify-out/graph.json)에는 spring_ai_react/ 의 Java 소스가 들어 있지 않습니다. 파이썬 파일과 설명 자료, 그림만 다룬 지도입니다. 또 보고서가 "길이 기반 품질 점수 (min(len/200, 1.0))"라고 적은 항목은 앞에서 본 이전 버전의 식입니다. 지도는 만든 시점의 스냅숏이므로, 세부 수치는 지금 코드에서 확인합니다.

3일차 정리

오늘의 한 문장

에이전트는 "모델 노드 + 도구 노드 + tool_calls 가 있으면 도구로 가는 조건부 엣지 + 도구에서 모델로 돌아가는 사이클"이고, 그 사이를 흐르는 것은 reducer가 누적해 주는 메시지 목록 하나입니다.

오늘 만든 것남은 숙제이어지는 날
ToolNode 에 맡긴 도구 실행도구 설명은 어떻게 써야 모델이 잘 고르나. 도구가 실패하면. 남이 만든 도구를 가져다 쓰려면4일차 도구 설계와 MCP
실행이 끝나면 사라지는 State대화를 이어 가고, 프로그램을 껐다 켜도 남게 하려면5일차 체크포인터와 메모리
모델이 부르면 곧바로 실행되는 도구위험한 도구는 실행 전에 사람이 승인하고, 입력과 출력을 걸러 내려면5일차 사람 승인과 가드레일
에이전트 하나, 도구 셋일이 커져 에이전트 하나로 감당이 안 되면6일차 멀티 에이전트
매 턴 즉석에서 판단하는 루프, 노드 단위 추적먼저 계획을 세우고 실행하려면. 숨은 호출까지 추적하고 품질을 채점하려면7일차 Plan-and-Execute, 추적, 평가

스스로 확인하기

노드 함수가 {"word_count": 2} 만 돌려줬는데 State의 다른 칸이 사라지지 않는 이유는 무엇인가요?

노드의 반환값은 새 State가 아니라 "바꿀 칸"의 목록이기 때문입니다. LangGraph가 그 dict를 기존 State에 합칩니다. reducer가 없는 칸은 새 값으로 덮어쓰고, 돌려주지 않은 칸은 그대로 둡니다.

messages: list 와 messages: Annotated[list, add_messages] 는 실행 결과가 어떻게 다른가요?

앞의 것은 노드가 메시지를 돌려줄 때마다 리스트가 통째로 교체되어 이전 대화가 사라집니다. 뒤의 것은 add_messages reducer가 새 메시지를 기존 리스트 뒤에 붙입니다. reducer.py 에서 메시지가 2개, 4개로 쌓인 것이 그 결과입니다. 다만 누적은 한 번의 실행 안에서만 일어나고, 실행 사이의 기억은 체크포인터가 맡습니다.

라우팅 함수 안에서 State의 값을 바꾸면 어떻게 되나요?

라우팅 함수는 노드가 아니어서 반환값이 "다음 노드의 이름"으로만 쓰입니다. State를 갱신하는 통로가 없습니다. 판단 결과를 남기고 싶으면 classify 나 check_quality 처럼 노드에서 State에 적고, 라우팅 함수는 그 값을 읽기만 하게 만듭니다.

LangGraph에는 반복문이 없는데 cycle.py 는 어떻게 반복하나요? 그리고 무엇이 반복을 멈추나요?

조건부 엣지의 라우팅 함수 route_quality 가 이미 지나온 노드 이름 "generate" 를 돌려주면 그리로 되돌아가고, 그것이 반복입니다. 멈추는 길은 라우팅 함수가 END 를 돌려주는 것뿐이며, 품질 통과와 시도 한도(attempts >= 10) 두 조건이 있습니다. 그 바깥에 LangGraph의 recursion_limit 이 마지막 방어선으로 있지만, 기본값은 버전마다 다르므로 직접 정해 주는 편이 안전합니다.

react_agent.py 에서 수동 루프의 if not ai_message.tool_calls: break 와 while 은 각각 무엇이 됐나요?

if ... break 는 라우팅 함수 should_continue 와 add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END}) 가 됐고, while 은 add_edge("tools", "agent") 한 줄이 됐습니다. 도구를 찾아 실행하고 ToolMessage로 포장하던 for 문은 ToolNode(tools) 가 대신합니다.

agent 노드가 두 번 실행됐다면 모델 호출도 항상 두 번인가요?

아닙니다. classify_request 처럼 도구 안에서 모델을 부르는 도구가 실행됐다면 그만큼 더 호출됩니다. 노드 단위 추적에는 이 내부 호출이 따로 보이지 않으므로, 비용을 따질 때는 도구 안의 호출을 별도로 세어야 합니다.

오늘 쓴 파일 한눈에

이 페이지에 실린 코드 파일 전체 목록입니다. 파일 이름을 누르면 본문에서 그 코드가 있는 자리로 이동합니다.

파일무엇을 하는 코드인가분량
chain_limit.py체인만으로는 재검색을 표현할 수 없어 바깥에 while 문을 두게 되는 과정 확인 (LLM 미사용)77줄
state_schema.pyTypedDict로 State 스키마를 선언하고, 실행 시점에는 dict임을 확인 (LLM 미사용)13줄
hello_graph.py노드 3개를 일렬로 이어 State가 단계마다 어떻게 바뀌는지 관찰 (LLM 미사용)69줄
reducer.pyadd_messages reducer가 메시지를 덮어쓰지 않고 누적하는 것을 확인 (LLM 미사용)35줄
conditional.py모델이 분류한 intent 값에 따라 답변 노드와 고정 반응 노드로 분기92줄
cycle.py답변 생성 → 길이로 품질 평가 → 미달이면 재생성하는 사이클 그래프92줄
branch_loop_graph.py검색 실패 시 질문을 바꿔 재검색하는 분기 + 루프 그래프 (LLM 미사용)111줄
manual_react.pyLangGraph 없이 while 문으로 ReAct 루프(도구 선택 → 실행 → 관찰 → 재판단)를 직접 구현77줄
react_agent.py수동 ReAct 루프를 StateGraph로 옮긴 에이전트: agent 노드 + ToolNode + 조건부 엣지 + 사이클153줄
langgraph.jsonlanggraph dev 가 읽는 설정: react_agent 와 cycle 두 그래프를 등록8줄
ReactAgent.java그래프의 agent 노드, should_continue, tools 노드, 사이클을 메서드와 while 루프로 옮긴 본체154줄
AgentTools.java파이썬의 @tool 함수 3개를 @Tool 메서드 3개로 옮긴 도구 모음62줄
ExpressionCalculator.javaeval 대신 쓰는 사칙연산 파서 (+ - * / 괄호, 단항 -, BigDecimal)71줄
ReactAgentApplication.java실행부: 파이썬과 같은 질문 두 개를 에이전트에 넣고 최종 답변 출력37줄
application.propertiesBedrock 리전과 모델, 자격 증명 방식, 자동 도구 호출 루프 끄기 설정15줄
pom.xmlMaven 빌드 설정: Spring Boot 4.0.0, Spring AI 2.0.2, Java 21, Bedrock Converse 스타터60줄

3일차 용어

LangGraph
LLM 프로그램의 흐름을 노드와 엣지의 그래프로 적는 라이브러리. 분기와 반복, 공유 상태를 구조로 표현합니다. 노드 안에서는 LangChain 부품을 그대로 씁니다.
State
그래프가 실행되는 동안 모든 노드가 함께 읽고 쓰는 데이터. TypedDict 로 칸의 이름과 타입을 선언하며, 실행 시점에는 평범한 dict입니다.
노드
그래프에서 일을 하는 단위. State를 받아 바꿀 칸만 dict로 돌려주는 파이썬 함수입니다. add_node("이름", 함수) 로 등록합니다.
엣지
한 노드가 끝난 뒤 다음에 실행할 노드를 정하는 연결. add_edge 로 그은 것은 목적지가 고정입니다.
StateGraph
State 스키마를 받아 그래프를 조립하는 설계도 클래스. 노드와 엣지를 등록한 뒤 compile() 해야 실행할 수 있습니다.
START / END
그래프의 시작점과 끝점을 뜻하는 특별한 이름. 실제 함수가 아니며 구조도에는 __start__, __end__ 로 그려집니다.
compile
조립한 설계도를 invoke, stream 으로 실행할 수 있는 그래프 객체로 바꾸는 단계.
stream_mode
graph.stream 이 단계마다 무엇을 내줄지 정하는 옵션. "values" 는 그 시점의 State 전체, "updates" 는 {노드 이름: 그 노드가 돌려준 값}입니다.
reducer
노드가 돌려준 값을 State의 기존 값과 합치는 규칙을 정하는 함수. Annotated[타입, reducer] 로 칸마다 지정하며, 지정하지 않으면 덮어쓰기입니다.
add_messages
대화 기록용 reducer. 새 메시지를 기존 목록 뒤에 붙이고, 같은 id 의 메시지는 교체합니다.
조건부 엣지
출발 노드가 끝난 뒤 라우팅 함수를 불러 그 반환값으로 목적지를 정하는 엣지. add_conditional_edges(출발 노드, 라우팅 함수, 목적지 정보) 로 등록합니다.
라우팅 함수
State를 읽고 다음 노드의 이름(또는 라벨)을 돌려주는 함수. 노드가 아니므로 State를 바꾸지 못합니다.
사이클
엣지를 따라가다 이미 지나온 노드로 되돌아오는 구조. LangGraph에서 반복을 표현하는 방법이며, 반드시 종료 조건과 짝을 이뤄야 합니다.
recursion_limit
한 번의 실행에서 그래프가 밟을 수 있는 단계 수의 한도. 넘으면 GraphRecursionError 가 납니다. config={"recursion_limit": N} 으로 정합니다.
ReAct
Reasoning + Acting. 모델이 판단하고, 도구를 실행하고, 결과를 관찰한 뒤 다시 판단하는 반복. 에이전트의 기본 동작 방식입니다.
bind_tools
도구 설명서를 매 요청에 함께 보내는 모델 객체를 만드는 메서드. 모델은 실행하지 않고 tool_calls 만 돌려줍니다.
ToolNode
LangGraph가 제공하는 도구 실행 노드. 모델이 요청한 도구들을 실행하고 결과를 요청마다 ToolMessage로 포장해 돌려줍니다.
with_structured_output
모델의 답을 지정한 스키마(Pydantic 클래스)의 객체로 받게 하는 메서드. Literal 과 함께 써서 분기에 쓸 값의 범위를 묶습니다.
langgraph.json
LangGraph 개발 서버(langgraph dev)가 읽는 설정 파일. 어느 파일의 어느 변수를 그래프로 등록할지, 환경 변수 파일이 어디인지를 적습니다.