콘텐츠로 이동

ReAct 패턴

ReAct는 Reasoning + Acting의 합성어입니다. 2022년 Yao et al.이 발표한 논문에서 제안된 이 패턴은, LLM이 단순히 답을 생성하는 것이 아니라 생각하고 → 행동하고 → 관찰하는 사이클을 반복하도록 구조화합니다.

ReAct 이전의 접근법은 두 가지였습니다:

  • 추론만 하는 방식: Chain-of-Thought처럼 생각은 하지만 실제 도구를 사용하지 못함
  • 행동만 하는 방식: 도구를 사용하지만 왜 그 도구를 쓰는지 설명이 없음

ReAct는 이 두 가지를 결합했습니다.

┌─────────────────────────────────────┐
│ Observation (환경 상태 관찰) │
│ "파일 목록: [main.py, utils.py ...]"│
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Thought (추론) │
│ "main.py를 먼저 읽어서 구조를 파악 │
│ 해야 할 것 같다" │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Action (도구 실행) │
│ read_file("main.py") │
└──────────────┬──────────────────────┘
↓
(다음 Observation)

이 사이클은 목표가 달성되거나 최대 스텝 수에 도달할 때까지 반복됩니다.

def react_agent(goal: str, tools: dict, max_steps: int = 20) -> str:
observation = get_initial_observation(goal)
for step in range(max_steps):
# Think: 현재 관찰을 바탕으로 추론
thought = llm.think(
goal=goal,
observation=observation,
history=history,
)
# 목표 달성 여부 확인
if thought.is_done:
return thought.final_answer
# Act: 도구 선택 및 실행
action = thought.chosen_action
tool_fn = tools[action.tool_name]
try:
result = tool_fn(**action.parameters)
observation = f"도구 결과: {result}"
except Exception as e:
observation = f"오류 발생: {e} — 다른 접근을 시도하세요"
return "최대 스텝 초과: 목표 미달성"

ReAct 에이전트가 실제로 어떻게 동작하는지 “src/auth 디렉토리의 버그를 찾아 수정하라”는 목표로 전체 사이클을 추적해 봅니다.

스텝 1: 초기 탐색

Observation: 작업 디렉토리 /workspace/src/auth
Thought: auth 디렉토리의 파일 목록을 먼저 확인해야 한다.
어떤 파일이 존재하는지 알아야 다음 행동을 결정할 수 있다.
Action: list_directory("src/auth")
Result: [login.py, token.py, middleware.py, __init__.py]

스텝 2: 변경 이력 확인

Observation: 4개 파일 확인됨.
Thought: git log로 최근 변경 사항을 확인하면 버그가 도입된
커밋을 찾을 수 있다. 이력 없이 모든 파일을 읽는 것은 비효율적이다.
Action: run_command("git log --oneline -5 src/auth/")
Result:
a3f2c1 fix: token expiry check
b8e901 feat: add refresh token
c2d445 refactor: extract validate_token
...

스텝 3: 의심 파일 집중 분석

Observation: 최근 커밋이 token.py에 집중되어 있다.
Thought: "fix: token expiry check" 커밋이 버그를 도입했을 가능성이 높다.
해당 커밋의 diff를 확인하겠다.
Action: run_command("git diff c2d445 a3f2c1 -- src/auth/token.py")
Result:
- return expires_at > datetime.now()
+ return expires_at > datetime.utcnow()

스텝 4: 버그 원인 파악 및 수정

Observation: timezone-naive datetime과 utcnow()를 혼용하는 버그 발견.
Thought: expires_at이 로컬 타임존 기준이라면 utcnow()와 비교 시
시간 오차가 발생한다. timezone-aware 비교로 수정해야 한다.
Action: write_file("src/auth/token.py", fixed_content)
Result: 파일 수정 완료
Thought: 수정이 올바른지 테스트를 실행해 검증한다.
Action: run_command("pytest src/auth/test_token.py -v")
Result: 5 passed, 0 failed
Thought: 테스트 통과. 목표 달성.
Final Answer: token.py의 timezone 혼용 버그를 수정하고 테스트로 검증 완료.

이 트레이스에서 핵심은 각 Observation이 다음 Thought를 형성하고, Thought가 Action을 결정한다는 점입니다. 에이전트는 사전 계획 없이도 중간 결과에 따라 전략을 조정했습니다.

ReAct는 여러 추론 패턴 중 하나입니다. 각 패턴이 어떤 문제에 적합한지 이해하면 올바른 패턴을 선택할 수 있습니다.

질문: 버그가 있는 함수의 복잡도를 계산하라.
Thought 1: 함수의 분기 수를 세어야 한다.
Thought 2: if문 3개, for문 1개 = 분기 4개.
Thought 3: 시작 경로 1 + 분기 4 = 복잡도 5.
Answer: Cyclomatic Complexity = 5

CoT는 도구 없이 순수 텍스트 추론만으로 답을 도출합니다. 수학 문제, 논리 추론처럼 외부 데이터가 필요 없을 때 적합합니다. 그러나 실제 파일을 읽거나 테스트를 실행해야 하는 태스크에는 사용할 수 없습니다.

