MINERVA 멀티스텝 플래닝 – Plan-and-Execute와 자기 수정

사전 계획 → 단계별 실행 → 결과에 따라 재계획

ReAct(C12)가 한 사이클씩 다음 행동을 결정한다면, Plan-and-Execute는 먼저 전체 계획을 세우고 단계별로 실행한 뒤 결과에 따라 재계획한다. Plan 노드·Execute 노드·Re-Plan 분기의 구조, MINERVA 복잡 질문 분해 시나리오, ReAct 대비 비용·예측 가능성 trade-off를 다룬다. 청사진 + 현 코드 분석 혼합.

Agent
저자

Kwangmin Kim

공개

2026년 05월 06일

1 Plan-and-Execute란

정의: Plan-and-Execute

Plan-and-Execute는 LLM이 (1) 전체 작업을 먼저 단계 목록으로 분해한 뒤 (2) 각 단계를 순차 실행하고, 필요하면 (3) 남은 계획을 다시 수정하는 에이전트 패턴이다.

  • 핵심 단위: Plan(계획) + Step(단계) + Re-Plan(수정)
  • 차별점: ReAct가 매 사이클마다 다음 행동을 LLM에 물어본다면, Plan-and-Execute는 처음 한 번에 N개 행동을 미리 결정한다
  • 효과: 사이클 수가 ReAct보다 적고, 단계가 명시적이라 디버깅·관측이 쉽다

LangGraph의 03-Use-Cases/05 Plan-and-Execute가 이 패턴의 reference 구현을 제공한다. C11~C12에서 만든 도구·ReAct 위에 한 단계 더 추상화를 올린다.

2 ReAct vs Plan-and-Execute — 핵심 차이

같은 멀티홉 질문을 두 패턴이 어떻게 다르게 푸는지 비교한다. 질문: “환자 식별 컬럼의 표준 약어와 도메인 그룹은?”

2.1 ReAct 흐름 (C12)

[사이클 1] LLM 추론 → search → 결과 관찰
[사이클 2] LLM 추론 → classify_domain → 결과 관찰
[사이클 3] LLM 추론 → 충분 → 답변

LLM 호출 횟수: 3회
사이클 길이: 동적 (LLM이 결정)

2.2 Plan-and-Execute 흐름 (C13)

[Plan] LLM 호출 1회 → 단계 목록 생성:
   1. "환자 식별" 검색
   2. "환자식별번호" 도메인 분류
   3. 결과 통합 답변
[Execute] 각 단계를 순차 실행 (도구 호출만, LLM 호출 없음)
[Synthesize] LLM 호출 1회 → 최종 답변

LLM 호출 횟수: 2회
사이클 길이: 정적 (계획에 명시)

핵심 차이:

측면 ReAct Plan-and-Execute
LLM 호출 횟수 사이클당 1회 (총 N회) Plan 1회 + Synthesize 1회 (+ Re-Plan N회)
도구 호출 결정 매 사이클마다 LLM에 묻음 한 번에 모든 도구 호출을 결정
토큰 누적 매 사이클 messages 증가 Plan 직후 단계 실행은 도구 호출만
실패 복구 사이클별 재시도 Re-Plan 노드로 명시적
예측 가능성 LLM 결정에 따라 변동 Plan 품질이 좌우
적합한 작업 결과를 봐야 다음 단계 결정 가능한 탐색 사전에 단계가 명확한 작업

3 왜 Plan-and-Execute가 필요한가

ReAct만으로 충분해 보이지만 두 가지 한계가 있다.

1. LLM 호출 비용 폭주

ReAct의 사이클 수 N에 비례해 LLM 호출이 늘어난다. MINERVA에서 4~5단계 작업이 흔하다면 사이클당 추론 토큰이 누적되어 토큰 비용이 빠르게 쌓인다. Plan-and-Execute는 LLM 호출 2회로 압축한다.

