ReAct 에이전트를 그래프로
manual_react.py의 while 루프를 LangGraph 그래프로 옮긴 파일입니다. 노드는 agent와 tools 두 개뿐이고,
tools → agent 엣지가 사이클을 만듭니다. 코드 주석의 [수동 루프의 ...]가 어느 부분을 옮겼는지 가리킵니다.
그래프 구조
agent가 도구를 요청하면 tools로 갔다가 다시 agent로 돌아옵니다. 요청이 없으면 END로 끝납니다.
수동 루프 → 그래프 대응
| manual_react.py | react_agent.py |
|---|---|
messages = [...] 리스트와 append | AgentState.messages: Annotated[list, add_messages] |
llm_with_tools.invoke(messages) + append | agent 노드가 {"messages": [응답]} 반환 |
if not ai_message.tool_calls: break | should_continue + add_conditional_edges |
for tool_call ... 실행 + ToolMessage 포장 | ToolNode(tools) |
while 처음으로 돌아가기 | add_edge("tools", "agent") |
MAX_TURNS = 10 | 코드에는 없음. recursion_limit(기본 25 step)이 대신 막음 |
구성 요소
State
AgentState
필드는
messages 하나입니다. add_messages 리듀서 덕분에 노드는 새 메시지만 돌려주면 기존 기록 뒤에 붙습니다.
모델 노드
agent
호출할 때마다
SystemMessage를 맨 앞에 붙여 보냅니다. System은 반환하지 않으므로 State에는 저장되지 않습니다. 그래서 State에는 대화만 남고, 시스템 프롬프트는 코드에서 한 곳으로 관리됩니다.
라우팅
should_continue
마지막 메시지의
tool_calls만 봅니다. "end"라는 라벨을 반환하고, path_map {"tools": "tools", "end": END}가 그 라벨을 실제 목적지로 바꿉니다.
도구 실행 노드
ToolNode(tools)
마지막 AIMessage의
tool_calls를 모두 실행하고, 각 결과를 같은 tool_call_id를 단 ToolMessage로 돌려줍니다. 수동 루프의 tools_by_name 조회와 포장 코드가 여기 들어 있습니다.
def agent(state):
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"] # 매번 앞에 붙여서 전송
return {"messages": [llm_with_tools.invoke(messages)]} # 새 메시지만 반환
def should_continue(state) -> Literal["tools", "end"]:
return "tools" if state["messages"][-1].tool_calls else "end"
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
builder.add_edge("tools", "agent") # 사이클
도구 세 개
search_employee
순수 함수
더미 사전에서 조회합니다. 없는 이름이면 예외 대신 조회 가능한 이름 목록을 문자열로 돌려줘서 모델이 사용자에게 설명할 수 있게 합니다.
calculate
순수 함수
eval로 계산합니다. 실패하면 "수식 형식을 확인하세요"를 붙여 돌려줘서 모델이 다시 시도할 여지를 줍니다.
classify_request
도구 안에서 LLM 호출
문의를 IT지원 / 인사 / 총무로 분류합니다. 에이전트 입장에서는 그냥 도구지만, 호출될 때마다 LLM 호출이 한 번 더 일어납니다.
시스템 프롬프트의 [도구 사용 전략]과 각 도구의 docstring이 모델이 도구를 고르는 기준입니다. 도구가 늘수록 이 두 곳의 설명이 겹치지 않게 쓰는 게 중요합니다.
실행 결과
이 파일에는 실행 코드가 없어서, graph를 import해 질문 세 개를 stream_mode="updates"로 돌려 봤습니다. 최종 답변은 LLM 출력이라 실행마다 달라질 수 있습니다.
"안녕! 오늘 기분 어때?"
agent→END
agent · 최종 답변 (LLM 호출 1번)저는 AI 어시스턴트라 감정은 없지만, 삼성SDS 임직원분들을 도와드릴 준비가 되어 있어서 늘 기쁩니다! ...
"김하늘 님 소속 팀 알려주고, 연봉 3480000에 10% 인상하면 얼마인지 계산해줘."
agent→tools→agent→END
agent · tool_calls 2개
search_employee(name="김하늘") calculate(expression="3480000 * 1.1")tools · ToolMessage 2개김하늘 / 클라우드운영팀 / haneul.kim@... ·
3828000.0000000005agent · 최종 답변김하늘 님 소속 팀: 클라우드운영팀 / 10% 인상 후: 3,828,000원
"노트북 화면이 안 켜져요. 어느 부서에 문의해야 하나요? 그리고 최서연 님 이메일도 알려줘."
agent→tools→agent→END
agent · tool_calls 2개
classify_request(text="노트북 화면이 안 켜져요") search_employee(name="최서연")tools · ToolMessage 2개IT지원 · '최서연' 님을 찾을 수 없습니다. 조회 가능한 임직원: ['김하늘', '박도윤']
agent · 최종 답변노트북 화면 문제는 IT지원 부서에 문의하시면 됩니다. 최서연 님은 사내 시스템에서 찾을 수 없습니다. ...
- 인사말은 도구 없이 바로 끝났습니다. 시스템 프롬프트 전략 4번이 그대로 동작했습니다.
- 두 질문 모두 독립적인 도구 2개를 한 턴에 요청해서,
tools노드를 한 번만 거쳤습니다. calculate는 부동소수점 오차가 있는3828000.0000000005를 돌려줬고, 모델이 답변에서 반올림했습니다. 금액 계산을 모델의 반올림에 맡기면 안 되는 이유입니다.- 마지막 질문은 LLM 호출이 3번입니다: agent 2번 +
classify_request안에서 1번.
주의할 점
| 지점 | 내용 |
|---|---|
| import 시 PNG 저장 | 시각화 코드가 if __name__ == "__main__": 밖에 있어서, 다른 파일에서 import만 해도 react_agent.png를 쓰고 출력이 찍힙니다. 위 실행 결과를 만들 때도 그랬습니다. |
| 턴 한도 | 수동 루프의 MAX_TURNS에 해당하는 코드가 없습니다. agent + tools 한 바퀴가 2 step이라, 기본 recursion_limit 25에서는 대략 12번 도구를 부르면 GraphRecursionError가 납니다. 필요하면 invoke(..., config={"recursion_limit": N})로 조정하고 예외를 처리합니다. |
eval | manual_react.py와 같은 계산기라 같은 보안 문제가 있습니다. |
| 금액 계산 | float 오차가 그대로 나옵니다. 금액은 Decimal로 계산하거나 도구 안에서 반올림해서 돌려줍니다. |
| 도구 안 LLM | classify_request는 호출될 때마다 비용과 지연이 추가됩니다. 에이전트 그래프의 노드 실행 기록에는 이 내부 호출이 따로 보이지 않습니다. |
정리
- ReAct 에이전트 = 모델 노드 + 도구 노드 + "tool_calls 있으면 도구로" 조건부 엣지 + "도구 다음은 모델로" 사이클.
add_messages덕분에 노드는 새 메시지만 반환하고,ToolNode가 실행과ToolMessage포장을 대신합니다.- 시스템 프롬프트는 State에 넣지 않고 모델 노드에서 매번 붙이는 방식이라, 기록이 대화만으로 유지됩니다.
- 턴 한도는 직접 쓰지 않아도
recursion_limit이 걸립니다. 실서비스에서는 그 예외를 잡아 처리합니다.