LLM 에이전트가 사용자에게 중복 결제를 하지 않도록 작은 라이브러리를 만들었습니다
요약
LLM 에이전트의 도구 호출 시 발생할 수 있는 중복 실행 문제를 해결하기 위한 Python 라이브러리 'latch'를 소개합니다. 멱등성 키(idempotency key)를 사용하여 중복 결제나 주문과 같은 부작용을 방지합니다.
핵심 포인트
- 에이전트의 재시도 메커니즘이 비멱등적 도구 호출 시 중복 결제 등의 문제를 야기함
- latch 라이브러리는 데코레이터 방식으로 도구 함수의 멱등성을 보장함
- 인자 자동 해싱 대신 명시적 멱등성 키 사용을 통해 호출자의 문맥을 존중함
- 분산 시스템의 고전적 문제인 멱등성을 에이전트 환경에 맞게 구현함
네트워크를 통해 도구 (tools)를 호출하는 에이전트 (Agents)들은 결국 호출해서는 안 될 호출을 재시도하게 됩니다. 만약 해당 도구가 멱등성 (idempotent)을 갖추지 않았다면, 그 재시도는 중복 결제, 중복 이메일, 중복 주문이 됩니다. 로그에는 이를 나타낼 아무런 표시도 없이 조용히 말이죠. 이것은 새로운 에이전트 (agent)의 탈을 쓰고 나타난 수십 년 된 분산 시스템 (distributed-systems) 문제이며, 제가 알기로는 에이전트 형태의 이 문제를 해결하기 위해 pip install이 가능한 작은 수정 라이브러리를 출시한 사람은 아무도 없었습니다. 그래서 제가 하나 만들었습니다. 이름은 latch입니다. 미리 말씀드리자면, 이것은 완전히 새로운 아이디어는 아닙니다. 누군가 댓글로 지적하기 전에 제가 먼저 말해두는 편이 낫겠다고 생각했습니다.
버그의 실제 상황
라이브러리를 사용하지 않고 파일 하나로 구현된 전체 코드는 다음과 같습니다:
def charge_card(order_id, amount):
time.sleep(0.4) # 오늘 결제 API가 약간 느리네요
ledger[order_id] += 1
...
결제 호출에는 0.4초가 소요됩니다. 에이전트 (agent)는 0.2초 동안 기다린 후 포기하고 재시도합니다. 첫 번째 시도는 여전히 백그라운드에서 실행 중입니다. 취소되지 않았으며, 안전하게 취소할 수도 없습니다. Python 스레드 (threads)는 그런 방식으로 작동하지 않기 때문입니다. 따라서 첫 번째 시도는 자체적인 시간에 완료되어 카드를 결제합니다. 그 후 재시도가 카드를 다시 결제합니다. 에이전트 (agent)가 바라보는 세상은 "모든 것이 괜찮고, 결국 응답을 받았다"는 상태입니다. 하지만 원장 (ledger)은 다르게 말합니다.
제 말을 믿기보다 직접 실행하여 발생하는 과정을 보고 싶다면, 저장소의 examples/naive_agent_example.py를 확인해 보세요.
이것은 전혀 생소한 일이 아닙니다. Stripe를 다뤄본 적이 있는 모든 백엔드 엔지니어들이 겪어본 "결제 게이트웨이의 웹훅 (webhook)이 두 번 발생하면 어떻게 되는가"와 같은 종류의 문제입니다. 해결책에는 이름이 있습니다 — 멱등성 키 (idempotency keys) — 그리고 이는 지난 수십 년 동안 백엔드 시스템에서 표준 관행이었습니다. 다만 동일한 문제의 에이전트-도구-호출 (agent-tool-calling) 버전에 대해 pip install로 바로 사용할 수 있는 기성품 형태로는 나타나지 않았을 뿐입니다. 그것이 바로 공백이었습니다.
latch의 실체
도구 함수 (tool function)를 래핑(wrap)하여, 동일한 idempotency_key로 두 번 호출하더라도 단 한 번만 실행되도록 하는, 의존성(dependencies)이 필요 없는 작은 Python 라이브러리입니다:
from latch import idempotent
@idempotent()
...
idempotency_key는 필수 사항이며, 인자(arguments)로부터 자동 생성되지 않습니다. 이 부분에 대해 고민을 많이 했습니다. 인자를 자동으로 해싱(auto-hashing)하는 것은 매력적이며 데코레이터 (decorator)를 더 마법처럼 느껴지게 만들 것입니다. 하지만 저는 그렇게 하지 않았습니다. 약간 다른 인자를 가진 두 호출을 "동일한 작업"으로 간주해야 하는지는 호출자만이 문맥 (context)을 가지고 판단할 수 있는 영역이기 때문입니다. 잘못된 추측을 조용히 수행하는 것은 호출자가 키워드 인자 (kwarg) 하나를 더 입력하게 만드는 것보다 더 나쁩니다.
멱등성 (Idempotency)은 약간 더 큰 문제의 가장 작은 조각임이 드러났고, 따라서 이후 몇 주 동안 세 개의 데코레이터와 하나의 작은 오케스트레이션 클래스 (orchestration class)가 동일한 패키지에 함께 포함되었습니다:
| 기능 | 데코레이터 | 방지 항목 |
|---|---|---|
| 멱등성 (idempotency) | @idempotent | 재시도 시 중복된 부작용 (side effects) |
| ... | ... | ... |
이들은 독립적입니다. 하나만 사용하든 다섯 개를 모두 사용하든 상관없지만, 동일한 함수 위에서 조합 (compose)될 수 있습니다:
@budget_guardrail(max_calls=100, window_seconds=3600)
@circuit_breaker(failure_threshold=5, recovery_timeout=30.0)
@with_timeout(seconds=10)
...
이것이 실제로 도움이 되는가, 아니면 그저 듣기 좋은 이야기인가
저는 단순히 느낌 (vibes)만으로 이것을 출시하고 싶지 않았습니다. 그래서 동일하게 주입된 지연 시간 (latency) 하에서 위에서 언급한 중복 결제 시나리오를 정확히 두 번 실행하는 벤치마크 (benchmark)를 준비했습니다. 한 번은 아무것도 적용하지 않은 상태로, 다른 한 번은 @idempotent를 @with_timeout으로 래핑한 상태로 실행합니다:
$ python benchmarks/chaos_benchmark.py --seed 1 --operations 30
metric naive protected
...
30개의 시뮬레이션된 주문, 동일한 시드(seed), 양측 모두 동일한 지연 시간 곡선(latency curve)을 적용했습니다. naive(단순 방식) 열을 보면: 30개 중 21개가 중복 결제되었으며, 에이전트 자체의 성공/실패 보고 또한 배치(batch)의 3분의 1이 잘못되었습니다 (카드가 가끔 결제되었음에도 불구하고 9개가 완전히 실패한 것으로 보고됨). protected(보호 방식) 열을 보면: 중복 결제가 0건이었으며, "실패" 케이스는 두 번째 실행 대신 재시도 시 캐시 히트(cache hit)로 처리되었습니다. 다른 시드(seed 7, 20개 주문)로 두 번째 실행을 수행했을 때도 naive는 12건의 중복 결제, protected는 0건으로 나타났습니다. 이는 특정 시드를 골라낸 것이 아니라 동일한 양상을 보였습니다.
제가 실제로 이야기하고 싶은 부분
"공개 사용 준비 완료" README 섹션을 작성하기 전에, 저는 이미 배포된 제 패키지를 대상으로 실제 감사를 수행했습니다. 단순히 코드를 다시 읽는 것이 아니라, 새로 설치하여 수행하는 스모크 테스트(smoke tests), 문서의 모든 코드 스니펫(snippet)을 실제로 실행하는 테스트, 그리고 — 가장 중요한 부분인 — 실제 멀티스레드(multi-threaded) 및 asyncio.gather 동시성(concurrency) 테스트를 진행했습니다. 기존의 111개 테스트는 모두 순차적(sequential)이었습니다: 호출, 재시도, 단언(assert). 두 개의 스레드가 동시에 데코레이터(decorator)를 호출하는 상황은 한 번도 없었습니다.
결과적으로 그것이 매우 중요했습니다. 저는 이미 PyPI에 올라가 있는 코드에서 네 가지의 실제적이고 조용히 숨어있던 버그를 발견했습니다:
@idempotent는None을 반환하는 함수를 중복 제거(dedupe)하지 못했습니다.store.get() is not None방식으로는 "캐시된 것이 없음"과 "None이 캐시됨"을 구분할 수 없었기 때문에, 실행 후 잊어버리는 방식(fire-and-forget)의 도구(삭제, 알림 등)는 전혀 중복 제거가 되지 않았습니다.- 데코레이터가 적용된 두 함수가 저장소(store)를 공유할 경우, 동일한 키(key)에서 충돌이 발생하여 서로의 캐시된 결과를 조용히 반환할 수 있었습니다.
- 순차적인 재시도가 아닌 실제 스레드가 경합하는 실제 동시성 환경에서, "캐시 확인 후 실행" 시퀀스가 원자적(atomic)이지 않았습니다. N개의 스레드가 동일한 순간에 동일한 키를 호출하면, 모두가 캐시 미스(cache miss)를 확인하고 모두 실행될 수 있었습니다.
- 서킷 브레이커(circuit breaker)의 반개방(half-open) 상태에서 무제한의 동시 호출이 "시험(trial)" 호출로서 통과되었습니다. 반개방 상태의 핵심 목적은 정확히 단 하나의 시험 호출을 허용하는 것입니다.
이러한 버그들은 시스템을 충돌시키거나, 예외(throw)를 발생시키거나, 코드 리뷰에서 드러나지 않는 종류의 버그들입니다. 그저 약속된 기능보다 조용히 덜 작동할 뿐입니다. 저는 다른 사람의 프로덕션 트래픽(production traffic)이 이를 발견하기 전에 직접 찾아내는 편을 택하겠습니다. 네 가지 버그를 모두 수정했고, 단순한 로직뿐만 아니라 동시성(concurrency)을 구체적으로 겨냥한 17개의 회귀 테스트(regression tests)를 추가했습니다. 또한 변경 로그(changelog)에 단순히 "기타 수정 사항(misc fixes)"으로 조용히 묻어버리는 대신, 무엇이 잘못되었는지 정확히 기록했습니다. 만약 여러분이 실제 서비스에 이와 같은 라이브러리를 검토하고 있다면, 단지 "개선 사항(improvements)"이라고만 적힌 변경 로그보다는 "내 코드에서 무엇이 잘못되었고 어떻게 해결했는지"를 보여주는 것이 훨씬 더 유용할 것입니다.
이것이 아닌 것
이 소프트웨어는 알파(Alpha) 단계이며, 여러분이 고생하며 한계에 부딪히는 것보다 차라리 과소평가하는 편이 낫다고 생각합니다.
Saga는 지속성(persistence)을 지원하지 않습니다. 보상(Compensation)은 프로세스 내(in-process)에서 실행됩니다. 만약 사가(saga) 도중에 프로세스가 종료되면 아무것도 자동으로 재개되지 않습니다. 충돌 내성(crash-durable)을 가진 다단계 워크플로(multi-step workflows)가 필요하다면, 이것이 아니라 Temporal이나 Step Functions를 사용해야 합니다.- 서킷 브레이커(circuit breaker)와 예산 가드레일(budget guardrail)은 프로세스당 상태를 유지합니다. 서비스의 복제본(replica)을 5개 실행하면, 직접 조정(coordination) 로직을 구축하지 않는 한 서로 통신하지 않는 5개의 독립적인 서킷이 생성됩니다.
- 동기식
@with_timeout은 실제 하위 호출(underlying call)을 종료할 수 없습니다. 파이썬(Python)에는 스레드(thread)를 강제로 종료할 수 있는 안전한 방법이 없기 때문입니다. 호출자의 차단(unblock)을 해제하고 예외를 발생시키지만, 원래의 호출은 여전히 백그라운드에서 실행 중일 수 있습니다. 이것이 바로@idempotent가 존재하는 시나리오이며, 그렇기에 이들은 서로를 대체하는 것이 아니라 함께 쌓아서(stacked) 사용하도록 설계되었습니다.
이 중 그 어느 것도 비밀스러운 결격 사유는 아닙니다. 단지 암시적으로 넘기지 않고 명문화한 실제 범위(scope)일 뿐입니다.
기존 기술(Prior art), 다시 한번
공로를 명확히 하자면: SagaLLM (arXiv 2503.11951)은 이미 멀티 에이전트 LLM 계획 (multi-agent LLM planning)에 Saga 패턴을 적용하고 있으며, Robust Agent Compensation (ACM CAIS)은 에이전트가 자신의 실패를 보상하는 법을 배우는 내용을 다루고 있고, ReliabilityBench는 정확히 이 영역을 위한 전체 벤치마크입니다. 서킷 브레이커 (Circuit breakers)와 타임아웃 (timeouts)은 수십 년 된 패턴이며, 일반적인 맥락에서 이를 잘 수행하는 기존 Python 라이브러리(pybreaker, tenacity)들이 이미 존재합니다. 제가 이 중 그 어떤 것도 발명했다고 주장하는 것이 아닙니다. 제가 생각하기에 실제로 부족했던 것은 지루한 부분, 즉 LLM 도구 호출 (tool call)의 형태에 특화되어, 테스트를 거쳤고, 의존성이 없으며 (zero-dependency), pip install이 가능한, 개발자가 논문을 먼저 읽지 않고도 오늘 오후에 바로 에이전트 루프 (agent loop)에 집어넣을 수 있는 작은 버전이었습니다. 만약 제 생각이 틀렸고 이미 이런 것이 존재하는데 제가 놓친 것이라면, 진심으로 알고 싶습니다. 댓글로 알려주세요.
사용해 보기
pip install latch-idempotent
- Repo: github.com/sangaraju1988/latch
- PyPI: pypi.org/project/latch-idempotent
- 위의 중복 결제 데모:
examples/naive_agent_example.py/examples/resilient_agent_example.py
만약 여러분이 실제 부작용 (side effect)을 동반하는 도구를 호출하고 실패 시 재시도 (retry)하는 무언가를 만들고 있다면 — 에이전트를 만들고 있다면, 여러분이 그렇게 생각했든 아니든 이미 그러고 있을 것입니다 — 이것이 여러분에게 유용할지, 혹은 어디에서 한계가 드러날지 궁금합니다. 이슈 (Issues)와 PR (Pull Requests)은 언제나 환영하며, 솔직히 지금 저에게는 스타 (stars)보다 "이것이 어디서 깨지는가"에 대한 피드백이 더 가치 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기