2. 사이클 도중 흐름 가시성 저하

ReAct의 messages 시퀀스는 사용자에게 직관적이지 않다. 운영 대시보드에서 “이 요청은 4사이클로 끝났다”는 정보는 알 수 있지만, “어떤 계획으로 진행 중인지”는 LLM의 자연어 thought를 파싱해야 안다. Plan-and-Execute는 명시적인 단계 목록이 State에 들어 있어 진행률 표시·중단 후 재개가 쉽다.

ReAct가 여전히 유리한 경우

Plan-and-Execute는 “단계가 사전에 결정 가능”할 때만 효율적이다. 결과를 보고 나서야 다음 단계를 결정할 수 있는 경우(예: 검색 결과가 나와야 추가 검색어를 알 수 있는 시나리오)는 ReAct가 자연스럽다. 두 패턴은 대체가 아니라 보완이다.

4 State 설계

Plan-and-Execute는 State에 계획 단계 목록실행 결과 누적을 함께 담는다.

from typing import Annotated, TypedDict
from operator import add
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages


class PlanExecuteState(TypedDict):
    # 입력
    input: str

    # Plan 노드가 채움
    plan: list[str]                              # 단계 설명 목록
    past_steps: Annotated[list[tuple[str, str]], add]  # (step, result) 누적

    # Execute 노드가 채움
    current_step_index: int

    # 최종 출력
    response: str

    # 관찰성
    messages: Annotated[list[BaseMessage], add_messages]

설계 결정 두 가지:

  • plan은 덮어쓰기: Re-Plan 시 새 계획으로 교체
  • past_steps는 누적: 이미 실행한 단계의 결과를 보존해 재계획 시 참고

5 그래프 구조

START
  ▼
[plan_node]                ← LLM이 단계 목록 생성
  ▼
[execute_step_node]        ← 다음 단계의 도구 호출 1회
  ▼
[should_continue?]         ← 라우팅 함수
  │
  ├── 단계 남음 → execute_step_node (순환)
  ├── 재계획 필요 → replan_node (LLM 호출, plan 갱신)
  │                        │
  │                        └→ execute_step_node
  │
  └── 모든 단계 완료 → synthesize_node (LLM, 최종 답변)
                                │
                                ▼
                              END

6 구현 — LangGraph

# 주요 패키지 (2026년 기준)
# langchain >= 1.1, langgraph >= 1.0

from langchain_openai import AzureChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langgraph.graph import StateGraph, START, END
from pydantic import BaseModel
from dotenv import load_dotenv

load_dotenv()
llm = AzureChatOpenAI(model="gpt-4.1", temperature=0.0)

6.1 Plan 노드

LLM이 입력 질문을 단계 목록으로 분해한다. 구조화된 출력으로 Pydantic 모델을 받는다.

class Plan(BaseModel):
    steps: list[str]


plan_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "사용자 요청을 풀기 위한 단계 목록을 만든다. "
     "각 단계는 도구 호출 1회로 끝나야 하며, 가능한 한 적은 단계로 분해한다. "
     "이전 단계 결과를 활용하는 단계는 그 의존성을 자연어로 표현한다."),
    ("human", "요청: {input}\n\n사용 가능한 도구:\n{tools_doc}"),
])
planner = plan_prompt | llm.with_structured_output(Plan)


def plan_node(state: PlanExecuteState) -> dict:
    result = planner.invoke({
        "input": state["input"],
        "tools_doc": _format_tools_doc(tools),
    })
    return {"plan": result.steps, "current_step_index": 0}

with_structured_output(Plan)이 LLM 출력을 Pydantic으로 검증한다. 자유 텍스트로 받으면 파싱 실패 위험이 크지만 구조화 출력은 그 위험을 LLM 측에서 흡수한다.

6.2 Execute 노드

다음 단계의 도구를 호출한다. ReAct 사이클의 한 행에 해당하지만, LLM 호출 없이 도구만 호출한다.

