Published on

AutoGPT 멀티에이전트 레이스·중복 실행 잡기

Authors

멀티에이전트 기반 AutoGPT류 시스템을 운영하다 보면, 어느 순간부터 같은 태스크가 두 번 실행되거나, 서로 다른 에이전트가 동일 리소스를 동시에 갱신해 결과가 흔들리는 문제가 나타납니다. 사용자 입장에서는 “왜 답이 두 번 오지?”, 운영자 입장에서는 “토큰 비용이 왜 두 배지?”, 데이터팀 입장에서는 “왜 DB에 동일 레코드가 중복 삽입되지?” 같은 증상으로 드러납니다.

이 글은 AutoGPT 스타일 멀티에이전트에서 발생하는 레이스 컨디션(race condition)중복 실행(duplicate execution)

  • 어떻게 재현하고
  • 어디서 관측하며
  • 어떤 설계/구현으로 차단할지

를 실전 관점에서 정리합니다.

또한 LLM API 과부하나 재시도로 인해 중복이 증폭되는 경우가 많으니, 재시도·큐잉 패턴은 Claude API 529 Overloaded 재시도·큐잉 패턴 정리도 같이 보면 도움이 됩니다.

왜 AutoGPT 멀티에이전트에서 레이스·중복이 잦은가

멀티에이전트 오케스트레이션은 보통 다음 요소를 동시에 갖습니다.

  1. 비동기 실행: 에이전트가 병렬로 돌아가고, 이벤트/큐 기반으로 트리거됨
  2. 외부 의존성: LLM API, 브라우저 자동화, 벡터DB, SaaS 호출 등 느리고 실패 가능
  3. 재시도: 타임아웃, 429, 5xx 등으로 동일 요청을 다시 보냄
  4. 상태 저장: 계획(plan), 메모리(memory), 도구 실행 결과를 DB/스토리지에 누적

이 조합은 “한 번만 실행되어야 하는 작업”을 “최소 한 번(at-least-once)” 처리로 만들기 쉽습니다. 특히 다음이 흔한 트리거입니다.

  • 워커가 타임아웃으로 죽고 재기동되며 같은 메시지를 재처리
  • 네트워크 단절로 클라이언트가 응답을 못 받고 재요청(서버는 이미 처리 중)
  • 에이전트 A/B가 동일한 목표를 공유하고 동일 도구를 동시 호출
  • 스트리밍 처리 중 연결이 끊겨 “부분 결과”를 재전송(중복 토큰/중복 메시지)

스트리밍 중 중복 출력은 UI/클라이언트 문제로 보이지만, 실제로는 서버 측에서 동일 청크를 두 번 발행한 경우도 많습니다. 관련 원인 정리는 LangChain 스트리밍 중복 토큰·끊김 7가지 원인도 참고하세요.

문제를 분해하자: 중복 실행의 3가지 유형

현장에서 “중복”이라고 뭉뚱그리면 해결이 늦어집니다. 아래처럼 유형을 나누면 처방이 명확해집니다.

1) 동일 태스크의 중복 소비(Queue redelivery)

큐/스트림에서 같은 메시지가 두 번 전달되는 케이스입니다. at-least-once 전달이 기본인 시스템(Kafka, SQS 등)에서는 정상 동작이기도 합니다.

  • 증상: 동일 task_id가 두 번 실행 로그에 찍힘
  • 원인: ack 누락, visibility timeout 만료, 컨슈머 크래시
  • 처방: idempotency 키, 작업 상태 머신, 원자적 상태 전이

2) 동시 실행 레이스(Concurrent execution)

같은 task_id를 두 워커가 동시에 잡거나, 서로 다른 task_id가 같은 리소스를 갱신하는 케이스입니다.

  • 증상: 결과가 비결정적, “마지막에 쓴 값”으로 덮임
  • 원인: 락 부재, 트랜잭션 경계 불명확, 공유 리소스 설계 문제
  • 처방: 분산 락, 낙관적 락(version), 직렬화 키(샤딩)로 단일 워커 처리

3) 재시도에 의한 중복 부작용(Retry side effects)

