콘텐츠로 이동

헬스, 캐싱 & 로깅

헬스 체크 섹션

백엔드 헬스 모니터링을 설정합니다:

health_checks:
  enabled: true                      # 모든 백엔드 프로브를 건너뛰려면 false로 설정
  interval: "30s"                  # 확인 주기
  timeout: "10s"                   # 요청 타임아웃
  unhealthy_threshold: 3           # 비정상 표시까지의 실패 횟수
  healthy_threshold: 2             # 정상 표시까지의 성공 횟수
  endpoint: "/health"              # 확인할 엔드포인트 (타입별 기본값은 아래 표 참고)
  warmup_check_interval: "1s"      # 워밍업 중 가속화된 확인 주기
  max_warmup_duration: "300s"      # 최대 워밍업 감지 시간
  block_startup: true              # 프리워밍과 첫 헬스 체크가 끝날 때까지 리스너 바인딩을 대기
  prewarm_timeout: "10s"           # 기동 시 프리워밍 프로브 요청 하나의 제한 시간
enabled의 기본값은 true입니다. false이면 라우터는 연결 프리워밍, 첫 헬스 체크 라운드, 백그라운드 모니터링을 모두 건너뛰며 애플리케이션 상태에 헬스 체커를 두지 않습니다. 실행 중인 라우터에서 이 값을 바꾸려면 재시작해야 하며 --disable-health-checks도 시작 전에 같은 설정을 적용합니다.

최상위 health_checks.interval과 health_checks.timeout 값은 시작 시점과 리로드 후 모두 기준 설정입니다. 레거시 timeouts.health_check 블록은 마이그레이션 호환성을 위해 계속 파싱되지만 폐기 예정이며 무시됩니다. 해당 블록을 명시했을 때만 유효 값 충돌 시 경고가 출력되며, 생략하면 경고가 발생하지 않습니다.

헬스 체크 프로세스:

  1. 라우터가 각 백엔드의 헬스 엔드포인트를 쿼리
  2. 성공적인 응답은 성공 카운터 증가
  3. 실패한 응답은 실패 카운터 증가
  4. 실패 임계값에 도달하면 백엔드를 비정상으로 표시
  5. 성공 임계값에 도달하면 백엔드를 정상으로 표시
  6. 정상 백엔드만 트래픽 수신

가속화된 워밍업 헬스 체크

라우터는 백엔드 워밍업 중 가속화된 헬스 체크를 지원합니다. 이는 모델 로딩 중 HTTP 503을 반환하는 llama.cpp와 같은 백엔드에 특히 유용합니다.

백엔드 상태:

상태 HTTP 응답 동작
ready 200 OK 일반 간격으로 체크
warming_up 503 Service Unavailable 가속화된 간격으로 체크
down 연결 실패 일반 간격으로 체크
unknown 초기 상태 첫 번째 체크로 상태 결정

워밍업 설정:

옵션 기본값 설명
warmup_check_interval 1s 워밍업 중 가속화된 체크 간격
max_warmup_duration 300s 가속화 모드 유지 최대 시간

동작 방식:

  1. 백엔드가 HTTP 503을 반환하면 warming_up 상태로 진입
  2. 헬스 체크가 가속화된 간격(기본값: 1초)으로 전환
  3. 백엔드가 HTTP 200을 반환하면 ready 상태가 되어 일반 간격으로 복귀
  4. 워밍업이 max_warmup_duration을 초과하면 백엔드를 비정상으로 표시

이 기능으로 모델 가용성 감지 지연 시간이 최대 30초(최악의 경우)에서 약 1초로 단축됩니다.

시작 시 동작

health_checks.block_startup(기본값: true)은 첫 헬스 체크 라운드가 끝나기 전에 리스너를 바인딩할지 여부를 결정합니다.

값 동작
true (기본값) 설정된 모든 백엔드의 연결 프리워밍과 첫 헬스 체크 라운드가 끝날 때까지 리스너 바인딩을 기다립니다. 이 라운드가 끝나기 전까지는 /health와 /v1/models에 접근할 수 없습니다.
false 리스너를 먼저 바인딩합니다. 프리워밍과 첫 헬스 체크는 백그라운드에서 실행되어 /health가 즉시 응답하고 백엔드별 상태는 admin과 모델 엔드포인트를 통해 비동기로 수렴합니다.
  • 생존(Liveness) vs. 준비(Readiness): /health는 백엔드 상태와 무관하게 라우터 자체의 생존 여부만 보고합니다. /health/ready는 서빙 풀의 준비 상태를 보고하며, 풀에 활성화된 비내부(non-internal) 백엔드가 하나도 없으면 503, 하나 이상 있으면 200을 반환합니다(드레이닝 중인 백엔드는 이미 활성 풀에서 제외되어 있음). 이 준비 상태 경계에는 요청 선택을 제어하는 업스트림 헬스나 서킷 브레이커 상태가 포함되지 않습니다. 외부 준비성 프로브가 짧은 데드라인을 강제하는 환경에서는 응답 없는 백엔드 하나 때문에 바인딩 전 대기 시간이 데드라인을 넘기지 않도록 block_startup: false로 설정하세요.
  • 프로브 및 프리워밍 타임아웃: block_startup 값과 무관하게 모든 헬스 체크 요청은 timeout으로 제한됩니다. 기동 시 프리워밍 요청에는 별도 상한인 health_checks.prewarm_timeout(기본값 10s)이 적용되므로, 응답 없는 백엔드가 라우터 공유 클라이언트의 타임아웃(기본 스트리밍 총합 600초)만큼 기동을 지연시키지 않습니다. Anthropic 분기와 일반 분기 모두 prewarm_timeout 값을 읽으며, 프리워밍은 기동 시에만 실행되므로 값을 변경하면 다음 재시작부터 적용됩니다.
  • 전용 프로브 연결 풀: 헬스 체크와 모델 카탈로그 조회는 프록시 클라이언트의 TLS, 프록시, 타임아웃, keep-alive, 풀 설정을 공유하되 연결 풀은 분리된 전용 HTTP 클라이언트에서 실행됩니다. 라우터가 소유한 프로브 및 디스커버리 응답에는 gzip을 협상하고 해제합니다. 추론 프록시와 SSE 클라이언트는 계속 identity 본문만 요청합니다. 따라서 프록시 스트림이 백엔드 연결의 MAX_CONCURRENT_STREAMS를 모두 채우더라도 헬스 프로브가 스트림 슬롯을 기다리다 타임아웃되지 않습니다. 프리워밍은 기존과 같이 프록시 연결 풀을 데웁니다.
health_checks:
  block_startup: false             # 리스너를 먼저 바인딩하고 헬스 상태는 백그라운드에서 수렴

짧은 데드라인을 가진 준비성 프로브

로드 밸런서 준비성 프로브는 /health/ready, 생존 프로브는 /health를 사용하십시오. 일부 임베딩 호스트는 여전히 /health를 고정된 준비성 캡으로 폴링합니다(예: Backend.AI GO의 30초 폴링). 네트워크가 저하된 상황에서 백엔드 프리워밍이나 첫 헬스 체크 라운드가 이 캡보다 오래 걸릴 수 있다면 block_startup: false로 설정해 프로브 데드라인과 경쟁하는 대신 라우터가 즉시 응답하도록 하세요.

백엔드별 헬스 체크 설정

각 백엔드 타입에는 합리적인 기본 헬스 체크 엔드포인트가 있습니다. 백엔드별로 커스텀 health_check 설정으로 이러한 기본값을 재정의할 수 있습니다.

백엔드 타입별 기본 헬스 체크 엔드포인트:

백엔드 타입 기본 엔드포인트 폴백 엔드포인트 메서드 참고
openai /v1/models/{설정된-모델} /v1/models GET 단일 모델 리소스를 지원하지 않거나 모델이 설정되지 않은 경우에만 카탈로그 사용
OpenRouter (openai) /api/v1/key - GET 인증된 키/계정 리소스, 공개 카탈로그를 자격 증명 근거로 사용하지 않음
vllm /health /v1/models GET /health는 모델 로드 후 사용 가능
sglang /health /v1/models GET sglang.srt 서버, 시작 중이거나 종료 중일 때 503
ollama /api/version /api/tags GET 모델 카탈로그보다 작은 버전 리소스를 우선 사용
llamacpp /health /v1/models GET llama-server 엔드포인트
mlxcel /health /v1/models GET MLxcel 서버 (llama-server 호환)
lmstudio /api/v0/models/{설정된-모델} /v1/models, /api/v1/models GET 모델이 설정된 경우 단일 모델 리소스 사용
continuum-router /health /v1/models GET 원격 CR / Backend.AI GO
anthropic /v1/messages - POST 200 및 요청 형태 오류 400 허용, 401/403/429는 비정상 유지
bedrock /anthropic/v1/messages - POST Anthropic과 동일한 상태 코드 의미 사용
gemini /v1beta/models/{설정된-모델} /v1beta/models GET 설정 기본 URL이 /v1beta/openai로 끝나도 네이티브 API 사용, 정적 API 키는 x-goog-api-key로 전송
azure /health /v1/models GET Azure OpenAI 엔드포인트
generic /health /v1/models GET 범용 폴백

폴백 동작:

내장 전략은 기본 엔드포인트가 경로나 메서드 미지원에 해당하는 HTTP 404 또는 405를 반환할 때만 폴백을 시도합니다. 인증 실패, 권한 실패, 속도 제한, 타임아웃, 연결 실패, 5xx 장애, 503 워밍업은 공개 카탈로그로 폴백하지 않습니다. 따라서 이런 결과가 정상 상태로 가려지지 않습니다.

카탈로그 크기의 내장 폴백은 백엔드와 유효 URL, 인증, 조직, 확장 헤더 컨텍스트별로 single-flight 처리됩니다. 전송 오류나 HTTP 실패를 포함한 정확한 결과를 5분 동안 유지합니다. 반복 헬스 틱은 가벼운 기본 엔드포인트를 계속 재시도하지만, 이 구간에는 카탈로그 폴백 요청을 최대 한 번만 보냅니다. 설정이나 자격 증명 컨텍스트가 바뀌면 이전 판정은 무효화됩니다. 재사용된 판정은 헬스 임계값을 진행시키거나 새 지연 시간 표본을 기록하거나 복구 이벤트를 발생시키지 않습니다. 기동은 제한된 예외입니다. 리스너를 바인딩하기 전에 healthy_threshold를 충족할 수 있도록 유한한 초기 라운드마다 새로운 관측을 사용합니다. 일반 모니터링에서 가벼운 엔드포인트가 전혀 동작하지 않으면 새로운 폴백 관측은 최대 5분에 한 번 생깁니다. 따라서 정상 또는 비정상 임계값 N에 도달하는 데 프로브 스케줄링과 요청 타임아웃에 더해 대략 N × 5분이 걸릴 수 있습니다. 공개 또는 알 수 없는 카탈로그의 200 응답은 HTTP 생존과 카탈로그 호환성만 입증합니다. 인증된 헬스 리소스나 인증 요구가 확인된 공식 공급자 카탈로그가 valid를 입증하지 않았다면 후보 프로브의 자격 증명 상태는 unknown으로 남습니다. 내장 OpenRouter /api/v1/key 프로브는 이 근거를 제공하며, 이후의 공개 카탈로그 응답은 이미 확인한 근거를 유지합니다.

이 제한은 내장 기본값에 적용됩니다. 백엔드별 health_check를 명시하면 설정한 엔드포인트, 메서드, 본문, 허용 상태, 타임아웃, 공급자에 맞는 자격 증명 헤더, 병합된 요청 확장 헤더로 전송합니다. 정적 네이티브 Gemini API 키는 x-goog-api-key를 사용하고, 명시한 Basic 자격 증명과 그 밖의 정적 자격 증명은 설정된 Authorization 스킴을 유지합니다. 따라서 카탈로그 엔드포인트를 명시하면 운영자가 선택한 헬스 주기마다 실행됩니다. 404나 405를 accept_status에 명시하면 폴백 판정보다 먼저 성공 처리되며, 그렇지 않으면 두 상태가 설정된 폴백 목록을 시도합니다.

같은 URL을 공유하는 별칭도 백엔드 이름별로 헬스 상태, 자격 증명, 헤더, 엔드포인트 설정을 분리합니다. 순서 변경은 기존 상태를 유지하고, 모호하지 않은 이름 변경도 누적 상태를 보존합니다. 재로드 전에 시작한 프로브 결과는 새 설정의 상태에 적용되지 않습니다. 캐시된 워밍업 결과도 실패 표본을 추가하지 않고 max_warmup_duration 기한을 적용합니다.