def execute_step_node(state: PlanExecuteState) -> dict:
    idx = state["current_step_index"]
    step_desc = state["plan"][idx]

    # 단계 설명에서 어떤 도구를 호출할지 결정 — 작은 LLM이 도구 선택
    tool_call = _resolve_tool_call(step_desc, state.get("past_steps", []))
    result = _invoke_tool(tool_call)

    return {
        "past_steps": [(step_desc, result)],
        "current_step_index": idx + 1,
    }

여기서 _resolve_tool_call은 단계 설명을 도구 호출로 변환한다. 두 가지 구현 선택:

  • Plan 단계에서 도구 시그니처까지 확정step{"tool": "...", "args": {...}} 형태로 이미 결정. Execute는 그대로 invoke.
  • Execute 시점에 작은 LLM이 결정 — Plan은 자연어로만 두고 Execute에서 매번 매핑. 유연하지만 LLM 호출이 늘어남.

MINERVA처럼 도구 수가 적으면(< 10개) 첫 번째 방식이 비용·예측 가능성에서 우월하다.

6.3 Should Continue — 라우팅

def should_continue(state: PlanExecuteState) -> str:
    if state["current_step_index"] >= len(state["plan"]):
        return "synthesize"
    if _last_step_failed(state):
        return "replan"
    return "execute_step"

마지막 단계의 결과가 실패(error 반환 등)하면 재계획으로 우회한다.

6.4 Re-Plan 노드

남은 계획을 LLM이 새로 작성한다. 이미 완료된 단계와 그 결과를 함께 보내 의도된 변경만 반영한다.

replan_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "이전 계획이 실패했다. 이미 완료된 단계와 결과를 참고해 남은 계획을 다시 작성한다. "
     "이미 성공한 단계는 반복하지 않는다."),
    ("human",
     "원 요청: {input}\n"
     "원 계획: {plan}\n"
     "완료된 단계와 결과: {past_steps}\n"
     "마지막 실패 이유: {failure_reason}"),
])
replanner = replan_prompt | llm.with_structured_output(Plan)


def replan_node(state: PlanExecuteState) -> dict:
    result = replanner.invoke({
        "input": state["input"],
        "plan": state["plan"],
        "past_steps": state["past_steps"],
        "failure_reason": _extract_failure(state),
    })
    return {"plan": result.steps, "current_step_index": 0}

current_step_index를 0으로 리셋해 새 계획의 첫 단계부터 실행한다. past_steps는 누적 reducer라 초기화되지 않고 보존된다.

6.5 Synthesize 노드

synthesize_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "이전 단계들의 결과를 사용자에게 자연어 답변으로 통합한다. "
     "도구 호출의 raw 결과를 그대로 노출하지 않고 핵심만 추린다."),
    ("human",
     "원 요청: {input}\n단계와 결과: {past_steps}"),
])


def synthesize_node(state: PlanExecuteState) -> dict:
    answer = (synthesize_prompt | llm).invoke({
        "input": state["input"],
        "past_steps": state["past_steps"],
    })
    return {"response": answer.content}

6.6 그래프 조립

graph = StateGraph(PlanExecuteState)
graph.add_node("plan", plan_node)
graph.add_node("execute_step", execute_step_node)
graph.add_node("replan", replan_node)
graph.add_node("synthesize", synthesize_node)

graph.add_edge(START, "plan")
graph.add_edge("plan", "execute_step")
graph.add_conditional_edges(
    "execute_step",
    should_continue,
    {"execute_step": "execute_step", "replan": "replan", "synthesize": "synthesize"},
)
graph.add_edge("replan", "execute_step")
graph.add_edge("synthesize", END)

app = graph.compile()

7 MINERVA 시나리오

7.1 시나리오 1 — 복잡한 표준화 요청

질문: “이 코드의 모든 컬럼 변수를 표준화하고, 각 컬럼의 도메인 그룹과 인덱스 후보를 알려줘.”

