Published on

LangChain 멀티에이전트 루프 방지 - 상태머신·가드레일

Authors

멀티에이전트(Planner/Executor/Reviewer, 혹은 역할 분리된 여러 에이전트)를 LangChain으로 구성하면, 단일 에이전트보다 성능과 확장성은 좋아지지만 루프(loop) 위험이 급격히 커집니다. 대표적으로 다음 문제가 자주 발생합니다.

  • 핑퐁: Reviewer가 “다시 수정”을 요구하고 Executor가 “수정했음”을 반복
  • 도구 재시도 폭주: 동일한 툴 호출이 파라미터만 조금 바뀌며 계속 반복
  • 목표 드리프트: 원래 목표와 무관한 하위 목표를 생성하며 계속 진행
  • 종료 조건 부재: “완료” 상태로 전이되지 못하고 계속 다음 액션을 생성

이 글은 루프를 “프롬프트로만” 막지 않고, 상태머신(state machine)가드레일(guardrails) 로 시스템적으로 차단하는 방법을 다룹니다. 특히 LangChain의 LangGraph(상태 기반 그래프 실행) 또는 유사한 상태 전이 모델을 기준으로 설명하되, LangGraph를 쓰지 않더라도 그대로 적용 가능한 원칙을 정리합니다.

아래 내용은 분산 시스템의 재시도 폭주/중복 처리 문제와 구조가 유사합니다. 이벤트 중복·재처리 관점은 Kafka 정확히-한번? MSA 중복이벤트 5분 진단 도 함께 보면 설계 감이 빨리 옵니다.

왜 멀티에이전트는 루프에 취약한가

1) “다음 행동” 생성기가 여러 개

각 에이전트가 독립적으로 다음 행동을 제안하면, 전역 관점에서 진행(progress) 을 보장하기 어렵습니다. 단일 에이전트는 적어도 하나의 정책으로 수렴하지만, 멀티에이전트는 정책이 여러 개라 상호 충돌이 잦습니다.

2) 실패 신호가 구조화되어 있지 않음

도구 호출 실패, 검증 실패, 리뷰 반려가 모두 텍스트로만 표현되면 “같은 실패를 다른 말로 반복”하게 됩니다. 실패는 텍스트가 아니라 상태와 코드(에러 타입) 로 승격되어야 합니다.

3) 비용/시간 예산이 공유되지 않음

각 에이전트가 토큰/시간/툴 호출 예산을 공유하지 않으면, 전체 시스템은 “조금씩 계속” 실행되며 폭주합니다. 이는 gRPC 데드라인 전파 누락이 타임아웃 폭증으로 이어지는 패턴과 닮았습니다. (관심 있으면 gRPC MSA 데드라인 전파 누락으로 타임아웃 폭증 해결 참고)

핵심 해법: 상태머신으로 루프를 ‘구조적으로’ 끊기

멀티에이전트를 운영 코드로 만들 때는 “에이전트가 대화로 합의해서 끝내도록” 기대하기보다, 다음을 명시해야 합니다.

  • 가능한 상태 집합
  • 상태 간 전이 조건
  • 각 상태에서 가능한 액션 집합(툴 호출 포함)
  • 종료 상태(성공/실패)와 강제 중단 규칙

추천 상태 모델

최소한 아래 정도는 분리하는 편이 안전합니다.

  • PLAN: 목표를 작업 단위로 분해
  • EXECUTE: 계획을 실행(툴 호출)
  • VERIFY: 결과 검증(스키마/테스트/정책)
  • REPAIR: 실패 원인 기반 수정(제한된 재시도)
  • DONE: 성공 종료
  • ABORT: 실패 종료(예산 초과, 반복 감지, 정책 위반)

중요한 점은 VERIFYREPAIR 를 분리하는 것입니다. 검증과 수정이 한 상태에 섞이면, 실패 시 “다시 해볼게요”가 무한히 반복되기 쉽습니다.

가드레일 1: 전역 예산(토큰/시간/툴 호출)과 데드라인

루프 방지의 1순위는 예산 기반 중단입니다.

  • 총 스텝 수 제한: max_steps
  • 총 툴 호출 수 제한: max_tool_calls
  • 총 토큰/비용 제한: max_tokens 또는 비용 추적
  • 벽시계 시간 제한: deadline (절대 시간)