LLM 호출 자체는 멱등이 아니고(같은 입력에도 출력이 달라질 수 있음), 도구 호출은 더더욱 멱등이 아닙니다(결제, 이메일 발송, DB insert 등).

  • 증상: 이메일/결제/티켓 생성이 두 번 발생
  • 원인: 타임아웃 후 재시도, 네트워크 오류, 서킷브레이커 미비
  • 처방: 멱등 엔드포인트, outbox 패턴, “확정 커밋 후 발행”

재현부터: 의도적으로 레이스를 만들어라

운영에서만 재현되는 레이스는 잡기 어렵습니다. 아래처럼 실험용 장애 주입을 넣으면 빨라집니다.

  • 워커 처리 시간을 랜덤 지연(sleep)으로 흔들기
  • 큐 ack를 일부러 늦추거나 누락시키기
  • DB 커밋 직전에 프로세스를 강제 종료
  • LLM 호출을 임의로 타임아웃 처리

Python 예시: 의도적 중복 소비 시뮬레이션

import random
import time
from concurrent.futures import ThreadPoolExecutor

# 같은 task_id를 두 번 소비하는 상황을 흉내
TASK_ID = "task-123"

processed = []

def worker(name: str):
    # 랜덤 지연으로 레이스 유도
    time.sleep(random.uniform(0.01, 0.2))
    processed.append((name, TASK_ID))

with ThreadPoolExecutor(max_workers=2) as ex:
    ex.submit(worker, "agent-a")
    ex.submit(worker, "agent-b")

print(processed)

이 코드는 단순하지만, 실제 시스템에서도 “동시 실행”은 결국 이런 형태로 발생합니다. 이제 중요한 건 중복을 허용하되 부작용을 1회로 수렴시키는 것입니다.

1차 방어선: Idempotency 키로 “부작용”을 1회로 만들기

중복 실행을 0으로 만드는 건 어렵습니다. 대신 부작용을 1회만 발생시키는 것이 현실적인 목표입니다.

핵심 규칙

  • 외부로 나가는 “부작용” 호출(결제, 이메일, 티켓 생성, DB insert)은 반드시 idempotency_key를 가진다.
  • 서버는 idempotency_key 기준으로 이미 처리된 요청이면 이전 결과를 반환한다.

SQL 테이블 예시

