AI Agent 첫걸음 — ReAct 패턴으로 LLM이 행동하게 만들기

AI Agent 첫걸음 — ReAct 패턴으로 LLM이 행동하게 만들기
ChatGPT에게 "오늘 서울 날씨 알려줘"라고 하면? "저는 실시간 정보에 접근할 수 없습니다." 하지만 Agent라면 날씨 API를 호출하고, 결과를 해석해서, 자연어로 답을 줍니다. 이 차이가 챗봇과 에이전트를 가르는 핵심입니다.
이 글에서는 Agent의 가장 기본이 되는 ReAct 패턴을 이해하고, 순수 Python으로 직접 구현한 뒤, 왜 Tool Calling으로 진화했는지까지 다룹니다.
시리즈: Part 1 (이 글) | Part 2: LangGraph + Reflection | Part 3: MCP + Multi-Agent | Part 4: 프로덕션 배포
챗봇 vs Agent: 무엇이 다른가?
대부분의 LLM 애플리케이션은 "챗봇"입니다. 사용자가 질문하면 모델이 학습된 지식으로 답변합니다. Input → Output, 끝.
Agent는 다릅니다. LLM이 도구(Tools)를 사용해서 외부 세계와 상호작용합니다. 검색하고, 계산하고, API를 호출하고, 데이터베이스를 조회합니다. 그리고 그 결과를 바탕으로 다음 행동을 스스로 결정합니다.
핵심 인사이트: Agent = LLM + Tools + Loop. LLM이 "무엇을 할지" 판단하고, Tool이 "실제 행동"을 수행하고, Loop가 "완료될 때까지" 반복합니다.
ReAct: Reasoning + Acting
2022년 Yao et al.이 발표한 ReAct(Reasoning and Acting) 논문은 LLM Agent의 기초를 놓았습니다. 핵심 아이디어는 단순합니다: LLM이 생각(Thought)하고 행동(Action)하는 과정을 번갈아 반복하게 만드는 것입니다.
Thought → Action → Observation 루프
ReAct 에이전트는 세 단계를 반복합니다:
- Thought — "지금 상황에서 무엇을 해야 하는가?" 추론합니다
- Action — 적절한 도구를 선택하고 실행합니다
- Observation — 도구의 결과를 받아 다음 판단에 활용합니다
사용자: "테슬라 현재 주가와 시가총액을 알려줘"
Thought: 테슬라의 현재 주가를 검색해야 합니다.
Action: search["Tesla current stock price"]
Observation: Tesla (TSLA) is currently trading at $248.50...
Thought: 주가를 찾았습니다. 이제 시가총액을 계산해야 합니다.
Action: search["Tesla market cap 2024"]
Observation: Tesla's market capitalization is $792 billion...
Thought: 두 정보를 모두 찾았으므로 답변할 수 있습니다.
Final Answer: 테슬라의 현재 주가는 $248.50이며, 시가총액은 약 7,920억 달러입니다.이 패턴이 강력한 이유는 추론 과정이 투명하다는 것입니다. 모델이 왜 그런 행동을 했는지, 중간 결과가 무엇이었는지 전부 볼 수 있습니다.
직접 만드는 ReAct Agent
이론은 충분합니다. 순수 Python과 OpenAI API만으로 ReAct Agent를 구현해봅시다.
1단계: 도구 정의
먼저 Agent가 사용할 도구를 만듭니다.
import math
import requests
def search(query: str) -> str:
"""웹 검색을 시뮬레이션합니다."""
# 실제로는 Tavily, SerpAPI 등을 사용합니다
responses = {
"서울 날씨": "서울 현재 기온 22°C, 맑음, 습도 45%",
"파이썬 최신 버전": "Python 3.13.0 (2024년 10월 릴리스)",
}
return responses.get(query, f"'{query}'에 대한 검색 결과가 없습니다.")
def calculate(expression: str) -> str:
"""수학 표현식을 계산합니다."""
try:
result = eval(expression, {"__builtins__": {}}, {"math": math})
return str(result)
except Exception as e:
return f"계산 오류: {e}"
# 도구 레지스트리
TOOLS = {
"search": search,
"calculate": calculate,
}2단계: 시스템 프롬프트
ReAct 패턴의 핵심은 시스템 프롬프트입니다. LLM에게 "어떤 형식으로 생각하고 행동할지" 명확히 알려줘야 합니다.
SYSTEM_PROMPT = """You are a helpful assistant that can use tools to answer questions.
Available tools:
- search[query]: Search the web for information
- calculate[expression]: Evaluate a mathematical expression
You MUST follow this exact format for each step:
Thought: <your reasoning about what to do next>
Action: <tool_name>[<argument>]
After receiving an Observation, continue with another Thought/Action,
or provide your final response:
Thought: <I now have enough information>
Final Answer: <your complete answer to the user>
Always think step by step. Use tools when you need external information."""3단계: 응답 파싱
LLM의 텍스트 응답에서 Action을 추출합니다. 여기서 정규표현식을 사용합니다.
import re
def parse_action(response_text: str) -> tuple[str, str] | None:
"""LLM 응답에서 Action: tool_name[argument] 패턴을 추출합니다."""
pattern = r"Action:\s*(\w+)\[(.+?)\]"
match = re.search(pattern, response_text)
if match:
tool_name = match.group(1)
argument = match.group(2)
return tool_name, argument
return None
def has_final_answer(response_text: str) -> str | None:
"""Final Answer가 있는지 확인합니다."""
pattern = r"Final Answer:\s*(.+)"
match = re.search(pattern, response_text, re.DOTALL)
if match:
return match.group(1).strip()
return None4단계: Agent 루프
모든 조각을 합치면 ReAct Agent가 완성됩니다.
from openai import OpenAI
client = OpenAI()
def react_agent(question: str, max_iterations: int = 5) -> str:
"""ReAct 패턴으로 동작하는 Agent입니다."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
for i in range(max_iterations):
# 1. LLM에게 다음 행동을 물어봅니다
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
temperature=0,
)
assistant_msg = response.choices[0].message.content
messages.append({"role": "assistant", "content": assistant_msg})
print(f"\n--- Step {i+1} ---")
print(assistant_msg)
# 2. Final Answer가 있으면 종료합니다
final = has_final_answer(assistant_msg)
if final:
return final
# 3. Action을 파싱하고 도구를 실행합니다
action = parse_action(assistant_msg)
if action:
tool_name, argument = action
if tool_name in TOOLS:
observation = TOOLS[tool_name](argument)
else:
observation = f"Error: Unknown tool '{tool_name}'"
# 4. Observation을 대화에 추가합니다
messages.append({
"role": "user",
"content": f"Observation: {observation}"
})
print(f"Observation: {observation}")
else:
# Action도 Final Answer도 없으면 응답 자체를 반환합니다
return assistant_msg
return "최대 반복 횟수를 초과했습니다."실행해봅시다:
answer = react_agent("서울 날씨가 어때? 그리고 화씨로 변환해줘")
print(f"\n최종 답변: {answer}")--- Step 1 ---
Thought: 서울의 현재 날씨를 검색해야 합니다.
Action: search[서울 날씨]
Observation: 서울 현재 기온 22°C, 맑음, 습도 45%
--- Step 2 ---
Thought: 서울 기온은 22°C입니다. 화씨로 변환하겠습니다. 공식: F = C * 9/5 + 32
Action: calculate[22 * 9/5 + 32]
Observation: 71.6
--- Step 3 ---
Thought: 모든 정보를 얻었습니다.
Final Answer: 서울의 현재 날씨는 맑음이며, 기온은 22°C (71.6°F), 습도는 45%입니다.
최종 답변: 서울의 현재 날씨는 맑음이며, 기온은 22°C (71.6°F), 습도는 45%입니다.40줄 남짓한 react_agent 함수 하나로, LLM이 검색하고 계산하는 Agent가 완성되었습니다.
ReAct의 한계 → Tool Calling으로 진화
위 구현은 잘 동작하지만, 프로덕션에서 사용하기에는 치명적인 약점이 있습니다.
정규표현식 파싱의 취약성
# 이 패턴이 깨지는 경우들:
pattern = r"Action:\s*(\w+)\[(.+?)\]"
# 1. LLM이 형식을 살짝 바꾸면?
"Action: search[ 서울 날씨 ]" # 공백 문제
"action: search[서울 날씨]" # 대소문자
"Action : search[서울 날씨]" # 콜론 뒤 공백
# 2. 인자에 대괄호가 포함되면?
"Action: calculate[len([1,2,3])]" # 중첩 대괄호 → 파싱 실패
# 3. LLM이 형식을 완전히 무시하면?
"서울 날씨를 검색해볼게요. search 서울 날씨" # 패턴 불일치Tool Calling으로 재구현
OpenAI의 Function Calling API와 Pydantic을 사용하면 이 문제를 깔끔하게 해결할 수 있습니다.
from pydantic import BaseModel, Field
class SearchInput(BaseModel):
"""웹 검색 도구의 입력 스키마"""
query: str = Field(description="검색할 질문이나 키워드")
class CalculateInput(BaseModel):
"""계산기 도구의 입력 스키마"""
expression: str = Field(description="계산할 수학 표현식 (예: '2 + 3 * 4')")Pydantic 모델을 OpenAI가 이해하는 JSON Schema로 변환합니다:
def to_openai_tool(model: type[BaseModel], name: str, description: str) -> dict:
"""Pydantic 모델을 OpenAI tool 형식으로 변환합니다."""
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": model.model_json_schema(),
}
}
tools = [
to_openai_tool(SearchInput, "search", "웹에서 정보를 검색합니다"),
to_openai_tool(CalculateInput, "calculate", "수학 표현식을 계산합니다"),
]이제 Agent 루프가 훨씬 깔끔해집니다:
import json
def tool_calling_agent(question: str, max_iterations: int = 5) -> str:
"""Tool Calling 기반 Agent입니다."""
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": question},
]
for i in range(max_iterations):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools, # 도구 목록 전달
tool_choice="auto", # 모델이 자동으로 도구 사용 여부 결정
)
msg = response.choices[0].message
messages.append(msg)
# 도구 호출이 없으면 최종 답변입니다
if not msg.tool_calls:
return msg.content
# 도구 호출 실행
for tool_call in msg.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
# Pydantic으로 인자 검증 후 실행
if name == "search":
validated = SearchInput(**args)
result = search(validated.query)
elif name == "calculate":
validated = CalculateInput(**args)
result = calculate(validated.expression)
else:
result = f"Unknown tool: {name}"
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
return "최대 반복 횟수를 초과했습니다."핵심 차이: 정규표현식 파싱이 사라졌습니다. 모델이 구조화된 JSON으로 도구 호출을 반환하고, Pydantic이 인자를 검증합니다. 프로덕션에서는 이 방식을 사용해야 합니다.
언제 Agent를 써야 하는가?
Agent는 강력하지만 만능이 아닙니다. 불필요한 곳에 Agent를 도입하면 복잡성만 높아집니다.
Agent가 필요한 경우
- 실시간 정보가 필요할 때: 날씨, 주가, 뉴스 등 실시간 데이터 조회
- 다단계 추론이 필요할 때: "A를 조회하고, 그 결과로 B를 계산해서, C와 비교해줘"
- 외부 시스템과 연동할 때: DB 조회, API 호출, 파일 읽기/쓰기
- 사용자 의도가 다양할 때: 하나의 인터페이스로 여러 기능을 제공해야 하는 경우
Agent가 불필요한 경우
- 단순 Q&A: 학습 데이터로 충분히 답변 가능한 질문
- 고정된 파이프라인: 입력 → 처리 → 출력이 항상 동일한 워크플로우
- 지연 시간이 중요할 때: 도구 호출은 추가 latency를 발생시킵니다
- 비용이 민감할 때: 루프를 돌 때마다 API 호출 비용이 발생합니다
질문이 들어왔을 때 의사결정 플로우:
외부 정보가 필요한가? ─── No ──→ 일반 LLM 호출
│
Yes
↓
단일 API 호출로 충분한가? ─── Yes ──→ 단순 함수 호출
│
No
↓
다단계 추론이 필요한가? ─── No ──→ RAG 파이프라인
│
Yes
↓
Agent 도입전체 실습은 Agent Cookbook에서
이 글에서는 핵심 개념과 코드 하이라이트만 다뤘습니다. 전체 실습 노트북에서는 Tavily 검색 연동, 에러 핸들링, 스트리밍 출력 등 실전에서 필요한 내용을 모두 포함하고 있습니다.
다음 편 예고
Part 2에서는 LangGraph와 Reflection 패턴을 다룹니다. 에이전트가 자기 출력을 스스로 검증하고 개선하는 Self-Critique 아키텍처와, 복잡한 태스크를 단계별로 분해하는 Planning Agent를 구현합니다.