ReAct 루프를 while 문으로 직접 돌리기
ReAct는 모델이 추론(Reason)해서 도구를 고르고, 코드가 실행(Act)한 결과를 다시 모델에게 보여 주는 반복입니다.
이 파일은 LangGraph 없이 while 문 하나로 그 루프를 구현합니다. 모델은 도구를 실행하지 않습니다. 실행은 전부 우리 코드가 합니다.
루프 구조
매 턴마다 messages 전체를 모델에 보내고, 응답에 tool_calls가 있으면 도구를 실행해 결과를 messages에 붙인 뒤 다시 보냅니다. tool_calls가 비면 그게 최종 답변입니다.
구성 요소
도구 정의
@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
search_employee(name="김하늘")search_employee(name="박도윤")calculate(expression="34 * 27")
3Tool김하늘 / 클라우드운영팀 / haneul.kim@samsungsds.example.com
4Tool박도윤 / 물류플랫폼팀 / doyun.park@samsungsds.example.com
5Tool918
턴 2 · LLM 호출 #2 (0~5번 전송)
6AI김하늘 님: 클라우드운영팀 소속 / 박도윤 님: 물류플랫폼팀 소속 / 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.py | LangGraph |
|---|---|
messages 리스트에 append | State의 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_TURNS | recursion_limit |
정리
- 모델은 도구를 요청만 하고, 실행과 결과 전달은 코드가 합니다.
tool_calls가 비면 루프가 끝납니다. - 모든
tool_call에는 같은tool_call_id의 ToolMessage가 짝으로 있어야 합니다. - 모델은 상태가 없어서, 매 턴
messages전체가 다시 전송됩니다. @tool의 docstring은 모델이 도구를 고르는 기준이므로 프롬프트처럼 신경 써서 씁니다.