Post

ReAct와 Plan-and-Execute를 LangGraph로 직접 구현하며 배운 것

ReAct와 Plan-and-Execute 두 에이전트 전략을 LangGraph로 직접 구현하고 비교한 실전 기록. 그래프 구조, 상태 설계, 재계획 주기와 컨텍스트 격리라는 두 축의 차이를 실제 코드와 실행 로그로 정리합니다.

ReAct와 Plan-and-Execute를 LangGraph로 직접 구현하며 배운 것

ReAct가 생각과 행동의 루프라면, LangGraph는 그 루프를 그래프로 그리는 도면이었습니다. 이번엔 그 도면 위에 전략을 두 개 올려 보았습니다. 공통 뼈대를 만들고 ReAct와 Plan-and-Execute를 갈아끼우며 비교한 실전 기록입니다.

구현하고 나니, 둘의 차이는 흔히 말하는 “루프냐 계획이냐”보다 훨씬 미묘했습니다. 이 글은 그 차이를 코드와 상태 설계로 풀어 봅니다.

TL;DR

  • ReAct는 agent ↔ tools 순환이 전부다. “툴을 쓸지”는 상태 플래그가 아니라 모델 출력의 tool_calls가 결정한다.
  • Plan-and-Execute는 계획을 상태(state["plan"])에 명시적으로 남기고, 단계마다 재계획한다.
  • 두 전략의 진짜 차이는 두 축이다. 하나는 재계획 주기(매 턴 vs 단계 경계), 다른 하나는 컨텍스트 격리(누적 vs 단계별 분리).
  • 이 두 축은 섞여 있어서, 지금처럼 둘만 비교하면 성능 차가 어느 축 덕인지 알 수 없다.

왜 이 비교인가

에이전트를 만들다 보면 “ReAct가 낫나, Plan-and-Execute가 낫나” 같은 질문에 부딪힙니다. 그런데 이 질문에 답하려면 먼저 둘을 같은 뼈대 위에 올려두고 갈아끼울 수 있어야 합니다. 그래서 LangGraph로 전략을 레지스트리에 등록해 교체하는 구조를 만들고, 두 전략을 구현했습니다.

앞에서 멀티에이전트의 토큰 비용과 컨텍스트를 이야기했다면, 이번 비교는 그 문제를 단일 그래프 안에서 더 작게 잘라 본 작업이기도 합니다. 컨텍스트를 한곳에 쌓을지, 단계마다 나눌지가 전략 선택의 핵심이 됩니다.

ReAct — 루프가 전부다

ReAct의 그래프는 단순합니다. 모델이 툴을 부르면 실행하고, 그 결과를 다시 모델에게 돌려줍니다. 툴을 더 부르지 않으면 끝냅니다.

1
2
3
4
START → agent ──(tool_calls 있음)──→ tools ──┐
          ↑                                  │
          └──────────────────────────────────┘
          └──(tool_calls 없음)──→ finish → END

핵심은 agent ↔ tools 순환입니다. 이 순환을 코드로 보면 이렇습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# src/agents/strategies/react.py
model = llm.bind_tools(tools)

def agent(state: AgentState) -> dict:
    # 시스템 프롬프트는 상태에 누적하지 않고 매 호출마다 앞에 붙인다.
    response = model.invoke([SystemMessage(SYSTEM_PROMPT), *state["messages"]])
    return {"messages": [response]}

def route(state: AgentState) -> str:
    last = state["messages"][-1]
    # 모델이 툴을 부르지 않았다 = 답할 준비가 됐다.
    if not isinstance(last, AIMessage) or not last.tool_calls:
        return "finish"
    tool_turns = sum(
        1 for m in state["messages"] if isinstance(m, AIMessage) and m.tool_calls
    )
    return "tools" if tool_turns <= MAX_TOOL_TURNS else "finish"

graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route, {"tools": "tools", "finish": "finish"})
graph.add_edge("tools", "agent")  # 툴 결과를 모델에게 돌려주는 루프
graph.add_edge("finish", END)

여기서 가장 중요한 지점은 route()의 판단 근거입니다. 다음에 툴로 갈지 끝낼지의 주 신호는 모델이 방금 뱉은 출력의 tool_calls입니다. 상태 어딘가에 use_tools 같은 플래그를 두고 그걸 켜고 끄는 방식이 아닙니다.

이 구분이 사소해 보이지만 결정적입니다. 만약 “툴을 쓸지”를 상태 플래그로 착각해서 설계하면, 그 플래그를 누가 언제 갱신할지가 모호해지고 루프 자체가 성립하지 않습니다. ReAct에서 툴 사용 여부는 매 턴 모델이 내리는 결정이고, 그 결정은 모델의 출력 안에 이미 담겨 나옵니다. 그래프는 그 출력을 읽어 경로를 정할 뿐입니다.