Unix 소켓 백엔드에도 동일한 전략, 카탈로그 주기, 인증, 명시적 설정을 적용합니다. 네이티브 Gemini 모델 경로를 포함하여 설정된 소켓에 경로 형태의 요청 대상을 전송합니다. 헬스 체크는 최대 64 KiB의 응답 헤더만 읽으며, 연결·쓰기·읽기 전체에 설정된 타임아웃을 적용하고 카탈로그 본문을 버퍼링하지 않습니다.

커스텀 헬스 체크 설정:

backends:
  - name: vllm-custom
    type: vllm
    url: http://localhost:8000
    models:
      - my-model
    health_check:
      endpoint: /custom-health          # 기본 엔드포인트
      fallback_endpoints:               # 기본 엔드포인트가 404 또는 405 반환 시 시도
        - /health
        - /v1/models
      method: GET                       # HTTP 메서드: GET, POST 또는 HEAD
      timeout: 10s                      # 전역 헬스 체크 타임아웃 재정의
      accept_status:                    # 정상을 나타내는 상태 코드
        - 200
        - 204
      warmup_status:                    # 모델 로딩 중을 나타내는 상태 코드
        - 503

헬스 체크 설정 옵션:

옵션 타입 기본값 설명
endpoint string 백엔드 타입별 상이 기본 헬스 체크 엔드포인트 경로
fallback_endpoints array 백엔드 타입별 상이 기본 엔드포인트가 404 또는 405 반환 시 시도할 엔드포인트
method string GET HTTP 메서드: GET, POST 또는 HEAD
body object null POST 요청용 JSON 본문
accept_status array [200] 백엔드가 정상임을 나타내는 상태 코드
warmup_status array [503] 백엔드가 워밍업 중임을 나타내는 상태 코드
timeout string 전역 타임아웃 전역 헬스 체크 타임아웃 재정의

예시: Anthropic 스타일 헬스 체크:

오류 상태를 생존 근거로 의도적으로 취급하는 커스텀 백엔드라면 해당 상태를 명시하십시오:

backends:
  - name: custom-api
    type: generic
    url: http://localhost:9000
    models:
      - custom-model
    health_check:
      endpoint: /api/v1/health
      method: POST
      body:
        check: true
      accept_status:
        - 200
        - 400    # Bad request는 서버가 동작 중임을 의미

타임아웃 섹션

요청 처리 타임아웃은 timeouts: 아래에 두고 비스트리밍, 스트리밍, 이미지 생성 요청별 예산과 모델별 오버라이드를 나눠 설정합니다:

timeouts:
  connection: "10s"                # TCP 연결 수립 타임아웃
  request:
    standard:
      first_byte: "30s"            # 폐기 예정이며 동작하지 않음. 실제 예산은 `total`
      total: "180s"                # 전체 요청 타임아웃 (기본 상한 540s)
    streaming:
      first_byte: "120s"           # 첫 SSE 청크에 적용되는 실제 기한
      chunk_interval: "30s"        # 두 번째 청크부터의 최대 간격
      total: "600s"                # 전체 스트리밍 타임아웃 (기본 상한 1200s)
    model_overrides:
      gpt-5-latest:
        streaming:
          total: "1200s"           # 모델별 오버라이드
  limits:                          # 선택 사항. 기동 시 고정되므로 바꾸려면 재시작
    max_standard_timeout: "900s"   # 기본값 540s, 절대 상한 1080s

어느 필드가 어디에 적용되는가

first_byte는 두 경로에서 같은 뜻이 아니며 둘 중 하나만 실제로 동작합니다.

  • streaming.first_byte는 강제됩니다. 스트리밍 응답은 스트림이 끝나기 한참 전에 첫 SSE 청크를 내보내므로, 첫 청크 기한은 전체 예산과 구별되는 실제 신호입니다. 기본값은 120s입니다. 추론 모델이 첫 토큰까지 60초를 넘기는 일이 흔하기 때문입니다. 첫 청크가 도착한 뒤부터는 chunk_interval이 이어받습니다.
  • standard.first_byte와 image_generation.first_byte는 폐기 대상이고 동작하지 않으며 다음 메이저 릴리스에서 제거됩니다. 비스트리밍 응답은 버퍼링된 본문이라 완성된 뒤에야 헤더가 도착하므로, 첫 바이트까지의 시간이 곧 전체 생성 시간입니다. 이 경로에서 예산은 total 하나뿐입니다. 두 필드는 여전히 파싱되고 first_byte <= total 검사도 유지되며 기본값과 다른 값을 쓰면 로드 시점에 폐기 경고가 나옵니다.

따라서 계속 타임아웃되는 추론 모델에는 스트리밍 경로에서 더 큰 streaming.first_byte를, 비스트리밍 경로에서 더 큰 standard.total을 주어야 합니다. standard.first_byte를 올려도 아무것도 바뀌지 않습니다.

검증 상한

이 섹션의 모든 상한은 timeouts.limits로 조정할 수 있습니다. 이 값은 부팅 설정에서 한 번 해석한 뒤 프로세스 수명 동안 고정됩니다. 핫 리로드, Admin API, 컨트롤 플레인 설정 동기화는 이를 바꾸는 후보를 모두 거부하므로 런타임 경로가 라우터 자신의 DoS 방어선을 넓힐 수 없습니다. 상한을 바꾸려면 파일을 고치고 재시작해야 합니다. 각 필드에는 컴파일 시점에 박힌 절대 상한도 있으며 그 값을 넘기면 조용히 잘리는 대신 로드 오류가 납니다.

필드 기본값 절대 상한 제한 대상
max_standard_timeout 540s 1080s request.standard.total
max_retry_timeout 30s 120s retry.timeout
max_streaming_timeout 1200s 3600s request.streaming.total
max_connection_timeout 15s 60s connection
min_chunk_interval 1s 10s chunk_interval의 하한
max_chunk_interval 60s 300s chunk_interval
max_image_generation_timeout 600s 1800s request.image_generation.total
max_first_byte_timeout 480s 1200s request.streaming.first_byte
large_model_bonus 600s 1200s extended_models에 주는 추가 스트리밍 예산
extended_models 기본 제공 프런티어 모델 목록 - large_model_bonus를 받는 모델 id

타임아웃 고려 사항:

  • 긴 total 값은 느린 모델 추론 수용
  • 스트리밍 요청은 비스트리밍보다 오래 걸릴 수 있음
  • 사용자 경험과 리소스 사용 간 균형
  • max_standard_timeout을 올리는 것만으로는 비용이 없습니다. 운영자가 standard.total 자체를 올리는 순간부터 멈춘 업스트림이 그만큼 오래 요청 슬롯을 차지하므로, connection과 헬스 체크 및 서킷 브레이커, server.max_concurrent_requests를 함께 두십시오

재시도 섹션

복원력을 위한 전역 재시도 설정:

retry:
  max_attempts: 3                  # 최대 재시도 횟수
  initial_delay: "100ms"           # 재시도 간 초기 지연
  max_delay: "30s"                 # 재시도 지연 상한
  backoff_multiplier: 2.0          # 지수 백오프 배수
  jitter: true                     # 무작위 지터 추가
  retryable_status_codes: [429, 502, 503, 504]
  prefer_untried_backends: false   # 이 요청이 아직 연결하지 않은 백엔드로 재시도

재시도 전략:

  • 지수 백오프: 지연이 지수적으로 증가 (100ms, 200ms, 400ms...)
  • 지터: 썬더링 허드 방지를 위한 무작위성 추가
  • 최대 지연: 극도로 긴 대기 방지

재시도는 방금 실패한 백엔드를 다시 선택할 수 있습니다. 선택 과정은 이전 시도를 제외하지 않으므로, 가중치 기반 풀에서는 가중치가 큰 백엔드가 계속 재시도를 받습니다.

prefer_untried_backends: true를 설정하면 재시도가 이 요청이 아직 연결하지 않은 백엔드 중에서 선택합니다. 사용 가능한 모든 백엔드에 이미 연결한 경우에는 전체 후보 집합으로 되돌아가므로, 백엔드가 하나인 배포에서도 모든 시도를 수행합니다. 이 설정은 편향이며 재전송 보장이 아닙니다.

요청에 부수 효과가 있는 백엔드는 replay: disabled를 설정하면, 요청이 도달했을 수 있는 첫 실패에서 재시도와 폴백 없이 요청이 끝납니다. 재전송 정책을 참고하세요.

모델 집계 섹션

model_aggregation 섹션은 집계된 모델 목록 캐시, 백엔드별 모델 페처, 중복 제거, 강제 갱신 엔드포인트, 백그라운드 갱신 작업을 설정합니다. 모든 필드에 기본값이 있어 섹션은 선택 사항이며 시작 시 해석된 스냅샷이 적용됩니다. 변경하려면 재시작이 필요합니다.

model_aggregation:
  cache_ttl: "60s"                  # 집계된 모델 목록의 하드 TTL
  soft_ttl_ratio: 0.8               # 사용 가능한 stale 구간 비율, (0.0, 1.0]
  request_timeout: "5s"             # 백엔드별 모델 목록 페치 타임아웃
  max_retries: 2                    # 백엔드별 페치 재시도 횟수
  retry_delay: "500ms"             # 페치 재시도 간 지연
  max_models_per_backend: 1000      # 단일 백엔드당 모델 상한 (> 0)
  max_total_models: 5000            # 전체 백엔드 모델 상한 (> 0)
  max_response_size: 10485760       # 백엔드 응답 최대 크기(바이트) (> 0)
  empty_response_base_ttl: "5s"     # 빈 응답 백오프 기준값
  empty_response_max_ttl: "60s"     # 빈 응답 백오프 상한 (>= base)
  max_cache_entries: 100            # 캐시 항목 최대 수, 초과 시 LRU 축출 (> 0)
  deduplication_strategy: merge_backends  # first_wins | last_wins | merge_backends
  allow_force_refresh: true         # false이면 POST /v1/models/refresh가 403 반환
  background_refresh:
    enabled: true                   # 만료 전 선제적 캐시 갱신
    check_interval: "10s"           # 백그라운드 갱신 폴링 주기

응답을 멈춘 백엔드의 페치 예산

모델 목록 갱신은 모든 백엔드에 동시에 팬아웃되므로, 한 번의 갱신 시간은 가장 느린 백엔드에 의해 결정됩니다. 기본값 기준으로 응답하지 않는 백엔드는 매 갱신마다 (max_retries + 1) × request_timeout + max_retries × retry_delay(16초)를 소모합니다.

이 지연 꼬리(tail latency)를 줄이기 위해 라우터는 최근 응답 결과에 따라 페치 예산을 조정합니다.

  • 실패 후 단일 시도 프로브: 직전 갱신에서 일시적 오류(타임아웃, 연결 실패, 5xx)로 실패한 백엔드는 다음 갱신에서 재시도 없이 request_timeout으로 한 번만 시도합니다(기본값 기준 16초 대신 5초 소요). 시도당 타임아웃 자체는 줄이지 않으므로, 느리지만 정상 동작하는 백엔드는 첫 성공 시 곧바로 전체 재시도 예산을 회복합니다.
  • 첫 갱신 및 전체 장애 시 전체 예산 부여: 실패 이력이 없는 백엔드(프로세스 기동 후 첫 갱신이나 새로 추가된 백엔드의 첫 갱신)는 항상 전체 재시도 예산을 받습니다. 만약 동일한 갱신 라운드에서 모든 백엔드가 실패하면, 한 번만 시도했던 백엔드들에 남은 재시도 예산을 모두 부여한 뒤 결과를 발행하여 일시적인 전체 네트워크 흔들림으로 빈 모델 목록이 캐시되는 일을 방지합니다.
  • 메트릭 및 헬스 상태 영향: 단축된 프로브가 실패하더라도 일반적인 일시적 실패로 처리되어 model_transient_errors_total에 집계되며, 마지막으로 알려진 모델 목록은 그대로 유지되고 백엔드도 비정상(unhealthy)으로 표시되지 않습니다. 이 동작은 별도 설정 없이 갱신 1회 실패 시 자동으로 적용됩니다.

캐시 스탬피드 방지

