day3_practice/manual_react.py

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

ReAct는 모델이 추론(Reason)해서 도구를 고르고, 코드가 실행(Act)한 결과를 다시 모델에게 보여 주는 반복입니다. 이 파일은 LangGraph 없이 while 문 하나로 그 루프를 구현합니다. 모델은 도구를 실행하지 않습니다. 실행은 전부 우리 코드가 합니다.

루프 구조

매 턴마다 messages 전체를 모델에 보내고, 응답에 tool_calls가 있으면 도구를 실행해 결과를 messages에 붙인 뒤 다시 보냅니다. tool_calls가 비면 그게 최종 답변입니다.

messages System, Human, AI, Tool ... 누적 전체 전송 Reason llm_with_tools .invoke(messages) AIMessage tool_calls 있음? 없음 최종 답변 break 있음 Act tools_by_name[name] .invoke(args) tool_calls 전부 반복 Observe ToolMessage( tool_call_id=...) append while turn < MAX_TURNS(10): 한 바퀴 = LLM 호출 1번

구성 요소

도구 정의

@tool 함수 이름이 도구 이름, docstring이 모델이 읽는 설명, 타입 힌트가 인자 스키마가 됩니다. 모델은 docstring을 보고 언제 이 도구를 쓸지 정하므로 사실상 프롬프트입니다.

도구 목록 전달

llm.bind_tools(tools) 호출할 때마다 도구 스키마를 요청에 같이 보내는 새 모델 객체를 만듭니다. 모델은 실행하지 않고 "이 도구를 이 인자로 불러 달라"는 tool_calls만 돌려줍니다.

디스패치

tools_by_name[tool_call["name"]] 모델이 돌려준 이름 문자열로 실제 함수를 찾아 tool_call["args"]로 실행합니다.

결과 돌려주기

ToolMessage(tool_call_id=...) tool_call_id로 어느 요청의 결과인지 짝을 맞춥니다. 요청이 3개면 ToolMessage도 3개가 있어야 하고, 하나라도 빠지면 다음 호출에서 API 에러가 납니다.
while turn < MAX_TURNS:
    ai_message = llm_with_tools.invoke(messages)   # Reason: 전체 기록을 보고 판단
    messages.append(ai_message)
    if not ai_message.tool_calls:                  # 도구 요청 없음 = 최종 답변
        break
    for tool_call in ai_message.tool_calls:        # Act: 요청된 도구 전부 실행
        result = tools_by_name[tool_call["name"]].invoke(tool_call["args"])
        messages.append(ToolMessage(content=str(result), tool_call_id=tool_call["id"]))  # Observe
else:                                             # break 없이 끝남 = 턴 한도 초과
    print("[중단] 최대 턴 수를 초과했습니다.")

while ... else의 else는 break 없이 루프가 끝났을 때만 실행됩니다. 그래서 최종 답변 없이 한도에 걸린 경우만 따로 잡을 수 있습니다.

실행 후 messages 기록

실제로 실행한 뒤 messages를 덤프한 결과입니다. LLM 호출은 2번이었습니다. 모델은 이전 대화를 기억하지 못하므로 두 번째 호출에는 0~5번 메시지가 전부 다시 전송됩니다.

0System
너는 삼성SDS 사내 어시스턴트야. 필요하면 도구를 사용해.
1Human
김하늘 님과 박도윤 님이 각각 어느 팀 소속인지 알려주고, 34 곱하기 27도 계산해줘.
턴 1 · LLM 호출 #1 (0~1번 전송)
2AI
content: (비어 있음) · tool_calls 3개search_employee(name="김하늘")search_employee(name="박도윤")calculate(expression="34 * 27")
3Tool
김하늘 / 클라우드운영팀 / [email protected]
4Tool
박도윤 / 물류플랫폼팀 / [email protected]
5Tool
918
턴 2 · LLM 호출 #2 (0~5번 전송)
6AI
tool_calls 없음 → break김하늘 님: 클라우드운영팀 소속 / 박도윤 님: 물류플랫폼팀 소속 / 34 × 27 = 918

모델이 서로 독립적인 도구 호출 3개를 한 턴에 한꺼번에 요청했습니다(병렬 tool call). 이건 모델이 정하는 것이라 항상 보장되지는 않습니다. 앞 결과가 다음 호출의 인자가 되는 작업이면 턴이 더 늘어납니다.

실서비스로 가져가기 전에

지점지금 코드문제와 대응
calculate eval(expression, {"__builtins__": {}}, {}) builtins를 비워도 안전하지 않습니다. 9**9**9 같은 입력으로 CPU를 묶을 수 있고, 객체 속성을 타고 올라가는 우회도 알려져 있습니다. ast로 숫자와 사칙연산 노드만 허용하는 계산기로 바꿉니다.
도구 이름 조회 tools_by_name[name] 모델이 없는 이름을 내면 KeyError로 루프 전체가 죽습니다. 에러 내용을 ToolMessage로 돌려줘서 모델이 고치게 합니다.
도구 예외 calculate만 try/except DB나 API를 부르는 도구는 실패가 흔합니다. 모든 도구 실행을 감싸고, 실패해도 같은 tool_call_id로 ToolMessage를 꼭 넣습니다. 빠지면 다음 호출이 거부됩니다.
턴 한도 MAX_TURNS = 10 한도에 걸리면 답변 없이 끝납니다. 사용자에게 보낼 대체 응답을 정하거나, 마지막 턴에는 도구 없이 요약만 하게 합니다.
토큰 사용량 매 턴 messages 전체 전송 턴과 도구 결과가 쌓일수록 입력 토큰이 늘어납니다. 긴 도구 결과는 잘라서 넣거나 요약합니다.

LangGraph로 옮기면

chain_limit.py에서 본 것처럼 반복과 판단이 그래프 바깥 while 문에 있습니다. 같은 구조를 LangGraph로 옮기면 각 부분이 이렇게 대응합니다.

manual_react.pyLangGraph
messages 리스트에 appendState의 messages: Annotated[list, add_messages] (reducer.py)
llm_with_tools.invoke(messages)모델 호출 노드
if not ai_message.tool_calls: break조건부 엣지 (conditional.py). 미리 만들어진 tools_condition도 있습니다.
for tool_call in ... 실행 + ToolMessage도구 실행 노드. 미리 만들어진 ToolNode가 이 부분을 대신합니다.
while로 다시 모델 호출도구 노드 → 모델 노드 엣지 = 사이클 (cycle.py)
MAX_TURNSrecursion_limit

정리