다만 제어가 모델에만 있는 것은 아닙니다. route()에는 MAX_TOOL_TURNS 상한이 있습니다. 이 상한이 없으면 모델이 계속 툴을 부르겠다고 할 때 agent ↔ tools 순환이 멈추지 않습니다. 그래서 지금까지 툴을 부른 횟수를 세어, 상한을 넘으면 강제로 finish로 보냅니다. 분기의 신호는 모델 출력이고, 종료의 안전장치는 코드입니다. 자율적인 루프에는 반드시 이런 하네스가 필요합니다.

Plan-and-Execute — 계획을 상태에 남긴다

Plan-and-Execute의 그래프는 다른 모양입니다. 먼저 계획을 세우고(planner), 한 단계를 실행하고(executor), 다시 계획을 점검합니다(replanner). 계획이 남아 있으면 다시 실행하고, 끝났으면 종료합니다.

1
2
3
START → planner → executor → replanner ──(계획 남음)──→ executor
                                  │
                                  └──(끝)──→ END

이 전략의 설계에서 눈여겨볼 부분은 재계획의 결과를 담는 스키마입니다.

1
2
3
4
5
6
7
8
9
# src/agents/strategies/plan_execute.py
class Replan(BaseModel):
    """재계획 결과. 두 갈래를 한 스키마에 담아 LLM 호출 한 번으로 분기를 정한다."""
    final_answer: str | None = Field(
        default=None, description="답을 낼 수 있으면 최종 답변. 아니면 null."
    )
    steps: list[str] = Field(
        default_factory=list, description="아직 남은 단계들. 답이 나왔으면 빈 리스트."
    )

Replan은 “이제 답을 낼 수 있다(final_answer)”와 “아직 이 단계들이 남았다(steps)”를 한 스키마에 함께 담습니다. 재계획할 때 “끝났는지”와 “남은 게 뭔지”를 따로 물으면 LLM을 두 번 호출해야 하지만, 이렇게 한 스키마로 받으면 호출 한 번으로 분기까지 정해집니다. 답이 나왔으면 final_answer가 채워지고 steps는 빈 리스트, 아직이면 그 반대입니다.

실행 단계의 코드에는 두 가지 함정에 대한 대응이 들어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
# src/agents/strategies/plan_execute.py
def execute_step(state: PlanExecuteState) -> dict:
    step = state["plan"][0]
    past = state.get("past_steps", [])
    # prompt: 현재 단계와 완료 요약으로 구성
    # 서브그래프에는 매번 다른 thread_id 를 준다.
    # 같은 걸 쓰면 이전 단계의 대화가 다음 단계에 섞여 들어온다.
    result = executor.invoke(
        {"messages": [HumanMessage(prompt)]},
        config={"configurable": {"thread_id": f"exec-{len(past)}"}},
    )
    # 소비한 단계를 잘라내야 같은 단계를 반복하지 않는다.
    return {"plan": state["plan"][1:], "past_steps": [(step, result["final_answer"])]}

첫째, thread_id입니다. executor는 서브그래프인데, 매번 다른 thread_id(exec-0, exec-1…)를 줍니다. 같은 thread_id를 재사용하면 이전 단계에서 오간 대화가 다음 단계의 컨텍스트에 섞여 들어옵니다. 각 단계를 독립적으로 실행하려면 스레드를 분리해야 합니다.

둘째, plan[1:]입니다. 실행이 끝나면 소비한 첫 단계를 잘라내고 나머지만 남깁니다. 이걸 안 하면 plan[0]이 계속 같은 단계를 가리켜서 동일한 단계를 무한히 반복하게 됩니다.

코드 바깥의 함정도 있습니다. 재계획 프롬프트에 “이미 완료된 단계는 다시 넣지 마라”는 지시가 없으면, replanner가 방금 끝낸 단계를 다시 계획에 넣어 루프가 끝나지 않습니다. 완료된 단계를 잘라내는 것(plan[1:])과 재계획이 그걸 다시 넣지 않게 하는 것(프롬프트), 두 가지가 함께 있어야 종료가 보장됩니다.

실행 후에는 무조건 재계획을 거칩니다.

1
2
3
4
5
# src/agents/strategies/plan_execute.py
graph.add_edge("planner", "executor")
# ReAct 와 달리 실행 후에는 무조건 재계획을 거친다. 그게 이 전략의 정체성이다.
graph.add_edge("executor", "replanner")
graph.add_conditional_edges("replanner", route, {"execute": "executor", "end": END})

상태 설계가 전략을 규정한다