라우터는 캐시 스탬피드(썬더링 허드 문제)를 방지하기 위해 세 가지 전략을 구현합니다.

  1. 싱글플라이트 패턴: 동일 집계 요청을 한 번에 하나만 실행
  2. Stale-While-Revalidate: 갱신 중에도 오래된 데이터를 사용 가능하게 유지(cache_ttl의 soft_ttl_ratio 지점 이후)
  3. 백그라운드 갱신: background_refresh.enabled이면 check_interval 루프가 소프트 TTL 이후 항목을 갱신

디스커버리 팬아웃은 캐시 의미에 연결되어 있습니다. 빈 캐시나 시작 시의 첫 패스, 백엔드 또는 설정 변경에 따른 무효화, 소프트/하드 TTL 재검증, 비정상에서 정상으로의 전환, 명시적 갱신이 카탈로그 요청을 시작할 수 있습니다. 정상 전환 이벤트는 5초 동안 디바운스되며 모든 트리거가 같은 재검증 single-flight를 공유합니다. 상태가 바뀌지 않는 주기적 헬스 성공은 이벤트를 내지 않으므로 추가 카탈로그 팬아웃을 만들지 않습니다.

오래된 데이터(stale) 제공은 소프트 구간뿐 아니라 하드 만료(cache_ttl) 이후에도 적용됩니다. cache_ttl이 지나도 이전에 집계한 목록을 즉시 반환하면서 백그라운드 재집계를 한 번만 실행하므로, TTL 경계에 도착한 요청이 모든 백엔드 팬아웃을 기다리지 않습니다. 이 유예 구간은 cache_ttl의 3배(기본 60초 TTL 기준 180초)로 제한되며, 그 한도를 넘으면 새 데이터가 준비될 때까지 요청이 대기합니다.

캐시 무효화에도 동일한 보존 방식이 적용됩니다. 핫 리로드로 백엔드 구성이 바뀌면 캐시된 목록을 즉시 삭제하지 않고 만료 상태로 표시하며, 경과 시간 타이머를 무효화 시점부터 다시 시작합니다. 백엔드 등록 직후 모델 목록을 조회하는 클라이언트는 보존된 목록을 즉시 받고, 새 토폴로지는 백그라운드에서 집계됩니다. 반면 POST /v1/models/refresh는 캐시를 완전히 비우고 새 데이터가 준비될 때까지 대기합니다.

빈 결과는 empty_response_base_ttl과 empty_response_max_ttl 사이의 제한된 지수 백오프를 사용합니다. 즉시 갱신이 필요하면 속도 제한이 적용된 POST /v1/models/refresh를 사용하십시오. allow_force_refresh: false이면 이 엔드포인트와 Admin 갱신 엔드포인트는 403 응답을 반환합니다.

응답 캐시 섹션

결정적(temperature == 0) 요청에 대해 완전한 LLM 응답을 캐싱하며 비스트리밍과 스트리밍 모두 지원합니다. 반복되는 동일한 요청은 백엔드를 호출하지 않고 메모리에서 제공됩니다.

response_cache:
  enabled: true                     # 응답 캐싱 활성화 (기본값: false)
  backend: memory                   # 캐시 백엔드: "memory" (기본값), "redis", 또는 "tiered"
  capacity: 1000                    # 최대 캐시 응답 수 (LRU 퇴거)
  ttl: "5m"                         # 캐시 항목 유효 기간 (예: "5m", "1h")
  max_response_size: 1048576        # 최대 응답 본문 크기 (바이트, 기본값: 1 MiB)
  max_stream_buffer_size: 10485760  # 최대 스트리밍 버퍼 크기 (바이트, 기본값: 10 MiB)

캐시 적격성

다음 조건이 모두 충족될 때만 응답이 캐싱됩니다:

  • response_cache.enabled가 true
  • 요청의 temperature 필드가 0이거나 없음 (결정적 출력)
  • 응답 본문이 max_response_size를 초과하지 않음
  • 비스트리밍 요청의 경우: 응답에 finish_reason: "error"가 포함되지 않음
  • 스트리밍 요청의 경우: 스트림이 성공적으로 완료됨 (최종 [DONE] 이벤트 수신); 중단되거나 오류가 발생한 스트림은 폐기되며 캐싱되지 않음

적격하지 않은 요청은 정상적으로 백엔드에 전달됩니다.

가드레일 정책 범위

가드레일이 설정되어 있으면 모든 응답 캐시 네임스페이스(정확 일치, 프롬프트 접두사, 시맨틱)는 현재 가드레일 정책, 라우터 버전, 일치하는 guardrails.routes 항목, 호출자의 bypass_api_keys 키 보유 여부로 계산한 범위를 함께 포함합니다. 캐시 적중 시 출력 단계 가드레일을 건너뛰므로, 캐시 항목은 저장 시점과 동일한 정책 범위를 가진 요청에만 제공됩니다.

  • 정책 변경 및 업그레이드: 가드레일 정책이 변경되거나(핫 리로드, Admin API 쓰기, 허브 정책 전달) 라우터가 업그레이드되면 각 고유 프롬프트마다 한 번 캐시 미스가 발생하고 새 범위로 다시 캐시됩니다. 이전 범위의 항목은 일반 TTL이나 LRU 축출로 만료됩니다.
  • 처리 중 변경 및 fail-open 제외: 요청이 처리되는 도중에 정책이 바뀌었거나 가드레일 프로바이더가 on_error: fail_open 상태에서 실패한 경우에는 응답을 캐시에 저장하지 않습니다.
  • 범위 외 변경: 동일한 엔드포인트 뒤의 분류기 모델 교체처럼 guardrails 설정 바깥의 변경은 캐시 범위를 바꾸지 않습니다. 가드레일을 사용하지 않는 배포에서는 캐시 키가 변경되지 않습니다. 자세한 내용은 응답 캐시와 정책 변경을 참고하세요.

스트리밍 캐시 동작

캐싱이 활성화되고 temperature가 0일 때, 스트리밍(stream: true) chat completions도 캐싱 대상이 됩니다:

  1. SSE 이벤트가 클라이언트로의 정상 스트림 전달과 함께 메모리에 버퍼링되며 관측 가능한 지연 증가는 없습니다.
  2. 스트림이 성공적으로 완료되면(최종 [DONE] 이벤트), 라우터가 버퍼링된 청크에서 완전한 chat.completion JSON 객체를 재구성하여 캐시에 저장합니다.
  3. 스트림이 중단되면(클라이언트 연결 해제, 타임아웃, 백엔드 오류), 버퍼가 폐기되고 아무것도 저장되지 않습니다.
  4. 캐시 히트 시, 캐시된 응답이 X-Cache: HIT 헤더와 함께 합성 SSE 스트림(text/event-stream)으로 클라이언트에 재생됩니다.
  5. max_stream_buffer_size 설정은 스트리밍 버퍼를 제한합니다. 총 버퍼 내용이 이 임계값을 초과하는 스트림은 캐싱되지 않습니다.

X-Cache 응답 헤더

모든 chat completion 응답에 X-Cache 헤더가 포함됩니다:

값 의미
HIT 캐시에서 응답이 제공됨; 백엔드 호출 없음
MISS 캐시를 확인했지만 항목이 없음; 백엔드에서 응답을 받아 저장함
BYPASS 요청이 캐싱 불가 (예: temperature > 0); 백엔드가 호출되었으나 저장하지 않음

캐시 설정 옵션

옵션 기본값 설명
enabled false 응답 캐싱 활성화 여부
backend "memory" 캐시 백엔드 타입: "memory", "redis", 또는 "tiered". 변경 시 재시작 필요.
capacity 1000 최대 항목 수; 제한에 도달하면 가장 오래된 항목이 퇴거됨 (LRU). Redis 또는 tiered 백엔드 사용 시 무시됨.
ttl "5m" 캐시 항목의 유효 기간. 기간 문자열 지원: "30s", "5m", "1h"
max_response_size 1048576 이 값(바이트)보다 큰 비스트리밍 응답은 캐싱되지 않음
max_stream_buffer_size 10485760 누적 버퍼가 이 값(바이트)을 초과하는 스트리밍 응답은 캐싱되지 않음 (기본값: 10 MiB)
redis.url -- Redis/Valkey 연결 URL (redis-cache 빌드 기능 및 backend: redis 필요).
redis.pool_size 8 Redis 연결 풀의 연결 수
redis.key_prefix "cr:resp:" 모든 캐시 키의 네임스페이스 접두사. 동일한 Redis 인스턴스의 다른 애플리케이션과의 충돌 방지.
redis.connect_timeout_ms 3000 새 Redis 연결 설정 타임아웃 (밀리초)
redis.command_timeout_ms 1000 개별 Redis 명령 타임아웃 (밀리초)
redis.tls false Redis 연결에 TLS 사용 여부 (또는 URL에 rediss:// 스킴 사용)
redis.fallback_to_memory true Redis에 연결할 수 없을 때 인메모리 캐시로 폴백
l1.type "memory" tiered 백엔드의 L1 티어 타입: "memory" 또는 "redis" (redis-cache 기능 필요)
l1.max_value_size 1048576 L1 저장 대상 최대 값 크기 (바이트). 이를 초과하는 값은 L2에만 저장됨.
l2.type "s3" L2 티어 타입 (현재 "s3"만 지원; s3-cache 기능 필요)
l2.endpoint -- S3 호환 엔드포인트 URL (예: https://s3.example.com:8080)
l2.bucket -- 캐시 객체 저장을 위한 S3 버킷 이름
l2.key_prefix "response-cache/" 버킷 내 객체 키 접두사
l2.region "us-east-1" AWS 호환 리전 문자열
l2.access_key -- S3 액세스 키. ${ENV_VAR} 확장 지원.
l2.secret_key -- S3 시크릿 키. ${ENV_VAR} 확장 지원.
l2.ttl_override -- L2 항목의 선택적 TTL 오버라이드 (예: "24h"). L2 저장 시에만 전역 ttl을 대체함.
tiered.promote_on_hit true L2 히트 시 L1으로 프로모션할지 여부
tiered.l1_promotion_ttl "5m" L2에서 L1으로 프로모션된 값에 적용되는 TTL

Redis/Valkey 백엔드

기본적으로 응답 캐시는 프로세스 메모리에 항목을 저장합니다. 여러 라우터 인스턴스 간에 캐시를 공유하거나 재시작 후에도 유지하려면 Redis 또는 Valkey 백엔드를 설정하세요.

빌드 요구 사항: 공식 산출물에는 별도 작업이 필요하지 않습니다. 릴리스 바이너리와 공식 컨테이너 이미지는 redis-cache Cargo 기능을 포함해 빌드되므로 설정만 추가하면 됩니다. 소스에서 직접 빌드할 때는 기본 full 세트에 이 기능이 포함되어 있지 않으므로 직접 활성화해야 합니다:

cargo build --release --features redis-cache

지원 토폴로지: 단일 관리형 엔드포인트입니다. 단독 서버, 고정된 DNS 이름이나 가상 IP 뒤에서 자동 페일오버를 제공하는 관리형 HA 엔드포인트(ElastiCache 자동 장애 조치, Azure Cache for Redis, Memorystore, 관리형 Valkey), 또는 클러스터를 단일 주소로 노출하는 프록시가 여기에 해당합니다. Redis Sentinel과 Redis Cluster를 직접 지정하는 구성은 지원하지 않습니다. 라우터는 Sentinel 마스터 탐색이나 클러스터 슬롯 라우팅을 수행하지 않는 단일 엔드포인트 클라이언트를 사용하므로, redis+sentinel://, redis+cluster://, 쉼표로 구분된 다중 호스트 URL은 설정 로드 시점에 즉시 거부됩니다. 배포 환경 구성은 배포 가이드를 참조하세요.

설정:

response_cache:
  enabled: true
  backend: redis                     # Redis 백엔드 선택
  ttl: "5m"
  redis:
    url: "redis://localhost:6379"    # 일반 TCP
    # url: "rediss://redis.example.com:6380"   # TLS
    # url: "redis://:password@localhost:6379"  # 인증 포함
    pool_size: 8                     # 연결 풀 크기 (기본값: 8)
    key_prefix: "cr:resp:"           # 키 네임스페이스 접두사 (기본값: "cr:resp:")
    connect_timeout_ms: 3000         # 연결 타임아웃 (기본값: 3000)
    command_timeout_ms: 1000         # 명령별 타임아웃 (기본값: 1000)
    tls: false                       # TLS 사용 (기본값: false)
    fallback_to_memory: true         # 실패 시 인메모리로 폴백 (기본값: true)

자격 증명: 자격 증명은 URL userinfo에 포함하되, url: "${REDIS_URL}"(REDIS_URL=rediss://:$PASSWORD@cache.example.com:6380)처럼 환경 변수 참조로 전체 URL을 전달하세요. 배포 차트는 별도로 생성된 Secret을 참조해 이 환경 변수를 주입합니다. continuum-router config validate는 자격 증명이 설정 파일에 평문으로 적혀 있거나, AUTH 교환이 암호화되지 않는 평문 redis:// 엔드포인트로 전송될 때 경고를 출력합니다. 라우터 로그는 항상 비밀번호를 마스킹합니다.

장애 시 동작:

  • 응답 캐시 (fallback_to_memory): fallback_to_memory: true(기본값)일 때 Redis에 연결할 수 없으면 라우터는 경고를 남기고 레플리카별 인메모리 캐시에서 응답합니다(fail-open). 백그라운드 헬스 모니터가 주기적으로 PING을 보내며 연결이 복구되면 다시 Redis로 전환합니다. fail-closed 동작을 원하면 fallback_to_memory: false로 설정하세요. 이 경우 로컬 메모리로 응답하는 대신 장애를 기록된 캐시 미스로 처리하므로, 요청은 정상적으로 백엔드에 전달되지만 Redis가 복구될 때까지 응답이 캐시되지 않습니다.
  • 분산 속도 제한 (rate_limiting.storage: redis): 기본값은 fail-open입니다 (redis.fallback_to_memory: true). Redis 장애 시 각 레플리카는 자체 인메모리 버킷으로 폴백하므로, 전체 플릿의 실효 허용량은 설정된 제한값에 레플리카 수를 곱한 크기가 됩니다. 속도 제한은 이 장애 시 상한을 고려해 산정하세요. 폴백은 rate_limit_redis_unavailable_total{dimension, action="fallback"}으로 집계됩니다(레플리카별 지표이며, 플릿 신호는 레플리카 전체 합계입니다). redis.fallback_to_memory: false로 설정하면 fail-closed로 동작합니다: Redis가 응답할 때까지 검사가 503(type: service_unavailable, Retry-After: 5)으로 거부되고 action="refused"로 집계되며, 가용성을 희생해 공유 할당량을 정확히 유지합니다. fail-closed에서 Redis에 연결할 수 없는 per_backend 차감은 해당 디스패치를 소진된 백엔드처럼 거부합니다. 500 ms 안에 응답이 없는 검사는 연결 불가로 처리하므로, 네트워크가 분리된 Redis가 요청을 붙잡아 두지 않습니다. Redis 백엔드는 시작 시 구성되므로 fallback_to_memory를 바꾸려면 재시작해야 합니다.

키 안전성: clear()는 FLUSHDB 대신 설정된 접두사 패턴으로 SCAN + DEL을 사용합니다. 접두사와 일치하는 키만 제거되므로 다른 애플리케이션과 Redis 인스턴스를 안전하게 공유할 수 있습니다.

S3 호환 계층형 백엔드

계층형 백엔드는 빠른 L1 캐시와 S3 호환 API로 지원되는 거의 무제한의 L2 캐시를 결합합니다. 대형 응답은 L1의 퇴거 압력을 방지하기 위해 L2에만 저장됩니다. L2 히트 시 값은 선택적으로 L1으로 프로모션되어 이후 접근 속도를 높입니다.

AWS S3, MinIO, Ceph, VAST Data 등 모든 S3 호환 스토리지를 L2로 사용할 수 있습니다.

빌드 요구 사항: 바이너리가 s3-cache Cargo 기능으로 컴파일되어야 합니다:

cargo build --release --features s3-cache

계층형 캐시 쓰기 경로:

  1. 모든 set()은 L2 (S3)에 무조건 씁니다.
  2. 값이 l1.max_value_size보다 작으면 L1에도 씁니다.
  3. 임계값을 초과하는 값은 L2 전용으로 강등되며 강등 카운터가 증가합니다.

계층형 캐시 읽기 경로:

  1. L1을 먼저 확인합니다. 히트 시 즉시 값을 반환합니다.
  2. L1 미스 시 L2 (S3)를 조회합니다.
  3. L2 히트 시, tiered.promote_on_hit이 true이고 값이 L1에 맞으면 tiered.l1_promotion_ttl을 TTL로 L1에 프로모션합니다.

TTL 적용: 각 S3 객체는 expires-at 메타데이터 필드(Unix 타임스탬프)를 가집니다. GET 시 타임스탬프가 지나면 객체가 지연 삭제되고 캐시 미스가 반환됩니다. l2.ttl_override를 사용하여 L2 항목을 전역 ttl보다 오래 유지할 수 있습니다.

설정:

response_cache:
  enabled: true
  backend: tiered                    # 계층형 L1/L2 백엔드 선택
  ttl: "5m"                          # 두 티어 모두의 기본 TTL

  l1:                                # L1 (핫) 티어: 빠르고 제한됨
    type: memory                     # "memory" 또는 "redis" (redis는 redis-cache 기능 필요)
    max_value_size: 1048576          # 이보다 큰 값은 L2 전용 (기본값: 1 MiB)

  l2:                                # L2 (웜) 티어: S3
    type: s3
    endpoint: "https://s3.example.com:8080"
    bucket: "llm-response-cache"
    key_prefix: "response-cache/"    # 객체 키 접두사 (기본값: "response-cache/")
    region: "us-east-1"              # 리전 문자열 (기본값: "us-east-1")
    access_key: "${S3_ACCESS_KEY}" # 환경 변수 확장 지원
    secret_key: "${S3_SECRET_KEY}"
    ttl_override: "24h"              # 선택적: L2 항목을 전역 TTL보다 오래 유지

  tiered:                            # 프로모션/강등 동작
    promote_on_hit: true             # L2 히트를 L1으로 프로모션 (기본값: true)
    l1_promotion_ttl: "5m"          # 프로모션된 항목의 TTL (기본값: "5m")

메트릭: s3-cache 빌드는 L1/L2 히트율, 프로모션/강등 카운트, S3 작업 지연 히스토그램(continuum_cache_s3_latency_seconds)에 대한 Prometheus 카운터를 추가합니다.

Redis를 L1으로 사용: redis-cache와 s3-cache 기능을 결합하여 Redis를 L1 티어로 사용할 수 있습니다:

cargo build --release --features redis-cache,s3-cache
response_cache:
  backend: tiered
  l1:
    type: redis                      # 인스턴스 간 공유를 위해 Redis를 L1으로 사용
    max_value_size: 1048576
  redis:                             # l1에서 재사용되는 공유 Redis 설정
    url: "redis://localhost:6379"
    pool_size: 8
  l2:
    type: s3
    endpoint: "https://s3.example.com:8080"
    bucket: "llm-response-cache"
    access_key: "${S3_ACCESS_KEY}"
    secret_key: "${S3_SECRET_KEY}"

제한 사항

  • 스트리밍 캐시는 OpenAI 호환 백엔드에서만 지원됩니다. Anthropic 및 Gemini 네이티브 스트리밍 형식은 캐싱되지 않습니다.
  • Anthropic(/anthropic/v1/messages) 및 OpenAI(/v1/chat/completions) 엔드포인트는 response_cache.enabled: true일 때 동일한 공유 캐시 인스턴스를 유지합니다.
  • Redis 백엔드를 설정하지 않으면 캐시는 인메모리에 저장되며 서버 재시작 후 유지되지 않습니다.

KV 캐시 인덱스 섹션

정밀한 오버랩 점수 기반 라우팅을 위한 실시간 KV 캐시 상태 추적을 활성화합니다. vLLM과 SGLang은 KV 이벤트를 ZMQ로 continuum-kv-listener에 게시합니다. listener는 block chain을 detokenize하고 라우터와 호환되는 prefix hash를 계산한 뒤 백엔드별 SSE 스트림을 노출합니다. 라우터는 이러한 listener 스트림을 구독하고 모든 백엔드의 캐시 상태 인덱스를 유지하며 이 데이터를 사용하여 관련 KV 캐시 데이터를 가지고 있을 가능성이 가장 높은 백엔드로 요청을 라우팅합니다.

kv_cache_index:
  enabled: true                      # KV 캐시 인덱스 활성화 (기본값: false)
  backend: memory                    # 인덱스 백엔드: "memory" (기본값) 또는 "redis"
  max_entries: 100000                # 추적할 최대 접두사 해시 항목 수
  entry_ttl_seconds: 600             # 인덱스 항목 TTL (초)
  scoring:                           # 백엔드 선택을 위한 스코어링 가중치
    overlap_weight: 0.6              # 캐시 오버랩 신호 가중치 (0.0-1.0)
    load_weight: 0.3                 # 백엔드 부하 신호 가중치 (0.0-1.0)
    health_weight: 0.1               # 백엔드 헬스 신호 가중치 (0.0-1.0)
    min_overlap_threshold: 0.3       # KV 인식 라우팅 활성화 최소 오버랩
    gpu_tier_weight: 1.0             # GPU 상주(핫) 캐시 데이터에 대한 계층 배수
    storage_tier_weight: 0.6         # 스토리지 오프로드(웜) 캐시 데이터에 대한 계층 배수
  storage_offloading:                # 계층형 스토리지 인식 (GPU 핫 / 스토리지 웜)
    enabled: false                   # 스토리지 계층 추적 활성화 (기본값: false)
    treat_eviction_as_offload: true  # 퇴거를 웜 스토리지로의 오프로드로 처리 (기본값: true)
  event_sources:                     # continuum-kv-listener SSE 엔드포인트
    - backend_name: "vllm-1"
      endpoint: "http://kv-listener.internal:7817/events/vllm-1"
      reconnect_interval_ms: 5000
    - backend_name: "vllm-2"
      endpoint: "http://kv-listener.internal:7817/events/vllm-2"
      reconnect_interval_ms: 5000
    # - backend_name: "vllm-3"         # 푸시 모드: 리스너가 라우터의 이벤트 버스에 접속
    #   transport: event_bus           # endpoint 없음; role: kv_listener인 dynamo.event_bus.brokers[] 항목 필요

transport: event_bus 또는 컨트롤 플레인 설정 동기화(kv_routing)를 사용할 때는 다음 규칙이 적용됩니다.

  • 검증 규칙: transport: event_bus일 때 소스의 backend_name은 유효한 이벤트 버스 소스 ID([A-Za-z0-9_-] 1~64바이트)여야 하고, 다른 소스와 중복될 수 없으며, endpoint를 생략해야 합니다. 이를 위반하면 설정 로드 오류가 발생합니다.
  • 런타임 요구 사항 및 경고: event_bus 소스를 사용하려면 dynamo.event_bus가 활성화되어 있고 role: kv_listener인 brokers[] 항목의 backends 목록에 해당 백엔드가 포함되어야 하며, 바이너리가 dynamo-event-bus 기능으로 빌드되어 있어야 합니다(이 기능이 없으면 소스는 비활성화되고 기동 시 경고가 출력됨). config validate와 시작 로그는 활성화된 dynamo.event_bus가 없는 버스 소스, kv_listener 항목에서 권한을 부여받지 못한 소스, 또는 활성 버스 소스가 소비하지 않는 kv_listener 허용 항목에 대해 설정을 실패시키지 않고 경고를 출력합니다.
  • 컨트롤 플레인 설정 동기화 (kv_routing): 허브는 selection_strategy, prefix_routing.{enabled,max_prefix_length,load_factor_epsilon,virtual_nodes,anthropic_cache_control_injection}, 그리고 transport: sse 소스의 kv_cache_index.{enabled,max_entries,entry_ttl_seconds,scoring.*,storage_offloading.*,event_sources.<backend>.{endpoint,reconnect_interval_ms}}를 관리합니다. 반면 prefix_routing.salt_echo, kv_cache_index.backend, 그리고 transport: event_bus인 kv_cache_index.event_sources 항목은 배포 로컬 설정으로 유지되어 허브 적용, 리베이스, 재시작 후에도 보존됩니다.

동작 방식

  1. 이벤트 소비: 엔진은 msgpack KV block 이벤트를 ZMQ로 continuum-kv-listener에 게시하고(vLLM <= 0.28 및 SGLang의 배열 인코딩 배치와 vLLM 0.29+의 맵 인코딩 tagged-struct 배치를 모두 수용), 라우터는 listener의 SSE 스트림을 구독합니다. 각 SSE 이벤트는 접두사 해시와 토큰 수를 포함한 CacheCreated, CacheEvicted, CacheOffloaded, CacheReloaded, 또는 CachePurged 액션을 보고합니다.
  2. 인덱스 업데이트: 이벤트가 접두사 해시를 캐시된 토큰 수와 스토리지 계층(GpuHot 또는 StorageWarm)과 함께 백엔드에 매핑하는 인메모리(또는 Redis 기반) 인덱스를 업데이트합니다.
  3. 스코어링: 각 요청 시 오버랩 스코어러가 요청의 접두사 키에 대한 인덱스를 쿼리하고 캐시 오버랩(계층 배수로 조정됨), 백엔드 부하, 백엔드 헬스를 결합한 가중 점수를 계산합니다.
  4. 라우팅 결정: 최고 점수가 min_overlap_threshold를 초과하면 해당 백엔드를 선택합니다. 그렇지 않으면 설정된 선택 전략(예: PrefixAwareHash)이 폴백으로 사용됩니다.

스코어링 공식

final_score = overlap_weight * (raw_overlap * tier_multiplier)
            + load_weight * (1 - load_ratio)
            + health_weight * health_score

여기서:

  • raw_overlap = backend_token_count / max_token_count_across_backends (0.0 ~ 1.0)
  • tier_multiplier = GPU 상주 데이터는 gpu_tier_weight, 오프로드 데이터는 storage_tier_weight
  • load_ratio = in_flight_requests / max_in_flight (0.0 ~ 1.0)
  • health_score = 백엔드 success_rate (0.0 ~ 1.0)

storage_offloading.enabled가 false이면 모든 데이터가 GpuHot으로 처리되며 tier_multiplier는 항상 gpu_tier_weight(기본값: 1.0)입니다.

설정 옵션

옵션 기본값 설명
enabled false KV 캐시 인덱스 활성화 여부
backend "memory" 인덱스 저장 백엔드: "memory" 또는 "redis". Redis는 redis-cache 빌드 기능과 response_cache.redis의 공유 Redis 설정이 필요.
max_entries 100000 최대 접두사 해시 항목 수. 용량 도달 시 LRU 퇴거 적용.
entry_ttl_seconds 600 인덱스 항목의 TTL. 오래된 항목은 주기적으로 정리됨.
scoring.overlap_weight 0.6 캐시 오버랩 신호의 가중치. 높은 값은 캐시 친화성을 선호.
scoring.load_weight 0.3 백엔드 부하 신호의 가중치. 높은 값은 부하가 적은 백엔드를 선호.
scoring.health_weight 0.1 백엔드 헬스 신호의 가중치. 높은 값은 더 건강한 백엔드를 선호.
scoring.min_overlap_threshold 0.3 KV 인식 라우팅 활성화를 위한 최소 오버랩 점수. 이를 초과하는 백엔드가 없으면 폴백 전략 사용.
scoring.gpu_tier_weight 1.0 데이터가 GPU 상주(GpuHot)일 때 오버랩 점수에 적용되는 배수. storage_offloading.enabled가 true일 때만 유효.
scoring.storage_tier_weight 0.6 데이터가 스토리지 오프로드(StorageWarm)일 때 오버랩 점수에 적용되는 배수. storage_offloading.enabled가 true일 때만 유효.
storage_offloading.enabled false 스토리지 계층 추적 활성화. false이면 퇴거 이벤트가 항목을 완전히 제거.
storage_offloading.treat_eviction_as_offload true true이면 cache_evicted 이벤트가 항목을 제거하는 대신 GpuHot에서 StorageWarm으로 다운그레이드. storage_offloading.enabled가 true일 때만 유효.
event_sources[].backend_name -- 백엔드 이름 (설정된 백엔드 이름과 일치해야 함)
event_sources[].transport sse sse(라우터가 리스너의 SSE 엔드포인트를 가져옴) 또는 event_bus(리스너가 라우터의 dynamo.event_bus에 접속해 같은 이벤트를 푸시; KV 캐시 최적화 참고). sse이면 직렬화 출력에서 생략
event_sources[].endpoint -- 해당 백엔드의 continuum-kv-listener SSE 엔드포인트 URL (http, https, ws 또는 wss). transport: sse에서는 필수이며 transport: event_bus에서는 없어야 함
event_sources[].reconnect_interval_ms 5000 연결 끊김 후 재연결 지연. transport: event_bus에서는 재연결 백오프의 기준값이며 이 값과 60s 중 큰 값까지 두 배씩 늘어납니다. 단, 살아 있는 리스너 세션이 소스를 아직 알리지 않은 동안에는 이 값을 유지합니다

Redis 백엔드

backend: redis일 때, KV 인덱스는 response_cache.redis의 공유 Redis 연결을 사용합니다. 여러 라우터 인스턴스가 동일한 인덱스를 공유하여 전체 플릿에서 일관된 라우팅 결정을 내릴 수 있습니다.

response_cache:
  redis:
    url: "redis://localhost:6379"
    pool_size: 8

kv_cache_index:
  enabled: true
  backend: redis    # response_cache.redis의 Redis 설정 사용

Admin 엔드포인트

  • GET /admin/kv-index/stats: 인덱스 크기, 이벤트 비율, 연결 상태, 스코어링 분포
  • GET /admin/kv-index/backends: 백엔드별 캐시 상태 요약
  • POST /admin/kv-index/clear: 인덱스 초기화 (디버깅용)

전체 응답 스키마는 Admin API를 참조하세요.

제한 사항

  • 이벤트 소스는 현재 vLLM 호환 SSE 엔드포인트만 지원합니다.
  • backend를 memory와 redis 간에 변경하려면 재시작이 필요합니다.
  • KV 인덱스는 선택적 개선 기능이며 사용 불가 시에도 라우팅은 정상 작동합니다.

로깅 섹션

로깅 출력을 설정합니다:

logging:
  level: "info"                   # trace, debug, info, warn, error
  format: "json"                  # json, pretty, compact

level과 format은 프로세스가 시작될 때 한 번만 읽습니다. 둘 중 하나를 바꾼 핫 리로드는 logging을 requires_restart로 보고하므로, 적용하려면 라우터를 재시작하세요.

로그 레벨:

  • trace: 매우 상세, 모든 세부 정보 포함
  • debug: 상세한 디버깅 정보
  • info: 일반적인 운영 정보
  • warn: 경고 메시지 및 잠재적 문제
  • error: 오류 조건만

로그 형식:

  • json: 구조화된 JSON 로깅. 한 줄에 timestamp, level, target, fields를 담은 객체 하나 (프로덕션 권장)
  • pretty: 사람이 읽기 쉬운 한 줄 텍스트 (개발에 적합)
  • compact: 축약된 한 줄 텍스트

우선순위:

  • 레벨 결정 순서: RUST_LOG가 CONTINUUM_LOG_LEVEL보다 우선하고, CONTINUUM_LOG_LEVEL이 logging.level보다 우선합니다. 아무것도 설정하지 않으면 기본 레벨은 info입니다.
  • RUST_LOG 필터 지시문: continuum_router::proxy=debug,info와 같은 전체 tracing 필터 지시문을 받습니다. 파싱할 수 없는 값은 stderr에 알린 뒤 무시하며, 빈 값(Compose 파일의 RUST_LOG=${RUST_LOG:-} 등)이나 지시문이 없는 값은 미설정으로 취급합니다.
  • 감사 및 의존성 로그 보존: 지시문에는 항상 기본 레벨을 함께 포함하십시오(예: continuum_router=debug,info). continuum_router 타깃만 지정한 RUST_LOG(continuum_router=info)는 타깃 이름이 continuum_router 바깥에 있는 admin_audit, guardrail::audit 로그와 의존성 크레이트의 경고까지 모두 숨깁니다. Admin 감사 로그가 활성화되어 있는데 필터에 의해 차단되면 라우터가 시작 시 경고를 출력합니다.
  • 형식 폴백: logging.format에는 환경 변수 오버라이드가 없습니다. 설정 파일이 없거나 로드할 수 없으면 프로세스는 logging 섹션이 없는 설정과 같은 기본값인 info 레벨의 json 형식으로 로그를 남깁니다. 파일은 있지만 로드에 실패하면(또는 --config로 지정한 파일이 없으면) 라우터는 이 기본값으로 폴백했다는 WARN 로그를 한 줄 남긴 뒤 stderr에 로드 오류를 알리고 종료합니다. 일회성 하위 명령(config, auth, metadata, mcp-serve, --migrate-config-file)은 logging을 읽지 않으며, 터미널에서 사람이 읽도록 항상 info 레벨의 pretty 형식으로 stderr에 로그를 남깁니다.

프로세스 로그 큐

로그 레코드는 이를 내보낸 스레드에서 포맷된 뒤, 전용 writer 스레드 하나가 비우는 제한된(bounded) 큐로 넘어갑니다. 따라서 로그 출력 대상이 느리거나 멈춰도 요청 처리는 막히지 않습니다. 큐가 가득 차면 새 레코드는 기다리지 않고 버려지며 그 수가 집계됩니다.

logging:
  level: "info"
  format: "json"
  queue_capacity: 8192            # writer를 기다리는 레코드 수 (1 ~ 1,000,000)
  queue_capacity_bytes: 16777216  # 큐에 보관된 레코드의 총 바이트 (65,536 ~ 1 GiB)
필드 기본값 범위 설명
queue_capacity 8192 1 ~ 1,000,000 큐에 쌓을 수 있는 최대 레코드 수
queue_capacity_bytes 16777216 (16 MiB) 65,536 ~ 1,073,741,824 큐의 레코드가 보관할 수 있는 최대 바이트

레코드는 두 한도를 모두 만족할 때만 큐에 들어갑니다. 포맷된 레코드 하나는 64 KiB로 제한되며 이보다 긴 레코드는 문자 경계에서 잘리고 끝에 [process log truncated]가 붙습니다. 두 필드 모두 재시작이 필요하며 범위를 벗어난 값은 설정을 불러올 때 거부됩니다.

손실은 로그 자체가 아니라 Prometheus에서 확인합니다:

메트릭 유형 의미
continuum_process_log_dropped_total Counter 큐가 가득 찼거나 출력 대상이 실패해 버려진 레코드
continuum_process_log_write_errors_total Counter 출력 대상 쓰기 실패
continuum_process_log_truncated_total Counter 64 KiB 한도에서 잘린 레코드
continuum_process_log_queue_depth Gauge 현재 큐에 있는 레코드 수
continuum_process_log_queue_bytes Gauge 현재 큐에 있는 바이트

출력 대상에 대한 쓰기가 한 번 실패하면 그 프로세스가 끝날 때까지 프로세스 로깅이 멈춥니다. 다시 기록하려면 Router를 재시작하십시오. 큐에 남은 레코드는 정상 종료 시와 시작 오류로 종료할 때 1초 예산 안에서 플러시됩니다.

요청 완료 로깅

클라이언트 API 경로(/v1/chat/completions, /v1/models 같은 OpenAI 호환 엔드포인트와 Anthropic 엔드포인트이며 /health, /metrics, /admin은 제외)의 모든 요청은 끝날 때 구조화된 INFO 로그 줄(event=request_completed) 하나를 남깁니다. 이 줄에는 request_id(x-continuum-request-id 응답 헤더와 같은 값), trace_id, method, route, status, duration_ms, model, backend(마지막으로 기록된 시도를 처리한 백엔드, 요청이 실패한 경우에는 마지막으로 연결을 시도한 백엔드), key_id(식별된 API 키 ID이며 원본 키 값은 절대 기록하지 않음), ttft_ms, prompt_tokens, completion_tokens, total_tokens, cached_tokens 필드가 담깁니다. 요청에서 생기지 않은 필드는 빠집니다. 디스패치 전에 거부된 요청에는 backend가 없고, 사용량을 보고하지 않은 백엔드의 요청에는 토큰 필드가 없습니다.

스트리밍 요청의 경우 스트림이 끝난 뒤에 줄을 남기므로 토큰 수와 ttft_ms(프로바이더 디스패치부터 첫 스트림 프레임까지)는 최종 값입니다. 버퍼링된 응답에서 ttft_ms는 업스트림 응답 헤더가 도착할 때까지의 시간입니다. duration_ms는 클라이언트가 응답 전체를 받거나 연결을 끊을 때까지의 시간입니다. 요청 시작 줄(Starting request with retry support)에도 요청한 model이 담깁니다.

요청 로깅 미들웨어

별도의 미들웨어가 요청마다 DEBUG 줄(Incoming request)을 남기고, TRACE 수준에서는 요청 헤더도 남깁니다. logging.access로 이 동작을 조정합니다.

logging:
  access:
    log_headers: true             # TRACE 수준으로 헤더를 기록
    exclude_paths: [/metrics]     # /health, /health/ready, /healthz를 대체
    completion_line: true         # 완료된 요청마다 한 줄
    success_level: info           # 2xx, 3xx
    client_error_level: warn      # 4xx
    server_error_level: error     # 5xx
필드 기본값 설명
log_headers true 요청 헤더를 TRACE 수준으로 기록함. 자격 증명 헤더(authorization, API 키 헤더, 쿠키, WebSocket 서브프로토콜)는 [REDACTED]로 바꿈
exclude_paths [] 로그 줄을 남기지 않을 경로. 각 항목은 그 아래 경로까지 포함하는 정확한 경로이며(/metrics는 /metrics/x와도 일치) glob이 아니고 /로 시작해야 함. 빈 목록이면 /health, /health/ready, /healthz가 계속 제외되고, 목록을 지정하면 이 기본값을 대체함
completion_line false 완료된 요청마다 메서드, 경로, 상태, 사유, 소요 시간을 담은 줄 하나를 남기고, 요청 ID, 사용자 ID, 조직 ID, 클라이언트 주소를 구조화 필드로 함께 기록함. 꺼 두면 기존 출력에 줄이 추가되지 않음
success_level info 2xx·3xx 응답에서 그 줄을 남길 수준: debug, info, warn, error
client_error_level warn 4xx 응답에서 그 줄을 남길 수준
server_error_level error 5xx 응답에서 그 줄을 남길 수준

세 수준 필드는 completion_line이 켜져 있을 때만 적용됩니다. 완료 줄은 제외되지 않은 모든 요청에 남으므로, API 라우트의 요청은 이 줄과 API 경로가 이미 남기는 event=request_completed 줄을 함께 남깁니다. 스트리밍 응답에서는 응답 헤더가 준비될 때 완료 줄을 남기므로 소요 시간도 그 시점까지이며, 스트림 종료까지의 시간은 event=request_completed가 보고합니다. 핸들러에 도달하기 전에 거부된 요청(인증, 속도 제한, 검증)은 대신 event=request_rejected로 보고되며, 제외된 경로는 완료 줄을 남기지 않습니다.

변경하려면 재시작해야 합니다. 이 미들웨어는 요청과 응답 본문을 기록하지 않습니다. 본문 수집은 선택적 프롬프트 로깅 에픽(#2294)에서 다룹니다.

백엔드 선택 전 거부 로깅

백엔드가 선택되기 전에 거부된 요청(auth, rate_limit, policy, admission, guardrail_input, routing 단계)은 trace_id, x_request_id, client_ref, request_id(x-continuum-request-id 응답 헤더와 같은 값), method, route, status, error.type, error.code, limit_type, stage, key_id(식별된 경우에만 기록하며 원본 키 값은 절대 기록하지 않음), latency 필드를 담은 구조화된 WARN 로그 줄(event=request_rejected) 하나를 남깁니다.

로그 한도와 요약 줄

인증 실패나 속도 제한 폭주 시 로그 양을 제한하기 위해 logging.rejections.max_per_minute(기본값 600)는 60초 창마다 출력되는 event=request_rejected 줄 수를 제한합니다. 억제된 거부마다 request_rejected_log_suppressed_total 카운터가 1씩 증가합니다.

logging:
  rejections:
    max_per_minute: 600

한도에 도달한 창은 event=request_rejected_summary WARN 줄을 두 개 남기며, 두 줄은 phase로 구분합니다:

  • phase="cap_reached": 창에서 처음 거부 로그가 억제될 때 한 번 남깁니다. max_per_minute와 window_secs를 담으며, 억제가 시작되었음을 알립니다.
  • phase="window_closed": 창이 끝난 뒤 한 번 남깁니다. 아래 필드로 그 창에서 억제된 줄의 수와 해당 키를 알려줍니다.
필드 내용
max_per_minute 창에서 마지막으로 줄이 억제될 때 적용된 한도
window_secs 창 길이(60)
suppressed 창에서 억제된 줄 수
suppressed_by_key 쉼표로 구분한 key_id:건수 쌍, 건수가 많은 순으로 최대 20개
unlisted_keys 억제된 줄이 있지만 suppressed_by_key에 나열되지 않은 키 레이블 수
unlisted_suppressed 나열되지 않은 키의 억제된 줄 수. 나열된 건수와 더하면 suppressed와 같음
suppressed_by_stage 쉼표로 구분한 stage:건수 쌍, 건수가 많은 순

suppressed_by_key의 키 레이블은 아래에서 설명하는 request_rejected_total 레이블과 같은 규칙을 따르며, other, unattributed, invalid 버킷도 같습니다. 창마다 최대 256개의 키 ID를 추적합니다. 카운터와 달리 창은 키 ID를 먼저 관측된 순서대로 받아들입니다. 창에서 처음 256개를 넘은 키 ID는 other로 집계되며, 제외되는 키는 없습니다. 키 ID에 들어 있는 , : = " \ % 문자는 퍼센트 인코딩되므로(,는 %2C), 이 필드는 항상 ,와 마지막 :를 기준으로 나눌 수 있습니다.

라우터는 이 줄을 위해 백그라운드 타이머를 두지 않습니다. 창이 끝난 뒤 헬스 체크를 포함해 어떤 요청이든 다음 요청이 들어오면 window_closed 요약을 남깁니다. 따라서 요청이 없는 라우터에서는 이 줄이 창이 끝난 시점보다 늦게 나옵니다.

API 키별 거부 카운터

백엔드 선택 전에 거부된 요청은 로그 한도를 적용하기 전에 API 키별로도 집계됩니다. 그래서 로그 줄이 억제되는 동안에도 건수는 정확합니다. 메트릭을 켜면 이 카운터는 request_rejected_total{stage, key_id}로 내보내집니다. 레이블 집합이 무한히 늘지 않도록, 라우터는 가장 최근에 거부된 256개의 키 ID만 개별 추적합니다. 256개를 추적하는 중에 새 키 ID의 거부가 들어오면 가장 오래전에 거부된 키를 제외합니다. 제외된 키의 건수는 key_id="other"로 옮겨지고 그 키의 시리즈는 삭제되므로, key_id를 합산한 총계는 정확하게 유지됩니다. 식별된 키가 없는 거부(알 수 없는 키, 인증 전에 발생한 속도 제한 429 등)는 key_id="unattributed"로 집계합니다. 128바이트보다 길거나, 출력 가능한 ASCII 이외의 문자를 포함하거나, 버킷 이름과 같은 키 ID는 key_id="invalid"로 집계합니다. 레이블에는 키 ID만 담기며 키 값은 절대 담기지 않습니다.

Continuum Hub 사용량 원장은 여전히 백엔드에 도달한 시도만 받습니다. 라우터가 Continuum Hub에 등록되어 있으면, heartbeat는 Hub가 발급한 키의 거부도 키와 단계별로 rejections_by_key에 담아 보고합니다. 이 목록에는 최대 32개의 키가 들어갑니다. 프로세스 시작 후 처음 거부된 32개의 Hub 키가 라우터를 재시작할 때까지 자리를 유지하므로 각 행의 값은 늘어나기만 합니다. 그 밖의 Hub 키에 대한 거부는 rejections_by_key_other에 합산됩니다. 로컬 키, AppProxy 키, 식별된 키가 없는 요청의 거부는 Hub가 그 키 ID를 해석할 수 없으므로 집계 카운터 rejected_requests로만 Hub에 전달됩니다.

억제 경보

요약 줄은 사후 분석용이므로, 경보는 카운터에 거세요. 아래 첫 번째 식은 최근 5분 동안 거부 로그가 한 줄이라도 억제되었으면 참입니다. 두 번째 식은 같은 기간의 키별 정확한 건수를 돌려주며, 로그 줄이 억제된 거부도 포함합니다. 예외는 key_id="other"입니다. 키가 제외되면 그 키가 프로세스 시작 이후 쌓은 건수가 한 번에 옮겨지므로, 이 시리즈의 increase()와 rate()는 제외가 일어날 때 급증하며 기간별 건수가 아닙니다. 경보는 key_id!="other" 시리즈에 거세요.

# Alert: rejection logs are being suppressed
rate(request_rejected_log_suppressed_total[5m]) > 0

# Rejections per key and stage over the last 5 minutes
sum by (key_id, stage) (increase(request_rejected_total[5m]))

파일 출력

기본 출력 대상은 stdout(stdio 모드에서는 stderr)이며 Docker, Kubernetes, journald가 보관과 로테이션을 맡는 환경에서는 이것이 맞는 선택입니다. 선택 항목인 file 블록을 쓰면 로테이션되는 파일에 기록합니다:

logging:
  level: "info"
  format: "json"
  file:
    directory: /var/log/continuum-router
    filename: continuum-router-{date}-{time}-{pid}-{sequence}.log
    rotation:
      max_bytes: 104857600        # 100 MiB (1 MiB ~ 1 TiB)
      max_files: 10               # 1 ~ 10,000
      max_age_days: 0             # 0 ~ 36,500, 0이면 기간 상한 없음
필드 기본값 설명
directory 필수 없으면 생성됩니다. Router 프로세스 하나만 쓰는 디렉터리를 사용하십시오. 보존 정리는 어느 프로세스가 썼는지와 관계없이 패턴에 맞는 가장 오래된 파일을 지웁니다
filename continuum-router-{date}-{time}-{pid}-{sequence}.log {date}, {time}, {pid}, {sequence}를 지원합니다. {sequence}는 필수이며 경로 구분자는 거부됩니다
rotation.max_bytes 104857600 레코드를 쓰면 이 크기를 넘게 될 때 먼저 로테이션합니다. 레코드 하나가 두 파일에 나뉘어 기록되지는 않습니다
rotation.max_files 10 로테이션 후 남겨 두는 패턴 일치 파일 수
rotation.max_age_days 0 0이 아니면 수정 시각이 이 일수보다 오래된 패턴 일치 파일도 시작 시와 매 로테이션 후 삭제함. 현재 열려 있는 파일은 삭제하지 않음. 0이면 개수 기준 보존만 적용

파일은 기존 이름을 덮어쓰지 않고 생성되며 Unix에서는 모드 0600을 갖습니다. 터미널 색상 코드 없이 일반 텍스트로 기록됩니다. writer는 최소 1초마다, 그리고 로테이션 시 플러시하지만 fsync는 호출하지 않으므로 호스트가 비정상 종료되면 마지막 몇 줄이 사라질 수 있습니다. 로테이션 중 새 파일을 만들 수 없거나(예: 디스크 가득 참) 오래된 파일을 정리하지 못하면, Router는 이를 stderr에 알리고 현재 파일에 계속 기록합니다.

같은 파일에 외부 logrotate의 이름 변경 방식 규칙을 함께 쓰지 마십시오. Router는 현재 파일을 열어 둔 채 유지합니다. config validate, mcp-serve 같은 일회성 명령은 항상 stderr에 기록하며 파일 싱크를 건드리지 않습니다.

설계, 실패 정책, 벤치마크는 로그 시스템 아키텍처에 정리되어 있습니다.

프롬프트 로깅 섹션

프롬프트와 완성 본문의 옵트인 캡처를 설정합니다. 이 섹션은 기본적으로 꺼져 있습니다. enabled가 false이면 어떤 요청 경로도 본문을 읽거나 저장하거나 내보내지 않고, 메타데이터 전용 로깅과 사용량 집계, 트레이싱은 그대로 동작합니다.

켜는 방법과 운영 방법은 프롬프트 로깅 가이드를 참고하십시오.

enabled가 true이면 동의 게이트를 통과한 POST /v1/chat/completions, POST /v1/responses, POST /anthropic/v1/messages 요청마다 directory 아래 파일에 JSONL 레코드 하나를 추가합니다. 레코드에는 클라이언트가 보낸 프롬프트, 백엔드가 생성한 완성과 tool call, 사용량 블록, 동의 근거, 그리고 다음 결과 중 하나가 들어갑니다.

  • ok: 응답을 전달했습니다.
  • refused: 가드레일이 요청이나 응답을 차단했습니다. 경로가 출력을 보관한 경우(buffered chat 응답과 모든 스트림) 백엔드가 생성한 출력을 레코드에 함께 기록하고, buffered /v1/responses, /anthropic/v1/messages, disaggregated 경로의 거부에는 출력이 없습니다.
  • backend_error: 응답한 백엔드가 없습니다.
  • client_cancelled: 클라이언트가 스트림을 일찍 끊었습니다. 레코드에 부분 응답을 기록합니다.
  • stream_errored: 스트림 시작 후 업스트림 오류 또는 타임아웃이 발생했습니다.

다른 모델로 fallback한 요청은 응답한 hop 기준으로 레코드 하나만 남깁니다. chat completions로 이어지는 A2A 브리지도 같은 방식으로 캡처합니다. /v1/completions, 임베딩, ACP는 아직 캡처하지 않습니다. 응답 캐시 적중, 검증이나 요청 정책으로 dispatch 전에 거부된 요청, 라우터가 내부 스트림을 읽는 동안 클라이언트가 취소한 buffered 요청은 레코드를 남기지 않습니다.

Disaggregated 서빙도 buffered와 스트리밍 모두 같은 방식으로 캡처하며, unified 서빙으로 fallback하면 unified 응답의 레코드를 남깁니다. 오케스트레이터가 거부한 요청은 Dynamo shim의 direct-mode 거부를 포함해 backend_error로 기록하고, error에 HTTP 상태를 적습니다.

prompt_logging:
  enabled: false
  directory: ./prompt-logs        # 0700으로 생성, 파일은 0600
  default_consent: allowed        # allowed, denied
  capture:
    prompts: true
    completions: true
    tool_calls: true
  redaction:
    patterns: []                  # 정규식
    masked_replacement: "[redacted]"
  retention:
    max_bytes: 104857600          # 파일당 100 MiB
    max_files: 10
    max_age_days: 7
  export:
    hub_push: false
    sinks: []
필드 기본값 설명
enabled false 마스터 스위치입니다.
directory ./prompt-logs 레코드 파일을 두는 디렉터리입니다. 없으면 소유자 전용(Unix에서 0700)으로 만들고, 이미 있으면 권한을 바꾸지 않습니다. 각 파일은 0600으로 만들며 이름은 prompt-log-<sequence>.jsonl입니다. 디렉터리를 만들거나 쓸 수 없으면 시작에 실패합니다. 보존 정책은 디렉터리 안의 일치하는 파일 전체에 적용되므로, Router 프로세스 하나만 쓰는 디렉터리를 사용하세요.
default_consent allowed API 키에 동의 값이 없거나 키가 없는 요청에 적용할 동의입니다. denied로 두면 캡처를 명시적으로 허용한 키만 기록합니다. API 키가 로컬(api_keys[].prompt_logging_consent) 또는 Continuum Hub에서 동의 값을 명시하면 허용과 거부 모두 이 값보다 우선합니다. Hub 정책 저장소가 있지만 정책이 없는 동안(첫 동기화 전, 또는 복원한 키 테이블이 만료된 뒤)에는 키로 인증한 요청을 이 값과 상관없이 캡처하지 않습니다.
capture.prompts true 요청 프롬프트를 기록합니다. 메시지마다 role: content 한 줄이고, 히스토리의 assistant tool call은 name(arguments)로 적습니다. 텍스트가 아닌 content part(이미지, 오디오)는 데이터 대신 자리표로 적습니다.
capture.completions true 완성 텍스트를 기록합니다. reasoning 출력은 기록하지 않습니다.
capture.tool_calls true 응답이 호출한 tool call을 name(arguments) 형식으로 기록합니다.
redaction.patterns [] 정규식 목록입니다. 레코드를 저장하기 전에 일치 구간을 교체합니다.
redaction.masked_replacement [redacted] 일치 구간 대신 기록할 문자열입니다. $는 사용할 수 없습니다.
retention.max_bytes 104857600 레코드 파일을 순환하는 크기입니다.
retention.max_files 10 보관할 순환 파일 수입니다. 9 미만이면 max_age_days보다 먼저 파일을 지울 수 있습니다.
retention.max_age_days 7 라우터 실행 중 레코드가 디스크에 남는 최장 기간입니다. 보존 기간을 참고하세요.
export.hub_push false 등록 자격 증명으로 레코드를 Continuum Hub에 전송합니다. control-plane 빌드 기능과 policy.enabled: true를 설정한 control_plane 섹션이 필요하며, 그렇지 않으면 경고만 남기고 동작하지 않습니다. 허브 푸시를 참고하십시오.
export.sinks [] 외부 전송 대상입니다. 내보내기 싱크를 참고하세요.

export.sinks의 각 항목은 type으로 대상을 고릅니다:

sinks:
  - type: webhook
    endpoint: "https://logs.example.com/ingest"
    headers_env:
      Authorization: COLLECTOR_AUTH
  - type: langfuse
    endpoint: "https://langfuse.example.com"
    public_key_env: LANGFUSE_PUBLIC_KEY
    secret_key_env: LANGFUSE_SECRET_KEY
  - type: s3
    bucket: prompt-logs
    prefix: router-1/

검증

다음 경우 라우터는 시작하지 않으며, config validate는 오류를 보고합니다:

  • directory가 비어 있는 경우.
  • enabled: true인데 capture 범위 세 개가 모두 false인 경우.
  • redaction.patterns 항목이 올바른 정규식이 아니거나 빈 문자열과 일치하는 경우. 이 검사는 enabled가 false일 때도 수행합니다.
  • retention 값이 0인 경우. 보존 기간은 항상 유한하므로 0은 무제한을 뜻하지 않습니다. 오래 보존하려면 큰 값을 지정하세요.
  • 싱크 endpoint가 호스트를 포함한 http 또는 https URL이 아니거나 자격 증명, 쿼리 문자열, 프래그먼트를 포함한 경우.
  • s3 싱크의 bucket이 버킷 이름 형식이 아니거나(key:secret@bucket 형태 포함) prefix 또는 region 형식이 잘못된 경우.
  • 싱크의 *_env 필드나 headers_env 값이 환경 변수 이름이 아니거나, 싱크 한도 값이 범위를 벗어난 경우.
  • redaction.masked_replacement에 $가 들어 있는 경우. $0 같은 참조는 일치한 원문을 레코드에 다시 기록하므로 거부하며, $$ 이스케이프도 예외가 아닙니다.

redaction.patterns 오류는 항목의 인덱스와 정규식 오류 종류만 알려 주며, 패턴 원문은 출력하지 않습니다.

싱크 endpoint에는 ${VAR} 참조를 쓸 수 있습니다. 변수가 설정되지 않았으면 config validate는 경고를 보고하고 URL 검사를 건너뛰며, 라우터는 시작하지 않습니다. 변수가 설정되어 있으면 해석된 값으로 검사합니다.

export.hub_push: true는 control-plane 기능 없이 빌드한 바이너리나 control_plane이 켜져 있지 않은 배포에서도 정상적으로 로드되지만, 허브로 보내는 코드가 없으므로 경고를 남깁니다. prompt-log-s3 기능 없이 빌드한 바이너리의 s3 싱크도 마찬가지입니다.

enabled가 true일 때 싱크의 endpoint가 사설 주소로 보이지 않는 호스트에 http://를 쓰면, 프롬프트 내용과 자격 증명이 평문으로 전송되므로 config validate와 시작 시점에 경고를 남깁니다. 싱크는 그대로 동작합니다. 루프백, 사설, 링크 로컬 주소와 localhost, 단일 레이블 이름, .svc 이름에는 경고를 남기지 않습니다.

어떤 필드를 변경해도 재시작이 필요합니다. 핫 리로드는 prompt_logging을 requires_restart에 보고합니다. 쓰기 스레드와 마스킹기, 열린 파일은 시작 시에 만들어지고 동의 게이트도 시작 시의 섹션을 읽습니다.

레코드 파일

레코드는 전용 스레드 하나가 쓰며 요청이 직접 쓰지 않습니다. 요청 경로는 레코드를 한 번 마스킹한 뒤 크기가 제한된 큐에 넘기므로 마스킹하지 않은 텍스트는 파일, 로그 한 줄, 오류 어디에도 닿지 않습니다. 마스킹은 내장 자격 증명 패턴을 먼저 적용하고 그다음 redaction.patterns를 적용하며, 일치 구간마다 redaction.masked_replacement를 그대로 넣습니다.

쓰기는 fail-open입니다. prompt_log_records_total{result}는 캡처 경로가 만든 레코드를 모두 셉니다.

  • written: 쓰기 스레드가 레코드를 파일에 추가했습니다.
  • queue_full: 큐가 가득 차서 레코드를 버렸습니다.
  • sink_error: 쓰기에 실패해(디스크 가득 참, 디렉터리 삭제) 레코드를 버렸습니다. 내용 없는 ERROR 한 줄이 실패를 알립니다.

버린 레코드는 요청 결과를 바꾸지 않습니다. 종료 시에는 요청 드레인이 끝난 뒤 큐를 비우고 파일을 flush합니다.

잘린 필드

레코드의 truncated 목록에는 요청이나 응답의 내용을 전부 담지 못한 필드를 적습니다.

  • 바이트 상한: model, prompt, completion, tool call 전체를 각각 256 KiB에서 자릅니다.
  • 스트림 조기 종료: client_cancelled, stream_errored, refused로 끝난 스트림은 임의의 지점에서 멈춘 것이므로, completion과 각 tool call의 인자를 그 지점에서 자릅니다.
  • 마스킹 상한: 마스킹 결과는 필드 하나당, 그리고 tool call 전체 합계로 최대 1 MiB까지만 유지합니다. 패턴이 자신의 치환 문자열에 다시 일치하면 이 상한에 도달할 수 있으며, 나머지 텍스트는 기록하지 않습니다.

자를 때는 텍스트를 마지막 공백까지 최대 4 KiB 되돌립니다. 그래서 경계에 걸친 자격 증명의 앞부분이 패턴을 피해 남지 않습니다. 이 구간에 공백이 없으면 구간 전체를 지우므로, 짧은 응답은 내용이 비어 있어도 필드가 목록에 남을 수 있습니다.

보존 기간

라우터가 실행 중이면 어떤 레코드도 retention.max_age_days를 넘겨 디스크에 남지 않습니다. 파일은 통째로만 지울 수 있으므로 다음 규칙으로 이 상한을 지킵니다.

  • 타이머: 쓰기 스레드는 트래픽과 관계없이 한 시간마다, 그리고 시작 시 보존 정책을 적용합니다. 중지된 라우터는 다시 시작할 때까지 아무것도 지우지 않습니다.
  • 파일 전환: 현재 파일은 첫 레코드의 나이가 max_age_days의 8분의 1에 이르면 새 파일로 전환합니다. 빈 파일은 전환하지 않습니다.
  • 삭제: 마지막 쓰기 시각이 max_age_days에서 8분의 1과 2시간을 뺀 값보다 오래되면 파일을 삭제합니다.

따라서 레코드는 상한의 약 8분의 7에서 상한 사이에 삭제되며, 기본값에서는 약 6~7일입니다. 트래픽이 있으면 상한의 8분의 1마다 파일이 하나씩 생기므로, 나이 기준이 실제로 적용되려면 retention.max_files가 9 이상이어야 합니다.

스트리밍 응답은 스트림이 끝날 때 라우터가 중계한 출력으로 한 번 기록합니다. 출력은 가드레일 출력 게이트보다 앞에서 읽으므로 refused 스트림 레코드에는 게이트가 클라이언트에게 보내지 않은 텍스트가 들어 있습니다. 요청별 fallback 목록, 설정된 체인, 스트림 중 hop은 모두 요청을 끝낸 hop 기준으로 레코드 하나를 남기며, 그 레코드의 completion은 해당 hop의 출력입니다.

내보내기 싱크

싱크마다 레코드 파일을 독립적으로 읽어 요청 경로 밖에서 배치를 전달합니다. 싱크는 enabled가 true일 때만 실행되며, 섹션을 끄면 내보내기 코드가 실행되지 않고 커서도 쓰지 않습니다. 허브 푸시는 같은 루프의 작업 하나를 더 실행하며, 자체 커서와 더 엄격한 동의 규칙을 사용합니다. 허브 푸시를 참고하십시오.

싱크 필드 싱크 기본값 설명
endpoint webhook, langfuse, s3 필수(s3는 선택) webhook URL, Langfuse 기본 URL, 또는 S3 호환 서비스 URL입니다. 비밀값처럼 마스킹합니다. s3에서는 값이 없을 때 AWS_ENDPOINT를 쓰고, AWS_ENDPOINT_URL_S3가 있으면 항상 그 값이 우선합니다. 전달 전 검사는 AWS_ENDPOINT_URL_STS, AWS_METADATA_ENDPOINT, AWS_CONTAINER_CREDENTIALS_FULL_URI에도 적용됩니다.
allow_private_endpoint 전체 false 사설, 루프백, 링크 로컬, 통신사급 NAT, 멀티캐스트, 메타데이터 주소의 endpoint와 S3 자격 증명 엔드포인트를 허용합니다. 허용하지 않으면 전달할 때마다 호스트 이름을 확인해 검사합니다. 확인되지 않는 이름은 해당 전달이 실패하고, 연결은 검사를 통과한 주소로만 이루어집니다. 리다이렉트는 따라가지 않습니다. ECS와 EKS Pod Identity의 고정 자격 증명 호스트는 예외입니다.
headers_env webhook {} 헤더 이름과 그 값을 담은 환경 변수의 맵입니다. 최대 16개입니다. 라우터가 읽을 수 있는 어떤 환경 변수든 지정할 수 있고, 그 값은 엔드포인트로 전송됩니다.
public_key_env, secret_key_env langfuse LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY 프로젝트 키를 담은 환경 변수입니다. Basic 인증으로 보냅니다.
bucket s3 필수 버킷 이름입니다. 소문자, 숫자, ., -로 이루어진 3~63자이며, ..이 없어야 하고 IP 주소 형태가 아니어야 합니다.
prefix, region s3 없음 오브젝트 키 접두사와 리전입니다. 리전이 없으면 표준 AWS 환경 설정을 따릅니다.
access_key_id_env, secret_access_key_env, session_token_env s3 없음 자격 증명을 담은 환경 변수입니다. 액세스 키와 시크릿은 함께 지정하고, 둘 다 없으면 표준 AWS 자격 증명 체인을 사용합니다.
max_batch_records 전체 100 배치당 레코드 수입니다. 1~10000입니다.
max_batch_bytes 전체 1048576 배치당 레코드 바이트 수입니다. 1 KiB~64 MiB이며, 이보다 큰 레코드 하나는 단독으로 전송합니다.
timeout_seconds 전체 30 전달 요청 한 건의 타임아웃입니다. 1~300입니다.
interval_seconds 전체 5 밀린 레코드가 없을 때 새 레코드를 확인하는 간격이자 첫 재시도 지연입니다. 1~3600입니다.
max_backoff_seconds 전체 300 최장 재시도 지연입니다. interval_seconds부터 86400까지입니다.
backfill 전체 false 커서 파일이 없는 싱크의 시작 위치입니다. false이면 싱크가 시작된 뒤에 기록된 레코드만 보내고, true이면 남아 있는 모든 레코드를 먼저 보냅니다. 전달 방식을 참고하세요.
on_permanent_failure 전체 block 대상이 영구적으로 거부한 배치의 처리 방식입니다. block은 계속 재시도하고, skip은 상태가 400, 409, 413, 415, 422일 때만 전달하지 못한 레코드를 dropped로 세고 넘어갑니다.
cursor_id 전체 없음 대상의 해시 대신 커서 파일 이름을 정하므로, 대상이 바뀌어도 커서가 유지됩니다. 영문자, 숫자, _, -로 이루어진 1~64자이며, 같은 싱크 유형 안에서 고유해야 합니다. 남은 레코드는 지금 지정된 대상으로 전달됩니다. 전달 방식을 참고하세요.

자격 증명은 항상 환경 변수 이름으로 지정하며 시작 시 한 번 읽습니다. 변수가 없거나 비어 있으면 라우터가 시작하지 않고, config validate가 먼저 경고합니다. prompt-log-s3가 없는 바이너리에서 s3 싱크는 동작하지 않으므로 변수를 읽지 않습니다. 메시지에는 변수 이름만 나오고 값은 나오지 않습니다.

전달 방식

전달은 최소 한 번(at-least-once) 보장입니다. 싱크마다 directory 아래 .export-cursor-<id>.json에 커서(파일 순번과 바이트 오프셋)를 0600으로 저장하고, 배치 전달이 끝난 뒤 커서를 옮깁니다. <id>는 싱크 종류와 대상의 해시입니다. 따라서 sinks 순서를 바꿔도 커서가 유지되고, 대상을 바꾸면(webhook URL의 토큰 변경 포함) 새 싱크가 됩니다. cursor_id를 설정하면 <id>는 <type>-<cursor_id>가 되어 대상을 바꿔도 커서가 유지됩니다. 기존 싱크에 추가하면 backfill을 따라 한 번 새 커서로 시작합니다.

싱크의 시작 위치는 커서 파일에 따라 정해집니다.

  • 커서 파일 없음, backfill: false(기본값): 남아 있는 레코드의 끝에서 시작해 이후에 기록된 레코드만 보냅니다. 시작 위치를 곧바로 저장하므로, 라우터가 멈춘 동안 기록된 레코드도 재시작 후 전달됩니다. 커서 파일을 지우면 다시 새 싱크가 됩니다.
  • 커서 파일 없음, backfill: true: 보존 정책이 아직 남겨 둔 가장 오래된 파일부터 시작해 남아 있는 모든 레코드를 배치로 곧바로 보냅니다.
  • 읽을 수 없는 커서 파일(예: EMFILE, EIO, EACCES): 경고를 한 번 남기고, 읽기에 성공하거나 라우터가 종료될 때까지 interval_seconds마다 재시도합니다. 그동안 아무것도 보내지 않습니다. 저장된 위치는 대개 그대로 남아 있으므로 가장 오래된 파일이나 끝으로 옮겨 가지 않습니다.
  • 올바른 커서가 없는 커서 파일, backfill: false: 경고를 남기고, 커서를 지웠을 때처럼 남아 있는 레코드의 끝에서 시작합니다. 잃어버린 위치 이후부터 재시작 전까지 기록된 레코드는 보내지 않습니다.
  • 올바른 커서가 없는 커서 파일, backfill: true: 경고를 남기고 가장 오래된 파일부터 다시 시작하므로, 이미 보낸 레코드가 다시 전달됩니다.

라우터가 레코드 파일을 열 때 커서 파일을 계속 읽을 수 없으면, 다음 시작까지 레코드를 쓰지 않습니다. 다음 파일 번호는 모든 커서보다 커야 하는데, 해당 커서의 번호를 알 수 없기 때문입니다. 요청은 계속 처리합니다. 올바른 커서가 없는 파일은 시작에 영향을 주지 않습니다.

커서가 이미 있는 싱크에는 backfill이 영향을 주지 않습니다. export는 현재 redaction.patterns를 적용하고, 동의 철회에서 설명하듯 각 레코드의 키 동의를 다시 확인합니다. capture 범위는 다시 판단하지 않으므로, 다음과 같은 결과가 생깁니다.

  • 백필되는 레코드: 이전 capture 범위로 캡처한 레코드도 포함됩니다.
  • 커서가 이미 있는 싱크: capture 범위를 좁힌 뒤에도 밀린 레코드를 계속 전달합니다. 이 전달을 막으려면 export.sinks에서 싱크를 제거하세요.

  • 실패: 배치는 디스크에 남고 interval_seconds부터 max_backoff_seconds까지 지수 백오프로 재시도합니다. 요청에는 영향이 없고, 장애가 길어져도 싱크가 메모리에 두는 배치는 하나입니다. 첫 실패는 경고로 남기고 시도마다 failed로 셉니다. 기본값인 on_permanent_failure: block에서는 대상이 레코드를 영구적으로 거부하면(예: 4xx 응답) 운영자가 문제를 고치거나 보존 정책이 해당 파일을 지울 때까지 더 이상 아무것도 전달되지 않습니다.

  • 거부 건너뛰기: on_permanent_failure: skip을 설정하면, 해당하는 상태에서 배치의 나머지를 버리고 커서를 옮깁니다. 프롬프트 로깅 가이드를 참고하세요.
  • 시작 시 읽을 수 없는 커서: 5번 재시도한 뒤, 다음 시작 때까지 캡처와 내보내기 없이 요청을 처리하고 prompt_log_capture_disabled{reason="cursor_unreadable"}을 1로 설정합니다. 보존 전용 타이머는 새 레코드 파일을 만들지 않고 max_age_days와 max_files를 계속 적용합니다. 프롬프트 로깅 가이드를 참고하세요.
  • 보존: 보존 정책은 싱크를 기다리지 않습니다. 싱크가 읽지 않은 파일이 지워지면 커서는 다음 파일로 이동하고 pruned가 이를 셉니다. 견뎌야 할 최장 장애 시간에 맞춰 retention을 정하세요.
  • 마스킹: 모든 레코드는 읽을 때 redaction.patterns를 다시 거칩니다. 캡처 후에 추가한 패턴도 기존 파일에 적용됩니다.
  • 잘못된 줄: 레코드가 아닌 줄은 건너뛰고 셉니다. 쓰는 중인 파일 끝의 불완전한 줄은 완성될 때까지 읽지 않습니다.

대상별 동작

  • webhook: 레코드의 JSON 배열을 POST합니다.
  • langfuse: {endpoint}/api/public/otel/v1/traces로 OTLP JSON을 POST하며 x-langfuse-ingestion-version: 4를 보냅니다. 레코드 하나는 관측 유형이 generation인 완결된 span 하나가 되고 입력, 출력, 모델, 토큰 사용량을 담습니다. traceId와 spanId는 request_id에서 유도합니다. Langfuse가 partialSuccess.rejectedSpans로 보고한 span은 rejected로 세고 재시도하지 않습니다. Langfuse가 폐기했고 Langfuse Cloud에서 2026-11-16에 종료되는 /api/public/ingestion은 사용하지 않습니다.
  • s3: 배치마다 JSONL 오브젝트 하나를 <prefix>/prompt-log-<sequence>-<offset>.jsonl로 올리므로, 다시 전달한 배치는 이전 업로드를 덮어씁니다. prompt-log-s3 빌드 기능이 필요하며 공식 릴리스 바이너리에 포함되어 있습니다. 이 기능이 없는 바이너리는 싱크가 동작하지 않는다고 경고합니다.

참고: Langfuse v4는 같은 span을 두 번 받으면 안정적으로 중복 제거하지 않으므로, 다시 전달한 레코드는 id가 같아도 두 번 보일 수 있습니다.

메트릭

prompt_log_export_backlog_bytes{sink}는 싱크가 아직 전달하지 않은 디스크의 레코드 바이트 수입니다. prompt_log_export_total{sink,result}는 결과별로 셉니다. sink는 싱크 종류이며, 같은 종류의 싱크가 더 있으면 -1, -2 등을 붙입니다.

result 의미
delivered 대상이 받은 레코드입니다.
rejected 대상이 영구적으로 거부한 레코드입니다. 커서는 이를 지나 이동합니다.
failed 실패해서 재시도할 배치의 레코드입니다. 시도마다 셉니다.
malformed 건너뛴 줄입니다.
pruned 싱크가 읽기 전에 보존 정책이 지운 파일을 지나 이동한 횟수입니다.

로그에는 싱크 라벨, 개수, 실패 종류만 나오고 레코드, 엔드포인트, 자격 증명은 나오지 않습니다.

출력 마스킹

웹훅 URL은 경로에 토큰을 담을 수 있고, 마스킹 패턴은 비밀 값을 그대로 인용할 수 있습니다. 검증으로는 둘 다 찾아낼 수 없으므로, 싱크 endpoint와 redaction.patterns는 다음 출력에서 마스킹합니다:

  • Admin 구성 응답, 내보내기, 이력. 변경하지 않은 자리 표시자는 가져올 때 현재 실행 중인 값으로 복원됩니다.
  • config diff와 MCP diff_configs, resolve_config 도구.
  • config show --resolved. 실제 값을 복원하기 전에는 이 출력을 구성으로 로드할 수 없습니다. 일반 config show는 파일 내용을 그대로 출력합니다.