예산은 “에이전트별”이 아니라 워크플로 전역으로 관리해야 합니다.

from dataclasses import dataclass, field
import time

@dataclass
class Budget:
    max_steps: int = 30
    max_tool_calls: int = 20
    deadline_epoch: float = field(default_factory=lambda: time.time() + 60)

@dataclass
class RuntimeCounters:
    steps: int = 0
    tool_calls: int = 0

class BudgetExceeded(Exception):
    pass

def enforce_budget(budget: Budget, c: RuntimeCounters):
    now = time.time()
    if c.steps >= budget.max_steps:
        raise BudgetExceeded("max_steps exceeded")
    if c.tool_calls >= budget.max_tool_calls:
        raise BudgetExceeded("max_tool_calls exceeded")
    if now >= budget.deadline_epoch:
        raise BudgetExceeded("deadline exceeded")

이 예산 체크는 “프롬프트 앞단”이 아니라 상태 전이 직전 또는 툴 호출 직전에 반드시 실행되어야 합니다.

가드레일 2: 반복 감지(사이클 디텍션)와 핑퐁 차단

루프는 대개 “상태-액션-관찰”이 유사하게 반복됩니다. 따라서 아래를 해시로 묶어 최근 N개 히스토리에서 반복을 감지합니다.

  • 상태(state)
  • 수행 액션(action_name)
  • 액션 입력의 정규화된 형태(정렬된 JSON)
  • 핵심 관찰(에러 타입/요약)
import json
import hashlib
from dataclasses import dataclass

@dataclass
class TransitionRecord:
    phase: str
    action: str
    action_input: dict
    observation_key: str

