
ReAct 루프 ― 생각하기 → 도구 사용하기 → 관측하기 최소 골격【프롬프트로 풀어보는 AI 에이전트 #2】
요약
AI 에이전트의 핵심 동작 원리인 ReAct(Reasoning and Acting) 루프의 개념과 최소 골격을 설명합니다. LLM의 추론, 도구 사용, 결과 관측이 반복되는 사이클을 통해 에이전트가 어떻게 태스크를 수행하는지 다룹니다.
핵심 포인트
- ReAct 루프는 추론(Reason), 행동(Act), 관측(Observe)의 반복으로 구성됨
- LLM은 도구 사용 의도를 결정하고, 실제 실행은 코드가 담당함
- 에이전트의 성능은 프롬프트와 루프 설계 방식에 따라 결정됨
- 실행형 에이전트의 핵심은 LLM 호출을 루프로 연결하는 구조에 있음
연재 「프롬프트로 풀어보는 AI 에이전트」 제2회 (제1장).
실재하는 3가지 AI 에이전트 OSS를 실제 코드와 실제 프롬프트를 원전에서 인용하며 분석하여,
최종적으로 「스스로 AI 에이전트를 만들 수 있는」 상태를 목표로 하는 연재입니다.
이 장의 전제 (제0장 복습)
제0장에서 본 연재가 다루는 3가지 OSS에는 두 가지 세계관이 있다고 지도를 그렸습니다. 하나는 실행형(agent-zero / Hermes) = LLM에게 도구를 사용하게 하여 태스크를 수행하게 하는 에이전트. 다른 하나는 학습형(Agent0) = 모델의 가중치(weights)를 업데이트하여 자기 진화시키는 연구 프로젝트입니다. 본 장은 제1부의 입구로서 실행형 2가지만을 다룹니다 (학습형인 Agent0는 제2부·제7장 이후).
실행형 에이전트의 정체는 파고들면 놀라울 정도로 단순합니다. 1회의 LLM 호출은 「문장을 하나 반환하는 것」뿐이며, 그 자체로는 아무것도 할 수 없습니다. 에이전트다움 ―― 도구를 사용하고, 결과를 보고, 다음 수를 생각하는 것 ―― 은 그 LLM 호출을 루프(loop)로 몇 번이고 반복하는 것에서 생겨납니다. 본 장의 목표는 이 루프의 최소 골격을 여러분이 직접 필사(写経)하여 움직일 수 있게 되는 것입니다.
ReAct 루프란
ReAct (리액트)는 "Reasoning and Acting"의 약자로, LLM에게 「추론(Reason)」과 「행동(Act)」을 교대로 시키고, 행동의 결과를 「관측(Observe)」으로서 추론으로 되돌리는 진행 방식을 가리킵니다 [1]. 세 가지를 하나의 사이클로 반복하므로, 본 연재에서는 이를 Reason–Act–Observe 루프라고 부릅니다.
Reason (생각하기): 「지금 무엇을 해야 하는가」를 LLM이 생각하게 함. 다음에 호출할 도구와 인자(argument)를 결정하는 단계. -
Act (도구 사용하기): LLM이 선택한 도구 (외부 함수·커맨드. 웹 검색, 파일 읽기/쓰기, 쉘 실행 등 LLM 스스로는 할 수 없는 조작을 대행함)를 프로그램 측에서 실제로 실행함. -
Observe (관측하기): 도구의 실행 결과(관측)를 대화 이력(conversation history)에 다시 기록하여 다음 Reason의 재료로 삼음. -
여기서 중요한 것은 역할 분담입니다. 생각하는 것은 LLM, 실제로 도구를 움직이는 것은 당신의 코드입니다. LLM은 「이 도구를 이 인자로 사용하고 싶다」라는 의도를 텍스트로 반환할 뿐이며, 손발은 가지고 있지 않습니다. 그 의도를 파싱(parse)하여 실제로 실행하고, 결과를 대화로 되돌리는 ―― 이 왕복을 돌리는 토대가 에이전트의 본체입니다.
그리고 본 연재의 주제와 직결되는 점이 하나 더 있습니다. 이 루프를 어디서 멈출 것인가, 어떤 형식으로 도구를 호출하게 할 것인가, 관측을 어떻게 이력으로 되돌릴 것인가는 모두 프롬프트와 수십 줄의 코드로 결정됩니다. 프레임워크마다의 개성은 거의 이 세 가지 설계 차이에서 옵니다. 우선은 소박한 최소 구현으로 루프의 뼈대를 잡고, 그 후에 agent-zero와 Hermes가 같은 뼈대를 어떻게 구현하고 있는지 대조해 보겠습니다.
최소 구현: 동작하는 최소 ReAct 루프
특정 프레임워크에 의존하지 않는 소박한 ReAct 루프를 의사 코드(pseudo code)로 보여줍니다. 이것이 이 장 ―― 나아가 연재 전체 ―― 의 토대입니다. 먼저 의사 코드로 골격을 보여드리고, 이어서 직접 필사할 수 있는 Python 코드를 배치합니다.
함수 run_agent(user_input):
history = [ system_prompt ] # ① 인격·규약·도구 목록 (제2, 3장에서 심층 분석)
history.append( user(user_input) ) # 사용자 입력을 이력에 쌓음
...
뼈대는 이것뿐입니다. 요점은 세 가지입니다.
이력(history)이 상태의 전부임. LLM은 스테이트리스(stateless)이므로 과거의 발언·관측은 매번 통째로 전달함. 에이전트의 「기억」은 제4장에서 다룰 영속 메모리(persistent memory)를 제외하면 이 이력에 다름없음. -
루프를 멈추는 것은 「도구 호출이 없는 것」임. LLM이 도구를 사용하지 않고 그냥 말만 한다면 그것이 최종 답변임. 이 판정 방식이야말로 나중에 볼 두 프레임워크의 가장 큰 차이점임. -
반복 상한(N회)은 필수임. LLM이 끝없이 도구를 호출하며 폭주하는 것을 물리적으로 막는 안전장치임. -
다음으로, 이것을 그대로 움직일 수 있는 Python 최소 구현으로 만듭니다 (LLM 클라이언트는 추상화함. OpenAI 호환이든 Anthropic이든, messages를 전달하여 하나의 응답을 얻는 형태라면 무엇이든 가능).
MAX_ITERATIONS = 10
# 도구의 실체. LLM이 할 수 없는 조작을 Python 측에서 대행함
def run_tool(name: str, args: dict) -> str:
...
parse_tool_calls
와 "도구 호출 형식"을 여기서는 의도적으로 추상화했습니다. 이 두 가지 점(응답을 어떻게 파싱할 것인가·어디서 멈출 것인가)이야말로 프레임워크마다 설계가 갈리는 핵심이기 때문입니다. 이후, agent-zero와 Hermes가 이 동일한 골격을 어떻게 구현하고 있는지 원문 코드를 통해 확인하겠습니다.
실물로 확인하기 ① ― agent-zero: JSON 응답 계약과 이중 루프
agent-zero (agent0ai/agent-zero @ f9d8167)는 에이전트의 동작을 Python 코드가 아닌 prompts/*.md에 외재화하는 것이 핵심 사상입니다. ReAct 루프의 "응답 형식"도 "종료 판정"도 우선 프롬프트로 정의되어 있습니다.
LLM에게 무엇을 반환하게 할 것인가 ―― JSON 응답 계약
agent-zero는 LLM에게 반드시 정해진 형태의 JSON만을 반환하게 합니다. 그 규칙을 정의하고 있는 것이 다음 프롬프트입니다.
### Response format (json fields names) - thoughts: array thoughts before execution in natural language - headline: short headline summary of the response - tool_name: use tool name - tool_args: key value pairs tool arguments
{ "thoughts": [ "instructions?", "solution steps?", "processing?", "actions?" ], "headline": "Analyzing instructions to develop processing actions", "tool_name": "name_of_tool", "tool_args": { "arg1": "val1", "arg2": "val2" } }
――
prompts/agent.system.main.communication.md @ f9d8167 (원문에서는 Response example의 코드 펜스에 백틱이 아닌 물결표 ~~~json를 사용함. 본 기사는 표시상의 편의를 위해 백틱으로 대체하였으나, 감싸진 내용은 원문과 일치함)
이 JSON이 ReAct의 Reason과 Act를 하나로 묶고 있습니다. thoughts가 추론 (Reason)이며, tool_name / tool_args가 "어떤 도구를 어떻게 사용하고 싶은가"라는 행동의 의도 (Act)입니다. 서두의 - Output must be valid JSON with double quotes for all keys and string values / - No text output before or after the JSON object (동일 파일)라는 지시를 통해, 응답 전체를 하나의 JSON 객체로 고정하고 있는 것이 포인트입니다. 이는 최소 구현인 parse_tool_calls를 LLM 측에 JSON 형식을 강제함으로써 성립시키고 있는 것입니다.
루프의 본체 ―― monologue의 이중 while
실행 루프의 본체는 agent.py의 monologue 메서드입니다. while True가 이중으로 되어 있습니다 (agent.py @ f9d8167: async def monologue가 L373, 바깥쪽 while True가 L374, 안쪽 while True가 L386).
async def monologue(self):
while True: # 바깥쪽: 메시지 루프 (1 유저 턴)
try:
...
―― agent.py @ f9d8167 (발췌)
안쪽의 while True가 바로 본 장에서 다루는 ReAct 루프입니다. 1회 반복에서 어떤 일이 일어나는지 동일한 monologue를 통해 따라가 보겠습니다.
Reason (추론): 프롬프트를 구성하여 LLM을 호출합니다.
# prepare LLM chain (model, system, history)
prompt = await self.prepare_prompt(loop_data=self.loop_data)
...
...
―― agent.py @ f9d8167, L401, L471-475
prepare_prompt가 「시스템 프롬프트 (system prompt) + 대화 이력 (conversation history) + extras」를 매번 통째로 다시 구성합니다 (agent.py @ f9d8167, L547-548, L576-579). 최소 구현에서 "이력을 매번 전달한다"라고 말했던 것과 동일한 작업을 함수로 분리해 놓은 것뿐입니다.
Act (실행) + 종료 판정: 응답을 process_tools에 전달하여 도구를 실행합니다.
else: # otherwise proceed with tool
# Append the assistant's response to the history
...
...
―― agent.py @ f9d8167, L504-511
process_tools 안에서 LLM이 반환한 JSON을 느슨하게 파싱하고 (tool_request = extract_tools.json_parse_dirty(msg), agent.py @ f9d8167, L869), tool_name에 대응하는 도구를 실행합니다. 중요한 것은 마지막 몇 줄입니다.
await tool.after_execution(response)
await self.handle_intervention()
if response.break_loop:
...
―― agent.py @ f9d8167, L946-950
도구의 실행 결과가 break_loop == True를 세우면 process_tools가 값을 반환하고, 이를 받은 내부 루프가 return tools_result로 루프를 빠져나갑니다. 그렇다면 break_loop를 세우는 것은 누구일까요?
response라는 이름의 전용 도구입니다. ### response: final answer to user ends task processing use only when done or no task active put result in text arg
―― prompts/agent.system.tool.response.md @ f9d8167
response 도구의 실체는 단 한 줄입니다.
class ResponseTool(Tool):
async def execute(self, **kwargs):
return Response(message=self.args["text"] if "text" in self.args else self.args["message"], break_loop=True)
―― tools/response.py @ f9d8167, L4-7
즉 agent-zero에서는 LLM이 response 도구를 호출했을 때만 루프가 종료됩니다. 그 외의 도구(검색이나 셸 실행)는 break_loop=False인 상태로 결과를 이력에 쌓으며 루프는 계속됩니다. "도구 호출이 없으면 최종 답변"이라는 최소 구현의 종료 판정을, agent-zero는 "전용의"라는 형태로 구현하고 있는 것입니다. 최종 답변 또한 response 도구를 호출하게 함으로써 response 도구를 경유하여 표현한다는 점이 최소 구현(도구 호출 여부로 판정)과 다릅니다. 한편 Observe (관측의 쓰기)는 도구 결과가 self.hist_add_tool_result(...)를 통해 이력에 추가됨으로써 실현됩니다 (agent.py @ f9d8167, L720-728). 만약 LLM이 규약을 어겨 JSON이 아닌 응답이나 알 수 없는 도구 이름을 반환할 경우에는 fw.msg_misformat.md 등의 경고 메시지를 이력에 쌓아 (agent.py @ f9d8167, L957, L963-964), 다음 반복에서 LLM이 수정하도록 합니다. 이 또한 "관측을 이력으로 되돌려 다음 추론 재료로 삼는" 루프의 일부입니다.
실물로 확인하기 ② ― Hermes: finish_reason 과 role:"tool"
Hermes (NousResearch/hermes-agent @ 6928692)는 agent-zero와 응답 형식이 근본적으로 다릅니다. Hermes는 LLM의 네이티브 함수 호출 (function calling) 기능을 사용합니다. 최근의 LLM API는 구조화된 도구 호출을 tool_calls라는 전용 필드로 반환하며, 그 응답이 "도구를 호출하고 싶은 상태인지 / 말을 마친 상태인지"를 finish_reason (응답의 종료 이유. "stop" = 통상 종료, "tool_calls" = 도구 호출, "length" = 출력 길이 상한 등)이라는 플래그로 알려줍니다. Hermes는 이를 토대로 작동합니다 (참고로 "stop" / "tool_calls" / "length"는 OpenAI 계열의 값이며, Anthropic은 stop_reason (end_turn / tool_use 등)이라는 다른 이름을 반환합니다). Hermes는 map_finish_reason을 통해 이 차이를 내부적으로 정규화합니다 ―― agent/conversation_loop.py @ 6928692, L1525.
루프의 본체 ―― run_conversation
핵심은 agent/conversation_loop.py의 run_conversation입니다. 루프의 헤더는 다음과 같습니다.
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
―― agent/conversation_loop.py @ 6928692, L761
api_call_count < agent.max_iterations가 최소 구현의 for _ in range(MAX_ITERATIONS) ―― 즉, 반복 상한에 의한 안전장치 ―― 그 자체입니다 (agent.iteration_budget은 여기에 더 세밀한 예산 제어를 추가한 것입니다). 루프에 진입하기 전에 시스템 프롬프트를 "다시 구성하지 않고" 세션 DB에서 복원한다는 점이 agent-zero와 대조적입니다.
if agent._cached_system_prompt is None:
_restore_or_build_system_prompt(agent, system_message, conversation_history)
―― agent/conversation_loop.py @ 6928692, L579-580
이는 지속되는 세션에서 시스템 프롬프트를 매 턴 동일한 바이트 열로 유지하여 프롬프트 캐시 (prompt cache)를 활용하기 위한 설계입니다 (자세한 내용은 제2장 참조). 본 장의 관심사에서는 "시스템 프롬프트 + 이력을 LLM에 전달하여 루프를 돌린다"라는 골격은 agent-zero와 동일하다고 파악하는 것으로 충분합니다.
Act 와 Observe ―― tool_calls의 유무로 분기
LLM 응답을 받은 후, Hermes는 assistant_message.tool_calls가 있는지 없는지에 따라 분기합니다. 이것이 종료 판정의 핵심입니다.
# Check for tool calls
if assistant_message.tool_calls:
...
...
―― agent/conversation_loop.py @ 6928692, L3565-3566, L3798
else:
# No tool calls - this is the final response
final_response = assistant_message.content or ""
―― agent/conversation_loop.py @ 6928692, L3892-3894
tool_calls가 있으면 실행 (Act) 하여 루프를 지속하고, 없으면 content가 최종 답변 (종료)이 됩니다. 이는 최소 구현의 if not tool_calls: return reply.content와 같습니다.
와 거의 어휘적으로 동일합니다. agent-zero가 전용 도구로 루프를 빠져나온 것과 달리, Hermes는 "도구 호출이 없는 응답 = 최종 답변"이라는, 최소 구현에서 보여준 그대로의 솔직한 판정을 채택하고 있습니다.
관측의 정준형 (Canonical Form) ―― role:"tool" 메시지
도구를 실행한 후, 결과(관측, Observation)를 어떻게 이력에 되돌릴 것인가. Hermes는 _execute_tool_calls 내부에서 각 도구 호출을 순차적으로 실행하고, 결과를 정준형의 도구 결과 메시지로 변환하여 이력에 추가합니다.
def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
"""Execute tool calls sequentially (original behavior). Used for single calls or interactive tools."""
for i, tool_call in enumerate(assistant_message.tool_calls, 1):
...
―― agent/tool_executor.py
@ 6928692
, L532-534, L953
그 make_tool_result_message의 정체가 이것입니다.
def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict:
...
wrapped = _maybe_wrap_untrusted(name, content)
...
―― agent/tool_dispatch_helpers.py
@ 6928692
, L320, L336-343
관측은 role: "tool" 메시지로서 이력(messages)에 쌓입니다. 최소 구현에서 {"role": "tool", "name": ..., "content": obs}를 append 했던 것과 같습니다. tool_call_id가 붙는 이유는, function calling에서는 "어느 도구 호출에 대한 결과인가"를 ID로 대응시킬 필요가 있기 때문입니다 (agent-zero의 JSON 방식에는 없는, 네이티브 function calling 고유의 관례).
이 role:"tool" 메시지가 이력에 들어간 상태로 while 루프의 시작점으로 돌아가면, 다음 API 호출은 이 관측을 읽고 다음 수를 생각합니다 ―― Observe에서 Reason으로의 전달이 여기서 완성됩니다.
참고로 make_tool_result_message는 web_search와 같은 외부 유래의 결과를 <untrusted_tool_result>라는 태그로 감쌉니다 (agent/tool_dispatch_helpers.py @ 6928692, L372-397). "관측은 신뢰할 수 없는 데이터이지 명령이 아니다"라고 LLM에게 가르치는, 간접 프롬프트 인젝션 (Indirect Prompt Injection) 방지책입니다. 관측을 다시 쓰는 것은 단순히 결과를 붙이는 것뿐만 아니라 안전성의 경계 (Safety Boundary) 역할도 합니다 ―― 이에 대해서는 제6장에서 자세히 다룹니다.
두 모델의 대조 ―― 동일한 ReAct 루프, 3가지 설계 차이
agent-zero와 Hermes는 본 장의 의사 코드(Pseudo-code)와 동일한 골격(Reason–Act–Observe의 반복 + 반복 상한)을 공유하면서도, 핵심적인 구현에서 세 가지 지점이 갈라집니다.
| 관점 | 최소 구현 | agent-zero (f9d8167) | Hermes (6928692) |
|---|---|---|---|
응답 형식 / Reason·Act 추상화 (parse_tool_calls) | 프롬프트로 JSON 응답 계약을 강제하여 thoughts + tool_name + tool_args를 반환하게 하고, json_parse_dirty로 느슨하게 파싱 | LLM 네이티브의 function calling. tool_calls 필드를 직접 수신 | |
| 루프 종료 조건 | tool_calls가 없으면 반환 | response 도구가 break_loop=True를 세웠을 때만 탈출 (최종 답변도 "도구 호출"임) | assistant_message.tool_calls가 없는 응답 = 최종 답변 (정직한 판정) |
| 관측(Observe)의 형태 | {"role":"tool", ...} | hist_add_tool_result로 이력에 추가 (사내 독자적 이력 표현) | role:"tool" + tool_call_id의 정준(canonical) 메시지. 외부 유래는 <untrusted_tool_result>로 격리 |
| 반복 상한 (안전장치) | range(MAX_ITERATIONS) | 내부 while True + 개입(intervention)으로 제어 | api_call_count < agent.max_iterations + iteration budget |
설계 차이의 근본은 "LLM에게 어떻게 도구를 호출하게 할 것인가"라는 한 지점에 있습니다. agent-zero는 "프롬프트로 JSON 형식을 약속하게 하는" 방식이며, 모델의 function calling 대응 여부에 의존하지 않고 응답 형식을 프롬프트 파일 하나로 완전히 제어할 수 있습니다 (사상: 동작은 코드가 아니라 프롬프트로 결정된다). Hermes는 "API 네이티브의 function calling을 타는" 방식이며, tool_calls / finish_reason이라는 표준화된 메커니즘을 사용하는 대신, 대응 모델과 대응 API가 전제되어야 합니다.
어느 쪽이 정답이라는 이야기가 아닙니다. 같은 ReAct 루프라도 응답 형식·종료 조건·관측의 취급을 바꾸면 이만큼이나 모습이 달라진다는 점이 핵심입니다. 당신이 직접 만들 때, 이 세 지점을 어떻게 설계할지가 첫 번째 판단 포인트가 됩니다 ―― 이 점을 가져가실 수 있다면 본 장의 목적은 달성된 것입니다.
요약 및 다음 장으로의 연결
본 장에서 확인한 것:
- 실행형 에이전트의 본체는 Reason–Act–Observe를 반복하는 루프이며, 그 최소 골격은 수십 줄로 작성할 수 있다.
- 생각하는 것은 LLM, 도구를 실제로 움직이는 것은 당신의 코드이다. LLM은 도구 호출의 "의도"를 텍스트로 반환할 뿐이다.
- agent-zero는 프롬프트로 JSON 응답 계약을 강제하고,
response도구로 루프를 탈출한다. - Hermes는 LLM 네이티브의 function calling을 사용하며,
tool_calls가 없는 응답을 최종 답변으로 삼고, 관측을role:"tool"메시지로 되돌린다. - 프레임워크의 개성은 응답 형식·종료 조건·관측의 취급이라는 세 가지 설계 차이에 거의 집약된다.
이 루프가 연재 전체가 쌓아 올릴 "동작하는 최소 에이전트"의 토대입니다. 루프의 뼈대는 완성되었습니다. 하지만 아직 뼈대뿐입니다 ―― 이 루프를 똑똑하게 움직이려면 SYSTEM_PROMPT의 내용(인격·행동 규범), 도구의 정의 방법, 관측을 쌓아두는 기억의 설계가 필요합니다. 본 장에서 추상화된 상태로 남겨둔 부분들을 다음 장 이후에서 하나씩 채워 나가겠습니다.
다음 장(제2장: 시스템 프롬프트 = 인격·행동 규범)에서는 본 장에서 루프의 ①번에 두었던 SYSTEM_PROMPT를 해부합니다. agent-zero의 prompts/agent.system.main*.md 군과, 본 장에서 살짝 언급했던 Hermes의 "세션을 넘나들며 동일하게 유지하는 시스템 프롬프트" (3층 프롬프트)를 원문 인용으로 대조하며, "에이전트의 인격·행동 규범은 코드가 아니라 프롬프트로 결정된다"라는 본 연재의 핵심 사상으로 깊이 들어갑니다. 루프라는 그릇에 무엇을 담을 것인가 ―― 그것이 다음 이야기입니다.
원 논문은 Yao et al., "ReAct: Synergizing Reasoning and Acting in Language Models" (2022)입니다. 본 연재에서는 프레임워크 (Framework)의 이름으로 사용하며, 원 논문의 특정 구현 (Implementation)에 대해서는 깊이 다루지 않습니다. ↩︎
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기