LLM Gateway Admission Control
- Rate Limits
- Budget Control — current
- Spend Limits
TL;DR
정책
- 각 Team은 하나의 USD Account를 가지며, 서로 다른 API Key도 같은 Account Balance를 공유합니다.
- Redis의 Account Balance Cache가 0 이상인 경우에만 request를 허용합니다.
- 성공한 inference는 즉시 rough debit하고, 별도 worker가 Usage Charge와 Account Balance를 확정합니다.
흐름
Redis Account Balance Cache로 Team의 추가 사용을 빠르게 막고, Worker가 DB에서 정확한 잔액을 확정합니다.
- Preflight: Cache가 0 이상일 때만 inference를 시작합니다.
- Postflight: 성공한 inference는 rough debit으로 Cache에 바로 반영합니다.
- Settlement: Worker가 Usage Charge·Ledger·Account Balance를 DB transaction으로 확정하고 Cache를 갱신합니다.
1. Budget Control은 잔액과 Cache를 분리합니다
Budget Control은 지금 inference를 계속 허용할지 판단합니다.
Rate Limits가 Team Tier의 RPM·TPM을 제어했다면, Budget Control은 Team이 잔액을 넘어 추가로 사용하는 것을 막습니다.
| 데이터 | 역할 | 제약 |
|---|---|---|
| Account Balance | 마지막 정산까지 확정된 Team의 USD 잔액 | Team당 하나이며 credit·debit이 같은 row lock을 공유 |
| Account Balance Cache | inference path가 읽는 Redis 잔액 | 정산된 잔액에 rough debit을 반영한 값 |
| Inference Record | Gateway가 관찰한 Provider 실행 1건의 모델·usage·provider_request_id | 자체 row ID와 (inference_id, attempt_no) unique |
| Usage Charge | usage와 가격표로 만든 정확한 사용료 | inference_record_id FK는 unique |
| Account Ledger Entry | Account credit·debit의 append-only 이력 | Account가 mutation의 부수 효과로 생성 |
Account Balance는 마지막 정산까지 확정된 원장 값이고, Account Balance Cache는 inference path가 빠르게 판단하기 위한 값입니다.
Cache는 정산된 잔액에 rough debit을 바로 반영하므로 두 값은 달라질 수 있고, 같은 것으로 취급하지 않습니다.
2. Inference path는 Cache만 읽습니다
Preflight에서 inference를 허용합니다
Preflight는 Account Balance Cache가 0 이상일 때만 Provider 호출을 허용하고, 음수이면 402로 끝냅니다.
호출 전에 금액을 reservation하거나 Account Balance를 바꾸지는 않습니다.
inference마다 DB를 읽으면 같은 Team의 Account row가 hot path가 됩니다.
DB에서 동기 정산까지 하면 가격 계산과 Ledger transaction도 inference 처리량을 제한합니다. 그래서 정확한 정산은 Worker path로 넘깁니다.
Provider dispatch의 identity를 정합니다
inference_id는 Client가 시작한 logical inference의 ID로, 첫 Provider dispatch 전에 발급합니다.
Gateway retry·fallback은 같은 inference_id를 유지하고, Provider dispatch마다 attempt_no를 하나씩 올립니다.
같은 payload를 다시 보냈다는 사실만으로 같은 inference가 되지는 않습니다.
명시적인 idempotency key가 있을 때만 Client 재시도를 기존 inference_id에 연결합니다.
Postflight에서 rough debit합니다
Provider가 성공하면 Gateway는 Account Balance Cache를 rough debit한 뒤 Inference Record를 Kafka stream으로 전달합니다.
Kafka broker ack가 정산 사실의 durable handoff이며, Gateway는 ack를 받은 뒤에만 inference 성공을 반환합니다.
Record 전달에 실패하면 Inference Record만 멱등하게 재발행합니다. Kafka 재전달이나 Worker 재시도가 rough debit을 다시 실행하지는 않습니다.
Redis 결과가 불명확할 때도 같은 debit을 다시 시도하지 않습니다. 다음 정산이 Cache를 정산된 Account Balance로 갱신합니다.
3. Worker가 정산을 확정합니다
Kafka consumer는 자체 row ID를 가진 PENDING Inference Record를 저장하고, (inference_id, attempt_no) unique로 재전달을 막습니다.
Usage Charge는 inference_record_id unique로 한 실행에 한 번만 만들며, logical inference 하나에는 여러 Charge가 생길 수 있습니다.
Worker는 Usage Charge를 Account별로 묶습니다
Scheduler가 PENDING Record를 batch로 가져오면, Worker는 실행 Record마다 Usage Charge를 계산하고 같은 Account의 Charge를 묶습니다.
- 각 Record의 usage와 가격표로 Usage Charge를 만듭니다.
- 같은 Account의 Charge 합계로
debit(USAGE, amount)을 요청합니다. - Account는 Balance를 차감하고
DEBIT · USAGELedger Entry를 자동으로 남깁니다. - 포함된 Inference Record를
SETTLED로 전이합니다.
Worker는 Ledger Entry를 직접 만들지 않습니다. Account debit이 Balance와 Ledger를 같은 DB transaction에서 확정합니다. 금액은 scale을 강제하지 않은 USD numeric으로 저장하며, Ledger는 수정하지 않습니다.
정산 뒤 Cache를 갱신합니다
DB commit 뒤 Worker는 정산된 Account Balance 값으로 Account Balance Cache를 갱신합니다. rough debit을 다시 합산하거나 정산 금액과의 차이만 보정하지 않습니다.
DB와 Redis는 하나의 transaction이 아닙니다. Redis 갱신 실패로 DB 정산을 되돌리지 않으며, 다음 정산이나 정상 Cache miss가 Account Balance를 기준으로 Cache를 다시 만듭니다.
같은 Account의 정산 batch는 한 번에 하나씩 처리하고, Cache도 정산 순서대로 갱신해 오래된 값이 최신 값을 덮지 않게 합니다.
다만 Cache SET과 새 rough debit이 겹치면 그 rough debit은 다음 정산까지 Cache에서 보이지 않을 수 있습니다. 이 gap은 재시도하지 않고 설계상 허용합니다.
4. 운영 시 고려할 점
Cache 수명과 key miss를 관리합니다
quota:{teamId}:account-balance
Cache는 정수 scaled USD로 저장합니다. rough debit에는 올림을 적용하고, postflight rough debit과 Worker의 Cache 갱신은 TTL을 연장합니다.
- TTL은 1시간에 0~3분 jitter를 더합니다. preflight read는 TTL을 연장하지 않습니다.
- 정상 key miss에서는 DB의 Account Balance를 읽어
SET NX로 Cache를 채운 뒤 허용 여부를 판단합니다. - Gateway DB connection pool이 동시에 허용할 Cache fill 조회 수를 제한해 DB를 보호합니다.
- Credit이나 관리자 조정은 DB 반영 후 기존 Cache에도 변경 금액을 즉시 반영합니다.
Redis 장애 시 신규 요청을 차단합니다
Rate Limits와 마찬가지로, Budget Control은 Cache 상태를 모르는 request를 통과시키지 않습니다.
Redis Cluster mode를 전제로 하므로, Redis가 응답하지 않으면 fail-open하지 않고 503으로 끝냅니다.
| 장애 패턴 | 처리 |
|---|---|
| Cache avalanche | 긴 TTL과 jitter로 동시 만료를 분산합니다. |
| Cache stampede | 정상 key miss는 DB look-aside로 채우되, Gateway DB connection pool로 동시에 실행할 Cache fill을 제한합니다. |
| Cache penetration | API Key auth가 먼저 실행되므로 유효하지 않은 Team은 Account Balance 조회까지 도달하지 않습니다. |
| Redis key 데이터 유실 | Redis가 응답하면 정상 key miss와 같이 DB look-aside로 Cache를 채웁니다. |
| Redis timeout·failover | 상태가 불명확하면 503을 반환합니다. Redis 없이 request를 허용하지 않습니다. |
음수 Account Balance는 허용하되, gap은 줄입니다
잔액을 미리 선점하면 음수를 막을 수 있지만 throughput이 낮아지고 구현이 복잡해집니다. 이 설계는 일시적인 음수 잔액을 허용하되, rough debit으로 gap을 최소화합니다.
negative balance gap
≈ in-flight request 수 × 최대 Usage Charge
+ postflight 지연 동안의 Usage Charge
+ 추정값과 Usage Charge의 차이
+ Cache 교체·key miss와 겹친 rough debit
음수 잔액의 규모는 Rate Limits, Team별 concurrency, 허용 모델, max_output_tokens로 줄일 수 있습니다. 이 식은 엄밀한 상한이 아니고, 함께 관측할 지표를 보여주는 근사식입니다.
HTTP/1.1 402 Payment Required
Content-Type: application/json
{
"error": {
"type": "insufficient_quota",
"code": "credit_balance_exhausted",
"message": "Team balance is negative. Retry after the balance is refreshed."
}
}
참고
- LLM Gateway Admission Control (1) — Rate Limits: Team 범위와 선행 admission policy.
- OpenAI Prepaid Billing: Usage Charge 반영 지연과 음수 Account Balance.
- Stripe Billing Credits: Credit Balance와 append-only Ledger.