두 전략의 차이는 사실 상태 스키마에서 이미 드러납니다.

1
2
3
4
5
6
7
8
9
10
11
# src/agents/state.py
class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]
    final_answer: str

class PlanExecuteState(TypedDict):
    input: str
    plan: list[str]                                              # reducer 없음 = 덮어쓰기
    past_steps: Annotated[list[tuple[str, str]], operator.add]   # 누적
    final_answer: str
    replans: int

핵심은 reducer의 유무입니다. LangGraph에서 상태 필드에 reducer를 붙이면, 노드가 반환한 값이 기존 값과 합쳐집니다. reducer가 없으면 반환값이 기존 값을 덮어씁니다.

AgentState.messages에는 add_messages reducer가 붙어 있습니다. 그래서 매 노드가 메시지를 반환할 때마다 히스토리에 누적됩니다. ReAct가 전체 대화를 계속 쌓아가는 건 이 설계 때문입니다.

PlanExecuteState는 필드마다 다릅니다. plan에는 reducer가 없습니다. 재계획할 때 계획은 이전 것을 이어붙이는 게 아니라 통째로 새 계획으로 교체돼야 하기 때문입니다. 반면 past_steps에는 operator.add가 붙어 누적됩니다. 실행 기록은 사라지면 안 되니까요.

“왜 plan은 덮어쓰고 past_steps는 쌓는가”에 대한 답이 여기 있습니다. 계획은 매 재계획마다 새로 갱신되는 “현재의 의도”라 최신본만 있으면 되고, 기록은 지금까지 무엇을 했는지 남겨야 하는 “누적된 사실”이라 쌓아야 합니다. 상태 필드에 어떤 reducer를 붙이느냐가 곧 그 전략이 무엇을 기억하고 무엇을 잊는지를 규정합니다.

실제로 무엇이 다른가

두 전략을 나란히 놓으면 네 개의 축에서 갈립니다.

 ReActPlan-and-Execute
재계획 주기매 턴단계 경계
계획의 실체없음 (히스토리에 암묵적)state["plan"]
컨텍스트단일, 누적단계별 격리
모델 분리불가planner / executor

이 중에서 특히 두 가지가 중요합니다.

(a) ReAct의 agent 노드는 이미 replanner다

Plan-and-Execute만 재계획을 하는 것처럼 보이지만, 사실 ReAct의 agent 노드도 매 턴 재계획을 합니다. agent는 매번 전체 메시지 히스토리를 다시 받아(*state["messages"]) 처음부터 상황을 재판단하기 때문입니다. 다음에 무엇을 할지를 이전 결정에 얽매이지 않고 매 턴 새로 정하는 것 — 이게 바로 재계획입니다. 다만 그 계획은 state["plan"] 같은 명시적 목록이 아니라, 히스토리 위에 암묵적으로 다시 그려집니다.

그렇게 보면 두 전략은 이분법이 아닙니다. 재계획 주기라는 연속적인 다이얼 위의 서로 다른 지점입니다. 한 번만 계획을 세우고 그대로 실행하면(이 글에서는 plan-solve) 재계획이 없고, 단계 경계마다 재계획하면 Plan-and-Execute, 매 턴 재계획하면 ReAct입니다. “루프냐 계획이냐”가 아니라 “얼마나 자주 다시 생각하느냐”의 문제인 셈입니다.

(b) 그런데 다이얼만으로는 설명이 안 된다 — 컨텍스트 격리가 절반이다

재계획 주기만으로 두 전략을 설명하려 하면 뭔가 빠집니다. 나머지 절반은 컨텍스트 격리입니다.

ReAct는 모든 걸 하나의 메시지 히스토리에 쌓습니다. 그래서 10번째 툴 호출 시점이 되면, 앞선 9개 툴의 원문 결과를 전부 다시 컨텍스트에 넣고 모델을 호출합니다. 코드로는 model.invoke([SystemMessage(SYSTEM_PROMPT), *state["messages"]])의 *state["messages"] 부분이 이 누적을 그대로 보여줍니다.

Plan-and-Execute는 다릅니다. 각 executor는 thread_id=f"exec-{len(past)}"로 매번 깨끗한 컨텍스트에서 시작하고, 부모 상태에는 (단계, 결과) 요약만 past_steps로 남습니다. 앞 단계의 원문이 다음 단계로 끌려오지 않습니다.

이 대비가 중요한 이유는, 긴 작업에서 ReAct가 무너지는 주된 원인이 재계획 주기가 아니라 컨텍스트가 터지기 때문인 경우가 많다는 데 있습니다. 툴 결과가 쌓일수록 컨텍스트는 계속 부풀고, 결국 모델이 감당할 수 있는 한계를 넘습니다. 같은 재계획 주기를 유지하더라도 컨텍스트를 격리하느냐 누적하느냐에 따라 긴 작업의 결과는 크게 달라질 수 있습니다.

