50달러 예산으로 20개의 에이전트를 실행해 보세요. 각 에이전트는 모델을 호출하기 전에 spent < limit을 확인하며, 다른 19개 에이전트가 아직 사용량을 보고하지 않았기 때문에 모두 통과합니다. 몇 초 후에는 71달러가 됩니다. 모든 에이전트는 올바르게 작동했지만 예산은 여전히 파괴되었습니다.
제가 가장 많이 보는 실패 사례는 자체 제작한 비용 보호 장치(cost guards)에서 발생합니다. 원인을 제공하는 코드, 이를 수정하는 코드, 그리고 사람들이 대신 의존하는 통제 메커니즘들을 비교해 보겠습니다.
순진한 가드 (The naive guard)
spent = 0.0
LIMIT = 50.0
...
보기에는 괜찮아 보이며, 단일 스레드(single-threaded) 스크립트에서는 대부분 그렇습니다. 버그는 확인(check)과 증가(increment) 사이의 간격에 있습니다. 이 간격은 제공업체(provider)로 돌아가는 전체 왕복 시간(round-trip)이며, 긴 완료(completion)의 경우 5초에서 30초까지 쉽게 걸릴 수 있습니다. 이 간격 동안 시작되는 모든 요청은 동일한 오래된 spent 값을 읽습니다. 워커를 20개 실행하면, 각자 전체 남은 예산을 가지고 있다고 믿는 20개의 요청이 비행(in flight)하게 됩니다.
또한 두 번째 버그가 있습니다: 이 확인 과정은 다음 호출이 얼마나 비쌀 수 있는지 무시합니다. 0.40달러가 남아 있어도, 8,000개 출력 토큰을 생성할 수 있는 요청은 여전히 통과됩니다.
일반적인 제어 메커니즘들이 막지 못하는 것들
무언가를 작성하기 전에, 아마 이미 가지고 있을 법한 것들에 대해 명확히 할 필요가 있습니다.
| 제어 (Control) | 실행 위치 (Where it runs) | 작동 시점 (When it acts) | 통과되는 것 (What gets through) |
|---|
| 예산 경고 (Budget alert) | 청구 시스템 (Billing system) | 지출이 기록된 후 (After spend is recorded) | 임계값(threshold)부터 사람이 이메일을 읽기까지의 모든 것 |
| ... | | | |
경고에 대해서는 공급업체들이 명확하게 밝히고 있습니다. Google Cloud의 예산 문서는 경고 전용 예산이 사용량을 제한하지 않으며, 경고 이메일이 청구를 막지 않는다고 말합니다. OpenAI의 지출 한도 가이드(spend-limit guide)는
수치는 많은 팀들이 이 사실을 아직 체득하지 못했음을 시사합니다. DoiT가 2026년 2월에 실시한 Sapio Research 설문조사에 따르면, 미국과 영국 대기업의 재무 책임자 500명을 대상으로 AI 지출에 대해 질문했습니다. 응답자의 79%가 지난 한 해 동안 AI 비용을 초과 지출했다고 답했습니다. '매우 성숙하거나 선도적인' 수준이라고 평가한 FinOps(Financial Operations) 관행을 가진 조직의 경우, 이 비율은 89%였으며 평균 초과 지출액은 30.9%에 달했습니다. 더 나은 대시보드가 청구서를 막아주지는 못합니다. 단지 그 사실을 더 빨리 알려줄 뿐입니다.
해결책: 최악의 경우를 예비 확보(reserve)한 후 정산하기
이 패턴은 결제 시스템에서 사용하는 방식과 같습니다. 호출 전에 요청이 발생할 수 있는 최대 비용만큼 가승인(hold)을 설정합니다. 만약 이 가승인이 불가능하면, 해당 요청은 아예 전송되지 않습니다. 그 후에 가승인을 해제하고 실제로 요청에 얼마가 들었는지 기록합니다.
import threading
from dataclasses import dataclass
...
Check-and-reserve는 하나의 락(lock) 획득 내에서 발생하므로, 두 워커가 같은 여유 자금(headroom)을 주장할 수 없습니다. $5를 예약하는 열 명의 워커가 $50 예산을 채우면, 열한 번째 요청은 소켓을 열기 전에 거부됩니다.
이제 클라이언트를 감싸는 방식을 구현합니다:
import openai
def estimate_input_tokens(messages) -> int:
...
여기에는 몇 가지 의도적인 선택이 포함되어 있습니다:
max_completion_tokens가 필수입니다. 출력에 대한 상한선(ceiling)이 없으면, 예비 확보할 최악의 경우가 존재하지 않으며 가드 기능이 작동할 수 없습니다. 추론 모델(reasoning models)의 경우 이 점이 더욱 중요합니다. 왜냐하면 숨겨진 추론 토큰도 출력으로 청구되며 이 동일한 한도에 포함되기 때문입니다.
타임아웃은 가승인 시점에 비용이 발생합니다. 사용자 측에서 타임아웃된 요청이라 하더라도, 제공자(provider) 측에서는 이미 완료되어 청구되었을 수 있습니다. 따라서 약간의 여유 자금으로 비용을 지불했다고 가정해야 합니다. 만약 그렇게 하지 않는다면 예산은 서서히 누수됩니다.
가격 책정되지 않은 모델은 실패해야 합니다. 알 수 없는 모델에 대해서는 KeyError를 발생시키는 딕셔너리 조회(dict lookup)를 통해 Price를 구축하세요. 가격을 0으로 대체하는 것은 상한선이 조용히 작동을 멈추게 만듭니다.
Anthropic의 버전도 형태가 같습니다. max_tokens는 이미 필수 인자이며, 사용량은 resp.usage.input_tokens와 resp.usage.output_tokens로 반환됩니다.
부하 테스트를 통해 증명하기
from concurrent.futures import ThreadPoolExecutor
guard = BudgetGuard(limit_usd=1.00)
...
여기서 각 호출은 최대 약 $0.04가 들 수 있으므로, 첫 번째 배치만 상한선까지 예약을 하고 나머지는 즉시 차단되며, 이 단언문(assertion)은 항상 성립합니다. 동일한 테스트를 순진한 가드(naive guard)에 적용하면 타이밍에 따라 단언문이 실패하며, 이는 CI에서 재현하기 어려운 까다로운 버그가 됩니다.
이것만으로는 부족한 경우
threading.Lock은 단일 프로세스만을 다룹니다. 여러 gunicorn worker, Celery fleet 또는 몇 개의 컨테이너를 사용하게 되면, 예약 기능은 공유 스토리지로 이동해야 합니다. 즉, 비교 및 증가(compare-and-increment) 작업을 원자적으로 수행하는 Redis Lua 스크립트나, 초과했을 경우 영향을 받는 행이 0개가 되도록 하는 Postgres의 UPDATE budgets SET reserved = reserved + $1 WHERE id = $2 AND spent + reserved + $1 <= cap_usd RETURNING ... 같은 구문입니다. 로직은 동일하며, 오직 잠금(lock) 메커니즘만 변경됩니다.
또한 예약된 금액이 만료되도록 처리하는 것도 원할 것입니다. 예약과 정산 사이에 충돌한 worker는 스태일(stale) 예약을 청소해주는 무언가가 없다면 영구적으로 예산을 잠가버립니다.
저는 이 패턴을 baar-core라는 오픈 소스 Python 라이브러리로 패키징했습니다. 이 라이브러리는 각 LLM 호출에 하드 캡(hard cap)을 적용하고, 제공업체(provider)에 연락하기 전에 402 에러를 발생시키며, 원자적 예약(atomic reservation)을 사용하여 동시 호출이 공동으로 초과하는 것을 방지합니다. 직접 예외 케이스를 관리하기 어렵다면 pip install baar-core를 사용하세요. SaaS 제품 전반에 걸쳐 사용자별 예산이 필요한 팀을 위해 noburn.dev가 이를 기반으로 구축되었으며, 사용자가 예산을 초과했을 경우 API 호출이 실행되기 전에 차단하여 제한을 강제합니다.
어떤 것을 사용하든 중요한 것은 확인(check) 과정이 요청이 외부로 전송되기 전에 수행되어야 한다는 것입니다. 그 이후의 모든 것은 단지 보고일 뿐입니다.
현재 프로덕션 환경에서 가장 최악의 max_tokens는 얼마이며, 그것들 중 스무 개를 동시에 실행하는 비용을 실제로 지불할 수 있습니까?
원문 출처: [https://www.robatdasorvi.com/chapters/building/why-i-built-hard-spending-caps-for-llm-apis]