Published on

LangGraph로 AutoGPT 다중에이전트 상태머신 구축

Authors

서로 다른 역할의 에이전트를 엮어 AutoGPT처럼 일하게 만들면, 처음에는 성능이 좋아 보이지만 곧바로 운영 이슈가 터집니다. 대표적으로 툴콜링 무한루프, 상태 폭증(메모리/토큰), 외부 API 타임아웃으로 인한 중단, 실패 후 재시작 시 컨텍스트 유실 같은 문제입니다.

LangGraph는 이런 문제를 “에이전트 프레임워크”가 아니라 “상태머신(그래프) 런타임”으로 풀게 해줍니다. 즉, 에이전트들을 노드로 두고, 상태를 명시적으로 정의한 뒤, 전이 조건을 코드로 고정해 예측 가능한 실행을 만들 수 있습니다. 이 글에서는 AutoGPT 스타일의 다중 에이전트를 LangGraph로 구성하는 설계와 구현 패턴을 실전 관점에서 정리합니다.

또한 운영에서 자주 마주치는 무한루프 원인을 점검하려면 LangChain 에이전트 툴콜링 무한루프 7원인도 같이 보면 좋습니다. 메모리 폭주를 다루는 관점은 AutoGPT 메모리 폭주? 벡터DB+요약압축 튜닝과 연결됩니다.

LangGraph로 보는 AutoGPT의 본질

AutoGPT류 시스템을 뜯어보면 핵심은 아래 4가지입니다.

  1. 목표를 작업 단위로 쪼개는 Planner
  2. 작업을 수행하는 Worker(Executor)
  3. 결과를 검증하고 다음 행동을 결정하는 Critic/Verifier
  4. 도구 호출, 메모리, 체크포인트 같은 런타임 레이어

많은 구현이 1,2,3을 “한 에이전트의 프롬프트”로 뭉쳐서 처리하다가 제어력을 잃습니다. LangGraph 접근은 반대로 각 역할을 노드로 분리하고, 상태 전이(조건)를 코드로 고정해 운영 가능한 형태로 만듭니다.

아키텍처: 다중 에이전트 상태머신 설계

여기서는 최소 구성으로 다음 노드들을 둡니다.

  • planner: 목표를 tasks로 분해하고 우선순위를 부여
  • router: 다음 실행할 task를 선택하고 어떤 에이전트로 보낼지 결정
  • executor: 툴을 호출하거나 문서를 작성하는 등 실제 수행
  • critic: 결과를 검증하고 done 또는 replan 또는 retry를 반환
  • finalizer: 최종 산출물 정리

그래프 전이는 대략 다음 흐름입니다.

  • planner에서 router
  • router에서 executor
  • executor에서 critic으로
  • critic에서 조건에 따라 router로 되돌리거나 finalizer로 종료

이 구조의 장점은 다음과 같습니다.

  • 무한루프를 “프롬프트”가 아니라 “그래프 정책”으로 차단 가능
  • 실패/재시도를 상태에 기록하고, 특정 횟수 초과 시 강제 종료 가능
  • 체크포인트를 상태 단위로 저장해 중단 후 재개 가능

상태(State) 스키마: 무엇을 저장할 것인가

상태는 “대화 기록”을 전부 들고 있는 것이 아니라, 의사결정에 필요한 최소 정보를 들고 있어야 합니다. AutoGPT가 망가지는 흔한 이유는 상태가 계속 누적되면서 컨텍스트가 비대해지고, 결국 모델이 핵심을 잃기 때문입니다.

권장 스키마 예시는 아래와 같습니다.

  • goal: 사용자 목표
  • tasks: 작업 큐(각 task는 id, description, status, attempts)
  • current_task_id: 현재 수행 task
  • artifacts: 산출물(요약된 형태로)
  • observations: 툴 결과 로그(필요 최소만)
  • messages: 모델 입력용 메시지(요약/압축된 버전)
  • loop_guard: 루프 방지용 카운터 및 최근 전이 기록
  • errors: 실패 사유와 마지막 예외

