MINERVA ReAct 루프 – Reasoning과 Acting의 반복으로 풀어내기

Tool Binding 위에서 LLM이 스스로 도구 시퀀스를 결정하는 흐름

Tool Binding(C11)이 도구를 등록하는 단계라면 ReAct 루프는 LLM이 그 도구를 언제·몇 번·어떤 순서로 호출할지 스스로 결정하는 단계다. Thought → Action → Observation 반복 구조, LangGraph의 create_react_agent 패턴, MINERVA의 멀티홉 질문 시나리오, 종료 조건과 무한 루프 방지, 토큰·비용·지연의 trade-off를 정리한다. 청사진 + 현 코드 분석 혼합.

Agent
저자

Kwangmin Kim

공개

2026년 05월 06일

1 ReAct란

정의: ReAct (Reasoning + Acting)

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 자주 발생하는 오류 패턴

WRONG:

agent = create_react_agent(
    model=llm,
    tools=tools,
    prompt="모든 도구를 활용해 답을 찾아라.",  # 종료 조건 미명시
)

CORRECT:

agent = create_react_agent(
    model=llm,
    tools=tools,
    prompt=(
        "주어진 도구를 단계별로 사용해 답을 찾는다. "
        "충분한 정보가 모이면 즉시 도구 호출을 멈추고 최종 답변만 반환한다. "
        "같은 도구를 같은 인자로 반복 호출하지 않는다."
    ),
)

프롬프트에 “충분하면 멈춰라” 지시가 없으면 LLM이 안전한 쪽으로 도구를 계속 호출해 사이클이 길어진다. 종료 조건을 명시해 평균 사이클 수를 줄인다.

WRONG:

@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가 자연스럽게 우회 경로를 찾는다.

WRONG:

result = agent.invoke({"messages": [...]})  # recursion_limit 미지정

CORRECT:

result = agent.invoke(
    {"messages": [...]},
    config={"recursion_limit": 8},
)

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 관련 주제

선행 지식 (같은 시리즈)

후속 주제 (Phase C-3)

다른 카테고리 연결

Subscribe

Enjoy this blog? Get notified of new posts by email: