콘텐츠로 이동

토큰-분당·일일 예산

tokens_per_minute와 tokens_per_day 차원은 요청이 1분 및 1일 동안 소비할 수 있는 토큰 양을 모든 수락 시점 속도 제한 차원에 적용합니다(이슈 #2395, #2327, #2419). 둘 다 출시된 기능이며 기본값은 꺼짐입니다. 0(기본값)이면 해당 기간의 차원은 무제한이고 이 필드들이 없는 설정은 이전과 똑같이 동작합니다.

요청 수 차원만으로는 빈틈이 남습니다. 한 테넌트가 보내는 몇 개의 거대한 입력이나, thinking 예산을 최대로 잡은 reasoning 모델은 어떤 RPS 상한 아래에서도 백엔드를 포화시킵니다. 라우터는 이미 요청별 사용량을 측정하고 요청별 입력 토큰 추정치(core::prompt_estimate, routing_policies[].when.input_tokens가 비교하는 값)를 계산합니다. 이 차원은 둘 다 버킷에 과금합니다.

허브에서 동기화된 키에는 같은 아이디어를 구현한 별도 경로가 있습니다. control-plane 경로는 키별 input_tpm, output_tpm 윈도를 적용하고(src/control_plane/enforcement.rs), control_plane.policy.minute_limits는 공유 Redis 카운터와 수락 시점 예약을 추가합니다(허브 티어 분당 한도 참고). 여기서 다루는 로컬 차원은 그 경로와 독립적이며 허브에 바인딩된 키의 요청은 두 경로의 제한을 모두 받을 수 있습니다.

설정 표면

tokens_per_minute와 tokens_per_day는 requests_per_second, burst_capacity, window_duration과 함께 RateLimitSettings의 필드입니다. 모든 수락 시점 차원이 기간마다 자기 버킷을 따로 가집니다.

차원 버킷 식별자 헤더 보고
per_api_key 키 id, 사용자, 조직의 안정적인 해시 x-ratelimit-tokens-* (분), x-ratelimit-tokens-day-* (일)
per_user 해석된 사용자 id의 레플리카 간 안정적인 해시 (사용자의 키들이 공유) 아니오
per_team 해석된 팀 id의 레플리카 간 안정적인 해시 (팀 구성원들이 공유) 아니오
per_client 피어 주소(또는 신뢰 프록시가 해석한 클라이언트) 아니오
per_model 클라이언트가 지정한 모델 이름 아니오
global 플릿 전체가 공유하는 버킷 하나 아니오
per_backend 토큰 차원이 아님 아니오

per_backend는 의도적으로 빠져 있습니다. 그 차원은 선택 시점의 후보 필터이고 수락 이후 디스패치마다 한 번 과금되므로, 토큰 예산을 붙일 지점이 없습니다. 두 필드 중 하나라도 설정에 있으면 config validate가 경고합니다. per_model에는 그런 경고가 필요하지 않습니다. per_model은 수락 시점의 토큰 차원이며, 테넌트 차원과 같은 예약으로 해석된 클라이언트 표시 모델 이름에 대해 과금되므로 예산이 무시되지 않고 실제로 적용됩니다 (이슈 #2899).

분 버킷의 용량은 설정된 속도의 1분치입니다. 그래서 테넌트는 자기 몫의 1분치를 한꺼번에 쓸 수 있습니다. 연속 보충 속도는 초당 tokens_per_minute / 60 토큰이며 기간 형식의 window_duration은 아무 역할도 하지 않습니다.

일일 예산(#2327)은 분 버킷 옆의 두 번째 독립 버킷이며 같은 수락 예약과 완료 재조정으로 과금합니다. 요청은 설정된 모든 차원의 두 예산을 모두 감당해야 합니다. 용량은 속도의 1일치이고 연속 보충 속도는 초당 tokens_per_day / 86400 토큰입니다. 테넌트가 자기 몫의 1일치를 한꺼번에 쓰면 버킷이 다시 채워지는 데는 1일이 걸립니다. 이는 누출 버킷(leaky-bucket) 윈도로 자정에 리셋되는 캘린더 데이 방식이 아닙니다. 요청 수 버킷의 보충 버킷 의미론을 1일로 확장한 것과 같고, #2323의 max_tokens_budget 롤링 윈도(사용 기록 1d/7d/30d에 걸쳐 사용량을 계속 리셋하는 슬라이딩 윈도 할당량)와는 다른 방식입니다.

수락

텍스트 생성 경로의 요청은 디스패치 전에 전체 요청 입력 추정치와 출력 한도를 설정된 차원마다 한 번씩 예약합니다. 추정하고 과금하는 경로는 다음과 같습니다.

  • /v1/chat/completions
  • /v1/completions
  • /v1/responses
  • /v1/responses/compact
  • /v1/messages와 /anthropic/v1/messages

입력 부분은 모든 메시지, tools, functions, response_format을 훑고 채팅 본문 순회가 보지 못하는 프롬프트 필드(prompt, input, instructions, system)를 더합니다. 그래서 completions, Responses, Anthropic 형식도 자기 입력만큼 과금됩니다. 출력 부분은 max_tokens, max_completion_tokens, max_output_tokens 중 가장 큰 값이고 셋 다 없으면 4096입니다. 32000.0이나 "32000"처럼 소수나 문자열로 적은 값도 같은 상한으로 읽습니다. pydantic 기반 업스트림이 이를 정수로 바꿔 그만큼 생성하므로, JSON 정수만 읽으면 예약이 요청보다 작아집니다. 생성된 길이가 아니라 출력 한도를 예약하는 이유는, 상한을 밝히지 않은 요청이 (작은) 입력만 잡고 무한히 생성하는 것을 막기 위해서입니다.

예약은 구조적으로 과대 추정입니다. 클라이언트가 추정치를 부풀릴 수 있고 출력 한도는 상한이기 때문입니다. 완료 시점 재조정이 차이를 예산으로 되돌립니다.

예약의 여유분은 이 두 부분 외에 없습니다. 입력 추정치와 출력 상한 위에 추가 비율이나 고정 여유를 두지 않습니다. 추정기는 이미 넉넉하게 계산하고(토크나이저 없이 프롬프트를 훑고 메시지별 하한을 더합니다), 재조정이 실제로 쓰지 않은 만큼을 돌려주기 때문입니다. 추가 여유분은 예약이 살아 있는 동안 요청이 절대 쓰지 않을 예산만 붙잡게 됩니다.

스토어가 예약을 돌려준 뒤 핸들이 요청 확장에 삽입되기 전까지, 핸들은 수락 future가 소유한 지역 값입니다. 이 구간에서 클라이언트가 취소하면 future가 버려지고, 핸들도 버려지며, 예약이 해제됩니다. 따라서 예약을 만든 노드가 항상 그 예약을 끝내며, 예약은 존재하는데 아무도 닿을 수 없는 구간은 없습니다.

수락 단계의 규칙은 다음과 같습니다.

  • 설정된 모든 차원에 두 기간 모두 과금합니다. 요청을 감당하지 못하는 첫 차원과 예산이 거부하며 이미 잡아 둔 앞 차원의 예약은 요청이 실행되지 않으므로 전부 해제됩니다.
  • 버스트 전체보다 큰 요청은 즉시 거부합니다. 기다린다고 들어갈 수 있는 크기가 아니므로, 허용하면 버킷이 지킬 수 없는 약속이 됩니다. 거부의 Retry-After는 여전히 크기를 반영해, 예산이 요청 비용을 다시 채우는 데 필요한 시간을 알려 줍니다.
  • 화이트리스트와 bypass_keys는 토큰 예산에서도 요청을 면제합니다. 면제는 리미터의 모든 차원에 적용됩니다.
  • 요청에 신원이 없는 차원은 건너뜁니다. rate_limit_skipped_total{dimension="token_budget_<차원>"}으로 집계합니다. per_api_key는 AuthContext가, per_user는 해석된 사용자 id가, per_team은 해석된 팀 id가, per_client는 피어 주소가, per_model은 해석된 모델이 필요합니다. 건너뛴 차원은 아무것도 과금하지 않으므로, 토큰 예산을 per_api_key에만 둔 배포는 신원을 붙일 수 없는 트래픽에 토큰을 하나도 부과하지 않습니다. 그 트래픽을 막는 것은 함께 설정한 공유 차원뿐입니다. 유효한 키마다 자기 예산을 받는 것이 설계이므로, 키를 바꾸면 부하가 다른 예산으로 옮겨 갈 뿐입니다. per_user와 per_team은 의도적으로 그 키들을 하나로 묶습니다.
  • 본문을 읽지 못하면 요청을 그대로 허용합니다. 본문 검증과 오류 형식은 라우트 핸들러가 소유하고 픽은 모든 바이트를 재생합니다. 이 요청은 예약을 만들지 않으며 버킷 맵이 가득 찼을 때와 같은 fail-open 태도입니다.

모델 추출기와 토큰 예약은 둘 다 요청 본문이 필요합니다. 상한이 있는 재생 안전 버퍼 하나가 둘을 모두 처리합니다. 토큰 예산이 설정되어 있으면 추출기가 버퍼를 PeekedBody 요청 확장으로 저장하고 수락 단계가 본문을 다시 읽지 않고 그 확장을 제거해 재사용합니다.

재조정

완료 경로는 예약을 백엔드가 보고한 사용량(입력과 출력 토큰)으로 대체합니다.

  • 예약보다 사용량이 적으면 그 차액을 모든 차원에 환급합니다.
  • 예약보다 사용량이 많으면 초과분을 모든 차원에 과금합니다. 버킷이 감당할 수 없는 초과분은 버킷을 일부만 비우는 대신 아무것도 소비하지 않으며 두 백엔드가 같게 동작합니다.
  • 정산이 없으면(실패, 사용량 블록이 없는 응답, 클라이언트 취소) 마지막 핸들이 버려질 때 예약 전체를 해제합니다.

스트리밍 응답은 스트림이 정상적으로 끝났을 때 최종 사용량으로 정산합니다. 백엔드의 사용량 블록이 있으면 그 값을 쓰고, 없으면 입력 추정치에 라우터가 센 델타 수를 더한 값으로 정산합니다.

  • 스트림이 끝나기 전에 끊긴 경우(클라이언트 취소, 타임아웃, 업스트림 오류, 가드레일 차단)는 정산하지 않습니다. 예약은 본문 가드가 버려질 때 전부 해제됩니다.
  • 스트림 중간의 fallback 홉도 정산하지 않습니다. 후속 시도가 최종 보고 사용량으로 예약을 정산합니다.

정산과 해제는 요청 태스크를 막지 않습니다. 인메모리 백엔드는 현재 태스크에서, Redis 백엔드는 분리된 태스크에서 적용합니다. Redis에 닿지 못한 정산은 버킷의 보충 속도로 예산을 되돌리며 손실은 한 윈도로 제한됩니다.

요청 중복 제거는 소유권을 명확히 나눕니다. 동일한 진행 중 요청 두 개는 각각 독립적으로 수락되므로 각자 예약을 잡습니다. 실제로 디스패치한 요청(리더)만 자기 예약을 백엔드가 보고한 사용량으로 정산합니다. 대기 요청은 백엔드를 호출하지 않고 리더의 사용량을 재생하므로 아무것도 정산하지 않으며, 응답 본문이 끝날 때 핸들이 전부 해제됩니다. 두 요청은 핸들을 공유하지 않으므로 같은 예약이 두 번 정산되거나 두 번 해제될 수 없고, 리더의 정산이 해당 요청의 유일한 토큰 과금입니다.

각 보류 항목은 자기를 처리한 백엔드를 기록합니다. Redis에 닿지 못한 동안 이 복제본의 인메모리 버킷에서 잡은 예약은 그 버킷으로 정산하거나 해제하며 애초에 잡은 적 없는 Redis 버킷에는 손대지 않습니다.

요청 수명 주기

결과 예산에 미치는 영향
사용량이 있는 버퍼링 성공 보고된 사용량으로 재조정
실패, 또는 사용량 없는 성공 응답 본문이 끝날 때 전부 해제
클라이언트 취소 버려진 본문 가드가 실행될 때 전부 해제
스트리밍 응답 스트림이 정상적으로 끝났을 때 최종 사용량으로 정산. 끊긴 스트림은 본문이 끝날 때 전부 해제
재시도 또는 fallback 홉 예약 하나가 클라이언트 요청 전체를 덮습니다. 최종 보고 사용량으로 한 번 정산하며 사용량을 내는 시도가 없으면 전부 해제합니다
중복 제거 대기 요청(진행 중인 리더에 합류) 전부 해제. 리더의 정산이 해당 요청의 유일한 과금
응답 캐시 적중 백엔드 호출도, 생성 출력도 없으므로 수락 예약을 해제하며 입력 토큰을 포함해 아무것도 과금하지 않음
처리 중 프로세스 종료 버킷의 정상 보충으로 회수되며 최대 한 윈도 뒤에 전부 회수

예약별 만료는 채택하지 않았습니다. 1분보다 오래 걸리는 정상 생성의 예약까지 만료시키는데 스트리밍 규칙은 그 예약을 계속 유지할 것을 요구합니다.

거부

소진된 예산은 기존 스로틀링 경로를 씁니다. 429, limit_type: "token_budget", 그리고 거부한 차원을 밝히는 error.details.token_dimension(global, client, api_key, model)입니다. 두 기간을 모두 설정하면 거부는 소진된 예산을 밝히는 error.details.token_budget(minute 또는 day)도 담습니다. 여러 차원을 설정한 호출자는 어느 예산을 올려야 하는지 알 수 있습니다.

신원 범위의 api_key 예산이 거부하면 그 버킷의 용량과 남은 토큰을 error.details와 x-ratelimit-tokens-*에 담고, 일일 예산 거부는 x-ratelimit-tokens-day-*에 담습니다. 공유 차원(global, client, model)의 거부는 차원 이름과 Retry-After만 담고, 버킷 잔량은 본문에서도 x-ratelimit-* 헤더에서도 숨깁니다. 그 버킷은 호출자 외의 테넌트도 함께 쓰므로, 실패한 요청이 버킷이 얼마나 찼는지 알려 주어서는 안 됩니다. Retry-After는 거부된 요청 자체의 추정치를 소진된 예산에 대비해 계산하므로, 이를 지키는 호출자는 앞으로 소비할 크기에 비례해 백오프합니다.

토큰 거부 응답에도 요청 수 x-ratelimit-* 헤더는 그대로 실립니다. 이 헤더는 호출자 본인의 요청 수 할당량을 나타내며, 토큰 검사가 실행되기 전에 이미 이 요청을 허용한 독립 버킷입니다. 따라서 토큰 거부는 이 값을 그대로 보고하고, 거부된 토큰 버킷의 잔량만 숨깁니다 (이슈 #2899).

Redis 기반 토큰 검사가 Redis에 닿지 못하면 rate_limiting.redis.fallback_to_memory를 따릅니다. true(기본값)이면 복제본 로컬 버킷에서 허용하고 예약하며 false(fail-closed)이면 Redis가 응답할 때까지 Retry-After: 5와 함께 503을 반환합니다. 어느 쪽이든 rate_limit_redis_unavailable_total{dimension, action}으로 집계됩니다.

두 기간이 모두 설정되어 있으면 두 예약은 Redis 왕복 두 번이 아니라 한 번으로 실행됩니다. 스크립트 하나가 두 버킷을 검사하고 둘 다 허용할 때만 둘 다에서 차감하므로, 분 버킷의 거부가 일일 예산을 소비하지 않습니다 (이슈 #2899). 이 스크립트는 두 키를 한 호출로 넘기는데, Redis 엔드포인트가 단일 서버이기 때문에 안전합니다. Sentinel과 Cluster URL은 설정 로드 시점에 거부됩니다.

배포 전체 예산

복제본마다 자체 토큰 버킷을 가지므로, 복제본 N개짜리 플릿은 한 복제본에 전체 중 얼마를 배정할지 알려 주는 장치가 없으면 설정한 허용량의 최대 N배까지 허용합니다. 일반적인 해법은 공유 상태이며 Redis 백엔드가 이미 이를 제공합니다. 인메모리 백엔드를 유지하는 배포에는 대신 분할 메커니즘이 필요합니다 (이슈 #2911). 이것은 결합 방식의 라우터 쪽 구현이며, 허브는 continuum-hub 저장소에서 라우터별 몫을 계산해 밀어 줍니다.

나눗셈 값은 기간별로 다음 우선순위로 결정됩니다.

  1. 허브 몫: deployment_token_budget.share_tokens_per_minute 또는 share_tokens_per_day. 몫이 있으면 해당 기간의 분할된 용량을 대체하고, 문장에 없는 기간은 로컬 힌트를 유지합니다. 따라서 두 기간은 서로 독립적입니다.
  2. 설정 힌트: rate_limiting.budget_division.replicas. 수락 시점 토큰 차원의 설정된 허용량을 이 수로 나누며 정수 내림을 적용합니다.
  3. 1: 두 소스가 모두 없을 때의 기존 복제본별 예산이며 동작이 바뀌지 않습니다.

허브 몫이 없는 세 가지 경우는 모두 같게 동작합니다. 구버전 허브는 멤버를 생략하고, 확정적 해제는 두 몫이 모두 없는 문장을 보내며, 적용된 정책이 없는 라우터는 아무것도 들고 있지 않습니다. 어느 경우든 모든 기간이 로컬 힌트로 돌아갑니다. 밀어 준 몫이 0인 경우는 다릅니다. 플릿이 예산보다 크면 정수 나눗셈이 정당하게 0토큰을 배정할 수 있으므로, 0 몫은 확정적 0 허용량입니다. 라우터는 힌트로 되돌아가지 않고 트래픽을 거부합니다.

0 허용량과 무제한의 구분

공개 설정 관례는 0 = 무제한이며, 분할된 예산이 이 관례를 물려받아서는 안 됩니다. 따라서 구현은 기간별로 해석된 허용량을 두고 무제한과 "적용 중이지만 용량이 0인 예산"을 구분합니다. 설정된 0, 나눗셈 소스가 전혀 없는 경우, 우회 설정만 무제한입니다. 양수 설정값이 0으로 내림된 경우나 밀어 준 몫이 0인 경우는 모든 양수 예약을 거부합니다. 예약과 정산 경로는 그대로이며, 용량이 0인 기간은 기존의 "버스트 초과" 검사에서 거부됩니다. 크기가 너무 큰 요청이 이미 받는 것과 같은 거부입니다.

분할 적용 범위

분할은 여섯 개 수락 시점 토큰 차원에 두 기간 모두 적용됩니다. global, per_client, per_api_key, per_user, per_team, 그리고 각 per_model 항목입니다. per_backend는 선택 시점 필터이지 토큰 차원이 아닙니다. 키별 rate_limit_tokens_per_minute와 rate_limiting.tier_overrides의 분 예산은 청구 시점에 적용되는 더 좁은 로컬 오버라이드이므로 운영자가 적은 값을 그대로 유지합니다.

공유 Redis 버킷은 이미 배포 전체 값을 담고 있으므로, Redis 백엔드를 쓰는 동안 설정 힌트는 적용되지 않고 둘 다 설정하면 config validate가 경고합니다. 허브 몫은 존재하는 동안 그대로 적용됩니다. 수락한 몫을 버리지 않기 위해서입니다.

핫 리로드

나눗셈 값이 바뀌거나 허브가 다른 몫을 밀어 주면 예산을 다시 계산하고 토큰 버킷 맵을 초기화합니다. tokens_per_minute가 바뀔 때와 똑같습니다. 새 요청은 다음 윈도부터 새 용량을 적용하고, 이미 잡힌 예약은 현재 값이 아니라 자신의 보류(hold)에 기록된 파라미터로 정산합니다.

인메모리 배포에 0이 아닌 수락 시점 토큰 예산이 있는데 budget_division이 없으면 config validate가 경고합니다. 이때 실제 한도는 복제본별이기 때문입니다. 경고는 설정 힌트와 몫을 밀어 주는 허브 등록이라는 두 가지 대안을 함께 알려 줍니다.

관측

메트릭은 모두 고정 차원 이름(global, client, api_key, model)을 레이블로 씁니다.

  • rate_limit_token_budget_tokens_total{dimension, outcome}: outcome이 reserved, refunded, charged인 토큰 수이며 두 기간을 합쳐 집계합니다.
  • rate_limit_token_budget_period_tokens_total{dimension, budget, outcome}: 같은 이동을 과금한 기간(minute, day)으로 나눈 값입니다(#2327).
  • rate_limit_token_budget_refusals_total{dimension}: 거부한 차원별 거부 횟수이며 두 기간을 합쳐 집계합니다.
  • rate_limit_token_budget_period_refusals_total{dimension, budget}: 거부한 기간으로 나눈 거부 횟수입니다.
  • rate_limit_exceeded_total{limit_type="token_budget"}: 요청 수 차원과 공유하는 전체 거부 카운터입니다.
  • rate_limit_skipped_total{dimension}: 신원이 없어 건너뛴 차원이며 token_budget_<차원> 이름으로 기록됩니다. 토큰 버킷 맵이 항목 상한에 도달하면 fail-open으로 허용하고 같은 카운터를 token_budget(분 맵) 또는 token_budget_day(일 맵) 이름으로 기록하므로, 조용히 허용되지 않고 용량 문제가 관측됩니다 (이슈 #2899).

설정을 다시 불러오면 예산을 다시 만들고 토큰 버킷 맵을 비웁니다. 요청 수 차원과 같은 방식입니다. 바뀐 tokens_per_minute나 tokens_per_day는 모든 버킷에 즉시 새 수치로 새 윈도를 주며, 다음 요청부터 새 수치가 적용됩니다.

응답 헤더는 신원 범위의 per_api_key 예산만 보고합니다. 분 버킷은 x-ratelimit-tokens-limit, x-ratelimit-tokens-remaining, x-ratelimit-tokens-reset이고 일일 버킷은 x-ratelimit-tokens-day-limit, x-ratelimit-tokens-day-remaining, x-ratelimit-tokens-day-reset입니다. per_client, per_user, per_team, per_model, global은 호출자 외의 사람들도 함께 쓰므로, 요청 수 헤더가 공유 할당량을 숨기는 것과 같은 이유로 값을 숨깁니다. 값은 버킷 용량, 이 요청의 예약 뒤에 남은 토큰, 버킷이 다시 가득 차는 Unix 시각입니다.

요청 수 할당량은 자체 x-ratelimit-* 헤더를 그대로 유지합니다. 토큰 계열을 따로 두는 이유는 두 버킷이 독립적이기 때문입니다. 요청 수 한도에 가까우면서 토큰 예산은 거의 빈 상태일 수도 있고 그 반대일 수도 있습니다.

레플리카 간 메트릭 집계

속도 제한 계열과 데이터 플레인 예산 계열(router_budget_*, 예산 참고)은 집계 방식이 네 가지로 나뉘며, 어느 분류인지에 따라 레플리카 간 sum이 올바른지 결정됩니다. 레플리카별 집행 카운터는 이 레플리카가 판정하는 지점에서 증가하므로 함대 전체 값은 합계이고, rate()/increase() 윈도가 재시작과 스케일 이벤트를 흡수합니다. 프로세스 로컬 게이지는 이 프로세스 자신의 메모리를 나타내므로 합산은 의미가 없습니다. 히스토그램은 레플리카의 버킷을 합산한 뒤 분위수를 계산합니다.

계열 분류 집계 규칙
rate_limit_checks_total 레플리카별 집행 카운터 rate()에 sum 적용
rate_limit_exceeded_total{limit_type} 레플리카별 집행 카운터 rate()에 sum by (limit_type) 적용
rate_limit_tokens_remaining{bucket_type, identifier} 공유 상태 뷰 합산 금지, 시리즈 하나를 관측하거나 max by (bucket_type) 사용
rate_limit_bucket_capacity{bucket_type} 프로세스 로컬 게이지 합산 금지, max by (bucket_type) 사용
rate_limit_whitelisted_requests_total, rate_limit_bypassed_requests_total 레플리카별 집행 카운터 계열마다 rate()에 sum 적용
rate_limit_skipped_total{dimension} 레플리카별 집행 카운터 rate()에 sum by (dimension) 적용
rate_limit_redis_unavailable_total{dimension, action} 레플리카별 집행 카운터 rate()에 sum by (action) 적용
rate_limit_token_budget_tokens_total{dimension, outcome} 레플리카별 집행 카운터 rate()에 sum by (dimension, outcome) 적용
rate_limit_token_budget_period_tokens_total{dimension, budget, outcome} 레플리카별 집행 카운터 rate()에 sum by (dimension, budget, outcome) 적용
rate_limit_token_budget_refusals_total{dimension} 레플리카별 집행 카운터 rate()에 sum by (dimension) 적용
rate_limit_token_budget_period_refusals_total{dimension, budget} 레플리카별 집행 카운터 rate()에 sum by (dimension, budget) 적용
rate_limit_inflight_requests{dimension, tenant} 프로세스 로컬 게이지 합산 금지, max by (dimension) 사용
rate_limit_inflight_refusals_total{dimension, tenant} 레플리카별 집행 카운터 rate()에 sum by (dimension) 적용
rate_limit_fair_queue_depth 프로세스 로컬 게이지 합산 금지, max 또는 레플리카 평균 사용
rate_limit_fair_queue_tenants 프로세스 로컬 게이지 합산 금지, max 사용
rate_limit_fair_queue_wait_seconds{tenant_dimension} 히스토그램 레플리카의 버킷을 합산한 뒤 histogram_quantile 적용
rate_limit_fair_queue_grants_total{tenant_dimension} 레플리카별 집행 카운터 rate()에 sum by (tenant_dimension) 적용
rate_limit_fair_queue_refused_total{tenant_dimension, reason} 레플리카별 집행 카운터 rate()에 sum by (tenant_dimension, reason) 적용
router_budget_rejections_total{scope, window} 레플리카별 집행 카운터 rate()에 sum by (scope, window) 적용
router_budget_used{scope, window, unit} 프로세스 로컬 게이지 합산 금지, 시리즈별 관측 또는 max 사용
router_budget_storage_unavailable_total{action} 레플리카별 집행 카운터 rate()에 sum by (action) 적용

버킷 자체가 공유될 때(storage: redis) rate_limit_tokens_remaining은 공유 상태 뷰입니다. 모든 레플리카가 같은 버킷을 관측하므로 시리즈를 합산하면 이중 계산되고, 시리즈 하나 또는 max by (bucket_type)이 배포 전체 수준을 나타냅니다. 인메모리 백엔드는 레플리카마다 버킷을 따로 두므로 각 시리즈는 해당 레플리카의 몫만 나타냅니다.

경보는 레플리카 한 개의 시리즈가 아니라 집계값에 걸어야 합니다. 레플리카가 추가되거나 제거되면 같은 함대 트래픽이 더 많은 시리즈로 나뉘거나 시리즈 하나가 사라지는데 함대 전체 값은 그대로이므로, 단일 시리즈의 고정 임계값은 잘못 울립니다. 카운터는 시리즈를 합산한 뒤 rate()를 적용하고, 히스토그램은 버킷을 합산한 뒤 histogram_quantile을 적용합니다. 레플리카별 분위수를 평균해서는 안 됩니다.

# 카운터: 레플리카 한 개의 시리즈가 아니라 함대 집계값을 사용합니다.
sum by (limit_type) (rate(rate_limit_exceeded_total[5m])) > 1

# 히스토그램: 레플리카의 버킷을 합산한 뒤 분위수를 계산합니다.
histogram_quantile(
  0.95,
  sum by (le, tenant_dimension) (rate(rate_limit_fair_queue_wait_seconds_bucket[5m]))
) > 5

키별 오버라이드

이슈 #2426의 결정: 수용하고 구현했습니다. 키는 자체 버스트와 분당 토큰 예산을 가질 수 있으며, 설정 파일과 Admin API에서 지정할 수 있습니다.

  • api_keys[].rate_limit_burst는 해당 키에 적용되는 요청 속도의 버킷 용량을 대체합니다. 키 자체의 rate_limit이 있으면 그 속도를, 없으면 rate_limiting.limits.per_api_key 속도를 따릅니다. 1초치 보충량보다 작은 버킷도 허용하며, 설정한 속도는 그대로 허용됩니다.
  • api_keys[].rate_limit_tokens_per_minute는 해당 키의 per_api_key 분당 예산을 대체하고, 0이면 비활성화합니다. 오버라이드한 예산이 해당 키의 x-ratelimit-tokens-* 헤더에 반영되며, 나머지 수락 시점 차원은 계속 적용됩니다.
  • 키별 일일 예산은 이 오버라이드에 포함되지 않습니다. tokens_per_day는 차원 수준 예산으로 남고, 한 사용자의 모든 키가 공유하는 예산은 이슈 #2381입니다.
  • 우선순위는 명시적입니다. 키 자체의 속도가 먼저이고, 다음이 per_api_key 기본값입니다. 오버라이드는 위에 적은 속성만 바꾸므로, rate_limit: 0은 여전히 해당 키의 요청 속도 제한을 하지 않는다는 뜻입니다.
  • 장기 키 예산(#2323, PR #2687): 1d, 7d, 30d 롤링 윈도의 max_tokens_budget은 분당 속도가 아니라 할당량입니다. 인증 단계의 별도 검사이며, 한 키에 둘 다 적용될 수 있고 요청은 둘 다 통과해야 합니다. 여기서 다루는 일일 예산은 다른 방식입니다. 초당 tokens_per_day / 86400 토큰의 누출 버킷 보충이며, 사용 기록의 슬라이딩 윈도가 아니라 라우터 자체 차원에서 수락 시점에 과금합니다.
  • 데이터 플레인 예산 엔진(#2461): 위 롤링 윈도 트래커를 이름이 붙은 윈도와 키별·모델별·전역 스코프로 확장하고, 토큰과 마이크로 USD 비용 차원을 추가하며, budgets: 섹션에서 핫 리로드합니다. max_tokens_budget은 계속 명시적 키별 재정의이며 해당 키의 per_api_key 기본값을 억제합니다. 여기서 설명하는 속도 차원은 독립적으로 유지되고, 요청은 둘 다 만족해야 합니다. 예산 (Budgets)를 참고하십시오.
  • 금액 기준 가격은 응답 비용 헤더에 쓰이고, 스코프가 cost_usd 한도를 설정하면 예산 비용 차원에도 쓰입니다. 분당 토큰 속도와는 별개입니다.
  • 허브 티어 분당 한도는 위에서 설명한 control-plane 쪽 대응물입니다.

결정

이슈 #2898이 이 기록에 남아 있던 네 가지 질문을 닫았습니다. 각 결정은 구현되어 있고 테스트로 고정되어 있습니다.

  • 입력·출력 분할 윈도는 없습니다. 로컬 차원은 기간당 하나의 윈도를 유지하며, 예약은 입력 추정치와 출력 한도를 같은 윈도에 함께 과금합니다. 분할 방식은 tokens_per_minute, tokens_per_day 옆에 호환되지 않는 두 번째 설정 표면을 만듭니다. 허브에 바인딩된 경로에는 이미 방향별 윈도가 되는 허브 티어 input_tpm/output_tpm 필드가 있습니다. 연기하지 않고 거부했습니다. 공유 윈도가 분할이라면 허용했을 트래픽을 거부한다는 증거가 배포에서 나오기 전에는 로컬 세 번째 윈도가 필요하지 않습니다. a_reservation_charges_input_and_output_against_one_window_per_period 테스트가 고정합니다.
  • 예약 여유분은 입력 추정치와 출력 상한 외에 없습니다. 추가 비율도, 별도 설정 필드도 없습니다. 추정치는 이미 과대 계산이고 출력 부분은 요청이 밝힌 상한을 예약하므로, 여유분을 더 두면 이중 계산이 됩니다. 정확도는 정산이 쓰지 않은 만큼을 돌려주는 지점에서 회복됩니다. 조정 가능한 여유분은 운영자가 추정기의 오차 분포를 알아야 쓸 수 있다는 문제도 있습니다. the_hold_is_the_estimate_plus_the_output_ceiling 테스트가 고정합니다.
  • 중복 제거 시 예약은 서비스를 수행한 요청이 소유합니다. 요청마다 자기 수락 예약을 잡습니다. 디스패치한 리더가 자기 예약을 정산하고, 백엔드 호출 없이 리더의 사용량을 재생하는 대기 요청은 아무것도 정산하지 않으며 전부 해제됩니다. 핸들을 공유하지 않으므로 두 번 정산되거나 두 번 해제될 수 없습니다. a_coalesced_pair_settles_once_and_releases_once 테스트가 고정합니다.
  • 캐시 적중은 입력 토큰을 포함해 아무것도 과금하지 않습니다. 토큰 예산은 백엔드 소비량을 측정합니다. 응답 캐시 적중은 백엔드를 호출하지 않으므로 생성되거나 읽힌 토큰이 없습니다. 호출자의 트래픽은 요청 수 차원이 계속 집계하며, 라우터가 직접 응답한 요청에는 그 집계가 맞습니다. 수락 예약은 정산되지 않고 해제됩니다. a_cache_hit_releases_its_input_and_output_hold 테스트가 고정합니다.