핵심은 messages를 “전체 로그”가 아니라 “요약된 작업 컨텍스트”로 유지하는 것입니다. 장기 메모리는 벡터DB로 보내고, 그래프 상태에는 요약만 남기는 전략이 안정적입니다. 이 튜닝 방향은 AutoGPT 메모리 폭주? 벡터DB+요약압축 튜닝에서 자세히 다룬 방식과 동일한 철학입니다.

구현: LangGraph로 상태머신 만들기

아래 코드는 개념을 전달하기 위한 “작동 가능한 골격”입니다. 실제 프로젝트에서는 모델 호출, 툴 실행, 체크포인트 저장소를 환경에 맞게 교체하면 됩니다.

1) 의존성 및 상태 타입

<> 기호가 본문에 노출되면 MDX에서 빌드 에러가 날 수 있으므로, 제네릭 표기는 피하고 타입 힌트는 단순화합니다.

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

# LangGraph
from langgraph.graph import StateGraph, END


@dataclass
class Task:
    id: str
    description: str
    status: str = "todo"  # todo | doing | done | failed
    attempts: int = 0


@dataclass
class AgentState:
    goal: str
    tasks: List[Task] = field(default_factory=list)
    current_task_id: Optional[str] = None
    artifacts: Dict[str, Any] = field(default_factory=dict)
    observations: List[Dict[str, Any]] = field(default_factory=list)
    messages: List[Dict[str, str]] = field(default_factory=list)
    loop_guard: Dict[str, Any] = field(default_factory=lambda: {
        "steps": 0,
        "max_steps": 30,
        "recent_nodes": [],
        "max_same_node": 5,
    })
    errors: List[Dict[str, Any]] = field(default_factory=list)

2) 노드 구현: Planner, Router, Executor, Critic

아래는 모델 호출을 call_llm으로 추상화합니다. 프로덕션에서는 OpenAI, Azure OpenAI, 사내 모델 등으로 교체하면 됩니다.

import json
import time


def call_llm(messages: List[Dict[str, str]]) -> str:
    # 예시용 더미. 실제로는 모델 API 호출.
    return "{}"


def planner_node(state: AgentState) -> AgentState:
    state.loop_guard["steps"] += 1

    system = {
        "role": "system",
        "content": "You are a planner. Decompose the goal into 3-7 actionable tasks.",
    }
    user = {"role": "user", "content": state.goal}

    raw = call_llm([system, user])

    # 예: 모델이 JSON 배열을 준다고 가정
    try:
        items = json.loads(raw) if raw.strip() else []
    except Exception as e:
        state.errors.append({"where": "planner", "error": str(e), "raw": raw})
        items = []

    if not items:
        # 최소 안전장치
        items = [
            {"id": "t1", "description": "Clarify requirements and constraints"},
            {"id": "t2", "description": "Draft solution outline"},
            {"id": "t3", "description": "Produce final output"},
        ]

    state.tasks = [Task(id=i["id"], description=i["description"]) for i in items]
    state.messages = [{"role": "system", "content": "Working on tasks."}]
    return state


def router_node(state: AgentState) -> AgentState:
    state.loop_guard["steps"] += 1

    # 다음 todo 선택
    for t in state.tasks:
        if t.status == "todo":
            t.status = "doing"
            state.current_task_id = t.id
            return state

    # 모두 끝났으면 finalizer로
    state.current_task_id = None
    return state


def executor_node(state: AgentState) -> AgentState:
    state.loop_guard["steps"] += 1

    task = next((t for t in state.tasks if t.id == state.current_task_id), None)
    if not task:
        state.errors.append({"where": "executor", "error": "no current task"})
        return state

    task.attempts += 1

    # 실제론 여기서 툴 호출, 검색, 코드 실행 등을 수행
    # 예시로는 관찰 로그만 추가
    obs = {
        "task_id": task.id,
        "ts": time.time(),
        "result": f"Executed: {task.description}",
    }
    state.observations.append(obs)

    # 산출물은 요약 형태로만 누적
    state.artifacts[task.id] = {"summary": obs["result"]}

    return state