실행 로그

claude-haiku-4-5와 Tavily 검색 툴로 두 전략을 실제로 돌렸을 때 관측된 로그입니다.

1
2
3
4
5
6
7
8
9
10
11
· agent
    → searching(query='LangGraph 공식 문서')
· tools
    ← searching: ...
· agent
    → crowling(url='https://langchain-ai.github.io/langgraph/')
· tools
    ← crowling: ...
· agent
· finish
react (5.23s, 6 steps)
1
2
3
4
5
6
· planner
· executor
· replanner
· executor
· replanner
plan-execute (24.41s, 5 steps)

로그의 흐름이 앞서 본 그래프 구조와 정확히 일치합니다. ReAct는 agent와 tools가 번갈아 나오다 finish로 끝나고, Plan-and-Execute는 planner 이후 executor와 replanner가 번갈아 반복됩니다.

한 가지 분명히 해둘 점이 있습니다. 위 두 실행은 서로 다른 질문으로 각각 한 번씩 돌린 결과입니다. 따라서 이 로그를 성능 벤치마크로 읽으면 안 됩니다. 시간이 다르게 찍힌 것을 두고 “어느 쪽이 몇 배 빠르다/느리다”고 말할 근거가 없습니다. 다만 구조적으로, Plan-and-Execute가 planner와 매 단계의 replanner까지 LLM을 더 여러 번 호출한다는 점은 짚을 수 있습니다. 호출 횟수가 많으면 그만큼 시간이 늘어나는 경향이 있습니다. 이건 측정이 아니라 단발 실행에서의 관찰이고, 구조가 시간에 어떻게 반영되는지를 설명하는 선까지만 유효합니다.

참고로 위 로그에서 crowling은 문자열만 돌려주는 더미 툴이라, 실제 페이지를 크롤링한 것이 아닙니다. 함수명 철자도 코드 그대로입니다. 또한 Plan-and-Execute 로그에는 executor 내부의 툴 호출이 보이지 않는데, 이는 부모 상태에 messages 키가 없어 서브에이전트의 툴 호출이 로그로 올라오지 않았기 때문입니다. 툴을 쓰지 않았다는 뜻이 아닙니다.

마무리 — 다음 실험

지금까지 본 것처럼, ReAct와 Plan-and-Execute의 차이는 최소 두 축(재계획 주기, 컨텍스트 격리)이 섞여 있습니다. 그래서 두 전략을 통째로 비교하면, 어떤 성능 차이가 나더라도 그게 재계획 주기 덕인지 컨텍스트 격리 덕인지 분리할 수 없습니다.

의미 있는 비교를 하려면 축을 하나씩 떼어내야 합니다.

  • plan-solve: Plan-and-Execute에서 재계획을 뺀 버전. 처음 세운 계획대로만 실행합니다. 이걸 Plan-and-Execute와 비교하면 재계획의 순수한 효과를 잴 수 있습니다.
  • react-isolated: ReAct에서 툴 결과를 원문 대신 요약해서 넣는 버전. 이걸 일반 ReAct와 비교하면 컨텍스트 격리의 순수한 효과를 잴 수 있습니다.

이렇게 한 축씩 고정하고 하나만 바꿔야, 관측된 차이를 특정 원인에 귀속시킬 수 있습니다. 두 전략을 한꺼번에 비교하는 것은 출발점일 뿐, 결론이 될 수 없습니다.


이 글의 코드는 실행해 동작을 확인한 것이며, 실행 로그의 수치는 측정이 아닌 단발 실행에서의 관찰입니다.


📚 시리즈: AI 에이전트, 만들고 평가하기

  1. Workflow와 Agent, 그리고 하네스 — LLM에게 얼마만큼의 자유를 줄 것인가
  2. 에이전트의 도구(Tool)와 세 층의 평가 — 선택·실행·결과
  3. 멀티에이전트의 두 숙제 — 토큰 비용과 안정성, 그리고 평가라는 해법
  4. ReAct와 Plan-and-Execute를 LangGraph로 직접 구현하며 배운 것 ← 지금 글
  5. 그래서 Agent에 데이터를 어떻게 입히나요? — 데이터가 작을 때의 온톨로지 설계
  6. 제조업 AI 에이전트 프로젝트 1차 회고 — 평가, 저장소, 코드 정리에 대한 세 가지 교훈
  7. AI 에이전트의 예시 데이터는 어떻게 다룰까 — 컨텍스트 엔지니어링부터 파인튜닝 준비까지
This post is licensed under CC BY 4.0 by the author.