목표: 최적의 리팩토링 전략 선택
Branch A: 인터페이스 추출 접근
├─ 장점: 테스트 용이성 향상
└─ 단점: 초기 구현 복잡도 증가
Branch B: 직접 수정 접근
├─ 장점: 변경 범위 최소화
└─ 단점: 미래 확장 어려움
평가: Branch A가 장기적으로 유리 → 선택

ToT는 여러 경로를 탐색하고 평가하여 최선을 선택합니다. 전략적 의사결정이 필요한 경우에 강하지만, 분기 탐색 비용이 높아 실시간 실행에는 부적합합니다.

| 패턴 | 도구 사용 | 경로 탐색 | 적합한 태스크 | |------|---------|---------|-------------| | CoT | 불가 | 단일 경로 | 수학, 논리 추론 | | ToT | 불가 | 다중 경로 | 전략 선택, 계획 수립 | | ReAct | 가능 | 단일 경로 (적응적) | 탐색, 코드 작업, 정보 수집 |

| 상황 | 적합 여부 | 이유 | |---|---|---| | 탐색적 태스크 (버그 찾기, 코드 이해) | 적합 | 중간 결과에 따라 방향 전환 가능 | | 단계가 사전에 명확한 태스크 | 비적합 | Plan-and-Execute가 더 효율적 | | 외부 도구 결과가 다음 결정에 영향 | 적합 | 관찰 기반 적응 가능 | | 병렬 실행이 필요한 태스크 | 비적합 | ReAct는 본질적으로 순차적 | | 짧은 응답 시간이 중요한 경우 | 주의 필요 | 여러 번의 LLM 호출 발생 |

ReAct는 강력하지만 몇 가지 잘 알려진 실패 패턴이 있습니다. 이를 미리 이해하고 설계에 반영하는 것이 중요합니다.

에이전트가 동일한 관찰에서 동일한 행동을 반복하는 경우입니다.

스텝 12: Thought: 파일이 존재하는지 확인해야 한다.
Action: check_file("config.json")
Result: 파일 없음
스텝 13: Thought: 파일이 존재하는지 확인해야 한다.
Action: check_file("config.json")
Result: 파일 없음
스텝 14: ... (반복)

완화 전략: max_steps 제한과 함께, 최근 N개 액션의 중복 여부를 감지하는 루프 탐지기를 추가합니다.

def detect_loop(history: list, window: int = 3) -> bool:
if len(history) < window * 2:
return False
recent = history[-window:]
prior = history[-window * 2:-window]
return recent == prior

LLM이 존재하지 않는 도구를 호출하거나, 올바르지 않은 파라미터를 전달하는 경우입니다.

Action: read_database_table("users", filter="active=true")
Error: 도구 'read_database_table'이 존재하지 않음

완화 전략: 도구 호출 전 스키마 검증을 수행하고, 오류 메시지를 Observation으로 에이전트에게 피드백합니다. 에이전트는 오류를 Observation으로 받아 수정된 행동을 취할 수 있습니다.

긴 ReAct 체인에서 초기 목표가 희석되는 경우입니다. 에이전트가 목표를 잊고 관련 없는 방향으로 탐색을 이어가기 시작합니다.

초기 목표: "로그인 버그 수정"
스텝 18: Thought: 데이터베이스 스키마를 최적화하면 성능이 좋아질 것 같다.
Action: alter_table("users", ...) ← 목표와 무관한 행동

완화 전략: 시스템 프롬프트에 목표를 반복 삽입하거나, 각 스텝에서 현재 목표와의 관련성을 self-check하는 단계를 추가합니다.

def goal_relevance_check(thought: str, goal: str, llm) -> bool:
prompt = f"목표: {goal}\n현재 추론: {thought}\n이 추론이 목표와 관련 있는가? (yes/no)"
return llm.complete(prompt).strip().lower() == "yes"

각 스텝마다 LLM을 호출하므로, 복잡한 태스크에서 비용이 예측하기 어렵게 증가합니다.

완화 전략: 단순 조회 액션(파일 목록, 검색)은 캐싱을 적용하고, 중간 결과를 메모리에 저장해 반복 호출을 줄입니다. 비용 임계치에 도달하면 에이전트를 중단하고 현재까지의 진행 상황을 사용자에게 보고합니다.

ReAct는 “생각하고 행동하고 관찰하는” 사이클로 에이전트가 복잡한 문제를 단계적으로 해결하게 합니다. 탐색적이고 중간 결과에 따라 방향을 바꿔야 하는 태스크에 강점이 있습니다. CoT는 도구 없는 추론, ToT는 전략 선택에 적합하며, ReAct는 실제 환경과 상호작용이 필요한 태스크에 맞습니다. 무한 루프, 환각 호출, 컨텍스트 손실, 비용 폭발이라는 네 가지 실패 모드를 인식하고 각각에 대한 완화 전략을 구현해야 합니다. 다음 챕터에서는 사전 계획이 더 적합한 Plan-and-Execute 패턴을 다룹니다.