1 ReAct란
ReAct는 LLM이 추론(Thought), 행동(Action), 관찰(Observation) 세 단계를 반복하면서 답에 도달하는 에이전트 패턴이다 (Yao et al., 2022).
- 핵심 단위: 한 사이클(Thought → Action → Observation)
- 종료 조건: LLM이 도구 호출 없이 최종 답변만 반환할 때
- 차별점: 단일 LLM 호출은 한 번만 추론, ReAct는 도구 호출 결과를 다시 컨텍스트에 넣어 추론을 이어간다
LangGraph의 create_react_agent는 이 패턴을 위한 prebuilt 그래프를 제공한다. C11에서 등록한 @tool들이 그대로 ReAct 루프 안에서 동작한다.
2 ReAct 한 사이클의 흐름
┌─────────────────────────────────────────────────────┐
│ 사용자 질문 │
└──────────┬──────────────────────────────────────────┘
▼
┌──────────────────────┐
│ LLM 추론 (Thought) │ "이 질문은 표준 약어를 두 단계로 찾아야 한다"
└──────────┬───────────┘
▼
┌──────────────────────┐
│ 도구 호출 (Action) │ search_standardization_docs(query="환자 식별")
└──────────┬───────────┘
▼
┌──────────────────────┐
│ 결과 관찰 │ docs=[3 results]
│ (Observation) │
└──────────┬───────────┘
▼
다시 LLM 추론 ──▶ 결과 충분? ──Yes──▶ 최종 답변 → END
│
└──No──▶ 다른 도구 호출 (다음 사이클)
이 사이클이 종료 조건을 만날 때까지 반복된다. 종료 조건은 LLM이 결정한다 — 도구 호출 없이 답변만 반환하면 그래프가 자동으로 END로 간다.
3 왜 ReAct가 필요한가
C11에서 Tool Binding을 도입하면 LLM이 도구 1개를 호출할 수 있다. 그러나 실제 사용자 질문은 종종 여러 도구를 순차로 호출해야 답할 수 있다.
| 시나리오 | 단일 호출의 한계 | ReAct가 푸는 방법 |
|---|---|---|
| “환자 식별 컬럼명이 뭐고 도메인은?” | 한 번 검색 → 답변. 도메인은 모름 | 검색 → 결과 보고 → 도메인 분류 도구 호출 → 통합 답변 |
| “이 코드의 변수를 표준화해줘” | 코드 표준화 도구 호출만 | 표준화 → 결과 보고 → 미흡하면 약어 사전 검색 → 재표준화 |
| 검색 결과가 부족할 때 | 빈 답변 또는 hallucination | LLM이 결과 보고 추가 키워드로 재검색 |
| 사용자가 후속 질문 | history 누적만 | LLM이 이전 도구 결과를 컨텍스트로 활용해 새 도구 호출 |
핵심은 “도구 호출 결과가 다시 LLM의 컨텍스트로 들어간다”는 것이다. 단일 LLM 호출에서는 한 번 입력에 한 번 출력이지만, ReAct는 도구 결과가 추가 입력이 되어 추론이 누적된다.
4 MINERVA 현재 상태 — ReAct 미적용
DataStandardizerAgent는 sub_agent를 정적 순서로 호출하므로 ReAct 루프가 아니다.
# 현 코드 — 정적 시퀀스
def run(self, query: Query) -> Response:
raw_text, docs = recommender.run(query.text) # 항상 1회
processed = _apply_post_processing(raw_text) # 항상 1회
if _is_full_format(processed):
processed = self._auditor.audit_and_fix(processed) # 조건부, 1회만
return Response(text=processed, ...)이 구조는 같은 도구를 두 번 호출할 수 없고, 첫 번째 결과를 보고 다음 도구를 선택할 수도 없다. “결과 부족 → 재시도” 같은 동적 행동이 불가능하다.
QnA Chatbot은 더 단순한 LCEL 체인이라 도구 호출 자체가 없다 — 단일 RAG → LLM 호출이 끝이다.
5 LangGraph create_react_agent로 가는 첫 걸음
LangGraph는 ReAct 패턴을 prebuilt 함수로 제공한다.
# 주요 패키지 (2026년 기준)
# langchain >= 1.1, langchain-openai >= 1.1, langgraph >= 1.0
from langchain_openai import AzureChatOpenAI
from langgraph.prebuilt import create_react_agent
from dotenv import load_dotenv
load_dotenv()
llm = AzureChatOpenAI(model="gpt-4.1", temperature=0.0)
# C11에서 정의한 도구들 그대로 사용
tools = [
search_standardization_docs,
classify_domain_group,
generate_physical_name,
audit_domain_priority,
]
agent = create_react_agent(
model=llm,
tools=tools,
prompt=(
"당신은 데이터 표준화 도우미다. "
"주어진 도구를 사용하여 단계별로 답을 찾는다. "
"충분한 정보가 모이면 도구 호출을 멈추고 최종 답변을 반환한다."
),
)
result = agent.invoke({
"messages": [{"role": "user", "content": "환자 식별 컬럼의 표준 약어와 도메인 그룹은?"}]
})
print(result["messages"][-1].content)내부 동작은 다음 그래프와 동치다.
START → [agent_node] ──┬── tool_calls=[] ──→ END
│
└── tool_calls=[...] ──→ [tool_node] ──→ [agent_node]
▲ │
└───────────────────┘
agent_node는 LLM 호출, tool_node는 ToolNode다. 두 노드 사이에 conditional edge가 들어가 LLM 출력에 도구 호출이 있으면 ToolNode로, 없으면 END로 간다.
6 State 흐름 — MessagesState
ReAct는 15편 — State 설계에서 본 MessagesState 패턴이 자연스럽다. 매 사이클의 모든 메시지(HumanMessage/AIMessage/ToolMessage)가 누적되어 LLM의 다음 추론 입력이 된다.
from langgraph.graph import MessagesState
class StandardizerState(MessagesState):
# MessagesState는 messages: Annotated[list[BaseMessage], add_messages] 보유
pass
# 한 사이클 후 messages 예시
messages = [
HumanMessage(content="환자 식별 컬럼의 표준 약어와 도메인은?"),
AIMessage(content="", tool_calls=[
{"name": "search_standardization_docs", "args": {"query": "환자 식별"}, "id": "1"}
]),
ToolMessage(content="docs=[...]", tool_call_id="1"),
AIMessage(content="", tool_calls=[
{"name": "classify_domain_group", "args": {"term_korean": "환자식별번호"}, "id": "2"}
]),
ToolMessage(content="{'group': '환자정보', 'confidence': 0.87}", tool_call_id="2"),
AIMessage(content="환자 식별 컬럼은 PT_ID로 표준화되며 도메인은 환자정보입니다."),
]add_messages reducer가 각 사이클의 메시지를 자동으로 누적한다. tool_call_id로 도구 호출과 결과가 짝지어진다.
7 MINERVA 시나리오 — 멀티홉 질문 풀이
ReAct가 가장 빛나는 사례는 하나의 질문이 여러 도구 호출을 요구할 때다.
7.1 시나리오 1: “환자 식별 컬럼의 표준 약어와 도메인은?”
[사이클 1]
Thought: "표준 약어부터 검색"
Action: search_standardization_docs(query="환자 식별")
Observation: docs with "PT_ID" found
[사이클 2]
Thought: "이제 도메인 그룹을 분류"
Action: classify_domain_group(term_korean="환자식별번호")
Observation: {"group": "환자정보", "confidence": 0.87}
[종료]
Final answer: "환자 식별 컬럼은 PT_ID로 표준화되며 도메인은 환자정보입니다."
정적 파이프라인이라면 두 도구를 항상 둘 다 호출해야 했지만, ReAct는 LLM이 첫 결과를 보고 두 번째 도구가 필요한지 결정한다.
7.2 시나리오 2: 검색 결과가 부족한 경우
[사이클 1]
Thought: "사용자가 '환자검사일'에 대해 묻는다. 검색해보자"
Action: search_standardization_docs(query="환자검사일")
Observation: docs=[] (빈 결과)
[사이클 2]
Thought: "결과가 없다. 약어 사전을 찾아보자"
Action: lookup_abbreviation(term="검사")
Observation: {"abbr": "TEST", "domain": "이력"}
[사이클 3]
Thought: "약어를 얻었으니 다시 표준 검색"
Action: search_standardization_docs(query="TEST 환자")
Observation: docs with "PT_TEST_DT" found
[종료]
Final answer: "환자검사일은 PT_TEST_DT로 표준화됩니다 (이력 도메인)."
이 동적 우회는 코드 분기로 표현하면 매번 추가해야 하는 복잡도다. ReAct는 LLM의 추론 자체가 분기를 결정하므로 코드는 도구만 등록하면 된다.
8 종료 조건 — 무한 루프 방지
ReAct의 위험은 LLM이 도구를 무한히 호출할 가능성이다. 다음 세 가지 안전장치가 필요하다.
8.1 recursion_limit
LangGraph는 그래프 컴파일 시점에 최대 사이클 수를 지정한다.
agent = create_react_agent(model=llm, tools=tools)
# 호출 시 recursion_limit 지정
result = agent.invoke(
{"messages": [{"role": "user", "content": "..."}]},
config={"recursion_limit": 10}, # 최대 10 사이클
)10번을 넘기면 GraphRecursionError가 발생한다. MINERVA의 표준화 질문은 보통 2~3 사이클로 끝나므로 10이 충분한 여유다.
8.2 도구별 호출 횟수 제한
같은 도구가 같은 인자로 반복 호출되는 패턴을 차단한다.
def make_search_tool_with_dedup():
seen_queries = set()
@tool
def search_standardization_docs(query: str) -> dict:
"""..."""
if query in seen_queries:
return {"error": "duplicate_query", "message": "같은 검색을 반복하지 마라"}
seen_queries.add(query)
return _real_search(query)
return search_standardization_docs이 dedup은 LLM이 같은 결과 부족을 같은 검색으로 우회하려는 안티패턴을 차단한다. 다만 thread_id마다 dedup 상태를 분리해야 thread 간 간섭을 막을 수 있다.
8.3 토큰·비용 가드
매 사이클마다 messages가 누적되므로 토큰이 빠르게 늘어난다. 운영에서는 사이클당 누적 토큰 임계값을 둔다.
def token_guard_node(state: MessagesState) -> dict:
total_tokens = sum(estimate_tokens(m) for m in state["messages"])
if total_tokens > 30_000:
# 강제 종료
return {"messages": [AIMessage(content="요청이 너무 복잡합니다. 질문을 나누어 주세요.")]}
return {}이 가드를 ReAct 루프 안에 추가 노드로 끼우면 비용 폭주를 막을 수 있다.
9 응용 분야
| 분야 | 활용 시나리오 |
|---|---|
| 데이터 표준화 (MINERVA DS) | 검색 → 분류 → 약어 사전 → 재검색 멀티홉 |
| 코드 분석 (MINERVA Insilico, 미작성) | AST 파싱 → 의존성 검색 → 문서 검색 → 요약 |
| QnA + 멀티 문서 | 1차 검색 결과 부족 → 다른 인덱스 검색 → 통합 |
| 사용자 의도 모호 | LLM이 의도 분류 도구 → 적절한 응답 도구 선택 |
| 외부 시스템 통합 | 사내 RAG → 외부 API → 결과 비교 |
10 ReAct vs 정적 파이프라인 trade-off
| 측면 | 정적 파이프라인 | ReAct |
|---|---|---|
| 결정성 | 입력 같으면 항상 같은 출력 | LLM 판단에 따라 다른 도구 시퀀스 |
| 응답 시간 | 빠름 (도구 호출 횟수 고정) | 느림 (사이클당 LLM 호출 추가) |
| 토큰 비용 | 낮음 | 높음 (messages 누적) |
| 새 시나리오 추가 | 코드 분기 추가 | 도구 등록만 |
| 디버깅 | 함수 추적 | messages 시퀀스 분석 |
| 운영 가시성 | 정해진 단계 | 사이클별 통계 필요 |
| 실패 처리 | try/except | LLM이 에러 보고 우회 |
운영에서 두 패턴을 혼합한다. 핵심 경로(반드시 거쳐야 하는 단계)는 정적, 선택적 단계(필요할 때만)는 ReAct로 노출한다 — C11의 “노드 분리 + ToolNode” 하이브리드 구조 그대로다.
11 마이그레이션 — Phase B의 DS 에이전트를 ReAct로
C11의 4단계 마이그레이션 위에 ReAct 적용은 다음과 같이 끼운다.
| 단계 | 작업 | 영향 |
|---|---|---|
| 1 (C11 도구 추출) | sub_agent 함수를 @tool로 래핑 |
동작 변화 없음 |
| 2 (C11 shadow) | 정적 vs ToolNode 비교 로깅 | 일부 트래픽 |
| 3 (C12 ReAct 적용) | create_react_agent로 묶기 |
A/B arm으로 격리 |
| 4 | recursion_limit + dedup + token guard 추가 | 안전성 보강 |
| 5 | 사이클 수·도구 호출 분포·토큰 메트릭을 monitoring/ab에 노출 | 관측성 |
| 6 | 정적 분기 제거, ReAct 단일 경로로 통합 | 점진적 전환 완료 |
각 단계가 06편 A/B 실험 프레임워크의 dotted-key override 위에서 안전하게 분리된다.
12 자주 발생하는 오류 패턴
CORRECT:
agent = create_react_agent(
model=llm,
tools=tools,
prompt=(
"주어진 도구를 단계별로 사용해 답을 찾는다. "
"충분한 정보가 모이면 즉시 도구 호출을 멈추고 최종 답변만 반환한다. "
"같은 도구를 같은 인자로 반복 호출하지 않는다."
),
)프롬프트에 “충분하면 멈춰라” 지시가 없으면 LLM이 안전한 쪽으로 도구를 계속 호출해 사이클이 길어진다. 종료 조건을 명시해 평균 사이클 수를 줄인다.
@tool
def classify_domain_group(term_korean: str) -> dict:
try:
return _classify(term_korean)
except Exception:
return {"error": "failed"} # LLM이 다음에 뭘 해야 할지 모름CORRECT:
@tool
def classify_domain_group(term_korean: str) -> dict:
try:
return _classify(term_korean)
except FileNotFoundError:
return {"error": "model_unavailable",
"suggestion": "domain 분류기가 비활성화 상태이므로 답변에 도메인을 생략하라"}
except Exception as e:
return {"error": str(e), "suggestion": "다른 도구로 우회 시도"}도구 에러 메시지에 LLM이 다음 사이클에서 활용할 suggestion을 포함하면 ReAct가 자연스럽게 우회 경로를 찾는다.
CORRECT:
recursion_limit 미지정 시 기본값(25)이 적용된다. MINERVA의 단순 표준화 질문에 25 사이클은 비용 낭비다. 도메인별로 적절한 상한을 둔다 (단순 QnA: 5, 표준화: 8, 코드 분석: 12).
13 정리
| 항목 | 핵심 |
|---|---|
| ReAct 한 사이클 | Thought → Action → Observation |
| 종료 조건 | LLM이 도구 호출 없이 답변만 반환 |
| State | MessagesState + add_messages reducer로 사이클 자동 누적 |
| 안전장치 | recursion_limit, 도구 dedup, 토큰 guard 3종 세트 |
| 정적 vs ReAct | 결정성·비용은 정적이 유리, 유연성·자율성은 ReAct가 유리 — 핵심 경로 정적 + 선택 경로 ReAct 혼합 |
| MINERVA 적용 | DS 에이전트를 A/B arm으로 격리 도입, monitoring/ab에 사이클 메트릭 노출 |
ReAct는 Tool Binding 위에서 LLM의 추론을 일급으로 격상시킨다. 그러나 자유에는 비용이 따른다 — recursion_limit, dedup, token guard 없이 운영하면 비용·지연이 폭주한다. 다음 편 멀티스텝 플래닝은 ReAct의 “한 단계씩 결정”을 “전체 계획을 먼저 세우고 실행”으로 대체해 사이클 수를 더 줄이는 패턴을 다룬다.
14 관련 주제
선행 지식 (같은 시리즈)
- Tool Binding – 정적 파이프라인에서 동적 도구 선택으로 – 도구 등록과 ToolNode
- BaseAgent 계약 v2 – LangGraph 호환 인터페이스
- State 설계 – TypedDict와 reducer – MessagesState 패턴
후속 주제 (Phase C-3)
- 멀티스텝 플래닝 – Plan-and-Execute와 자기 수정 – 사전 계획 후 실행, ReAct 사이클 수 절감
- 에이전트 위임 – Supervisor가 하위 에이전트를 호출하는 패턴 – ReAct 위에서 멀티 에이전트로
다른 카테고리 연결
- LangGraph Agentic RAG – ReAct를 RAG에 적용한 첫 응용
- LangGraph Adaptive RAG – 검색 결과 평가 후 분기
- LangGraph Self-RAG – 답변 자기 평가 루프
- LangGraph CRAG – Corrective RAG, 검색 품질 개선 루프
- ReAct Agent (LangChain v1) – create_react_agent 사용법 입문