Plan 단계 출력:

[
    "1. 입력 코드에서 컬럼 변수 추출 (parse_columns)",
    "2. 추출된 컬럼들에 대해 batch 표준 약어 검색 (search_standardization_docs)",
    "3. 각 컬럼의 도메인 그룹 분류 (classify_domain_group, batch)",
    "4. 도메인별 인덱스 후보 조회 (lookup_index_candidates)",
    "5. 결과를 표 형태로 통합",
]

5단계 = LLM 호출 2회(Plan + Synthesize) + 도구 호출 4회. ReAct였다면 사이클당 LLM 호출 1회로 5~6회가 필요했다.

7.2 시나리오 2 — 재계획이 필요한 경우

Plan 1: 검색 → 분류 → 통합

[Execute] 1단계 검색 → 결과 부족 (docs=[])
[should_continue] → replan

Re-Plan: 사용자가 약어를 다르게 표현했을 가능성. 약어 사전 우회 단계 추가:
   1. (skip) 이전에 시도한 검색
   2. 약어 사전에서 유사 용어 조회 (lookup_abbreviation)
   3. 약어로 다시 검색
   4. 분류 → 통합

[Execute] 새 계획의 1단계부터 실행

past_steps가 보존되어 재계획 시 “이전 검색 결과 빈”이 컨텍스트로 들어간다. 같은 검색을 반복하지 않는다.

8 ReAct + Plan-and-Execute 혼합 패턴

운영에서는 두 패턴을 혼합한다.

사용자 입력
   ▼
[질문 분류 LLM] ──── 질문 복잡도 평가
   │
   ├── 단순 (1~2 도구) ──→ ReAct 그래프 (C12)
   │
   └── 복잡 (3+ 도구) ──→ Plan-and-Execute 그래프 (C13)

질문 분류는 작은 LLM(또는 휴리스틱) 한 번 호출. 분류 비용은 작지만 잘못된 라우팅의 영향이 크므로 분류 정확도를 monitoring/ab에 추적한다.

9 응용 분야

분야 Plan-and-Execute가 빛나는 시나리오
데이터 표준화 (MINERVA DS) 다중 컬럼 일괄 표준화 + 도메인 분류 + 인덱스 추천
코드 분석 (MINERVA Insilico) AST 파싱 → 의존성 검색 → 문서 검색 → 요약 (단계 사전 결정 가능)
보고서 생성 데이터 수집 → 분석 → 차트 → 요약 (선형 작업)
데이터 마이그레이션 스키마 분석 → 매핑 작성 → 변환 → 검증
사용자 온보딩 자동화 계정 생성 → 권한 부여 → 환경 설정 → 안내 메시지

10 자기 수정 깊이 — 몇 번까지 Re-Plan할 것인가

Re-Plan도 무한 반복할 수 있으므로 상한이 필요하다. Re-Plan 카운터를 State에 둔다.

class PlanExecuteState(TypedDict):
    # ... (기존 필드)
    replan_count: int


def replan_node(state):
    if state.get("replan_count", 0) >= 3:
        return {"response": "복잡도가 너무 높아 자동 처리할 수 없습니다. 질문을 단순화해 주세요."}
    # ... (정상 재계획)
    return {"plan": ..., "current_step_index": 0, "replan_count": state.get("replan_count", 0) + 1}

3회를 넘기면 사용자에게 단순화를 요청한다. MINERVA의 표준화 요청은 보통 1회 Re-Plan이면 충분하다.

11 ReAct vs Plan-and-Execute 적용 결정 트리

사용자 질문
   ▼
[1] 단계가 사전에 결정 가능한가?
   │
   ├── No (탐색·결과 의존) ──→ ReAct (C12)
   │
   └── Yes (선형 작업) ──→
          [2] 단계가 3개 이상?
            │
            ├── No (1~2 단계) ──→ ReAct (단순 시나리오)
            │
            └── Yes ──→
                  [3] 동일 도구 반복?
                    │
                    ├── Yes ──→ Plan-and-Execute (batch)
                    │
                    └── No ──→ Plan-and-Execute (sequential)

