에이전트 시리즈 (3): Plan-and-Solve — 먼저 생각하고, 그 다음에 행동하라
요약
ReAct 방식의 탐욕적 전략이 가진 한계를 극복하기 위한 Plan-and-Solve 프롬프팅 기법을 소개합니다. LLM이 실행 전 전체 계획을 먼저 수립하고 단계별로 해결하는 2단계 아키텍처와 LangGraph를 활용한 구현 방안을 다룹니다.
핵심 포인트
- ReAct의 국소적 최적화 문제를 전역적 계획 수립으로 해결
- Plan-and-Solve의 2단계(Plan-Solve) 아키텍처 구조
- 실패 시 재계획(Replan)을 포함한 결함 허용 메커니즘
- LangGraph를 이용한 상태 머신 기반 에이전트 모델링
ReAct는 어디에서 한계에 부딪히는가? 이전 글에서는 ReAct의 탐욕적 전략(greedy strategy) — 즉, 각 단계가 오직 현재 상태만을 바라보고 다음 행동을 결정하는 방식 — 에 대해 다루었습니다. 이는 대부분의 경우 잘 작동하지만, 한 가지 유형의 작업에서는 비틀거립니다. 에이전트(Agent)에게 다음과 같은 작업을 요청한다고 상상해 보세요: Python, Java, Go의 출시 연도를 검색하라. 이를 연도순으로 정렬하라. Python과 Go의 출시 연도 차이가 몇 년인지 계산하라. 일반적인 ReAct 실행은 다음과 같을 수 있습니다:
Action: web_search("Python release year")
Action: web_search("Java release year")
Action: web_search("Go release year")
Action: calculator("...")
(때때로 검색을 반복하거나 불필요한 단계를 거칩니다)
이것이 아주 끔찍한 것은 아니지만, 잠재적인 문제가 있습니다. ReAct는 행동하기 전에 전역적인 계획(global plan)을 세우지 않는다는 점입니다. 작업에 몇 단계가 필요한지, 어떤 단계가 어떤 단계에 의존하는지, 그리고 전체 작업 중 현재 어디에 와 있는지 알지 못합니다. 모든 단계가 국소적으로 최적화(locally optimal)되어 있을 뿐, 전역적으로 최적화(globally optimal)되어 있지 않습니다. 명확한 의존성이 있는 다단계 작업의 경우, 이는 지도 없이 길을 찾는 것과 같습니다. 결국 도착은 하겠지만, 우회로를 거치게 될 것입니다.
Plan-and-Solve의 해답: LLM(Large Language Model)을 사용하여 먼저 완전한 행동 계획을 생성한 다음, 단계별로 실행하는 것입니다.
2단계 아키텍처 (The Two-Phase Architecture)
이 패러다임은 2023년 논문인 Plan-and-Solve Prompting에서 유래되었습니다. 핵심 아이디어는 두 단계로 나뉩니다:
1단계 — 계획 (Plan): LLM에게 전체 작업을 조감도(bird's-eye view) 관점에서 분석하고 순서가 지정된 단계 목록을 출력하도록 요청합니다. 이 단계에서는 도구(tools)를 호출하지 않으며, 순수한 사고 과정입니다.
2단계 — 해결 (Solve): 계획된 각 단계를 하나씩 실행합니다. 각 단계에서 도구를 호출할 수 있습니다. 이전 단계의 결과는 다음 단계의 컨텍스트(context)에 주입됩니다.
실제 운영에 필수적인 결함 허용(fault-tolerance) 메커니즘이 추가된 전체 아키텍처는 다음과 같습니다:
Task │
▼
[Plan Node] ← LLM이 3~7단계의 계획을 생성 (실행 없이 계획만 수립)
│
▼
[Execute Node] ← 현재 단계 실행 (내장된 ReAct, 도구 호출 가능)
│
├─ 단계 실패 시?
─→ [Replan Node] ← 지금까지의 진행 상황을 바탕으로 남은 단계 재계획 (Re-plan) │ │ │ └──────────────┐ │ ▼ ├─ 단계가 더 남았나요? ─→ [Execute Node]로 돌아가 실행 계속 │ └─ 모두 완료되었나요? ─→ [Finalize Node] ← 최종 답변 출력 │ ▼ END
ReAct와의 핵심 차이점: ReAct는 개방형 루프(open-ended loop)인 반면, Plan-and-Solve는 정의된 종료 지점이 있는 시퀀스(sequence)입니다.
LangGraph 구현: State + Graph
LangGraph는 이러한 아키텍처를 위한 이상적인 도구입니다. LangGraph는 에이전트(Agent)를 상태 머신(State Machine, StateGraph)으로 모델링하며, 상태(State)가 노드 사이를 흐르도록 합니다.
상태 설계 (State Design)
from typing import TypedDict
class PlanSolveState(TypedDict):
task: str # 원래 사용자 작업
plan: list[str] # 현재 계획 (단계 리스트)
completed_steps: list[str] # 결과 요약이 포함된 완료된 단계들
current_step_index: int # 현재 진행 중인 단계 인덱스 (0부터 시작)
step_result: str # 현재 단계의 결과
replan_count: int # 재계획(Re-plan)을 수행한 횟수
final_answer: str # 최종 답변
상태(State)는 전체 그래프의 "혈류(bloodstream)"와 같습니다. 모든 노드는 상태를 읽고 상태에 기록합니다. 상태를 잘 설계하는 것이 승부의 절반을 차지하는 일입니다.
계획 노드 (Plan Node)
def plan_node(state: PlanSolveState) -> dict:
messages = [
SystemMessage(content=PLANNER_SYSTEM), # 플래너 전문가 프롬프트
HumanMessage(content=f"Task: {state['task']}"),
]
response = llm.invoke(messages)
plan = parse_plan(response.content) # "1. xxx\n2. xxx" 형식 파싱
return {
"plan": plan,
"current_step_index": 0,
"completed_steps": [],
}
플래너(Planner) 시스템 프롬프트가 매우 중요합니다:
PLANNER_SYSTEM = """
당신은 작업 계획 전문가입니다.
규칙:
- 작업을 3~7개의 독립적인 단계로 나누십시오.
- 각 단계는 구체적이고 실행 가능해야 합니다.
- 단계 간에는 명확한 의존성이 있어야 합니다 (나중 단계가 이전 단계의 결과를 사용할 수 있어야 함).
- 마지막 단계는 "모든 정보를 종합하여 답변을 제공한다"여야 합니다.
출력 형식 (단계 리스트만 출력하고 다른 내용은 포함하지 마십시오):
- [단계 설명]
- [단계 설명]
...
"""
Execute Node (Embedded ReAct Sub-Agent)
def execute_node ( state : PlanSolveState ) -> dict :
idx = state [ " current_step_index " ]
current_step = state [ " plan " ][ idx ]
# 실행 컨텍스트 구축 (완료된 단계의 결과 포함)
system_prompt = EXECUTOR_SYSTEM . format (
completed_steps = format_completed_steps ( state [ " completed_steps " ]),
current_step = current_step ,
)
# 단일 단계를 실행하기 위해 ReAct 서브 에이전트 (Sub-Agent) 사용 (도구 필요 가능성 있음)
sub_agent = create_react_agent ( model = llm , tools = [ calculator , web_search ])
result = sub_agent . invoke (
{ " messages " : [
SystemMessage ( content = system_prompt ),
HumanMessage ( content = f " Execute this step: { current_step } " ),
]},
config = { " recursion_limit " : 8 },
)
step_result = result [ " messages " ][ - 1 ]. content
new_completed = state [ " completed_steps " ] + [ f " { current_step } → { step_result [ : 100 ] } " ]
return {
" step_result " : step_result ,
" completed_steps " : new_completed ,
" current_step_index " : idx + 1 ,
}
여기에는 중요한 설계 선택 사항이 있습니다. Execute 노드는 ReAct 서브 에이전트 (Sub-Agent)를 내장(embed)합니다. Plan-and-Solve과 ReAct는 상호 배타적이지 않습니다. Plan-and-Solve은 전역적인 구조 (Global structure)를 제공하고, ReAct는 각 단계 내에서 도구 호출 (Tool calls)을 처리합니다.
Routing Function
MAX_REPLAN = 2
def should_continue ( state ) -> Literal [ " execute " , " replan " , " finalize " ]:
idx = state [ " current_step_index " ]
total = len ( state [ " plan " ])
if idx >= total :
return " finalize " # 모든 단계 완료
# 단계 실패 감지
result = state . get ( " step_result " , "" )
failed = any ( kw in result for kw in [ " Calculation error " , " Search failed " , " Error " ])
if failed and state [ " replan_count " ] < MAX_REPLAN :
return " replan " # 실패했으나 재시도 예산이 남아 있음
return " execute " # 계속 진행
Building the Graph
from langgraph.graph import END , START , StateGraph
graph = StateGraph ( PlanSolveState )
graph . add_node ( " plan " , plan_node )
graph .
add_node ( " execute " , execute_node ) graph . add_node ( " replan " , replan_node ) graph . add_node ( " finalize " , finalize_node ) graph . add_edge ( START , " plan " ) graph . add_edge ( " plan " , " execute " ) graph . add_conditional_edges ( " execute " , should_continue , { " execute " : " execute " , " replan " : " replan " , " finalize " : " finalize " }, ) graph . add_conditional_edges ( " replan " , after_replan , { " execute " : " execute " , " finalize " : " finalize " }, ) graph . add_edge ( " finalize " , END ) agent = graph . compile () 전체 코드: agent-02-plan-and-solve/plan_and_solve_agent.py 실제 실행: 계획이 만들어지는 것을 관찰하는 데모 1: 다국가 인구 데이터 작업: 중국, 미국, 인도 인구를 검색합니다. 총합과 중국의 비중을 계산합니다. 플래너의 출력 : 1. 최신 수치를 얻기 위해 "China population", "US population", "India population"을 검색합니다. 2. 중국, 미국, 인도 인구수를 기록합니다. 3. 세 국가의 인구를 더하여 총합을 구합니다. 4. 세 국가 총합 대비 중국의 인구 비율을 계산합니다. 5. 모든 정보를 종합하여 최종 답변을 전달합니다. 실행 추적 : [Step 1] web_search("China population") → 1.40489 billion web_search("US population") → 341 million web_search("India population") → 1.451 billion [Step 2] 결과 기록 (도구 호출 없음, 모델이 통합) → 중국 1.40489B, 미국 341M, 인도: 데이터 사용 불가 ← ⚠️ [Step 3] calculator("14048900000.0 + 3400000000.0") → 17448900000 ← ⚠️ 인도가 누락! [Step 4] calculator("14.0489 / 17.4489 * 100") → 80.5145% [최종 답변] 세 국가 총합: 1.74489B, 중국의 비중: 80.5145% 잠깐. 무슨 일이 일어난 걸까요? Step 1에서 인구(1.451 billion)가 성공적으로 발견되었습니다. 하지만 Step 2에서는 "인도에 데이터 사용 불가"라고 했습니다. Step 3의 계산은 중국과 미국만 더했습니다. 이것이 Plan-and-Solve의 가장 흔한 함정 중 하나입니다: 정보가 단계 간 전송 과정에서 손실됩니다.
Step 1의 결과는 completed_steps에 저장되었지만, 요약(summary) 과정에서 내용이 잘렸습니다 (단 100자만 유지). 이 과정에서 중요한 수치들이 유실되었을 가능성이 있습니다. Step 2에서는 도구 호출 (tool calls)이 없었습니다. 모델이 컨텍스트 (context)로부터 Step 1의 결과를 완전히 "기억"하는 것에만 의존했기 때문입니다. 모델은 "사용 가능한 데이터가 없음"이라고 환각 (hallucination)을 일으켰습니다. 이것은 버그가 아니라, 설계 결정에 따른 내재적인 비용입니다. 즉, 정보 사슬 (information chain)이 길어질 때 요약 방식의 전달은 정보 손실을 유발합니다. 해결책은 마지막 섹션에서 다룹니다.
데모 2: 의존성 체인 작업 (Dependency Chain Task) (위안화(CNY) 기준 iPhone 가격)
작업: 최신 iPhone의 USD 가격 검색, 환율 검색, CNY로 변환.
Planner는 7단계 계획을 생성했습니다. 실제로는 3단계(가격 검색, 환율 검색, 계산)만으로도 충분했을 것입니다. 이는 Planner가 단순한 작업을 과도하게 계획 (over-plan)하여, 모든 작은 동작을 각각의 단계로 분리하려는 경향이 있음을 보여줍니다. Step 6에서는 흥미로운 도구 실패 (tool failure)가 발생했습니다:
[Step 6] 8836.45를 반올림해야 함 → calculator("round(8836.45)") → 에러: 지원되지 않는 AST 노드: Call → calculator("round(8836.45, 0)") → 에러: 지원되지 않는 AST 노드: Call → 결과: 죄송합니다, 이 요청을 처리하려면 더 많은 단계가 필요합니다.
우리의 계산기 (calculator)는 산술 연산만 지원하며, 함수 호출 (function calls)은 지원하지 않습니다 (인젝션 (injection) 방지를 위한 설계 의도). 모델은 round()를 두 번 시도했으나 모두 실패했고, 불확실한 응답과 함께 포기했습니다. 하지만 Step 7 (최종 합성)에서 모델은 이를 우아하게 해결했습니다:
1299 USD × 6.8025 = 8836.45 CNY
약 8836 CNY로 반올림
모델은 도구를 사용하지 않고 자연어 (natural language)를 통해 "반올림"을 수행했습니다. 도구 실패가 끝은 아닙니다. 모델 자체의 능력이 폴백 (fallback, 대체 수단) 역할을 할 수 있습니다.
데모 3: 단순 작업 계획 (Simple Task Planning)
작업: 2^10 + 3^5 계산.
Planner는 다음과 같이 4단계 계획을 생성했습니다:
- 2의 10제곱을 계산한다.
- 3의 5제곱을 계산한다.
- 1단계와 2단계의 결과를 더한다.
- 모든 정보를 합성하여 최종 답변을 제공한다.
ReAct의 방식과 비교해 보십시오: 단 한 번의 calculator("2**10 + 3**5") 호출로 끝납니다.
Plan-and-Solve는 여기서 명백히 "과잉 (overkill)"입니다. 단 한 번의 계산을 4단계로 늘려버리기 때문입니다. 이는 우리가 논의해야 할 핵심적인 트레이드오프 (trade-off) 중 하나입니다.
5가지 주요 발견 사항 (Five Key Findings)
이 데모를 실행한 후, 실제 엔지니어링에서 중요한 5가지 관찰 결과는 다음과 같습니다:
발견 1: 플래너 (Planner)는 과도하게 계획하는 경향이 있음
단순한 작업에 대해 LLM은 모든 미세한 동작 (micro-action)을 각각의 단계로 변환합니다. 이는 실행 횟수와 토큰 소비량을 증가시켜 속도를 느리게 만듭니다. 좋은 플래너 프롬프트 (Planner prompt)는 다음과 같이 명시적으로 제한해야 합니다: 단순한 작업에는 3단계를 넘지 않도록 하고, 진정한 의존성 (dependency)이 있을 때만 단계를 나눌 것.
발견 2: 단계 간의 정보 전달에는 세심한 설계가 필요함
각 단계의 결과는 completed_steps에 자연어 요약 형태로 저장됩니다. 요약이 너무 짧으면 중요한 숫자(데모 1의 인도 인구수 등)가 잘려 나갈 수 있습니다. 해결책: 단축된 산문 대신 구조화된 형식 (JSON 또는 키-값 쌍)을 사용하여 단계별 결과를 저장하십시오.
발견 3: 도구 실패(Tool failure) ≠ 단계 실패(Step failure)
도구가 실패할 경우 모델은 자신의 지식으로 대체할 수 있습니다 (데모 2의 반올림 사례). 도구 실패 시 즉시 재계획 (Replan)을 트리거하지 마십시오. 먼저 실행 (Execute) 노드가 이를 처리하도록 두십시오. 모델이 정말로 합리적인 결과를 생성할 수 없을 때만 재계획을 트리거하십시오.
발견 4: 재계획 (Replan)은 양날의 검임
재계획은 시스템에 결함 허용 능력 (fault tolerance)을 부여하지만, 불확실성도 도입합니다. 새로운 계획이 원래 계획과 충돌하거나 필요한 단계를 건너뛸 수 있기 때문입니다. 프로덕션 권장 사항: 재계획 시도를 최대 2회로 제한하십시오. 그것으로 충분하지 않다면, 우아하게 성능을 저하시키십시오 (degrade gracefully) — 사용자에게 작업을 완료할 수 없음을 알리십시오.
발견 5: Plan-and-Solve와 ReAct는 반대 개념이 아님
우리의 구현 방식에서, 각 실행 (Execute) 단계는 내부적으로 ReAct 서브 에이전트 (sub-agent)를 사용합니다. Plan-and-Solve는 "전략적 계획 (strategic planning)"을 제공하고, ReAct는 "전술적 실행 (tactical execution)"을 제공합니다. 이러한 계층적 설계는 실제 에이전트 (Agent) 엔지니어링에서 매우 흔하며, 본질적으로 LangGraph가 구축된 목적이기도 합니다.
ReAct와 Plan-and-Solve 중 무엇을 선택할 것인가
이것은 핵심적인 엔지니어링 판단 사항입니다:
작업 분석 (Task analysis)
│
├─ 3단계 미만인가?
│ └─ ReAct 사용 (경량화, 빠름)
│ ├─ 단계 간의 강력한 의존성(Dependencies)이 있는가?
│ │ └─ (이전 단계의 정확한 결과가 이후 단계에 필요한 경우)
│ │ └─ Plan-and-Solve (명시적인 계획이 의존성 순서를 강제함)
│ ├─ 명확한 작업 경계와 열거 가능한 단계가 있는가?
│ │ └─ Plan-and-Solve 또는 Workflow-Driven 사용
│ ├─ 개방형 작업(Open-ended task)이며 경계가 모호한가?
│ │ └─ ReAct (미지의 요소에 적응 가능)
│ └─ 장기 계획(Long-horizon planning, 10단계 이상)이 필요한가?
│ └─ Multi-Agent 아키텍처 고려 (다음 기사에서 다룸)
실제 사례:
| 시나리오 | 권장 방식 | 이유 |
|---|---|---|
| 사실 검색 및 답변 | ReAct | 단일 단계이며 계획이 필요 없음 |
| 다중 소스 비교 분석 | Plan-and-Solve | 데이터 수집에 의존성 순서가 존재함 |
| 코드 자동 작성 및 테스트 | Plan-and-Solve | 명확한 단계: 작성 → 실행 → 수정 |
| 개방형 경쟁 시장 조사 | ReAct | 검색 방향이 동적으로 진화함 |
| 데이터 처리 파이프라인 | Workflow-Driven | 단계가 완전히 고정되어 있음 |
| 복잡한 결함 진단 |
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기