def critic_node(state: AgentState) -> AgentState:
    state.loop_guard["steps"] += 1

    task = next((t for t in state.tasks if t.id == state.current_task_id), None)
    if not task:
        return state

    # 간단 검증 규칙: attempts 2회 초과면 실패 처리
    if task.attempts >= 2 and task.status != "done":
        task.status = "failed"
        state.errors.append({
            "where": "critic",
            "task_id": task.id,
            "error": "max attempts exceeded",
        })
        return state

    # 예시에서는 항상 성공 처리
    task.status = "done"
    return state


def finalizer_node(state: AgentState) -> AgentState:
    state.loop_guard["steps"] += 1

    # 최종 결과를 artifacts에서 조립
    done = [t for t in state.tasks if t.status == "done"]
    failed = [t for t in state.tasks if t.status == "failed"]

    state.artifacts["final"] = {
        "done": [t.id for t in done],
        "failed": [t.id for t in failed],
        "notes": "Finalized output.",
    }
    return state

3) 조건부 전이(Conditional Edge)로 무한루프 차단

LangGraph의 핵심은 “다음 노드 선택”을 프롬프트에 맡기는 게 아니라 조건부 전이 함수로 고정하는 것입니다.

아래에서는 다음을 강제합니다.

  • 전체 스텝이 max_steps를 넘으면 종료
  • router에서 current_task_id가 없으면 finalizer
  • critic 이후 task 상태에 따라 router로 돌아가거나 종료
def guard_or_next(state: AgentState, default_next: str) -> str:
    lg = state.loop_guard

    if lg["steps"] >= lg["max_steps"]:
        return "finalizer"

    # 최근 노드 반복 감지(운영에서 꽤 유용)
    recent = lg["recent_nodes"]
    recent.append(default_next)
    lg["recent_nodes"] = recent[-20:]

    if len(recent) >= lg["max_same_node"]:
        tail = recent[-lg["max_same_node"]:]
        if all(x == tail[0] for x in tail):
            state.errors.append({
                "where": "loop_guard",
                "error": "same node repeated",
                "node": tail[0],
            })
            return "finalizer"

    return default_next


def route_after_router(state: AgentState) -> str:
    if state.current_task_id is None:
        return guard_or_next(state, "finalizer")
    return guard_or_next(state, "executor")


def route_after_critic(state: AgentState) -> str:
    # 현재 task가 failed면 다음 task로 이동
    task = next((t for t in state.tasks if t.id == state.current_task_id), None)
    if task and task.status in ["done", "failed"]:
        return guard_or_next(state, "router")

    # 그 외는 재시도
    return guard_or_next(state, "executor")

4) 그래프 조립 및 실행

def build_graph():
    g = StateGraph(AgentState)

    g.add_node("planner", planner_node)
    g.add_node("router", router_node)
    g.add_node("executor", executor_node)
    g.add_node("critic", critic_node)
    g.add_node("finalizer", finalizer_node)

    g.set_entry_point("planner")

    g.add_edge("planner", "router")

    g.add_conditional_edges(
        "router",
        route_after_router,
        {
            "executor": "executor",
            "finalizer": "finalizer",
        },
    )

    g.add_edge("executor", "critic")

    g.add_conditional_edges(
        "critic",
        route_after_critic,
        {
            "router": "router",
            "executor": "executor",
            "finalizer": "finalizer",
        },
    )

    g.add_edge("finalizer", END)

    return g.compile()


if __name__ == "__main__":
    app = build_graph()
    init = AgentState(goal="Design a multi-agent AutoGPT system with safety guards")
    out = app.invoke(init)
    print(out.artifacts.get("final"))