질문 분류 LLM의 출력은 이 트리의 답으로 매핑된다. 도구가 적은 MINERVA는 분류 정확도를 ~90%까지 올릴 수 있다.

12 자주 발생하는 오류 패턴

WRONG:

plan = [
    "1. tools 변수 정의",
    "2. llm 인스턴스 생성",
    "3. agent 변수에 create_react_agent 호출 결과 할당",
    "4. result 변수에 invoke 결과 저장",
    # ...
]

CORRECT:

plan = [
    "1. 입력 코드에서 컬럼 변수 추출",
    "2. 표준 약어 batch 검색",
    "3. 도메인 그룹 batch 분류",
    "4. 결과 통합",
]

Plan은 도구 호출 단위여야 한다. 코드 구현 단계까지 분해하면 Execute 단계가 폭증해 Plan-and-Execute의 장점이 사라진다.

WRONG:

def replan_node(state):
    # past_steps를 무시하고 새 계획만 작성
    new_plan = planner.invoke({"input": state["input"]})
    return {"plan": new_plan.steps, "current_step_index": 0}

CORRECT:

def replan_node(state):
    # past_steps를 보내 이미 시도한 것을 회피
    new_plan = replanner.invoke({
        "input": state["input"],
        "past_steps": state["past_steps"],
        "failure_reason": _extract_failure(state),
    })
    return {"plan": new_plan.steps, "current_step_index": 0}

Re-Plan에 past_steps를 보내지 않으면 LLM이 이미 실패한 단계를 다시 포함시킬 가능성이 크다. 같은 실패를 반복하지 않도록 컨텍스트를 채운다.

WRONG:

graph.add_edge("replan", "execute_step")  # Re-Plan 카운터 없음

CORRECT:

def replan_node(state):
    if state.get("replan_count", 0) >= 3:
        return {"response": "단순화 요청 메시지"}
    # ...

graph.add_conditional_edges(
    "replan",
    lambda s: "synthesize" if s.get("response") else "execute_step",
    {"synthesize": "synthesize", "execute_step": "execute_step"},
)

Re-Plan 상한을 두지 않으면 무한 루프가 발생할 수 있다. 카운터로 격리하고 상한 도달 시 우아하게 종료.

13 정리

항목 핵심
Plan 단계 LLM이 도구 호출 단위 단계 목록을 한 번에 생성
Execute 단계 도구 호출만 (LLM 미호출), past_steps에 누적
Re-Plan 실패 시 past_steps 컨텍스트로 남은 계획 재작성
종료 조건 모든 단계 완료 또는 Re-Plan 상한 도달
ReAct 대비 LLM 호출 횟수 ↓, 토큰 ↓, 예측 가능성 ↑ — 단 사전 분해 가능한 작업에 한정
혼합 운영 질문 분류 → 단순=ReAct / 복잡=Plan-and-Execute
MINERVA 적용 DS 다중 컬럼 일괄 표준화, Insilico 코드 분석 (선형 작업)

Plan-and-Execute는 ReAct의 자유에 구조를 더한다. 단계가 사전에 명확한 작업에서는 LLM 호출과 토큰을 크게 줄이지만, 결과를 봐야 다음을 알 수 있는 탐색 작업에는 부적합하다. 두 패턴은 대체가 아니라 보완이며, 운영에서는 질문 분류 라우터로 갈라 쓴다. 다음 편 에이전트 위임은 한 그래프 안의 단계가 아니라 여러 에이전트 간의 협업으로 한 단계 더 추상화를 올린다.

14 관련 주제

선행 지식 (같은 시리즈)

후속 주제 (Phase C-3)

다른 카테고리 연결

Subscribe

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