목차
- 문제와 접근
- ReAct 패턴 개념
- 수동 ReAct 에이전트 구현
- ToolNode + tools_condition (prebuilt)
- LLM 분류 라우터 에이전트
- MemorySaver — 대화 기억 (체크포인트)
- thread_id로 사용자 분리
- get_state / get_state_history
- update_state — 수동 상태 수정
- interrupt_before — Human-in-the-Loop (HITL)
- HITL에서 도구 실행 거부
- 핵심 개념 총정리
- 자주 나오는 실수/주의사항
- 재개 가능한 실행의 조건
1. 문제와 접근
에이전트 실행은 모델과 도구를 번갈아 호출하는 흐름에 상태 저장과 중단·재개를 더한 문제로 볼 수 있다. 수동 구현과 제공되는 노드를 비교하면서 어디까지 공통 동작을 맡기고 어떤 정책을 직접 관리할지 살펴본다. 체크포인트와 사용자 구분도 각각의 책임을 나누어 본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| import os
from typing import TypedDict, Annotated, Literal
from dotenv import load_dotenv
load_dotenv()
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import MemorySaver
llm = ChatOpenAI(model="gpt-4o-mini")
|
2. ReAct 패턴 개념
ReAct = Reasoning + Acting
1
2
3
4
5
6
7
8
9
10
| [ReAct 루프]
START
↓
agent 노드 — LLM이 생각하고 tool_calls 결정
↓ tool_calls 있음
tools 노드 — 실제 도구 실행
↓
agent 노드 — 결과를 보고 다시 생각
↓ tool_calls 없음 (최종 답변)
END
|
LLM이 “생각(Reasoning)” → “행동(Acting)” → “관찰” → “다시 생각” 을 반복
이전 tool_loop와 달리, LangGraph는 이 루프를 그래프로 표현한다
3. 수동 ReAct 에이전트 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| @tool
def add(a: int, b: int) -> int:
"""두 정수를 더한다"""
return a + b
@tool
def multiply(a: int, b: int) -> int:
"""두 정수를 곱한다"""
return a * b
tools = [add, multiply]
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
def agent_node(state):
return {'messages': [llm_with_tools.invoke(state['messages'])]}
def tools_node(state):
out = []
for c in state['messages'][-1].tool_calls:
out.append(ToolMessage(
content = str(tool_map[c['name']].invoke(c['args'])),
tool_call_id = c['id']
))
return {'messages': out}
def should_continue(state):
return "tools" if state['messages'][-1].tool_calls else END
b = StateGraph(AgentState)
b.add_node('agent', agent_node)
b.add_node('tools', tools_node)
b.add_edge(START, 'agent')
b.add_conditional_edges('agent', should_continue, {'tools': 'tools', END: END})
b.add_edge('tools', 'agent') # 도구 실행 후 다시 agent로
react_app = b.compile()
r = react_app.invoke({'messages': [HumanMessage(content="(3+5)*4 는?")]})
r['messages'][-1].content
# → '(3 + 5) * 4 의 결과는 32입니다.'
|
실행 메시지 흐름 확인
1
2
3
4
5
6
7
8
| [type(m).__name__ for m in r['messages']]
# → ['HumanMessage', 'AIMessage', 'ToolMessage', 'AIMessage', 'ToolMessage', 'AIMessage']
# 1. 사용자 질문
# 2. LLM: add(3, 5) 호출
# 3. ToolMessage: 8
# 4. LLM: multiply(8, 4) 호출
# 5. ToolMessage: 32
# 6. LLM: 최종 답변
|
수동 구현 대신 LangGraph 내장 도구 노드를 사용한다.
1
2
3
4
5
6
7
8
9
10
| from langgraph.prebuilt import ToolNode, tools_condition
b2 = StateGraph(AgentState)
b2.add_node('agent', agent_node)
b2.add_node('tools', ToolNode(tools)) # 수동 tools_node 대신
b2.add_edge(START, 'agent')
b2.add_conditional_edges('agent', tools_condition) # should_continue 대신
b2.add_edge('tools', 'agent')
auto_app = b2.compile()
|
ToolNode: 자동으로 tool_calls를 읽어 도구 실행 + ToolMessage 생성
tools_condition: tool_calls 있으면 ‘tools’, 없으면 END 반환
수동 vs prebuilt 비교
1
2
3
4
5
6
7
| [수동 구현]
def tools_node(state): ... # 직접 tool_calls 순회
def should_continue(state): ... # 직접 조건 작성
[prebuilt]
ToolNode(tools) # tool_calls 자동 처리
tools_condition # 조건 자동 처리
|
세 가지 도구 에이전트 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @tool
def substract(a, b):
"""a에서 b를 뺀다"""
return a - b
three_tools = [add, multiply, substract]
three_llm = llm.bind_tools(three_tools)
class ThreeState(TypedDict):
messages: Annotated[list, add_messages]
tb = StateGraph(ThreeState)
tb.add_node('agent', lambda s: {'messages': [three_llm.invoke(s['messages'])]})
tb.add_node('tools', ToolNode(three_tools))
tb.add_edge(START, 'agent')
tb.add_conditional_edges('agent', tools_condition)
tb.add_edge('tools', 'agent')
three_app = tb.compile()
r = three_app.invoke({'messages': [HumanMessage(content='100에다가 5를 더한 뒤, 그 결과에서 2를 빼고, 그 결과에 3을 곱하면?')]})
print(r['messages'][-1].content)
# → '계산 결과는 309입니다.' ((100+5-2)*3 = 309)
|
5. LLM 분류 라우터 에이전트
LLM으로 질문 유형을 분류하고, 유형에 맞는 노드로 분기한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| class QState(TypedDict):
question: str
qtype : Literal['article', 'case', 'term', 'general']
answer : str
def classify(state: QState) -> dict:
response = llm.invoke([
SystemMessage(content="""질문을 다음 4가지 카테고리 중 하나로 분류하세요:
- article : 법률 조문 (~조, ~항)에 대한 질문
- case : 판례 번호나 특정 사건에 대한 질문
- term : 법률 용어 정의나 개념 설명 질문
- general : 인사, 일상 대화 등 기타 질문
답변은 반드시 하나의 단어('article', 'case', 'term', 'general')로만 하세요"""),
HumanMessage(content=state['question'])
])
qtype = response.content.strip().lower()
if qtype not in ['article', 'case', 'term', 'general']:
qtype = 'general'
return {'qtype': qtype}
def article_search(s): return {'answer': f'[조문 검색] {s["question"]}'}
def case_search(s): return {'answer': f'[판례 검색] {s["question"]}'}
def term_explain(s): return {'answer': f'[용어 설명] {s["question"]}'}
def general_chat(s): return {'answer': f'[일반 대화] {s["question"]}'}
qb = StateGraph(QState)
qb.add_node('classify', classify)
for n, fn in [('article', article_search), ('case', case_search),
('term', term_explain), ('general', general_chat)]:
qb.add_node(n, fn)
qb.add_edge(START, 'classify')
qb.add_conditional_edges(
'classify', lambda s: s['qtype'],
{'article': 'article', 'case': 'case', 'term': 'term', 'general': 'general'}
)
for n in ['article', 'case', 'term', 'general']:
qb.add_edge(n, END)
q_app = qb.compile()
q_app.invoke({'question': '민법 제760조가 뭔가요?', 'qtype': 'general', 'answer': ''})
# → {'qtype': 'article', 'answer': '[조문 검색] 민법 제760조가 뭔가요?'}
|
6. MemorySaver — 대화 기억 (체크포인트)
메모리에 상태를 저장하는 예제는 같은 프로세스 안에서 실행 흐름을 확인하는 용도로 본다. 프로세스가 다시 시작된 뒤에도 이어야 한다면 저장소의 지속성과 복구 동작을 별도로 확인해야 하며, thread_id를 사용한다는 사실만으로 접근 권한이 확인되는 것은 아니다.
그래프 실행 상태를 저장해서 다음 실행 시 이전 대화를 기억한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| from langgraph.checkpoint.memory import MemorySaver
memory = MemorySaver()
mem_app = b2.compile(checkpointer=memory) # checkpointer 추가
# thread_id로 대화 세션 구분
cfg = {'configurable': {'thread_id': 'alice'}}
mem_app.invoke({'messages': [HumanMessage(content='내 이름은 앨리스야')]}, config=cfg)
# → '안녕하세요, 앨리스! 만나서 반가워요.'
r = mem_app.invoke({'messages': [HumanMessage(content='내 이름이 뭐였지?')]}, config=cfg)
r['messages'][-1].content
# → '앨리스님이신데요!' (이전 대화 기억)
|
MemorySaver는 인메모리 저장 (프로세스 종료 시 사라짐)
영구 저장은 SqliteSaver, PostgresSaver 등 사용
7. thread_id로 사용자 분리
같은 앱에서 여러 사용자의 대화를 독립적으로 관리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # 사용자별 대화 초기화
prefs = {'u1': '피자', 'u2': '초밥', 'u3': '떡볶이'}
for uid, food in prefs.items():
mem_app.invoke(
{'messages': [HumanMessage(content=f'내가 좋아하는 음식은 {food}야')]},
config={'configurable': {'thread_id': uid}}
)
# 사용자별 대화 조회 (각자 자기 음식만 기억)
for uid in prefs:
r = mem_app.invoke(
{'messages': [HumanMessage(content='내가 좋아하는 음식은?')]},
config={'configurable': {'thread_id': uid}}
)
print(f"[{uid}] {r['messages'][-1].content}")
# [u1] 당신이 좋아하는 음식은 피자입니다!
# [u2] 당신이 좋아하는 음식은 초밥입니다!
# [u3] 당신이 좋아하는 음식은 떡볶이입니다!
|
8. get_state / get_state_history
현재 상태 조회
1
2
3
4
5
| cfg_a = {'configurable': {'thread_id': 'alice'}}
snap = mem_app.get_state(cfg_a)
len(snap.values['messages']) # → 저장된 메시지 수
snap.next # → () (더 실행할 노드 없음)
|
과거 상태 이력 조회
1
2
3
4
| history = list(mem_app.get_state_history(cfg_a))
len(history) # → 실행 횟수에 따라 다름
history[-1].values.get('messages', []) # → 가장 오래된 상태
|
9. update_state — 수동 상태 수정
외부에서 상태를 직접 주입할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
| cfg_x = {'configurable': {'thread_id': 'manual'}}
# 메시지를 외부에서 주입
mem_app.update_state(cfg_x, {'messages': [HumanMessage(content='안녕!')]})
snap = mem_app.get_state(cfg_x)
snap.next # → ('agent',) — agent 노드가 실행 대기 중
# None을 invoke하면 저장된 상태부터 이어서 실행
r = mem_app.invoke(None, config=cfg_x)
r['messages'][-1].content
# → '안녕하세요! 어떻게 도와드릴까요?'
|
10. interrupt_before — Human-in-the-Loop (HITL)
중단 위치를 정할 때는 사람이 확인해야 하는 값과 이미 실행된 작업을 구분한다. 재개 후 도구가 다시 실행될 가능성을 확인하고, 외부 상태를 바꾸는 작업은 같은 요청이 반복돼도 어떤 결과가 되는지 검토한다.
특정 노드 실행 전에 일시 정지해서 사람이 확인/승인할 수 있게 한다.
1
2
3
4
5
6
7
8
9
| hitl_app = b2.compile(
checkpointer = MemorySaver(),
interrupt_before = ['tools'] # tools 노드 실행 전 정지
)
cfg_h = {'configurable': {'thread_id': 'hitl-1'}}
hitl_app.invoke({'messages': [HumanMessage(content='100 더하기 200은?')]}, config=cfg_h)
# → agent가 add(100, 200) 호출을 결정했지만, tools 노드 실행 전 정지
|
정지 상태 확인
1
2
3
4
5
6
7
| state = hitl_app.get_state(cfg_h)
state.next # → ('tools',) — tools 노드가 실행 대기 중
# 대기 중인 tool_call 확인
pending = state.values['messages'][-1].tool_calls[0]
pending
# → {'name': 'add', 'args': {'a': 100, 'b': 200}, 'id': 'call_xxx', 'type': 'tool_call'}
|
승인 (None invoke)
1
2
3
4
| # 사람이 확인 후 승인 → None으로 이어서 실행
result = hitl_app.invoke(None, config=cfg_h)
result['messages'][-1].content
# → '100 더하기 200은 300입니다.'
|
흐름 다이어그램
1
2
3
4
5
6
7
8
| invoke(질문) → agent 실행 → tool_calls 결정
↓
[interrupt_before tools]
↓
사람이 get_state로 확인
↓
승인: invoke(None) → tools 실행 → agent → END
거부: update_state → invoke(None) → 수정된 결과
|
11. HITL에서 도구 실행 거부
사람이 도구 실행을 거부하고 직접 답변을 주입한다.
1
2
3
4
5
6
7
8
9
10
11
12
| cfg_r = {'configurable': {'thread_id': 'hhhh'}}
hitl_app.invoke({'messages': [HumanMessage(content='50 곱하기 4는?')]}, config=cfg_r)
# → agent가 multiply(50, 4) 결정 후 interrupt (정지)
# 사람이 거부 → AIMessage로 대체 답변 주입
hitl_app.update_state(cfg_r, {
'messages': [AIMessage(content='죄송합니다. 정책상 도구 실행을 거부합니다')]
})
r = hitl_app.invoke(None, config=cfg_r)
r['messages'][-1].content
# → '죄송합니다. 정책상 도구 실행을 거부합니다'
|
12. 핵심 개념 총정리
LangGraph 에이전트 컴포넌트
| 컴포넌트 | 역할 | 대안 |
|---|
| agent_node | LLM 호출, tool_calls 결정 | - |
| tools_node (수동) | tool_calls 순회 실행 | ToolNode(tools) |
| should_continue | 분기 조건 | tools_condition |
| MemorySaver | 대화 기억 (인메모리) | SqliteSaver 등 |
| interrupt_before | 특정 노드 전 정지 | interrupt_after |
전체 ReAct 에이전트 구조
1
2
3
4
5
6
7
| START
↓
agent (LLM)
↓ tool_calls 있음 ↓ 없음
tools (ToolNode) END
↓
agent (반복)
|
config 구조
1
2
3
4
5
| config = {
'configurable': {
'thread_id': 'unique-session-id' # 필수 (MemorySaver 사용 시)
}
}
|
13. 자주 나오는 실수/주의사항
실수 1: checkpointer 없이 thread_id 전달
1
2
3
4
5
6
7
| # checkpointer 없으면 thread_id 무시됨 (메모리 저장 안 됨)
app = b.compile() # checkpointer 없음
app.invoke(messages, config={'configurable': {'thread_id': 'abc'}})
# → 매 호출마다 독립적으로 실행, 이전 대화 기억 못 함
# 올바른 코드
app = b.compile(checkpointer=MemorySaver())
|
실수 2: interrupt 후 None이 아닌 새 메시지로 invoke
1
2
3
4
5
| # 틀린 코드 → 새 입력이 들어와서 중단점 이전부터 다시 실행
hitl_app.invoke({'messages': [HumanMessage(content='계속해')]}, config=cfg_h)
# 올바른 코드 → None을 전달해야 중단점 이후부터 이어서 실행
hitl_app.invoke(None, config=cfg_h)
|
1
2
3
4
5
6
7
| # ToolNode를 사용할 때는 tools_condition도 함께 사용
b.add_node('tools', ToolNode(tools))
b.add_conditional_edges('agent', tools_condition) # 올바름
# 수동 tools_node를 사용할 때는 should_continue와 함께
b.add_node('tools', tools_node)
b.add_conditional_edges('agent', should_continue, {'tools': 'tools', END: END})
|
1
2
3
4
5
| # tools_node에서 state['messages'][-1].tool_calls 접근 시
# 마지막 메시지가 HumanMessage면 .tool_calls 속성 없음 → AttributeError
# → should_continue / tools_condition이 먼저 분기하므로
# tools_node는 항상 AIMessage가 마지막인 상태에서 실행됨
# → 하지만 구조가 달라지면 에러 가능 → 방어 코드 고려
|
실수 5: MemorySaver를 여러 앱에서 공유
1
2
3
4
5
6
7
8
9
| # 같은 MemorySaver 인스턴스를 여러 앱이 공유하면
# thread_id 충돌로 다른 앱의 대화가 섞일 수 있음
memory = MemorySaver()
app1 = graph1.compile(checkpointer=memory)
app2 = graph2.compile(checkpointer=memory) # 같은 memory → 충돌 위험
# 앱마다 별도 MemorySaver 사용 권장
app1 = graph1.compile(checkpointer=MemorySaver())
app2 = graph2.compile(checkpointer=MemorySaver())
|
14. 재개 가능한 실행의 조건
정상 실행뿐 아니라 중단 직전과 재개 직후의 상태를 비교한다. 어떤 도구까지 실행됐고 어떤 결과가 저장됐는지 설명할 수 있어야 중복 실행과 누락을 구분할 수 있다.
수동 루프와 제공 노드 중 무엇을 사용할지는 공통 처리가 줄어드는 이점과 필요한 실행 정책을 표현할 수 있는지로 판단한다. 상태 저장, 호출 한도, 도구 실패 처리처럼 애플리케이션이 책임질 부분은 따로 남는다.