이렇게 만들면 “어떤 상황에서 어디로 가는지”가 코드로 고정되며, 모델이 일시적으로 이상한 출력을 하더라도 시스템 전체가 무너질 확률이 줄어듭니다.

멀티 에이전트 확장: 역할별 Executor 분리

AutoGPT 스타일의 “다중 에이전트”를 제대로 하려면 executor를 하나로 두기보다, 역할별로 분리하고 router가 task 유형에 따라 라우팅하도록 만드는 편이 낫습니다.

예를 들어:

  • researcher_executor: 검색/요약
  • coder_executor: 코드 작성/테스트
  • writer_executor: 문서화

이때 중요한 포인트는 “에이전트가 서로 대화한다”가 아니라, 상태를 공유하고, 전이 규칙은 그래프가 가진다입니다. 에이전트 간 메시지 교환을 늘리면 컨텍스트가 기하급수로 커지기 쉽습니다.

운영에서 반드시 넣어야 하는 3가지 가드레일

1) 무한루프 방지: 정책 기반 종료

무한루프는 대개 다음 중 하나입니다.

  • 툴 결과가 비결정적이라 같은 시도를 반복
  • 에이전트가 성공 조건을 명확히 모르고 “조금 더”를 반복
  • 라우터가 계속 같은 task를 선택

따라서 max_steps, max_attempts, same-node repetition 같은 정책을 그래프 레벨에 둬야 합니다. 프롬프트에 “반복하지 마”를 적는 것만으로는 부족합니다. 원인 분석 체크리스트는 LangChain 에이전트 툴콜링 무한루프 7원인에서 많은 힌트를 얻을 수 있습니다.

2) 메모리 폭주 방지: 상태는 얇게, 기록은 외부로

  • 상태에는 요약키 포인트만 남기기
  • 원문 로그는 외부 스토리지에 저장하고 참조 키만 상태에 보관
  • 일정 스텝마다 messages를 리캡(압축 요약)하기

이 전략을 적용하면 “작업이 길어질수록 더 똑똑해지는” 게 아니라 “작업이 길어질수록 더 멍청해지는” 현상을 줄일 수 있습니다.

3) 외부 API 장애 대응: 재시도와 체크포인트

실서비스에서 LLM 호출, 검색, 크롤링, 사내 API는 항상 끊깁니다. 특히 스트리밍 기반 응답은 중간 끊김과 타임아웃이 빈번합니다. 이때는 다음이 필요합니다.

  • 노드 단위 재시도(지수 백오프)
  • idempotency 키(같은 task 재실행 시 중복 부작용 방지)
  • 체크포인트(중단 후 이어서 실행)

스트리밍 타임아웃과 재시도/체크포인트 관점은 OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드에서 운영적으로 매우 유용합니다.

디버깅 팁: 상태머신 관점으로 로그를 설계하라

LangGraph 기반 시스템은 “대화 로그”보다 “상태 전이 로그”가 디버깅에 더 중요합니다.

권장 로그 필드:

  • run_id, node_name, step, current_task_id
  • task_status_before, task_status_after
  • tool_name, tool_latency_ms, tool_error
  • state_size_bytes 또는 messages_token_estimate

이렇게 찍으면 장애가 났을 때 “모델이 이상하다”가 아니라 “어떤 전이가 어떤 조건에서 반복되었는지”를 재현할 수 있습니다.

마무리: LangGraph는 에이전트가 아니라 제어의 프레임워크

LangGraph로 AutoGPT 스타일 다중 에이전트를 만들 때의 핵심은, 모델을 똑똑하게 만드는 게 아니라 실패해도 시스템이 안전하게 움직이게 만드는 것입니다.

  • 역할을 노드로 분리하고
  • 상태를 얇게 설계하며
  • 조건부 전이로 루프와 재시도를 통제하고
  • 체크포인트로 재개 가능하게 만들면

“데모는 되는데 운영이 안 되는” 에이전트에서 벗어나, 실제 서비스에 투입 가능한 상태머신 기반 멀티 에이전트로 발전시킬 수 있습니다.