- Published on
AutoGPT 멀티에이전트 레이스·중복 실행 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
멀티에이전트 기반 AutoGPT류 시스템을 운영하다 보면, 어느 순간부터 같은 태스크가 두 번 실행되거나, 서로 다른 에이전트가 동일 리소스를 동시에 갱신해 결과가 흔들리는 문제가 나타납니다. 사용자 입장에서는 “왜 답이 두 번 오지?”, 운영자 입장에서는 “토큰 비용이 왜 두 배지?”, 데이터팀 입장에서는 “왜 DB에 동일 레코드가 중복 삽입되지?” 같은 증상으로 드러납니다.
이 글은 AutoGPT 스타일 멀티에이전트에서 발생하는 레이스 컨디션(race condition) 과 중복 실행(duplicate execution) 을
- 어떻게 재현하고
- 어디서 관측하며
- 어떤 설계/구현으로 차단할지
를 실전 관점에서 정리합니다.
또한 LLM API 과부하나 재시도로 인해 중복이 증폭되는 경우가 많으니, 재시도·큐잉 패턴은 Claude API 529 Overloaded 재시도·큐잉 패턴 정리도 같이 보면 도움이 됩니다.
왜 AutoGPT 멀티에이전트에서 레이스·중복이 잦은가
멀티에이전트 오케스트레이션은 보통 다음 요소를 동시에 갖습니다.
- 비동기 실행: 에이전트가 병렬로 돌아가고, 이벤트/큐 기반으로 트리거됨
- 외부 의존성: LLM API, 브라우저 자동화, 벡터DB, SaaS 호출 등 느리고 실패 가능
- 재시도: 타임아웃,
429,5xx등으로 동일 요청을 다시 보냄 - 상태 저장: 계획(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_idstate:queued,running,tool_pending,waiting_llm,done,failedstate_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_idrun_id(한 번의 시도)agent_ididempotency_key
이 네 가지가 있으면 “같은 태스크가 왜 두 번 실행됐는지”를 타임라인으로 복원할 수 있습니다.
2) 단계별 타이밍
- LLM 호출 시작/끝
- 도구 실행 시작/끝
- DB 상태 전이 성공/실패
타이밍이 있어야 “visibility timeout 때문에 재전달” 같은 문제를 빠르게 특정합니다.
3) 메트릭
duplicate_task_detected_totallock_acquire_failed_totalidempotency_replay_totaltask_state_transition_conflict_total
이 지표들이 올라가면, 레이스가 늘고 있다는 조기 신호가 됩니다.
흔한 함정 7가지와 처방
- 락 TTL이 짧다: 처리 중 TTL 만료로 중복 실행 발생
- 락 해제가 안전하지 않다: A가 잡은 락을 B가 해제하는 사고(소유자 토큰 필요)
- DB 상태 전이가 원자적이지 않다:
SELECT후UPDATE사이에 레이스 - 도구 호출이 멱등이 아니다: 이메일/결제/티켓 생성은 반드시 idempotency 적용
- 재시도가 무제한: 폭주로 중복이 증폭(지수 백오프, 최대 횟수, 지연 큐)
- 스트리밍 재연결 처리 없음: UI에 중복 토큰 출력
- 에이전트 간 공유 메모리 동시 갱신: 마지막 쓰기 승리로 메모리 오염(버전 필드, 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분 진단도 함께 점검해보세요.