CREATE TABLE idempotency_keys (
  key TEXT PRIMARY KEY,
  status TEXT NOT NULL,
  response_json TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

처리 흐름(의사코드)

def handle_side_effect(request, idempotency_key: str):
    row = db.get("SELECT status, response_json FROM idempotency_keys WHERE key = ?", idempotency_key)

    if row and row["status"] == "done":
        return json.loads(row["response_json"])

    # 없으면 선점(원자적으로 insert)
    try:
        db.execute(
            "INSERT INTO idempotency_keys(key, status) VALUES(?, ?)",
            idempotency_key,
            "processing",
        )
    except UniqueViolation:
        # 누군가 이미 처리 중이거나 완료
        row = db.get("SELECT status, response_json FROM idempotency_keys WHERE key = ?", idempotency_key)
        if row and row["status"] == "done":
            return json.loads(row["response_json"])
        raise RetryLater("processing")

    # 실제 부작용 실행
    result = do_external_call(request)

    db.execute(
        "UPDATE idempotency_keys SET status = ?, response_json = ? WHERE key = ?",
        "done",
        json.dumps(result),
        idempotency_key,
    )

    return result

여기서 중요한 포인트는 INSERT락 역할도 한다는 점입니다. “처리 중”을 선점한 주체만 부작용을 실행하고, 나머지는 결과를 재사용하거나 잠시 후 재시도하게 만듭니다.

2차 방어선: 분산 락으로 동시 실행 자체를 줄이기

Idempotency는 부작용을 막지만, LLM 호출 비용이나 도구 실행 비용까지 줄여주진 않습니다. 같은 태스크를 두 번 실행하면 토큰 비용은 그대로 두 배가 됩니다. 그래서 태스크 단위 락이 필요합니다.

Redis 분산 락(SET NX) 예시

import time
import redis

r = redis.Redis(host="localhost", port=6379)

def acquire_lock(lock_key: str, ttl_sec: int = 60) -> bool:
    # NX: 없을 때만 set, EX: TTL
    return bool(r.set(lock_key, "1", nx=True, ex=ttl_sec))

def release_lock(lock_key: str):
    r.delete(lock_key)

def run_task(task_id: str):
    lock_key = f"lock:task:{task_id}"
    if not acquire_lock(lock_key, ttl_sec=120):
        return {"status": "skipped", "reason": "locked"}

    try:
        # LLM 호출 + 도구 실행 등
        result = heavy_work(task_id)
        return {"status": "done", "result": result}
    finally:
        release_lock(lock_key)

락 설계 체크리스트

  • TTL은 “최악 처리 시간”보다 충분히 길게(짧으면 락이 풀려 중복 실행)
  • 워커가 죽었을 때 TTL로 자동 해제되도록(영구 락 방지)
  • 가능하면 락 해제는 안전하게(소유자 토큰 비교) 구현

Redis 락을 제대로 하려면 소유자 토큰, 연장(renewal), 네트워크 분할 고려가 필요합니다. 단순 DEL은 위험할 수 있으니, 프로덕션에서는 검증된 라이브러리나 Redlock 계열을 신중히 검토하세요.

3차 방어선: 상태 머신으로 “중복 실행”을 설계로 흡수하기

멀티에이전트는 한 번의 호출로 끝나지 않고, 보통 다음 단계를 순환합니다.

  • 계획 수립
  • 도구 실행
  • 관측/검증
  • 다음 계획

이때 “현재 태스크가 어느 단계인지”가 DB에 명확히 기록되지 않으면, 워커 재시작/중복 소비 시 같은 단계를 다시 실행해버립니다.

권장: 단일 테이블 상태 머신

  • task_id
  • state: queued, running, tool_pending, waiting_llm, done, failed
  • state_version: 낙관적 락을 위한 버전
CREATE TABLE tasks (
  task_id TEXT PRIMARY KEY,
  state TEXT NOT NULL,
  state_version INT NOT NULL DEFAULT 0,
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

낙관적 락으로 원자적 전이

UPDATE tasks
SET state = 'running', state_version = state_version + 1, updated_at = NOW()
WHERE task_id = $1 AND state = 'queued' AND state_version = $2;
  • 업데이트된 row 수가 1이면 전이 성공
  • 0이면 누군가 먼저 가져갔거나 상태가 바뀐 것

이 방식은 “동시 실행”을 DB 레벨에서 제어할 수 있어, Redis 같은 외부 락 없이도 상당 부분 해결됩니다.

LLM 호출 중복을 줄이는 실전 패턴

1) 프롬프트/툴 입력 해시로 결과 캐시

같은 입력으로 같은 호출을 반복하는 경우가 많습니다(특히 플래너 에이전트). 입력을 정규화한 뒤 해시를 키로 캐시하면 토큰 비용을 크게 줄일 수 있습니다.

import hashlib
import json

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

cache_key = stable_hash({
    "model": model_name,
    "system": system_prompt,
    "messages": messages,
    "tools": tools_schema,
})

주의할 점은 “같은 입력이어도 출력이 달라도 된다”는 제품 요구사항입니다. 플래닝/요약처럼 결정성이 크게 중요하지 않은 단계에만 캐시를 적용하는 것이 안전합니다.

2) 스트리밍 응답은 chunk_id로 중복 제거

서버가 SSE/WebSocket으로 토큰을 밀 때, 네트워크 재연결로 같은 청크를 재전송하면 클라이언트는 두 번 출력합니다. 해결은 간단하게 청크에 단조 증가 chunk_id 를 붙이고, 클라이언트에서 마지막 처리 chunk_id 이후만 반영하는 것입니다.

type Chunk = { chunk_id: number; text: string }

let last = -1
function onChunk(c: Chunk) {
  if (c.chunk_id <= last) return
  last = c.chunk_id
  appendToUI(c.text)
}

큐잉/워커 설계: “한 태스크는 한 워커”로 직렬화하기

멀티에이전트라고 해서 모든 것을 무조건 병렬로 돌리면 레이스가 폭발합니다. 특히 다음처럼 직렬화 키를 두면 안정성이 크게 올라갑니다.

  • conversation_id 또는 workspace_id 단위로 파티셔닝
  • 같은 파티션은 단일 컨슈머만 처리

