1 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에 들어 있어 진행률 표시·중단 후 재개가 쉽다.
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 자주 발생하는 오류 패턴
plan = [
"1. tools 변수 정의",
"2. llm 인스턴스 생성",
"3. agent 변수에 create_react_agent 호출 결과 할당",
"4. result 변수에 invoke 결과 저장",
# ...
]CORRECT:
Plan은 도구 호출 단위여야 한다. 코드 구현 단계까지 분해하면 Execute 단계가 폭증해 Plan-and-Execute의 장점이 사라진다.
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이 이미 실패한 단계를 다시 포함시킬 가능성이 크다. 같은 실패를 반복하지 않도록 컨텍스트를 채운다.
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 관련 주제
선행 지식 (같은 시리즈)
- Tool Binding – 도구 등록과 ToolNode
- ReAct 루프 – 사이클 단위 도구 호출
- State 설계 – TypedDict와 reducer (past_steps 누적)
- BaseAgent 계약 v2 – LangGraph 호환 인터페이스
후속 주제 (Phase C-3)
- 에이전트 위임 – Supervisor가 하위 에이전트를 호출하는 패턴 – 그래프 안 단계가 아닌 에이전트 간 협업
다른 카테고리 연결
- LangGraph Plan-and-Execute – 본 패턴의 reference 구현
- LangGraph Self-RAG – Re-Plan과 유사한 자기 수정 루프
- LangGraph Adaptive RAG – 검색 결과 평가 후 분기
- LangGraph Branching – conditional edge 심화