def stable_hash(d: dict) -> str:
    s = json.dumps(d, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(s.encode("utf-8")).hexdigest()

def transition_fingerprint(r: TransitionRecord) -> str:
    payload = {
        "phase": r.phase,
        "action": r.action,
        "action_input_hash": stable_hash(r.action_input),
        "observation_key": r.observation_key,
    }
    return stable_hash(payload)

class LoopDetected(Exception):
    pass

def detect_loop(history: list[TransitionRecord], window: int = 8, threshold: int = 2):
    # 최근 window 내 동일 fingerprint가 threshold+1번 이상 나오면 루프로 판단
    recent = history[-window:]
    fps = [transition_fingerprint(r) for r in recent]
    for fp in set(fps):
        if fps.count(fp) >= (threshold + 1):
            raise LoopDetected(f"repeated transition detected: {fp}")

핑퐁 전용 규칙

Reviewer/Executor 구조라면 다음 규칙이 특히 효과적입니다.

  • REVIEW_REJECT 가 연속 2회 발생하면 ABORT
  • 혹은 2회째부터는 수정이 아니라 축소(simplify) 로 전환: 요구사항을 줄이거나, 불확실한 부분을 사용자에게 질문

즉 “같은 종류의 반려”가 반복되면, 더 열심히 수정하는 게 아니라 게임 규칙을 바꿔야 합니다.

가드레일 3: 실패를 텍스트가 아닌 ‘타입’으로 다루기

멀티에이전트 루프의 큰 원인은 실패가 자연어로만 남아 동일 실패를 다른 문장으로 재시도하기 때문입니다.

툴 호출 결과는 다음처럼 구조화하세요.

  • 성공: ok=true, result 포함
  • 실패: ok=false, error_type, retryable, hint 포함
from typing import TypedDict, Any, Literal

class ToolResult(TypedDict, total=False):
    ok: bool
    result: Any
    error_type: Literal[
        "NETWORK",
        "RATE_LIMIT",
        "AUTH",
        "INVALID_INPUT",
        "NOT_FOUND",
        "POLICY",
        "UNKNOWN",
    ]
    retryable: bool
    hint: str

def classify_exception(e: Exception) -> ToolResult:
    msg = str(e)
    # 예시는 단순화. 실제로는 HTTP status, provider error code로 분기
    if "429" in msg:
        return {"ok": False, "error_type": "RATE_LIMIT", "retryable": True, "hint": "backoff"}
    if "401" in msg or "403" in msg:
        return {"ok": False, "error_type": "AUTH", "retryable": False, "hint": "check credentials"}
    return {"ok": False, "error_type": "UNKNOWN", "retryable": False, "hint": "inspect logs"}

그리고 상태 전이에서 다음처럼 강제합니다.

  • retryable=false 인 실패는 REPAIR 로 보내지 말고 즉시 ABORT
  • retryable=true 여도 동일 error_type 이 2회 반복되면 ABORT 또는 대체 경로로 전환

이 방식은 운영에서 “재시도 정책”을 다루는 것과 동일합니다.

가드레일 4: 검증(VERIFY)은 기계적으로, 가능한 자동화로

VERIFY 가 LLM 텍스트 평가에만 의존하면, Reviewer가 “애매하게” 반려하면서 루프가 길어집니다. 가능한 검증을 자동화하세요.

  • JSON 스키마 검증
  • 타입/정적 분석
  • 단위 테스트 실행
  • 규칙 기반 정책 검사(금칙어, PII, 보안 정책)

예: 산출물이 JSON이어야 한다면, 파싱 실패는 즉시 REPAIR 로 보내되 재시도 횟수는 제한합니다.

import json

class VerificationFailed(Exception):
    pass

def verify_json(text: str) -> dict:
    try:
        obj = json.loads(text)
    except json.JSONDecodeError as e:
        raise VerificationFailed(f"invalid json: {e}")

    # 필수 키 체크(간단 예시)
    required = ["answer", "sources"]
    missing = [k for k in required if k not in obj]
    if missing:
        raise VerificationFailed(f"missing keys: {missing}")

    return obj

핵심은 “검증 실패의 이유”를 자연어로 길게 쓰는 게 아니라 에러 타입과 최소 힌트로 남기는 것입니다. 그래야 REPAIR 가 구체적으로 한 번에 고치고, 반복이 줄어듭니다.

상태머신 기반 오케스트레이션 예시(LangGraph 스타일)

아래는 LangGraph를 직접 호출하지 않더라도 이해 가능한, 상태머신 루프 제어의 골격 예시입니다. 포인트는 while 루프가 아니라 phase 전이가 중심이라는 점입니다.

from dataclasses import dataclass, field
from typing import Any

@dataclass
class AgentState:
    phase: str = "PLAN"
    goal: str = ""
    plan: list[str] = field(default_factory=list)
    last_output: Any = None
    last_error_type: str | None = None
    history: list[TransitionRecord] = field(default_factory=list)
    counters: RuntimeCounters = field(default_factory=RuntimeCounters)


def step(state: AgentState, budget: Budget) -> AgentState:
    enforce_budget(budget, state.counters)
    state.counters.steps += 1

    if state.phase == "PLAN":
        # planner_agent(...) 호출 결과라고 가정
        state.plan = ["task1", "task2"]
        state.history.append(TransitionRecord("PLAN", "make_plan", {"goal": state.goal}, "ok"))
        state.phase = "EXECUTE"
        return state

    if state.phase == "EXECUTE":
        enforce_budget(budget, state.counters)
        state.counters.tool_calls += 1

        # tool 호출/실행 결과라고 가정
        tool_res: ToolResult = {"ok": False, "error_type": "RATE_LIMIT", "retryable": True, "hint": "backoff"}

        obs_key = "ok" if tool_res.get("ok") else f"err:{tool_res.get('error_type')}"
        state.history.append(TransitionRecord("EXECUTE", "call_tool", {"task": state.plan[0]}, obs_key))
        detect_loop(state.history)

        if tool_res.get("ok"):
            state.last_output = tool_res.get("result")
            state.last_error_type = None
            state.phase = "VERIFY"
        else:
            state.last_error_type = tool_res.get("error_type")
            if not tool_res.get("retryable"):
                state.phase = "ABORT"
            else:
                state.phase = "REPAIR"
        return state

    if state.phase == "VERIFY":
        # verify(...) 수행
        state.history.append(TransitionRecord("VERIFY", "verify", {"has_output": state.last_output is not None}, "ok"))
        detect_loop(state.history)
        state.phase = "DONE"
        return state

    if state.phase == "REPAIR":
        # repair_agent(...) 호출 결과를 반영
        # 예: 백오프 후 재시도, 파라미터 수정, 대체 툴 선택
        state.history.append(TransitionRecord("REPAIR", "backoff", {"error": state.last_error_type}, "ok"))
        detect_loop(state.history)
        state.phase = "EXECUTE"
        return state

    return state


def run(goal: str):
    state = AgentState(goal=goal)
    budget = Budget(max_steps=15, max_tool_calls=5)

    while state.phase not in ["DONE", "ABORT"]:
        state = step(state, budget)

    return state

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

  • 루프가 발생해도 detect_loopBudget 에서 강제 종료
  • 실패가 error_type 으로 구조화되어 REPAIR 정책을 분기 가능
  • VERIFY 가 독립 상태라 “검증을 통과해야만 종료”가 보장

가드레일 5: “진행” 메트릭을 정의하고, 진행이 없으면 중단

루프는 결국 진행이 없는 실행입니다. 따라서 진행(progress)을 수치화하면 강력한 차단 장치가 됩니다.

예시 진행 메트릭:

  • 계획 체크리스트 완료 개수(done_tasks)
  • 새로 생성된 정보량(새로운 엔티티/새로운 파일/새로운 근거 링크)
  • diff 크기(코드 수정 라인 수) 또는 테스트 통과 수

정책 예:

  • 최근 5스텝 동안 done_tasks 증가가 0이면 ABORT 또는 사용자 질문으로 전환
  • 최근 3회 수정에서 diff가 거의 없으면 “수정 불가”로 판정

이건 운영에서 캐시/라우트 갱신이 안 되어 같은 응답만 반복되는 문제를 진단할 때와 결이 비슷합니다. 상태가 변하지 않는데 계속 요청만 반복하면 비용만 증가합니다. (관련해서 Next.js Route Cache로 데이터가 갱신 안될 때 같은 “변화 없음” 진단 관점이 도움이 됩니다.)

프롬프트 가드레일: 필요하지만, ‘마지막 방어선’으로 두기

프롬프트에 다음을 넣으면 루프 확률을 낮출 수는 있습니다.

  • “이미 시도한 방법을 반복하지 말 것”
  • “실패 원인을 한 문장으로 요약하고, 다음 액션은 하나만 제시”
  • “종료 조건을 만족하면 반드시 DONE 을 선언”

하지만 프롬프트는 강제력이 약합니다. 따라서 상태머신/예산/반복감지가 1차 방어선이고, 프롬프트는 보조로 두는 게 안정적입니다.

운영 관측(Observability): 루프는 로그 설계가 80%

루프를 잡으려면 “대화 로그”가 아니라 전이 로그가 필요합니다.

권장 로그 필드:

  • trace_id (요청 단위)
  • phase_from, phase_to
  • action_name, tool_name
  • action_input_hash
  • error_type, retryable
  • budget_remaining (steps/tool_calls/time)
  • loop_fingerprint (반복 감지 키)

이렇게 남기면 “어떤 전이가 반복되는지”가 즉시 보이고, 프롬프트 수정이 아니라 정책 수정으로 해결할 수 있습니다.

실전 체크리스트

  • 상태를 최소 PLAN/EXECUTE/VERIFY/REPAIR/DONE/ABORT 로 분리했는가
  • 전역 예산(max_steps, max_tool_calls, deadline)이 있는가
  • 툴 실패가 error_typeretryable 로 구조화되어 있는가
  • 반복 감지(최근 N개 전이 fingerprint)가 있는가
  • VERIFY 가 LLM 평가가 아니라 가능한 자동 검증으로 구성되어 있는가
  • 진행(progress) 메트릭이 있고, 진행이 없으면 중단/질문으로 전환하는가

마무리

LangChain 멀티에이전트의 루프는 “모델이 멍청해서”가 아니라, 종료 조건과 실패 처리의 구조가 부족해서 생깁니다. 상태머신으로 전이를 제한하고, 예산/반복감지/실패 타입화/자동 검증을 가드레일로 두면, 멀티에이전트는 훨씬 예측 가능하게 동작합니다.

다음 단계로는 (1) 각 error_type 별 재시도/대체 경로를 더 정교화하고, (2) 전이 로그를 기반으로 자주 반복되는 fingerprint를 상위 정책으로 승격해 “아예 그 루트로 가지 않게” 만드는 것이 운영 효율을 크게 올립니다.