Kafka라면 파티션 키로, SQS라면 FIFO의 MessageGroupId 같은 개념으로 구현합니다.

추가로, LLM API가 과부하일 때 재시도가 폭주하면 중복 실행이 더 늘어납니다. 이때는 “재시도”를 각 워커가 제각각 하지 말고, 중앙 큐에서 지연 재시도로 통제하는 것이 좋습니다. 자세한 패턴은 Claude API 529 Overloaded 재시도·큐잉 패턴 정리에 잘 정리되어 있습니다.

관측(Observability): 중복을 ‘보이게’ 만들어야 잡힌다

중복 실행은 로그 몇 줄로는 놓치기 쉽습니다. 최소한 아래 3가지는 꼭 남기세요.

1) 실행 상관관계 ID

  • task_id
  • run_id (한 번의 시도)
  • agent_id
  • idempotency_key

이 네 가지가 있으면 “같은 태스크가 왜 두 번 실행됐는지”를 타임라인으로 복원할 수 있습니다.

2) 단계별 타이밍

  • LLM 호출 시작/끝
  • 도구 실행 시작/끝
  • DB 상태 전이 성공/실패

타이밍이 있어야 “visibility timeout 때문에 재전달” 같은 문제를 빠르게 특정합니다.

3) 메트릭

  • duplicate_task_detected_total
  • lock_acquire_failed_total
  • idempotency_replay_total
  • task_state_transition_conflict_total

이 지표들이 올라가면, 레이스가 늘고 있다는 조기 신호가 됩니다.

흔한 함정 7가지와 처방

  1. 락 TTL이 짧다: 처리 중 TTL 만료로 중복 실행 발생
  2. 락 해제가 안전하지 않다: A가 잡은 락을 B가 해제하는 사고(소유자 토큰 필요)
  3. DB 상태 전이가 원자적이지 않다: SELECTUPDATE 사이에 레이스
  4. 도구 호출이 멱등이 아니다: 이메일/결제/티켓 생성은 반드시 idempotency 적용
  5. 재시도가 무제한: 폭주로 중복이 증폭(지수 백오프, 최대 횟수, 지연 큐)
  6. 스트리밍 재연결 처리 없음: UI에 중복 토큰 출력
  7. 에이전트 간 공유 메모리 동시 갱신: 마지막 쓰기 승리로 메모리 오염(버전 필드, append-only 로그)

추천 아키텍처 조합(현실적인 타협안)

완벽한 단일 해법은 없습니다. 다만 운영 난이도 대비 효과가 좋은 조합은 아래입니다.

  • 큐는 at-least-once로 두고, 컨슈머는 멱등 처리를 전제로 설계
  • task_id 단위로 DB 상태 머신 + 낙관적 락 적용
  • 부작용 도구 호출은 idempotency 키로 보호
  • 비용 큰 작업(LLM, 브라우저)은 분산 락으로 중복 실행 자체를 억제
  • 스트리밍은 chunk_id로 중복 제거
  • 재시도는 워커가 임의로 하지 말고 지연 큐/스케줄러로 통제

이 구성을 적용하면 “가끔 중복 실행이 발생하더라도 결과와 부작용은 1회로 수렴”하고, 토큰 비용도 상당히 줄일 수 있습니다.

마무리: 목표는 ‘0중복’이 아니라 ‘1회 효과’

AutoGPT 멀티에이전트에서 레이스와 중복은 구조적으로 발생합니다. 중요한 건 이를 버그로만 보지 않고, 분산 시스템의 기본 전제인 중복 전달/중복 실행 가능성을 받아들인 뒤

  • 멱등성(idempotency)
  • 원자적 상태 전이(state machine)
  • 동시성 제어(lock/version)
  • 재시도 통제(queueing)
  • 관측성(observability)

로 “부작용과 결과를 1회로 수렴”시키는 것입니다.

다음 단계로는, 실제 운영 환경에서 중복이 폭증하는 순간이 종종 CrashLoopBackOff 같은 워커 재시작과 맞물리므로, 컨테이너/오케스트레이션 레벨 진단도 같이 준비해두면 좋습니다. 필요하면 Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단도 함께 점검해보세요.