아키텍처 가이드¶
이 문서는 Continuum Router의 아키텍처, 설계 결정, 확장 지점을 다룹니다.
목차¶
- 개요
- 4계층 아키텍처
- 라이브러리 진입점 (src/server/)
- 핵심 컴포넌트
- 데이터 흐름
- 의존성 주입
- 오류 처리 전략
- 확장 지점
- 설계 결정
- 성능 고려 사항
- 속도 제한 → 설정
- 모델 폴백 시스템 → 오류 처리
- 서킷 브레이커 → 오류 처리
- 파일 저장소 → 아키텍처 상세
- 실시간 WebSocket 프록시 → 설정
- Dynamo 워커 탐색 → 아키텍처 상세
- Agent Communication Protocol (ACP)
- 백엔드 패스스루 계약
개요¶
Continuum Router는 깔끔한 4계층 아키텍처를 사용하여 명확한 관심사 분리, 테스트 가능성, 유지보수성을 제공하는 고성능, 프로덕션 레디 LLM API 라우터로 설계되었습니다. 이 아키텍처는 도메인 주도 설계 원칙과 의존성 역전을 따라 견고하고 확장 가능한 시스템을 만듭니다.
아키텍처 목표¶
- 관심사 분리: 각 계층은 단일의 명확하게 정의된 책임을 가집니다
- 의존성 역전: 상위 계층은 구체적인 구현이 아닌 추상화에 의존합니다
- 테스트 가능성: 각 컴포넌트를 독립적으로 단위 테스트할 수 있습니다
- 확장성: 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있습니다
- 성능: 깔끔한 아키텍처를 유지하면서 최소한의 오버헤드
- 안정성: 다층 오류 처리를 갖춘 빠른 실패 설계
4계층 아키텍처 { #4-layer-architecture }¶
계층 설명¶
1. HTTP Layer (src/http/)¶
책임: HTTP 요청, 응답 및 웹 관련 관심사 처리
구성 요소¶
- Routes (
routes.rs): HTTP 엔드포인트 및 라우트 처리 정의 - Middleware (
middleware/): 횡단 관심사 (인증, 로깅, 메트릭, 속도 제한) - DTOs (
dto/): HTTP 직렬화/역직렬화를 위한 데이터 전송 객체 - Streaming (
streaming/): Server-Sent Events (SSE) 처리
주요 파일¶
src/http/
├── mod.rs # HTTP 계층 내보내기
├── routes.rs # 라우트 정의 및 핸들러
├── dto.rs # 요청/응답 DTO
├── handlers/ # 요청 핸들러
│ ├── mod.rs
│ └── responses.rs # Responses API 핸들러
├── middleware/ # HTTP 미들웨어 컴포넌트
│ ├── mod.rs
│ ├── auth.rs # API 키 인증 미들웨어
│ ├── admin_auth.rs # Admin API 인증 미들웨어
│ ├── files_auth.rs # Files API 인증 미들웨어
│ ├── admin_audit.rs # Admin 작업 감사 로깅
│ ├── cors.rs # CORS (Cross-Origin Resource Sharing) 미들웨어
│ ├── logging.rs # 요청/응답 로깅
│ ├── metrics.rs # 메트릭 수집
│ ├── metrics_auth.rs # 메트릭 엔드포인트 인증
│ ├── model_extractor.rs # 요청에서 모델 추출
│ ├── prometheus.rs # Prometheus 메트릭 통합
│ ├── rate_limit.rs # 속도 제한 미들웨어 (레거시)
│ └── rate_limit_v2/ # 향상된 속도 제한 (모듈화)
│ ├── mod.rs # 모듈 내보내기
│ ├── middleware.rs # 속도 제한 미들웨어
│ ├── store.rs # 속도 제한 저장소 및 추적
│ └── token_bucket.rs # 토큰 버킷 알고리즘
└── streaming/ # SSE 스트리밍 핸들러
├── mod.rs
└── handler.rs # 스트리밍 응답 처리
미들웨어 구성 요소¶
HTTP 계층에는 횡단 관심사를 제공하는 여러 미들웨어 구성 요소가 포함됩니다:
-
auth.rs: 메인 엔드포인트 (
/v1/chat/completions,/v1/models,/anthropic/*등)에 대한 API 키 인증Authorization: Bearer <key>또는 Anthropic 고유의x-api-key: <key>헤더로 전달된 API 키 검증. 둘 다 있으면Authorization: Bearer를 우선config.yaml에 구성된 다중 API 키 지원- 유효하지 않거나 누락된 키에 대해 401 Unauthorized 반환
-
admin_auth.rs: admin 엔드포인트 (
/admin/*)에 대한 별도 인증- 명시적
none, 베어러 토큰, HTTP Basic, IP 허용 목록, 공유 저장소 API 키 모드 지원 - 민감한 작업 보호(설정 리로드, 서킷 브레이커 제어, 헬스 관리)
admin.auth로 설정. 블록이 없으면 실패 시 차단하고method가 없는 블록은 기본적으로api_key를 사용
- 명시적
-
files_auth.rs: Files API (
/v1/files/*)에 대한 인증 미들웨어- 파일 업로드/다운로드/삭제 작업에 특화된 API 키 검증
- 무단 파일 접근 및 조작 방지
- 권한 확인을 위해 파일 저장소 서비스와 통합
-
admin_audit.rs: admin 작업에 대한 감사 로깅 미들웨어
- 타임스탬프 및 호출자 식별과 함께 모든 admin API 호출 기록
- 민감한 작업의 매개변수와 결과 로깅
- 규정 준수 및 보안 모니터링을 위한 감사 추적 제공
- 설정 가능한 로그 레벨 및 보존 정책
-
cors.rs: CORS (Cross-Origin Resource Sharing) 미들웨어
- 웹 애플리케이션, Tauri 앱, Electron 앱에 라우터 임베딩 가능
- 와일드카드 출처 (
*), 정확한 출처, 포트 와일드카드 (http://localhost:*) 지원 - 데스크톱 앱용 사용자 정의 스킴 지원 (예:
tauri://localhost) - 메서드, 헤더, 자격 증명, 프리플라이트 캐시 기간 설정 가능
- 적절한 프리플라이트 처리를 위해 미들웨어 스택 초기에 적용
-
rate_limit_v2/: 향상된 속도 제한 시스템 (속도 제한 섹션 참조)
- 클라이언트별 추적이 있는 토큰 버킷 알고리즘
- 지속적인 속도 및 버스트 보호를 위한 별도 제한
- 만료된 클라이언트 항목의 자동 정리
- 모니터링을 위한 상세 메트릭
2. Services Layer (src/services/)¶
책임: 비즈니스 로직 조율 및 인프라 구성 요소 간 조정
구성 요소¶
- Backend Service (
backend_service.rs): 백엔드 풀 관리, 로드 밸런싱, 헬스 체크 - Model Service (
model_service.rs): 백엔드에서 모델 집계, 캐싱 처리, 메타데이터로 보강 - Proxy Service (
proxy_service.rs): 요청 라우팅, 재시도 처리, 스트리밍 관리 - Health Service (
health_service.rs): 서비스 헬스 모니터링, 상태 추적 - Web Search Service (
search/): 자체 호스팅 백엔드를 위해 라우터가 관리하는web_search도구입니다. Serper, Exa, Brave, SearXNG 구현체를 갖춘 플러그형 프로바이더 트레이트와 반복 횟수, 경과 시간, 바이트 예산 상한이 있는 도구 실행 루프를 제공합니다(웹 검색 참조) - System One Client (
systemone/):generic,typesafe,foundry,llama_cpp프로바이더 프로필(호스팅 TypeSafe Jev, Foundry의 Microsoft-Decision-1, 자체 호스팅 Laya / Kev / Nimble / VLLM Decision 2/D3)을 아우르는 공유 System One 결정 모델 클라이언트입니다(의사 결정 모델 참조).- 핵심 기능:
state에 대한 타입 지정noul/choice/score/box질의, 429 / 529 / 연결 실패 시 1회 제한 재시도, 크기 제한 오류 본문 처리, 고정 백엔드 계약에 따른backend:참조 해석을 제공합니다. - 캐시 무효화 및 공유:
cache_target_identity()는 라이브Config스냅샷이 리로드될 때마다 단조 증가하는CacheTopologyGeneration카운터를 사용해 자격 증명을 해시하지 않고 하위 캐시를 무효화합니다. 가드레일 프로바이더, 스마트 라우팅 분류기, 액션 안전 평가기, 응답 품질 폴백 검사, 공개/v1/decisions네이티브 패스스루가 모두 이 클라이언트와SystemOneTargetConfig(src/core/config/models/systemone.rs)를 공유합니다.ClientCache(systemone/cache.rs)는 소비자와 라우터마다(/v1/decisions는get_or_build_keyed로 소비자, 라우터, 타깃 이름마다) 타깃 블록을 키로 삼아 클라이언트를 하나씩 유지하며, 타깃이 리로드되면 다음 요청에서 재생성합니다.
- 핵심 기능:
- Answer Quality (
answer_quality/): 소비자 중립적인 응답 품질 질문 묶음입니다.- 질문 및 상태 생성: 세 가지
noul질문(answered,refusal,truncated,questions설정으로 부분 집합 선택), 임계값에 따라 판정을 내리는AnswerJudgment::worst_problem, 마지막 사용자 메시지와 응답만 읽어 각각 앞뒤 일부만 남기는{user_message, ai_response}상태 생성기를 제공합니다. - 품질 기반 폴백:
fallback.quality_check(src/proxy/quality_fallback.rs)는 비스트리밍 채팅 응답에 대해 가드레일 출력 게이트 직후에 이 검사를 실행하며, 체인 실행기와 동일한 홉 경로(src/proxy/request.rs의execute_quality_hop)로 폴백 체인을 진행합니다(max_extra_hops,max_fallback_attempts, 비스트리밍 체인 예산 적용).
- 질문 및 상태 생성: 세 가지
- Usage Quality Judgment (
src/control_plane/usage_quality/,control-plane기능):usage.quality_judgment는 질문 묶음에grounded와language_match를 추가하여 가드레일 출력 게이트를 통과한 버퍼링 및 스트리밍 응답을 평가합니다.- 샘플링 및 비동기 봉인:
UsageQualityPlan은 디스패치 전에 요청을 샘플링해 길이 제한이 적용된 텍스트를 보관하며, 비스트리밍 통계 탭이 그QualityTicket을 요청의UsageEvent에 첨부합니다. 응답이 확정되면finish_text_with(OpenAI용finish/finish_with래퍼 뒤의 와이어 중립 코어)가 라우터별 동시 실행 상한 안에서 별도 평가 태스크를 띄우거나,fallback.quality_check와 호출을 공유하는 경우 이미 수행된 판정을 그대로 전달합니다. 사용량 루프는 판정을 기다리지 않고 레코드를 즉시 변환, 시퀀싱, 차감, 버퍼링한 뒤 푸시 배치를 구성할 때 티켓을 봉인합니다(그 시점까지 완료된 판정만 레코드에 실리고 지연된 판정은late로 집계되며, 허브는idempotency_key로 중복을 제거합니다). - 스트리밍 채팅 경로: 채팅 인그레스가 입력 게이트 직후에 요청을 샘플링해
StreamQualityHandle(단일 소비 공유 핸들)로 스트리밍 핸들러에 전달하며, 캐시 재생이 아님이 확인되면StreamObservabilityContext::usage_quality에 장착됩니다.record_stream_usage가 티켓을 스트림 종료UsageEvent에 싣고, OpenAI 와이어 릴레이(변환기, thinking 변환기, mid-stream 폴백 릴레이)는StreamReplyAccumulator(src/services/answer_quality/stream.rs, 전체 응답의cap_middle과 동일한 결과를 내는 앞뒤 버퍼링 판독기)에 청크를 누적한 뒤 게이트의finalize이후 한 번 평가합니다. 정상 종료된 스트림만 평가하며, 게이트 차단·변환, mid-stream 홉 이어붙이기, 오류, 클라이언트 연결 종료, 또는 어떤 릴레이도 꺼내지 않은 계획은 모두skipped로 집계됩니다(모든 결과에stream레이블 부여). - Anthropic Messages 인그레스:
QualityRequest트레이트(src/services/answer_quality/state.rs)를 통해 요청을 샘플링하고(판정 대상 턴은 액션 안전 평가기와 공유하는src/http/handlers/anthropic/user_turn.rs의judged_user_turn이 선택하며,tool_result와 하네스<system-reminder>블록을 제외한 마지막 사용자 메시지를 사용),anthropic_reply_text와StreamReplyAccumulator::observe_anthropic_event(src/services/answer_quality/anthropic.rs)로 응답을 추출합니다. 버퍼링 요청은with_request_plan(src/control_plane/usage_quality/scope.rs) 범위 안에서 실행되어 응답 경로의 탭이claim_current_ticket으로 티켓을 가져가고build_gated_anthropic_response가take_request_plan으로 본문을 평가합니다. 스트리밍 요청은AnthropicStreamOutput(src/http/handlers/anthropic/stream_output.rs)을 통해 스트림 전 시도들이StreamQualityHandle을 공유합니다. /v1/responses및responses_only브리지:/v1/responses는ResponsesRequest에QualityRequest를 구현하고responses_reply_text(src/services/answer_quality/responses.rs,previous_response_id연쇄 요청에서도 현재 요청의input내 마지막 사용자 메시지만 평가)와src/http/handlers/responses_quality.rs를 통해 연결됩니다. 스트리밍 변환 경로의CompletedResponsePersister와 패스스루 릴레이는 종료 이벤트(response.completed/response.incomplete)의 응답 객체를 게이트 검사 뒤에 정산합니다. 채팅 인그레스의responses_only브리지(src/proxy/responses_only/bridge_usage.rs)도 입력 게이트 직후 자체 계획을 샘플링해 버퍼링 응답은 출력 게이트 뒤에, 스트리밍 응답은BridgeStreamEnd::record에서 정산합니다. 모델 실험 요청의 모든 사용량 레코드에는experiment_id와experiment_variant_id(ExperimentArm)가 함께 기록됩니다.
- 샘플링 및 비동기 봉인:
- System One Classifier (
smart_routing/system_one_classifier.rs): 스마트 라우팅method: system_one/hybrid_system_one분류기입니다.- 분류 및 신뢰도 게이트: 캐시되지 않은 요청마다 공유 클라이언트를 통해 복잡도 수준(
score)과 도메인(choice)을 단일 결정 호출로 질의하고, 점수를 가장 가까운 수준으로 반올림한 뒤 두 응답 신뢰도의 최솟값과 함께ClassificationResult를 구성합니다.SmartRouter는min_confidence로 결과를 게이트하며(미달 시 규칙 기반 폴백 또는hybrid_system_one의 LLM 에스컬레이션 실행), 기능(capabilities)과 언어는 규칙 분류기에서 가져옵니다.shadow모드에서는 라우팅 결정을 바꾸지 않고 주 분류기와 나란히 실행해 일치율을 기록합니다. - 캐싱 및 후보 사전 필터링: 캐시 키는
cache_namespace({version, target, questions}),SystemOneClient::cache_target_identity(), 가드레일 통과 후 길이 제한된state로 구분됩니다. 분류 및 정책 선택 전에collect_smart_routing_candidates_for_request(src/proxy/smart_routing_candidates.rs)가 호출자 API 키의allowed_models및allowed_backends, 백엔드 헬스 상태, 서킷 브레이커 허용 여부, 채팅 출력 적격성(결정 전용 모델은excluded_incompatible로 제외)을 교차 검증합니다.
- 분류 및 신뢰도 게이트: 캐시되지 않은 요청마다 공유 클라이언트를 통해 복잡도 수준(
주요 파일¶
src/services/
├── mod.rs # 모듈 내보내기
├── model_service.rs # 모델 집계 서비스
├── proxy_service.rs # 요청 프록시 및 라우팅
├── health_service.rs # 헬스 모니터링 서비스
├── deduplication.rs # 요청 중복 제거 서비스
├── responses/ # Responses API 지원
│ ├── mod.rs
│ ├── converter.rs # 응답 형식 변환
│ ├── router.rs # 라우팅 전략 결정 (패스스루 vs 변환)
│ ├── passthrough.rs # OpenAI/Azure 및 `responses_api: native` 백엔드용 직접 패스스루
│ ├── passthrough_lifecycle.rs # 응답을 처리한 백엔드로 GET/DELETE/cancel 전달
│ ├── passthrough_routes.rs # 라우터별로 제한된 (소유자, 응답 ID) -> 백엔드 바인딩
│ ├── session.rs # 세션 관리
│ ├── stream_service.rs # 스트리밍 서비스 오케스트레이션
│ └── streaming.rs # 스트리밍 응답 처리
├── answer_quality/ # 응답 품질 질문 묶음과 결정 상태
│ ├── battery.rs # QualityQuestion, AnswerJudgment, judge()
│ └── state.rs # {user_message, ai_response[, tool_results]} 상태 생성기와 상한
├── search/ # 라우터 관리형 웹 검색 서비스
│ ├── mod.rs # SearchProvider 트레이트, SearchError, SearchResult, build_provider
│ ├── executor.rs # WebSearchExecutor: 도구 주입, 도구 실행 루프
│ └── providers.rs # 프로바이더 구현체 (Serper, Exa, Brave, SearXNG)
├── streaming/ # 스트리밍 유틸리티
│ ├── mod.rs
│ ├── parser.rs # 스트림 파싱 로직
│ └── transformer.rs # 스트림 변환 (OpenAI/Anthropic)
├── tokenize/ # 엔진 토크나이저 접근, 항상 컴파일
│ ├── wire.rs # 백엔드 타입별 지원 여부, 경로, 페이로드. count_tokens와 공유
│ ├── response.rs # 크기 제한된 바이트에서 JSON 트리 없이 엔진 응답을 읽는 리더
│ ├── lenient.rs # 타입이 다른 필드를 없는 것으로 읽는 비지터
│ ├── token_strs.rs # 버퍼 하나와 u32 오프셋으로 담는 토큰 문자열 목록
│ ├── upstream.rs # 시간 예산과 크기 제한 본문 읽기. count_tokens와 공유
│ ├── validate.rs # /tokenize와 /detokenize의 클라이언트 계약
│ ├── dispatch.rs # 정확한 모델 해석 (재시도와 폴백 없음)
│ ├── call.rs # 업스트림 호출, 또는 /apply-template과 /tokenize 두 호출
│ ├── chat_render.rs # llama-server 엔진별로 반영되는 messages 옵션과 두 호출의 본문
│ └── handlers.rs # build_api_routes가 마운트하는 두 핸들러
└── systemone/ # 공유 System One 결정 모델 클라이언트
├── mod.rs # 모듈 문서와 재수출
├── cache.rs # ClientCache: 소비자, 라우터, 타깃 키당 클라이언트 하나
├── client.rs # SystemOneClient: 대상 해석, 제한된 재시도, 디코딩
├── metering.rs # SystemOneConsumer별 시도 단위 사용량 기록
└── types.rs # Question / Answer / Usage 와이어 타입과 질문 검증
3. Infrastructure Layer (src/infrastructure/)¶
책임: 외부 시스템 및 기술적 기능의 구체적인 구현 제공
구성 요소¶
- Backends (
backends/): 특정 백엔드 구현 (OpenAI, Anthropic, Gemini, vLLM, SGLang, Ollama, llama.cpp, LM Studio, Continuum Router) - Cache (
cache/): 캐싱 구현 (LRU, TTL 기반) - Configuration (
config/): 설정 로딩, 감시, 검증 - HTTP Client (
common/http_client.rs): 모든 백엔드 클라이언트에 쓰이는HttpClientFactory프리셋(유일한 클라이언트 팩토리) - Backend HTTP Protocol (
backend_http/): 백엔드별http_version선택.auto백엔드는 공유 프록시 클라이언트를 쓰고http1과http2백엔드는 같은ProxyClientTuning(타임아웃과server.http_client)으로 만든 형제 클라이언트를 씁니다. 형제 클라이언트는 이 튜닝과 버전을 키로 프로세스 전역에 캐시됩니다.AppState::backend_client는 요청이 라우팅된 설정에 있는 백엔드 항목으로 클라이언트를 고르므로 리로드는 다음 디스패치부터 적용되고BackendClients는 이 선택을 mid-stream 릴레이 태스크로 전달합니다. 디스패치 관측성(디스패치마다 debug 로그, HTTP/2를 협상할 수 있는 백엔드가 in-flight 100에 이르면 빈도 제한된 경고, 버려진 대기를cancelled로 기록하는ResponseWait를 통한continuum_backend_response_wait_seconds)도 여기서 나옵니다. 형제 클라이언트 빌드 실패는 캐시되어 한 번만 로그되고 해당 백엔드는 공유 클라이언트로 대체됩니다. 호스트당 두 번째 HTTP/2 연결은 구현하지 않았습니다. reqwest 풀은 오리진마다 HTTP/2 연결 하나를 재사용하며 스트림 수를 고려하는 훅이 없기 때문입니다. - Auth (
auth/): OAuth 2.0 디바이스 인가 그랜트(RFC 8628) 서브시스템.AuthStrategyRegistry는 시작 시 채워지고 핫 리로드 중에 갱신되며 모든 프록시 핫 패스 핸들러(chat completions, responses, image generation)가 이걸 조회해 outbound 요청에 서명하고401이 오면 강제 갱신을 트리거합니다. 스트리밍 요청 빌더(초기 디스패치, 홉 단위 스트림 중간 폴백, 자동 백엔드 선택, Unix 소켓 전송)도 같은 레지스트리를 조회합니다. 공유 프록시 인증 헬퍼는src/proxy/oauth_helper.rs에 있습니다.
주요 파일¶
src/infrastructure/
├── mod.rs # 인프라 내보내기 및 유틸리티
├── backends/ # 백엔드 구현
│ ├── mod.rs
│ ├── anthropic/ # 네이티브 Anthropic Claude 백엔드
│ │ ├── mod.rs # 백엔드 구현 및 요청 변환
│ │ └── stream.rs # SSE 스트림 변환기 (Anthropic → OpenAI)
│ ├── gemini/ # 네이티브 Google Gemini 백엔드
│ │ ├── mod.rs # TTFB 최적화가 있는 백엔드 구현
│ │ └── stream.rs # SSE 스트림 변환기 (Gemini → OpenAI)
│ ├── openai/ # OpenAI 호환 백엔드
│ │ ├── mod.rs
│ │ ├── backend.rs # OpenAI 백엔드 구현
│ │ └── models/ # OpenAI 특화 모델 정의
│ ├── factory/ # 백엔드 팩토리 패턴
│ │ ├── mod.rs
│ │ └── backend_factory.rs # 설정에서 백엔드 생성
│ ├── pool/ # 백엔드 풀링 및 관리
│ │ ├── mod.rs
│ │ ├── backend_pool.rs # 연결 풀 관리
│ │ └── backend_manager.rs # 백엔드 라이프사이클 관리
│ ├── generic/ # 제네릭 백엔드 구현
│ │ └── mod.rs
│ ├── llamacpp/ # llama.cpp / llama-server 백엔드
│ │ ├── mod.rs
│ │ ├── backend.rs # LlamaCppBackend 구현
│ │ ├── model_parser.rs # 하이브리드 응답 형식 파싱
│ │ └── props.rs # 도구 호출 감지를 위한 /props 엔드포인트
│ ├── continuum_router/ # Continuum Router / Backend.AI GO 백엔드
│ │ ├── mod.rs
│ │ └── backend.rs # ContinuumRouterBackend 구현
│ └── vllm.rs # vLLM/SGLang/Ollama/LM Studio 백엔드 구현
├── common/ # 공유 인프라 유틸리티
│ ├── mod.rs
│ ├── executor.rs # 재시도/메트릭이 있는 요청 실행
│ ├── headers.rs # HTTP 헤더 유틸리티
│ ├── http_client.rs # 풀링이 있는 HTTP 클라이언트 팩토리
│ ├── statistics.rs # 백엔드 통계 수집
│ └── url_validator.rs # URL 검증 및 보안
├── transport/ # 전송 추상화 레이어
│ ├── mod.rs # Transport 열거형 (HTTP, UnixSocket)
│ └── unix_socket.rs # Unix Domain Socket 클라이언트
├── auth/ # OAuth 2.0 디바이스 인가 그랜트 (RFC 8628)
│ ├── mod.rs
│ ├── device_flow.rs # 표준 RFC 8628 디바이스 플로우 클라이언트
│ ├── openai_codex_flow.rs # OpenAI Codex 헤드리스 디바이스 코드 플로우 (PKCE 포함)
│ ├── strategy.rs # AsyncAuthStrategy 트레이트 + OAuthAuthStrategy (proactive refresh)
│ ├── token_store.rs # 0600 권한, 원자적 쓰기, ~ 확장
│ ├── registry.rs # 백엔드 이름 키 기반 AuthStrategyRegistry
│ └── error.rs # 공유 AuthError 타입
├── cache/ # 캐싱 구현
│ ├── mod.rs
│ ├── lru_cache.rs # LRU 캐시 구현
│ └── retry_cache.rs # 재시도 인식 캐시
├── config/ # 설정 관리
│ ├── mod.rs
│ ├── loader.rs # 설정 로딩
│ ├── validator.rs # 설정 검증
│ ├── timeout_validator.rs # 타임아웃 설정 검증
│ ├── watcher.rs # 핫 리로드를 위한 파일 감시
│ ├── migrator.rs # 설정 마이그레이션 오케스트레이터
│ ├── migration.rs # 마이그레이션 타입 및 트레이트
│ ├── migrations.rs # 특정 마이그레이션 구현
│ ├── fixer.rs # 자동 수정 로직
│ ├── backup.rs # 백업 관리
│ └── secrets.rs # 시크릿/API 키 관리
└── lock_optimization.rs # 락 및 동시성 최적화
4. Core Layer (src/core/)¶
책임: 도메인 모델, 비즈니스 규칙, 기본 추상화 정의
구성 요소¶
- Models (
models/): 핵심 도메인 엔티티 (Backend, Model, Request, Response) - Traits (
traits.rs): 핵심 인터페이스 및 계약 - Errors (
errors.rs): 도메인 특화 오류 타입 및 처리 - Retry (
retry/): 재시도 정책 및 전략 - Scoring (
scoring.rs): 백엔드 선택 스코어링 파이프라인 (BackendScorer)
주요 파일¶
src/core/
├── mod.rs # 코어 내보내기 및 유틸리티
├── models/ # 도메인 모델
│ ├── mod.rs
│ ├── backend.rs # 백엔드 도메인 모델
│ ├── model.rs # LLM 모델 표현
│ ├── request.rs # 요청 모델
│ └── responses.rs # 응답 모델 (Responses API)
├── traits.rs # 핵심 트레이트 및 인터페이스
├── errors.rs # 오류 타입 및 처리
├── container.rs # 미사용 레거시 (런타임에 배선되지 않음)
├── async_utils.rs # 비동기 유틸리티 함수
├── duration_utils.rs # Duration 파싱 유틸리티
├── streaming/ # 스트리밍 모델
│ ├── mod.rs
│ └── models.rs # 스트리밍 특화 모델
├── retry/ # 재시도 메커니즘
│ ├── mod.rs
│ ├── policy.rs # 재시도 정책
│ └── strategy.rs # 재시도 전략
├── circuit_breaker/ # 서킷 브레이커 패턴
│ ├── mod.rs # 모듈 내보내기
│ ├── config.rs # 설정 모델
│ ├── state.rs # 상태 머신 및 브레이커 로직
│ ├── error.rs # 서킷 브레이커 오류
│ ├── metrics.rs # Prometheus 메트릭
│ └── tests.rs # 단위 테스트
├── files/ # 파일 처리 유틸리티
│ ├── mod.rs # 모듈 내보내기
│ ├── resolver.rs # 채팅 요청에서 파일 참조 해석
│ ├── transformer.rs # 파일 콘텐츠가 있는 메시지 변환
│ └── transformer_utils.rs # 변환 유틸리티 함수
├── tool_calling/ # 도구 호출 변환
│ ├── mod.rs # 모듈 내보내기
│ ├── definitions.rs # 도구/함수 타입 정의
│ └── transform.rs # 백엔드별 변환
└── config/ # 설정 모델
├── mod.rs
├── models/ # 설정 데이터 모델 (모듈화 구조)
│ ├── mod.rs # 공개 모듈 재내보내기
│ ├── config.rs # 메인 Config 구조체, ServerConfig, BackendConfig
│ ├── backend_type.rs # BackendType 열거형 정의
│ ├── model_metadata.rs # ModelMetadata, PricingInfo, CapabilityInfo
│ ├── global_prompts.rs # GlobalPrompts 설정
│ ├── samples.rs # 샘플 생성 설정
│ ├── validation.rs # 설정 검증 로직
│ └── error.rs # 설정 특화 오류
├── timeout_models.rs # 타임아웃 설정 모델
├── cached_timeout.rs # 캐시된 타임아웃 해석
├── optimized_retry.rs # 최적화된 재시도 설정
├── metrics.rs # 메트릭 설정
└── rate_limit.rs # 속도 제한 설정
라이브러리 진입점 (src/server/)¶
역할: ContinuumRouter 빌더 API를 통해 Continuum Router를 임베딩 가능한 Rust 라이브러리 크레이트로 노출
src/server/ 모듈은 모든 초기화 로직을 바이너리 진입점(main.rs)에서 분리하여, 하위 Rust 프로젝트에서 서브프로세스를 생성하지 않고 라우터를 직접 임베딩할 수 있게 합니다. 바이너리 main.rs는 이 모듈에 위임하는 씬 CLI 래퍼(약 220줄)가 됩니다.
주요 파일:
src/server/
├── mod.rs # ContinuumRouter 구조체, 플루언트 API를 가진 ContinuumRouterBuilder
├── init.rs # 개별 서비스 초기화 함수 (HTTP 클라이언트, 헬스 체커,
│ # 서킷 브레이커, 파일 서비스, API 키 스토어, 속도 제한)
├── state.rs # initialize_services() - 모든 서비스를 AppState로 조합
├── routes.rs # API, 관리자, 파일, WebUI, 메트릭, CORS용 라우트 빌더
└── serve.rs # 서버 라이프사이클: 리스너 바인딩, 핫 리로드, 그레이스풀 셧다운
빌더 API:
use continuum_router::{ContinuumRouter, Config};
// 설정 파일에서 (핫 리로드 활성화)
let router = ContinuumRouter::from_config_file("config.yaml")
.await?
.enable_hot_reload(true)
.build()
.await?;
router.serve("0.0.0.0:8080").await?;
// 프로그래밍 방식 Config 구조체에서 (파일 의존성 없음)
let router = ContinuumRouter::from_config(Config::default())
.enable_health_checks(false)
.with_config_dir("/etc/continuum")
.build()
.await?;
// 기존 Axum 애플리케이션에 임베딩
let app = axum::Router::new()
.nest("/llm", router.into_router());
자세한 사용법은 라이브러리 사용법 가이드를 참조하세요.
핵심 컴포넌트¶
백엔드 풀¶
위치: src/infrastructure/backends/pool/ (backend_pool.rs + lifecycle.rs)
목적: 백엔드 멤버십, 선택, 라이프사이클에 대한 단일 진실 공급원입니다. 이 풀은 Arc<dyn Backend> 실행 객체를 보유하고 헬스 상태를 고려한 선택(라운드 로빈 / 가중치 / 최소 지연 시간 / 일관 해시 / 프리픽스 인식, 인플라이트 카운팅과 백엔드별 통계를 포함한 조합 가능한 스코어러 파이프라인)을 실행하며 그레이스풀 드레인과 핫 리로드 라이프사이클을 관리합니다.
모든 라이브 HTTP 요청 경로(채팅 컴플리션, 스트리밍 채팅, Responses, Anthropic Messages와 count_tokens, 이미지 생성)는 최종 백엔드 선택을 이 풀을 통해 수행합니다:
- 후보 필터링 및 스코어러 파이프라인: 각 경로는 먼저 자체 후보 집합(모델 조회, 내부 백엔드 필터, 키별 허용 목록, 재시도 상태 제외)을 해석하고 사전 필터링한 뒤, 공유 선택 지점(
proxy::selection::select_pooled_backend)으로 전달합니다. 이 지점은 헬스 상태로 후보를 걸러내고BackendPool::select_from_candidates를 호출하므로, 후보 목록의 순서가 아니라 설정된selection_strategy와 등록된 스코어러(예:prefix_routing.enabled와 KV 인덱스가 활성화된 경우의KvOverlapScorer)가 트래픽 분산을 결정합니다. - 일관 해시 링 캐시: 참여 백엔드 이름 벡터(순서 포함)를 키로 사용하는 크기 제한 LRU 캐시이므로, 모델별 후보 부분집합이 번갈아 사용되어도 각 링을 무제한 증가 없이 재사용합니다. 각 선택 호출은 링 순회 전에
Arc스냅샷을 복제하므로 캐시 잠금을 유지하지 않습니다. - 타입 안전한 라우팅 가시성(
proxy::selection::UserRoutableCandidates): 후보 목록은 단순Vec<String>이 아니라UserRoutableCandidates타입으로 전달됩니다.- 사용자 라우팅 가시성 필터는 키별 허용 목록보다 먼저 실행되어야
internal: true또는enabled: false백엔드만 서빙하는 모델이 잘못된403대신 모델 없음(404)으로 보고됩니다. - 일반 생성자는
filter_user_routing_candidates(이름 목록)와filter_user_routing_backends(BackendConfig항목)뿐이며, 나머지 메서드는 모두 집합을 좁히기만 하므로 필터를 빠뜨린 디스패치 경로는 컴파일 시점에 차단됩니다. - 필터를 거치지 않는 유일한 생성자는
UserRoutableCandidates::bypass_for_management이며(현재 호출자 없음), 숨김 백엔드를 정당하게 다루는 내부 소비자(관리 프로브, 헬스 모니터링, 관리자 카탈로그, 허브 인벤토리, 모델 디스커버리)는 이 선택 지점을 거치지 않고 백엔드를 직접 해석합니다.
- 사용자 라우팅 가시성 필터는 키별 허용 목록보다 먼저 실행되어야
pub struct BackendPool {
backends: Arc<RwLock<Vec<Arc<dyn Backend>>>>,
counter: Arc<AtomicUsize>,
selection_strategy: SelectionStrategy,
stats: Arc<RwLock<HashMap<String, BackendStats>>>,
scorers: Arc<RwLock<Vec<Arc<dyn BackendScorer>>>>,
// 라이프사이클 사이드 테이블(백엔드 이름을 키로 사용), lifecycle.rs 참조:
backend_states: Arc<RwLock<HashMap<String, BackendState>>>, // Active/Draining/Removed
draining: Arc<RwLock<Vec<DrainingEntry>>>, // + 드레인 타임스탬프
// ... 해시 링 캐시, 엡실론, 스코어러 임계값 ...
}
impl BackendPool {
// 라이브 백엔드에 대한 헬스/스코어러 인식 선택.
pub async fn select_backend(&self, model: Option<&str>) -> CoreResult<Arc<dyn Backend>> { /* ... */ }
// 모든 라이브 HTTP 경로에서 사용하는 후보 제한 선택:
// 호출자가 사전 필터링한 후보 이름과 풀 멤버십을 이름만으로 교집합하고
// (순서 유지), 정확히 그 부분집합에 스코어러 파이프라인과 설정된 전략을 실행합니다.
pub async fn select_from_candidates(
&self,
candidates: &[String],
model: Option<&str>,
context: &ScoringContext,
) -> CoreResult<Arc<dyn Backend>> { /* ... */ }
// 그레이스풀 드레인(로테이션에서 제외하고 인플라이트 요청은 완료) + 정리.
pub async fn drain_backend(&self, name: &str) -> Option<Arc<dyn Backend>> { /* ... */ }
pub async fn cleanup_draining(&self) -> usize { /* ... */ }
// 제자리 핫 리로드: 새 설정에서 백엔드를 추가/드레인/재생성합니다.
pub async fn update_from_config(&self, configs: &[BackendConfig], factory: &BackendFactory) { /* ... */ }
}
변경 가능한 라이프사이클 상태는 풀의 기존 이름 기반 stats 맵과 마찬가지로 백엔드 이름을 키로 사용하는 사이드 테이블(backend_states, draining)에 저장되므로, 핫 선택 경로는 단순한 Vec<Arc<dyn Backend>> 레이아웃을 유지합니다. AppState는 정확히 하나의 backend_pool을 보유하며 프록시/패스스루 계층은 선택 경계에서 선택된 Arc<dyn Backend>를 경량 PooledBackend { name, url } 핸들로 투영합니다.
이 풀은 백엔드별 엔진 통계 저장소(src/infrastructure/backends/engine_stats/)도 소유합니다. 항상 컴파일되는 이 모듈은 백그라운드 폴러가 각 자체 호스팅 엔진 자체의 부하 또는 메트릭 엔드포인트(vLLM /metrics, SGLang /v1/loads, llama.cpp /slots·/metrics, Ollama /api/ps, LM Studio /api/v0/models)를 읽어 백엔드당 하나의 EngineStatsSnapshot으로 정규화하고 인플라이트 트래커 옆에 TTL로 유지합니다. Prometheus 스크레이프 갱신(backend_engine_*{backend} 패밀리), GET /admin/backends/{name}/engine-stats, WebUI 대시보드 카드, Backend::get_current_load()(풀이 add_backend에서 붙이는 EngineLoadProbe 핸들 경유), KV 오버랩 스코어러의 엔진 부하 항과 선택 지점의 포화 어드미션 힌트(둘 다 routing.engine_load로 게이트)가 모두 이 저장소 하나를 읽습니다. 자세한 내용은 engine_stats/snapshot.rs의 모듈 문서와 docs/ko/metrics.md의 엔진 통계 메트릭 절을 참고하세요.
헬스 체커¶
위치: src/health.rs → src/services/health_service.rs
목적: 설정 가능한 임계값, 자동 복구, 가속화된 워밍업 감지로 백엔드 헬스 모니터링
pub struct HealthChecker {
backends: Arc<RwLock<Vec<Backend>>>,
config: HealthConfig,
status_map: Arc<RwLock<HashMap<String, HealthStatus>>>,
}
pub struct HealthConfig {
pub interval: Duration,
pub timeout: Duration,
pub unhealthy_threshold: u32, // 비정상으로 표시하기 전 실패 횟수
pub healthy_threshold: u32, // 정상으로 표시하기 전 성공 횟수
pub warmup_check_interval: Duration, // 워밍업 중 가속화된 간격 (기본값: 1초)
pub max_warmup_duration: Duration, // 워밍업 모드 최대 시간 (기본값: 300초)
}
pub enum HealthStatus {
Healthy, // 백엔드가 HTTP 200으로 응답
Unhealthy, // 연결 실패 또는 오류
WarmingUp, // HTTP 503 - 백엔드 로딩 중 (가속화된 체크)
Unknown, // 초기 상태
}
가속화된 워밍업 헬스 체크¶
백엔드가 HTTP 503 (Service Unavailable)을 반환하면 WarmingUp 상태로 진입합니다. 이 상태에서는:
- 헬스 체크가 일반 간격 대신
warmup_check_interval(기본값: 1초)로 실행됩니다 - 모델 가용성 감지 시간이 ~30초에서 ~1초로 단축됩니다
max_warmup_duration이후에는 백엔드가Unhealthy로 표시됩니다- 모델 로딩 중 HTTP 503을 반환하는 llama.cpp 백엔드에 특히 유용합니다
Transport 레이어¶
위치: src/infrastructure/transport/
목적: HTTP/HTTPS 또는 Unix Domain Socket을 통한 백엔드 통신을 위한 통합 전송 추상화 제공
Transport 레이어는 TCP 포트 노출 없이 Unix 소켓을 통한 안전한 로컬 LLM 통신을 가능하게 합니다.
URL 스킴¶
| 스킴 | 전송 방식 | 예시 |
|---|---|---|
http:// | TCP/HTTP | http://localhost:8080/v1 |
https:// | TCP/HTTPS | https://api.openai.com/v1 |
unix:// | Unix Socket | unix:///var/run/llama.sock |
Transport 열거형¶
pub enum Transport {
Http { url: String },
UnixSocket { socket_path: PathBuf },
}
impl Transport {
pub fn from_url(url: &str) -> Result<Self, TransportError>;
pub fn is_unix_socket(&self) -> bool;
pub fn is_http(&self) -> bool;
}
Unix Socket 클라이언트¶
UnixSocketClient는 HTTP-over-Unix-socket 통신을 제공합니다:
pub struct UnixSocketClient {
socket_path: PathBuf,
config: UnixSocketClientConfig,
}
impl UnixSocketClient {
pub async fn get(&self, endpoint: &str, headers: Option<Vec<(String, String)>>)
-> Result<UnixSocketResponse, UnixSocketError>;
pub async fn post(&self, endpoint: &str, headers: Option<Vec<(String, String)>>, body: Bytes)
-> Result<UnixSocketResponse, UnixSocketError>;
pub async fn health_check(&self) -> Result<bool, UnixSocketError>;
}
보안 기능¶
- 경로 순회 보호: 디렉토리 순회 공격을 방지하기 위한 소켓 경로 검증
- CRLF 인젝션 보호: HTTP 헤더 인젝션을 위한 엔드포인트 및 헤더 검증
- 응답 크기 제한: 설정 가능한 최대 응답 크기 (기본값: 100MB)
플랫폼 지원¶
| 플랫폼 | 지원 |
|---|---|
| Linux | 네이티브 AF_UNIX(tokio::net::UnixStream)를 통한 완전 지원 |
| macOS | 네이티브 AF_UNIX(tokio::net::UnixStream)를 통한 완전 지원 |
| Windows | socket2 크레이트를 통한 완전 지원 (Windows 10 1809+ / 빌드 17063+) |
| 기타 | PlatformNotSupported 오류 반환 |
모델 집계 서비스¶
위치: src/models/ (모듈화 구조)
목적: 모든 백엔드에서 모델 정보를 집계하고 캐시하며 메타데이터로 보강하고 캐시 스탬피드 방지
모듈 구조:
src/models/
├── mod.rs # 공개 모듈 재내보내기
├── types.rs # Model, AggregatedModel, ModelList, SingleModelResponse 타입
├── metrics.rs # ModelMetrics 추적 (스탬피드 메트릭 포함)
├── cache.rs # stale-while-revalidate 및 singleflight를 갖춘 ModelCache
├── config.rs # ModelAggregationConfig
├── fetcher.rs # 백엔드에서 모델 가져오기
├── handlers.rs # /v1/models 및 /v1/models/{model} 엔드포인트용 HTTP 핸들러
├── background_refresh.rs # 백그라운드 주기적 캐시 갱신 서비스
├── pattern_matching.rs # 6단계 모델 ID 조회 파이프라인 (날짜 / 형식 접미사 정규화, HuggingFace 리포지토리 접두사 제거, 와일드카드)
├── utils.rs # 유틸리티 함수 (normalize_model_id 등)
└── aggregation/ # 핵심 집계 로직
├── mod.rs # ModelAggregationService 구현
└── tests.rs # 단위 테스트
메타데이터 보강 중 사용되는 모델 ID 결정 파이프라인은 설정 가이드의 메타데이터 우선순위 및 별칭 해석을 참조하세요. 새 매핑을 명시적 YAML 별칭으로 추가할지 peel 허용 목록의 확장이나 HuggingFace 접두사 제거 레이어로 추가할지에 대한 정책 지침은 별칭과 접미사 정규화: 선택 기준을 참조하세요. 접두사 제거 레이어는 접미사 peel과 조합됩니다. 단일 조회에서 vendor/repo 접두사를 제거한 뒤 허용 목록의 접미사 토큰을 peel할 수 있어서 unsloth/Qwen3.6-35B-A3B-GGUF는 수동으로 등록된 별칭 없이 qwen3.6-35b-a3b 메타데이터로 라우팅됩니다.
캐시 스탬피드 방지¶
모델 집계 서비스는 캐시 스탬피드(썬더링 허드 문제)를 방지하기 위해 네 가지 전략을 구현합니다:
-
싱글플라이트 패턴: 한 번에 하나의 집계 요청만 실행됩니다. 동시 요청은 진행 중인 집계가 완료될 때까지 기다린 후 결과를 공유합니다.
-
Stale-While-Revalidate: 캐시가 오래된 경우(소프트 TTL과 하드 TTL 사이), 백그라운드에서 갱신을 트리거하면서 즉시 오래된 데이터를 반환합니다. 클라이언트는 약간 오래된 데이터로 빠른 응답을 받습니다.
-
백그라운드 주기적 갱신: 백그라운드 태스크가 만료 전에 사전에 캐시를 갱신합니다. 요청은 콜드 스타트를 제외하고 캐시 갱신을 기다리지 않습니다.
-
대체 스탬프(Supersession Stamps): 모든 집계는 풀을 스냅샷하기 직전에 풀의
membership_generation과 캐시의invalidation_epoch을 기록하고 저장 시점에 두 값을 다시 비교합니다.- 만료 상태 저장: 팬아웃이 실행되는 동안 스탬프가 바뀐 결과(핫 리로드, Admin API, AppProxy가 백엔드를 추가·제거·드레인한 경우, 또는 멤버십 변경 없이
clear_cache()를 호출한 설정 변경)는Fresh가 아니라 이미 만료된 항목으로 저장됩니다. 덕분에 stale-serve 범위 안의 읽기는 이전 목록을 계속 제공받고 후속 갱신은 즉시 시작됩니다(백그라운드 재검증은 최대 3회까지 제자리에서 재집계하고, 요청 경로는 백그라운드 재검증 태스크를 하나 띄웁니다). - 스냅샷 이전 읽기 및 사후 검증: 스탬프는 경쟁 조건을 막기 위해 반드시 풀 스냅샷 이전에 읽습니다. 멤버십 비교는 삽입 잠금과 하나로 묶여 있지 않으므로, 저장 직후 세대를 다시 읽어 변경되었으면 항목을 즉시 만료시킵니다. 핫 리로드는 헬스 체크 왕복보다 먼저 캐시를 무효화하여 두 스탬프를 연달아 올리므로 진행 중인 팬아웃에 단일 이벤트로 도달합니다.
- 잠금 해제 백오프 및 재가동 간격: 제자리 재시도는 패스 사이에 집계 잠금을 풀고 지터가 섞인 백오프를 두므로 대기 중인
POST /v1/models/refresh가 체인 전체를 기다리지 않습니다. 모든 패스가 대체된 체인은 항목을 만료 상태로 남기고 다음 재검증을 2초 동안 유예하며, 어느 경로에서든Fresh저장이 성공하면 이 유예 간격은 즉시 해제됩니다.
- 만료 상태 저장: 팬아웃이 실행되는 동안 스탬프가 바뀐 결과(핫 리로드, Admin API, AppProxy가 백엔드를 추가·제거·드레인한 경우, 또는 멤버십 변경 없이
pub struct ModelAggregationService {
cache: ModelCache, // 싱글플라이트 잠금 및 백그라운드 갱신 추적 포함
config: ModelAggregationConfig,
fetcher: ModelFetcher,
}
impl ModelAggregationService {
// 싱글플라이트 보호가 있는 모델 집계
pub async fn aggregate_models_with_singleflight(&self, state: &Arc<AppState>)
-> Result<AggregatedModelsResponse, StatusCode> { /* ... */ }
// stale-while-revalidate 지원이 있는 백엔드 찾기
pub async fn find_backends_for_model(&self, state: &Arc<AppState>, model_id: &str)
-> Vec<String> { /* ... */ }
// 캐시 지우기 (핫 리로드 시 사용)
pub fn clear_cache(&self) { /* ... */ }
}
캐시 TTL 설정¶
캐시는 듀얼 TTL 방식을 사용합니다:
| TTL 유형 | 기간 | 동작 |
|---|---|---|
| 소프트 TTL | 하드 TTL의 80% | 백그라운드 갱신 트리거, 오래된 데이터 반환 |
| 하드 TTL | 설정된 값 | 블로킹 갱신 필요 |
| 빈 응답 TTL | 5초 | DoS 방지를 위한 짧은 TTL |
메트릭¶
캐시 스탬피드 모니터링을 위한 새 메트릭:
stale_while_revalidate: 갱신 중 오래된 데이터를 반환한 요청coalesced_requests: 진행 중인 집계를 기다린 요청background_refreshes: 시작된 백그라운드 갱신 작업background_refresh_successes/failures: 백그라운드 갱신 결과singleflight_lock_acquired: 집계 잠금이 획득된 횟수
프록시 모듈¶
위치: src/proxy/ (모듈화 구조)
목적: 요청 프록시, 백엔드 선택, 파일 해석, 이미지 생성/편집 처리
모듈 구조:
src/proxy/
├── mod.rs # 공개 모듈 재내보내기
├── backend.rs # 백엔드 선택 및 라우팅 로직
├── request.rs # 재시도 로직이 있는 요청 실행
├── files.rs # 요청에서 파일 참조 해석
├── image_gen.rs # 이미지 생성 처리 (DALL-E, Gemini, GPT Image)
├── image_edit.rs # 이미지 편집 지원 (/v1/images/edits)
├── image_utils.rs # 이미지 처리 유틸리티 (멀티파트, 검증)
├── handlers.rs # 프록시 엔드포인트용 HTTP 핸들러
├── utils.rs # 유틸리티 함수 (오류 응답 등)
└── tests.rs # 단위 테스트
주요 책임¶
- 백엔드 선택: 사용 가능한 백엔드로 지능형 라우팅
- 파일 해석: 채팅 요청에서 파일 참조 해석
- 이미지 생성: OpenAI (DALL-E, GPT Image) 및 Gemini (Nano Banana) 이미지 모델 지원
- 이미지 편집: 이미지 편집 및 변형 엔드포인트
- 요청 재시도: 지수 백오프가 있는 자동 재시도
- 오류 처리: OpenAI 형식의 표준화된 오류 응답
- 내부 스트리밍: 조건을 만족하는 버퍼링 chat 및 completions 요청을 백엔드에서 스트리밍으로 받아 클라이언트 취소를 계량(
proxy.internal_streaming)
버퍼링 요청의 내부 스트리밍¶
proxy.internal_streaming(src/proxy/internal_streaming.rs와 internal_streaming_read.rs)은 make_http_request 안에서 모든 페이로드 변환을 마친 뒤, 조건을 만족하는 버퍼링 디스패치를 업스트림에는 stream: true와 공용 apply_router_stream_options가 생성한 stream_options(include_usage: true, vllm 백엔드에는 continuous_usage_stats: true 추가, 클라이언트가 보낸 stream_options는 제거)로 전송합니다.
- 적용 범위 및 적격 조건:
- 대상 라우트: chat completions와 completions 핸들러가 정책 적용 이후 작업을 태스크 로컬 범위(
client_route) 안에서 실행하므로, 재시도, 폴백 홉, 중복 제거, 웹 검색 턴, 품질 홉, 분리형 서빙 및 shim 디스패치가 모두 대상이 됩니다. 반면/v1/decisions에뮬레이션 홉, Anthropic Messages와 Responses 인그레스, 임베딩, 리랭크, 이미지, 파일, 합성 프로브, 라우터 자체 호출은 대상이 아닙니다. - 백엔드 및 요청 조건: 백엔드의 유효 플래그가 켜져 있고, 타입이
azure가 아닌 OpenAI 와이어이며, 전송 방식이 HTTP(S)이고, 요청 형태가n == 1, 로그 확률 없음,response_format이text또는json_object이며functions,function_call,audio, 오디오modalities,web_search_options를 포함하지 않을 때만 활성화됩니다.
- 대상 라우트: chat completions와 completions 핸들러가 정책 적용 이후 작업을 태스크 로컬 범위(
- 응답 조립 및 정산: 2xx가 아닌 응답은 읽어서 오류로 분류하고, 2xx
application/json응답은 그대로 전달합니다. 2xx 스트림은 디스패치 타임아웃 아래에서 단일 릴레이 루프가 읽어 표준 모드SseParser로 파싱한 뒤StreamUsageFinalizer에 전달하고,services::streaming::assemble(스트리밍 캐시 재생과 공유)이 버퍼링 본문으로 조립합니다(tests/stream_early_exit_audit_test.rs가 단일 종료 경로를 검증).- 출력 생성 전 실패: 데이터 이벤트 없음, 오류 이벤트, 역할만 담은 청크 뒤의 전송 오류나 타임아웃, 또는
[DONE]과finish_reason없이 잘린 본문은 여기서 기록하지 않고 일반 버퍼링 읽기와 동일하게 재시도합니다(출력은 라우터 델타 수, 엔진 완성 토큰 수, 또는role외에 비어 있지 않은 채팅 델타 필드로 판별하므로 모델 거부 응답도 출력으로 인정되어 재시도를 막습니다). - 완료된 스트림: 확정된 사용량을 시도별 키로 요청 원장에 남기고, 해당 시도를 기록하는 버퍼링 행(응답 행, 대체된 웹 검색 턴, 품질 홉)이 이를 가져갑니다. 토큰 수, 결과(엔진 사용량이 없으면
usage_missing), 토큰 출처는 스트림에서 가져오고 TTFT는 응답 헤더 관측값으로,stream은false로 기록합니다. - 출력 생성 후 실패:
[DONE]이나finish_reason없이 끊긴 본문을 포함해 출력 이후 발생한 실패는 확정된 부분 사용량(partial)을 담은BackendRequestFailure를 반환합니다. 재시도 지점은 이를upstream_error또는timeout_mid_stream으로 기록하고replay: disabled와 동일하게 최종 실패(TriggerReason::PartialOutput)로 처리하여 재시도 루프, 폴백 체인, 분리형 서빙 폴백이 재전송하지 않도록 막습니다. - 클라이언트 연결 종료: 첫 이벤트 이후 클라이언트가 연결을 끊어 핸들러 퓨처가 드롭되면
Drop에서 동기적으로client_cancelled시도 하나를 기록하고 업스트림 본문을 즉시 닫습니다. 서킷 브레이커는 조립된 본문에는settle_success, 실패에는settle_error를 기록합니다.
- 출력 생성 전 실패: 데이터 이벤트 없음, 오류 이벤트, 역할만 담은 청크 뒤의 전송 오류나 타임아웃, 또는
재시도 핸들러¶
위치: src/services/deduplication.rs
목적: 지터가 있는 지수 백오프 및 요청 중복 제거 구현
EnhancedRetryHandler는 코어 재시도 엔진(src/core/retry/strategy.rs)과 DeduplicationManager를 조합합니다. 설정은 공개 문자열 기간 RetryConfig(max_attempts, initial_delay, max_delay, backoff_multiplier, jitter, 재시도 가능 상태/오류 목록, 전체 timeout)를 사용합니다.
백엔드별 재전송 정책과 시도 추적¶
- 재전송 정책(
backends[].replay): 모든 재시도 시도가 거치는 단일 지점인src/proxy/request.rs의execute_single_retry_attempt_observed에서 적용됩니다.replay: disabled백엔드로의 디스패치가 실패했고 미전송임이 증명되지 않으면(BackendRequestFailure::not_sent는 연결 실패나 전송 전 라우터 측 거절에만 설정됨) 재시도 루프가 즉시 종료되고, 요청 단위 차단 플래그가 폴백 체인 트리거를TriggerReason::ReplayDisabled로 고정해 추가 홉을 막습니다.- 스트리밍 연결 단계, mid-stream 릴레이, 프로바이더 인그레스 체인(
src/http/ingress_fallback.rs::seal_replay_barrier), 품질 홉 드라이버, 분리형 서빙 통합 폴백도 모두 동일한 규칙을 따르며,replay: disabled백엔드가 후보에 포함된 요청은 중복 제거(deduplication)를 건너뜁니다. (RetryState::backend_attempted는 사용 중단되었으며 선택 과정에서 참조하지 않습니다.)
- 시도(Attempt) 수명주기:
- 백엔드 선택, 홉 다이얼 제한, 서킷 승인이 모두 성공한 뒤 요청이 나가기 직전에 요청 원장에 시도를 등록합니다(
src/services/stats/request_ledger_attempts.rs의begin_attempt). 따라서 디스패치 이전 거절은 시도로 집계되지 않습니다. - 각 시도는 클라이언트 요청의 다음
attempt_seq를 할당받고 직전 시도를parent_attempt로 가리킵니다. - 실패 시에는 재시도 대기나 체인 홉보다 먼저
record_attempt_usage(src/services/stats/attempts.rs)가 실패한 시도를 즉시 기록합니다(업스트림 상태는BackendRequestFailure::upstream_status에서, 토큰은 사용량이 포함된 오류 본문에서 추출). - 최종 응답 시도는 요청 단위 기록기가 요청 원장의 현재 시도로 기록합니다.
connect_with_pre_stream_fallback의 스트림 전 다이얼, Anthropic 웹 검색 호출기, 웹 검색 루프의 대체된 턴 훅도 동일한 인터페이스를 사용합니다.
- 백엔드 선택, 홉 다이얼 제한, 서킷 승인이 모두 성공한 뒤 요청이 나가기 직전에 요청 원장에 시도를 등록합니다(
요청 중복 제거 (In-Flight Coalescing)¶
replay: disabled 백엔드가 처리할 수 없고 may_coalesce(src/services/deduplication/eligibility.rs)가 허용하는 10KB 미만의 비스트리밍 POST 요청은 execute_with_deduplication_attributed를 통해 설정 스냅샷, 엔드포인트, 디스패치 본문, 인바운드 헤더 허용 목록, 유효 백엔드 허용 목록을 키로 삼아 하나로 합쳐집니다.
- 엔드포인트 및 요청 적격성(
may_coalesce):/v1/chat/completions와/v1/completions는 요청 본문에temperature: 0이 명시된 경우에만 합칩니다(OpenAI 기본값이1이므로 생략된temperature는 합치지 않으며, 응답 캐시의 규칙과는 독립적입니다). 적격성 판단은 디스패치 전에 수행되므로 백엔드별request_extensions.body.overrides.temperature는 영향을 주지 않습니다.- 결정론적 엔드포인트인
/v1/embeddings,/v1/rerank,/embed_sparse,/v1/models는 항상 허용하며,/v1/images/generations와 그 밖의 엔드포인트는 제외됩니다. /v1/decisions에뮬레이션 홉은temperature: 0을 보내므로 대상이 되지만, 웹 검색 도구 루프와 답변 품질 폴백은 턴이나 홉마다 개별 시도를 기록해야 하므로without_coalescing범위 안에서 실행됩니다.
- 리더 선출, 대기자 수명주기, 타임아웃 예산:
- 동일한 키로 처음 승인된 요청이 단일
DashMap::entry연산(DeduplicationManager::admit)을 통해 리더가 되어 진행 중 마커를 소유합니다. - 마커가 유지되는 동안 도착한 동일 요청은 대기자(
src/services/deduplication/waiters.rs)로서 마커의tokio::sync::watch채널을 구독하며, 리더가 완료되면 성공 응답뿐 아니라503이나Retry-After가 포함된429오류까지 그대로 공유받습니다. - 리더는 완료 시 마커를 먼저 제거한 뒤 채널에 결과를 게시하므로, 이후 도착하는 요청은 완료된 결과를 재사용하지 않고 새 리더로 실행됩니다(완료된 응답의 재사용은 응답 캐시가 담당).
- 대기 시간과 마커 수명은 리더의 최대 실행 예산(시도당 전체 타임아웃 +
max_retry_timeout으로 제한된retry.timeout+ 폴백 홉의 경우timeouts.connection다이얼 대기 시간)으로 제한되며, 예산 소진 시 대기자는408 timeout을 받습니다. - 리더가 완료 전에 드롭(클라이언트 연결 종료)되면 드롭 가드가 마커를 제거하고 채널을 닫아 대기자 중 하나가 새 리더로 승격되며, 두 번째 리더마저 드롭되면 남은 대기자는 중복 제거 없이 각자 재시도 루프를 실행합니다.
- 동일한 키로 처음 승인된 요청이 단일
- 대기자 통계 및 사용량 집계(
src/services/stats/coalesced.rs):- 중복 제거 지점이 요청 원장에
note_coalesced_result를 표시하면, 공용 통계 탭은 대기자 요청을 백엔드 미전송(not_dispatched),attempt_seq: 0(허브 매핑 시 최소1로 보정하고parent_attempt제거), 프롬프트 토큰은 리더의 값, 출력 토큰은0으로 기록합니다. - 또한 로컬 캐시 재생과 동일한 사용량 내보내기 레코드(
cache_hit_type: exact)와 컨트롤 플레인record_local_cache_hit이벤트(토큰 윈도우 및 예산 미차감)를 방출합니다. 미들웨어의 요청 수 기반 속도 제한(RPM)은 대기자에게도 정상 적용됩니다.
- 중복 제거 지점이 요청 원장에
- 중복 제거 키 헤더 허용 목록(
src/services/deduplication/key_headers.rs):- 키 해시는 닫힌 허용 목록에 포함된 헤더만 대소문자 구분 없이 정규화하여 반영합니다: 자격 증명 헤더(
is_credential_header:authorization,x-api-key,x-goog-api-key등), 프로바이더 동작 변경 헤더(anthropic-version,anthropic-beta,openai-organization,openai-project,openai-beta,x-vllm-priority), 라우터 제어 헤더(x-backend,x-continuum-router-hops,x-smart-route-bypass, 허브x-continuum-substitution/-arbitrage/-budget-fallback/-compression/-batch), 라우터가 기록한 Dynamo shim 워커 고정 헤더(x-dynamo-prefill-instance-id,x-dynamo-prefill-dp-rank,x-dynamo-worker-instance-id,x-dynamo-dp-rank), 활성 설정의smart_routing.routing_policies[].when.header및model_experiments[].sticky_key: header:<name>헤더. X-Trace-ID,traceparent,x-request-id,user-agent,x-stainless-*등 그 밖의 모든 헤더는 무시됩니다. 같은 API 키를 공유하는 호출자(또는 무인증 라우터의 모든 호출자)가 동일한 적격 요청을 동시에 보내면 결과를 공유하므로, 격리가 필요하면 별도의 API 키를 발급해야 합니다.
- 키 해시는 닫힌 허용 목록에 포함된 헤더만 대소문자 구분 없이 정규화하여 반영합니다: 자격 증명 헤더(
pub struct EnhancedRetryHandler {
pub retry_handler: RetryHandler,
deduplication: DeduplicationManager,
enable_deduplication: bool,
}
서킷 브레이커¶
위치: src/core/circuit_breaker/
목적: 백엔드별 독립 3상태 서킷 브레이커 상태 머신 유지
pub struct CircuitBreaker {
states: Arc<DashMap<String, BackendCircuitState>>,
config: CircuitBreakerConfig,
metrics: Option<CircuitBreakerMetrics>,
}
pub struct CircuitBreakerConfig {
pub enabled: bool,
pub failure_threshold: u32, // 열리기 전 실패 횟수 (기본값: 5)
pub failure_rate_threshold: f64, // 실패율 임계값 (기본값: 0.5)
pub minimum_requests: u32, // 비율 계산 전 최소 요청 수
pub timeout: Duration, // 열린 상태 대기 시간 (기본값: 60초)
pub half_open_max_requests: u32, // half-open 상태에서 최대 요청 수
pub half_open_success_threshold: u32, // 닫히는 데 필요한 성공 횟수
}
pub enum CircuitState {
Closed, // 정상 작동 - 요청 통과
Open, // 빠른 실패 - 요청 즉시 거부
HalfOpen, // 복구 테스트 - 제한된 요청 허용
}
주요 기능¶
- 독립적인 상태를 가진 백엔드별 서킷 브레이커
- 핫 패스에서 락 프리 상태 확인을 위한 원자적 연산
- 성공/실패 패턴에 기반한 자동 상태 전환
- 실패율 계산을 위한 슬라이딩 윈도우
- 메인 메트릭 레지스트리에 등록되어 프록시 경로가 값을 채우는 메트릭 수집기
- 조회 및 수동 제어를 위한 Admin 엔드포인트
표준 LLM 프록시 경로는 후보를 선택하는 동안과 요청을 보내기 직전에 브레이커를 조회하고 백엔드 결과를 기록합니다. 열린 서킷은 일반 추론 트래픽에서 제외되고, half-open 상태의 프로브는 용량이 제한되며, 기본 메트릭 레지스트리가 브레이커 카운터를 그대로 노출합니다. 능동 상태 필터, 재시도, 폴백은 이를 보완하는 계층입니다.
컨테이너 (배선되지 않은 레거시)¶
위치: src/core/container.rs
Container(src/core/container.rs)는 예전 설계에서 남은 코드로, 런타임에 배선되지 않았습니다. 자기 단위 테스트 안에서만 생성되고 어떤 서비스도 등록하지 않습니다.
라우터는 런타임 DI 컨테이너를 사용하지 않습니다. 서비스는 평범한 생성자 인자로 전달되며, 자세한 내용은 의존성 주입을 참고하세요.
데이터 흐름¶
요청 처리 흐름¶
sequenceDiagram
participant Client
participant HTTPLayer as HTTP Layer
participant ProxyService as Proxy Service
participant BackendService as Backend Service
participant ModelService as Model Service
participant Backend as LLM Backend
Client->>HTTPLayer: POST /v1/chat/completions
HTTPLayer->>HTTPLayer: 미들웨어 적용 (인증, 로깅, 메트릭)
HTTPLayer->>ProxyService: 요청 전달
ProxyService->>ModelService: 모델 정보 가져오기
ModelService->>ModelService: 캐시 확인
alt 캐시 미스
ModelService->>BackendService: 모델용 백엔드 가져오기
BackendService->>Backend: 모델 쿼리
Backend-->>BackendService: 모델 목록
BackendService-->>ModelService: 필터링된 백엔드
ModelService->>ModelService: 캐시 업데이트
end
ModelService-->>ProxyService: 백엔드에서 모델 사용 가능
ProxyService->>BackendService: 정상 백엔드 선택
BackendService->>BackendService: 로드 밸런싱 적용
BackendService-->>ProxyService: 선택된 백엔드
ProxyService->>Backend: 요청 전달
Backend-->>ProxyService: 응답 (스트리밍 또는 비스트리밍)
ProxyService->>ProxyService: 응답 처리 적용
ProxyService-->>HTTPLayer: 처리된 응답
HTTPLayer-->>Client: HTTP 응답 모델 이름 해석과 모델 실험¶
백엔드를 선택하기 전에, 모델을 기준으로 라우팅하는 모든 인그레스는 src/proxy/alias_dispatch.rs의 dispatch_model_for_request라는 단일 지점에서 디스패치할 모델 이름을 해석합니다(이 지점은 클라이언트가 보낸 이름을 기준으로 판단하는 model_aliases, 스마트 라우팅, 요청 파라미터 정책 스냅샷, 키별 모델 게이트 이후에 실행됩니다):
- 실험 변형 배정 및 메타데이터 별칭 해석:
- 먼저 모델 실험(
src/proxy/model_experiments/)을 해석합니다. 클라이언트 모델 이름은 SHA-256 고정 키에 대한 랑데부 해싱으로 가중치가 부여된 변형 하나에 배정되며, 요청은 그 변형의 업스트림 모델로 진행되고 변형의 백엔드 고정이 요청의 백엔드 허용 목록을 좁힙니다. - 그다음 그 결과 이름에 대해 메타데이터 별칭 디스패치를 실행합니다.
- 반환되는
DispatchModel쌍에는 클라이언트 모델 이름, 디스패치 이름, 실험 배정이 함께 담기므로, 선택 오류와 폴백 귀속은 클라이언트가 보낸 이름을 그대로 표시하고fallback: within_variant실험은 모델 간 폴백 체인을 막을 수 있습니다.
- 먼저 모델 실험(
- 요청 범위 태스크 로컬 귀속: API 라우트 그룹을 감싸는
model_experiment_middleware가 설치하는tokio::task_local!셀을 사용합니다. 디스패치 지점이 배정을 기록하고 응답 캐시 경로 식별자가 이를 exact, prefix, semantic 캐시 네임스페이스에 반영하며, 핸들러가 반환된 뒤 미들웨어가model_experiment_*메트릭을 기록하고 선택적 귀속 헤더를 출력하며response_model: client를 적용합니다.
가드레일 레이어¶
가드레일이 설정되면(guardrails.enabled: true) 콘텐츠 안전 레이어가 추론 요청 경로에서 백엔드 호출을 감쌉니다. 대상은 OpenAI chat completions(업스트림 /v1/responses로 가는 responses_only 브리지 포함), /v1/responses 엔드포인트, 레거시 /v1/completions, 네이티브 Anthropic Messages입니다(라우트별 단계 적용 범위는 가드레일: 라우트별 적용 범위 참조).
- 단일 게이팅 지점, 와이어별 뷰, 입력 마스킹:
- 서비스 레이어의
GuardrailService(src/services/guardrail/)와 단일 게이팅 지점(src/services/guardrail/gate.rs)을 통해 구동됩니다. 각 와이어(Wire::OpenAi,Wire::Responses,Wire::Completions,Wire::Anthropic)는 전용 요청/응답 뷰(responses_input_view.rs,responses_view.rs,completions_view.rs,anthropic_input_view.rs)와 차단 형태를 가집니다. - 입력 마스킹(
masking.rs)은 와이어에 독립적입니다: 각 와이어가 검사 가능한 문자열과 JSON 위치를 나열하면, 마스킹 판정을 반환한 프로바이더로 문자열마다 다시 도출해 제자리(도구 호출arguments는masking_args.rs에서 토큰 단위)에 덮어쓰고 기한 안에 재검사합니다(적용 불가 시 또는 재검사 프로바이더 실패 시 fail-closed). 범위를 좁힌 단계는GuardrailService::run_input_pass(service_pass.rs)를 거칩니다. - 모든 와이어의 차단은
response::block_into_response가 프로세스 내부GuardrailBlocked마커를 붙여 HTTP 응답으로 변환하며, 인그레스 폴백 드라이버와/v1/decisions체인(TriggerReason::GuardrailBlocked)은 마커가 붙은 응답을 최종 확정 시도로 취급해 폴백 모델로 넘어가지 않게 막습니다. 가드레일이 비활성화되면 핸들러는 이 지점에 진입하지 않아 오버헤드가 없습니다.
- 서비스 레이어의
- 락 프리 동적 초기화 (
GuardrailServiceHandle):- 서비스는 시작 시 고정되는
Option이 아니라arc-swap기반 락 프리 슬롯인GuardrailServiceHandle(src/services/guardrail/handle.rs) 뒤에 놓입니다. 요청 경로는 요청당 한 번 원자적 로드로 핸들을 읽어 모든 게이트에서 재사용합니다. - 가드레일이 비활성 상태로 시작한 라우터도 로컬
enabled: true핫 리로드 시 가드레일 설정 감시자가, 집행 가능한 허브 정책 도착 시 컨트롤 플레인 가드레일 정책 reconciler가 시작 시점과 동일한 팩토리 경로(src/server/state.rs의guardrail_service_factory,backend:프로바이더 참조용 백엔드 리졸버 포함)를 통해 서비스를 동적으로 생성합니다. - 라우터가 실행할 수 없는 집행을 요구하는 허브 정책은
guardrails_unavailable코드로 전체 거부되어 컨트롤 플레인 정책 스토어에 기록되며 로컬 설정은 그대로 유지됩니다.
- 서비스는 시작 시 고정되는
이 레이어는 요청 라이프사이클의 두 지점에 훅합니다:
- 입력 pre-call: 백엔드 디스패치 전에 요청당 한 번 모든 입력 프로바이더를 실행합니다.
Block판정은 백엔드를 호출하지 않고 요청을 중단하며Transform은 디스패치 전에 프롬프트를 다시 씁니다. OpenAI chat completions에서는 스트리밍과 비스트리밍 경로 모두에 대해 응답 캐시 조회, 전역 프롬프트 주입, 분리형 디스패치보다 먼저 한 번 실행되므로(src/proxy/chat_input_gate.rs), 차단된 스트리밍 요청도 SSE 시작 전에 동일한 차단 응답을 받습니다(GuardrailService::run_input_during_call은 라이브러리 호출자를 위해 유지). - 출력 post-call: 백엔드가 반환한 후 응답이 전달되거나 캐시되기 전에 생성된 텍스트를 검사합니다. 스트리밍 응답에서는 롤링 윈도우 검사(
buffer_full/chunked/passthrough)로 동작합니다.
GuardrailService는 모든 정책(monitor 대 enforce 모드, 우회 허용 목록, 라우트별 프로바이더 부분집합, 카테고리별 임계값, 허용/차단 리스트, 프로바이더별 타임아웃, fail-open/fail-closed 결정)을 소유하며, 설정된 프로바이더(OpenAI Moderation, 자체 호스팅 분류기, PII, AWS Bedrock, Azure Content Safety)의 판정을 가장 심각한 판정 우선 규칙으로 집계하고 모든 결정에 대해 Prometheus 메트릭과 감사 로그를 내보냅니다.
sequenceDiagram
participant Client
participant Gate as Guardrail Gate
participant Service as GuardrailService
participant Backend as LLM Backend
Client->>Gate: 요청 (prompt / messages)
Gate->>Service: 입력 pre-call
alt Block (enforce)
Service-->>Client: 차단 응답 (content_filter / refusal / error)
else Allow or Transform
Service-->>Gate: 판정
Gate->>Backend: (변환된) 요청 전달
Backend-->>Gate: 응답
Gate->>Service: 출력 post-call
alt Block (enforce)
Service-->>Client: 차단 응답
else Allow or Transform
Service-->>Client: 응답 (정제될 수 있음)
end
end 운영자 대상 가이드(개념, 프로바이더 설정, 설정, 임계값 튜닝, 관리자 제어, 메트릭)는 가드레일을 참고하세요.
Claude Code 세이프가드 레이어¶
운영자가 safeguards.action_safety.enabled: true를 명시적으로 설정하면, 액션 세이프가드 어댑터(src/services/action_safety.rs의 ActionSafetyEvaluator)가 변환 백엔드 응답이 제안한 도구 액션을 클라이언트가 실행하기 전에 평가합니다:
- 도구 액션 추출 및 평가:
- Anthropic Messages 핸들러와
/v1/responses변환 계열에서 구동됩니다.tool_use블록(또는/v1/responses의tool_calls)을 제안하는 응답에서 액션을 와이어 순서대로 추출해 고정된 평가기 백엔드로 액션당 한 번 디스패치합니다. - 판정 결과는
action_safety::wire가 생성하는safeguard_results(도구 사용 ID를 키로 하는dangerous_tool_use검사,not_flagged/flagged, 그리고 하나라도indeterminate이면unavailable검사)로 첨부되며, 스트리밍/v1/responses는 결과 이벤트를[DONE]센티널 직전에 방출하고, 스트리밍 Anthropic Messages는 평가가 끝날 때까지 마지막message_delta와message_stop을 보류했다가 판정 결과를 그message_delta의delta.safeguard_results에 담아 보냅니다(#2682). 도구 사용을 제안하지 않은 응답에는tool_uses맵이 빈available검사를 싣습니다. 설정 섹션이 없으면 네이티브 경로는 변경 없이 전달하고 변환 경로는 아무것도 합성하지 않습니다.
- Anthropic Messages 핸들러와
- System One 평가기 모드(
safeguards.action_safety.evaluator: systemone):- 공유
SystemOneClient위에 구축된SystemOneEvaluator(src/services/action_safety/systemone.rs, 라우터당 클라이언트 1개 캐시)를 통해 각 액션을{tool, arguments, user_request, recent_tool_results}에 대한 단일 결정 호출로 평가합니다. advisory모드에서는 채팅 모델 평가기와 동시에 실행되어 진단 및safeguards_action_safety_advisory_total메트릭에만 쓰이고,enforce모드에서는 fail-closed 매핑이 실제 방출 판정이 됩니다.
- 공유
- 공개
POST /v1/decisions엔드포인트(별칭/v1/systemone):- 핸들러(
src/proxy/decisions/)는 디스패치 전에 공유 질문 규칙으로 본문을 검증하고 키별 모델 게이트를 적용한 뒤FallbackService::execute_with_fallback_snapshot으로fallback.fallback_chains를 순회합니다. - 패스스루 타깃: 공유
ClientCache에 캐시된SystemOneClient(src/services/decisions/targets.rs)를 통해decisions.targets[]항목으로 전달합니다(allowed_backends가 설정된 키에는 인라인 엔드포인트 타깃을 거부하며, 체인 홉에서는 이를 사용 불가 백엔드로 처리해 건너뜁니다). - 채팅 모델 에뮬레이션: 질문 맵을 강제
emit_decision도구 호출 하나로 변환하고, 채팅request_params정책과 입력 가드레일 사전 게이트(src/proxy/decisions/emulation_gate.rs)를 거친 뒤 일반 채팅 재시도 루프(proxy::request::execute_decision_chat_hop)로 전송해 도구 인자를emulated: true가 표시된 one-hot 응답으로 복원합니다(src/services/decisions/emulation.rs). 모델 이름은dispatch_model_for_request로 해석하며 사용량은 실제 응답한 모델 이름으로 호출자 키에 기록됩니다.
- 핸들러(
- 구조적 fail-closed 불변성: 평가기 사용 불가, 타임아웃, 잘못되었거나 알 수 없는 판정, 거부, 예산 소진은 모두 합성된 allow가 아닌 non-allow 결과로 귀결됩니다. 스트리밍 경로는 평가가 진행되는 동안 요청당 액션 예산 범위 안에서 마지막
message_delta와message_stop을 보류하여 판정 결과가 턴 종료 전에 도달하도록 보장하며, 진단 로그에는 페이로드, 도구 인자, 자격 증명을 절대 기록하지 않습니다.
운영자 대상 가이드(네이티브 패스스루, 어댑터 설정, 클라이언트 측 우회 방법, 프로토콜 기록)는 Integrating with Claude Code를 참고하세요.
responses_only 모델용 Responses-API 라우팅¶
일부 OpenAI Pro 모델(현재 gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro, gpt-5.5-pro)은 /v1/responses로만 노출되며 /v1/chat/completions에서는 404 not_found가 돌아옵니다. model-metadata.yaml(과 내장 OpenAI 레지스트리)의 responses_only: true capability 플래그가 이런 모델을 표시하면, 라우터는 클라이언트가 어느 surface로 요청했는지에 관계없이 Responses API 쪽으로 투명하게 dispatch합니다.
플래그는 모델 조회 직후, 백엔드 dispatch 직전인 프록시 계층에서 검사합니다. capability 정보를 해석하는 모델 메타데이터 파이프라인이 그대로 responses_only도 해석하므로, 백엔드별 오버라이드, 외부 메타데이터 파일, 내장 기본값이 표준 우선순위 순서로 그대로 작동합니다(메타데이터 우선순위 및 별칭 해석 참조).
sequenceDiagram
participant Client
participant ChatHandler as POST /v1/chat/completions
participant AnthropicHandler as POST /anthropic/v1/messages
participant Lookup as is_responses_only_model()
participant Converter as Request Converter
participant Backend as OpenAI / Azure OpenAI
alt OpenAI surface
Client->>ChatHandler: chat.completion 요청
ChatHandler->>Lookup: (backend, model)
else Anthropic surface
Client->>AnthropicHandler: messages 요청
AnthropicHandler->>Lookup: (backend, model)
end
alt responses_only = true
alt 백엔드가 OpenAI / Azure OpenAI
Lookup-->>Converter: /v1/responses payload으로 변환
Converter->>Backend: POST /v1/responses
Backend-->>Converter: Responses API 응답 (또는 SSE)
Converter-->>Client: 원래 surface 모양으로 다시 변환
else 백엔드가 OpenAI / Azure OpenAI 아님
Lookup-->>Client: 400 invalid_request_error
end
else responses_only = false
Lookup-->>Backend: 표준 /v1/chat/completions dispatch
Backend-->>Client: chat.completion / Anthropic Messages
end 주요 속성:
- 두 surface 모두에서 dispatch가 투명합니다. 클라이언트는
/v1/chat/completions나/anthropic/v1/messages를 그대로 쓰면서 매칭되는 모양의 응답을 받습니다. /v1/responses는 OpenAI와 Azure OpenAI 백엔드만 제공합니다. 다른 유형의 백엔드는 업스트림 호출 전에400 invalid_request_error로 거절됩니다.(backend, model)쌍별 첫 dispatch는info레벨로 로깅되어, 디버그 로그를 켜지 않고도 운영자가 Responses-API 라우팅 여부를 확인할 수 있습니다.- 스트리밍 요청은 이벤트 단위로 브리지됩니다. Responses API SSE 이벤트는 전체 응답을 버퍼링하지 않고
chat.completion.chunk(OpenAI surface)나 Anthropic Messages SSE 이벤트(Anthropic surface)로 변환됩니다.
설정 문법, 현재 모델 목록, 새 모델을 표시하는 방법은 설정 가이드의 Responses-API 전용 모델을 참조하세요.
헬스 체크 흐름¶
sequenceDiagram
participant HealthService as Health Service
participant BackendPool as Backend Pool
participant Backend as LLM Backend
participant Cache as Health Cache
loop 매 간격마다
HealthService->>BackendPool: 모든 백엔드 가져오기
BackendPool-->>HealthService: 백엔드 목록
par 각 백엔드에 대해
HealthService->>Backend: GET /v1/models (또는 /health)
alt 성공
Backend-->>HealthService: 200 OK + 모델 목록
HealthService->>Cache: 업데이트: consecutive_successes++
HealthService->>HealthService: 임계값 충족 시 정상으로 표시
else 실패
Backend-->>HealthService: 오류/타임아웃
HealthService->>Cache: 업데이트: consecutive_failures++
HealthService->>HealthService: 임계값 충족 시 비정상으로 표시
end
end
HealthService->>BackendPool: 백엔드 헬스 상태 업데이트
end 핫 리로드 서비스¶
위치: src/infrastructure/config/hot_reload.rs, src/infrastructure/config/hot_reload_service.rs
목적: 서버 재시작 없이 런타임 설정 업데이트 제공
핫 리로드 시스템은 자동 파일 감시와 지능형 컴포넌트 업데이트를 통해 무중단 설정 변경을 가능하게 합니다.
주요 아키텍처 컴포넌트¶
- ConfigManager:
notify크레이트를 사용한 파일 시스템 감시,tokio::sync::watch채널을 통해 업데이트 게시 - HotReloadService: 설정 차이점 계산, 변경 사항 분류 (즉시/점진적/재시작)
- 컴포넌트 업데이트: HealthChecker, CircuitBreaker, RateLimitStore, BackendPool에 대한 원자적 업데이트를 위한 내부 가변성 패턴 (RwLock)
백엔드 풀 핫 리로드¶
BackendPool은 서비스 중단 없이 런타임 백엔드 추가 및 제거를 지원하기 위해 내부 가변성(Arc<RwLock<Vec<Arc<Backend>>>>)을 사용합니다.
백엔드 상태:
- Active: 정상 운영, 새 요청 수락
- Draining: 설정에서 제거됨, 기존 요청은 계속, 새 요청 없음
- Removed: 모든 참조 해제 후 완전히 정리됨
우아한 드레이닝 프로세스:
- 백엔드가 설정에서 제거되면
Draining으로 표시됩니다 - 새 요청은 드레이닝 백엔드를 건너뜁니다
- 기존 진행 중인 요청/스트림은 중단 없이 계속됩니다 (Arc 참조 카운팅)
- 백그라운드 정리 태스크가 10초마다 실행됩니다
- 백엔드는 다음 경우에 제거됩니다: 참조가 없거나 5분 타임아웃 초과 시
설정 변경 중에도 진행 중인 연결에 영향을 주지 않습니다.
변경 분류¶
- 즉시 업데이트: 활성 속도 제한과 서킷 브레이커 정책, 전역 프롬프트, 요청 매개변수, 스트리밍/스마트 라우팅 스냅샷, 선택 전략, prefix 라우팅, 재시도 정책, 요청별 타임아웃 예산, 문서화된 동적 필드
- 점진적 업데이트: 백엔드와 상태 확인
- 재시작 필요: 서버/CORS, 로깅과 추적, HTTP 클라이언트의 연결 및 전체 타임아웃, 캐시 구성 및 시작 시 생성되는 서비스
Admin API: 핫 리로드 기능 검사를 위한 /admin/config/hot-reload-status
핫 리로드 설정, 프로세스 흐름, 사용 예제에 대한 자세한 내용은 핫 리로드 섹션을 참조하세요.
설정 마이그레이션 시스템¶
위치: src/infrastructure/config/{migrator,migration,migrations,fixer,backup}.rs
목적: 설정 문제를 자동으로 감지 및 수정하고 스키마를 마이그레이션하며 설정 유효성을 보장
설정 마이그레이션 시스템은 설정 진화와 유지보수를 처리하며 자동으로 다음 작업을 수행합니다.
- 오래된 스키마 버전 감지 및 마이그레이션
- YAML/TOML 파일의 일반적인 구문 오류 수정
- 설정 값 검증 및 수정
- 변경 전 백업 생성
- 변경 사항 미리보기를 위한 dry-run 기능 제공
아키텍처 컴포넌트¶
1. 마이그레이션 오케스트레이터 (migrator.rs) - 마이그레이션 작업의 메인 진입점 - 전체 마이그레이션 워크플로우 조율 - 백업 생성 및 복원 관리 - 보안 검증 구현 (경로 탐색, 파일 크기 제한)
2. 마이그레이션 프레임워크 (migration.rs) - 마이그레이션을 위한 핵심 타입 및 트레이트 정의 - 버전 업그레이드 구현을 위한 Migration 트레이트 - 문제 분류를 위한 ConfigIssue 열거형 - 변경 사항 추적을 위한 MigrationResult
3. 스키마 마이그레이션 (migrations.rs) - 구체적인 마이그레이션 구현 (예: V1ToV2Migration) - 버전 간 설정 구조 변환 - 예시: backend_url을 backends 배열로 변환
4. 자동 수정 엔진 (fixer.rs) - 일반적인 설정 오류 감지 및 수정 - Duration 형식 수정 (예: '10 seconds' → '10s') - URL 검증 및 프로토콜 추가 - 필드 비권장화 처리
5. 백업 관리자 (backup.rs) - 수정 전 타임스탬프가 있는 백업 생성 - 리소스 제한 구현 (파일당 10MB, 총 100MB, 최대 50개 백업) - 오래된 백업의 자동 정리 - 파일 권한 보존
마이그레이션 워크플로우¶
graph TD
A[설정 파일 읽기] --> B[경로 및 크기 검증]
B --> C[백업 생성]
C --> D[설정 파싱]
D --> E{파싱 성공?}
E -->|아니오| F[구문 오류 수정]
F --> D
E -->|예| G[스키마 버전 감지]
G --> H{마이그레이션 필요?}
H -->|예| I[마이그레이션 적용]
H -->|아니오| J[값 검증]
I --> J
J --> K{문제 발견?}
K -->|예| L[자동 수정 적용]
K -->|아니오| M[설정 반환]
L --> N[업데이트된 설정 쓰기]
N --> M 보안 기능¶
- 경로 탐색 보호: 디렉토리 탐색 공격 방지를 위한 경로 검증
- 파일 크기 제한: DoS 방지를 위한 최대 10MB 설정 파일
- 형식 검증: .yaml, .yml, .toml 파일만 처리
- 시스템 디렉토리 보호: 민감한 시스템 경로 접근 차단
- 테스트 모드 완화: 테스트 친화적 검증을 위한 조건부 컴파일 사용
예시 마이그레이션: v1.0에서 v2.0으로¶
// V1ToV2Migration 구현
fn migrate(&self, config: &mut Value) -> Result<(), MigrationError> {
// 단일 backend_url을 backends 배열로 변환
if let Some(backend_url) = config.get("backend_url") {
let mut backends = Vec::new();
let mut backend = Map::new();
backend.insert("url".to_string(), backend_url.clone());
// 모델을 백엔드로 이동
if let Some(model) = config.get("model") {
backend.insert("models".to_string(),
Value::Sequence(vec![model.clone()]));
}
backends.push(Value::Mapping(backend));
config["backends"] = Value::Sequence(backends);
// 이전 필드 제거
config.remove("backend_url");
config.remove("model");
}
Ok(())
}
설정 로딩 흐름¶
graph TD
A[애플리케이션 시작] --> B[Config Manager 초기화]
B --> C{설정 파일 지정됨?}
C -->|예| D[지정된 파일 로드]
C -->|아니오| E[표준 위치 검색]
E --> F{설정 파일 발견?}
F -->|예| G[설정 파일 로드]
F -->|아니오| H[CLI 인자 + 환경 변수 + 기본값 사용]
D --> I[YAML 파싱]
G --> I
H --> J[인자에서 설정 생성]
I --> K[환경 변수 오버라이드 적용]
J --> K
K --> L[CLI 인자 오버라이드 적용]
L --> M[설정 검증]
M --> N{유효함?}
N -->|예| O[설정 반환]
N -->|아니오| P[오류와 함께 종료]
O --> Q[핫 리로드를 위한 파일 감시 시작]
Q --> R[애플리케이션 실행 중]
Q --> S[설정 파일 변경됨]
S --> T[리로드 및 검증]
T --> U{유효함?}
U -->|예| V[새 설정 적용]
U -->|아니오| W[오류 로깅, 이전 설정 유지]
V --> R
W --> R 의존성 주입¶
런타임에 서비스 컨테이너에서 무언가를 꺼내 쓰는 구조는 없습니다. 서비스는 평범한 생성자 인자입니다. src/server/state.rs가 시작 시점에 서비스를 만들고 AppState(src/lib.rs)가 이를 보관하며 각 컴포넌트는 필요한 핸들을 넘겨받습니다. 소비자가 특정 구현 하나에 묶이면 안 되는 자리에서는 구체 타입 대신 트레이트 객체를 넘깁니다.
그 이음매가 바로 BackendService(src/core/traits.rs)입니다. 라우터의 백엔드에 대해 get_backend, list_backends, get_healthy_backends, get_stats를 노출하며 프로덕션 구현체는 BackendManager(src/infrastructure/backends/pool/backend_manager.rs) 하나뿐입니다. BackendManager는 BackendPool과 선택적인 Arc<dyn HealthChecker>에 작업을 위임합니다. BackendPool이 Arc 기반이라 BackendManager::from_pool은 실행 중인 풀을 복사하지 않고 감쌉니다.
src/core/container.rs에는 예전 설계에서 남은 Container가 아직 있지만 끝내 배선되지 않았습니다. 자기 단위 테스트 안에서만 생성되고 어떤 서비스도 등록하지 않습니다.
서비스 구성¶
src/server/state.rs의 두 곳에서 공유 풀을 BackendService로 감싸 소비자에게 넘깁니다.
// src/server/state.rs: 가드레일 서비스 팩토리.
pub(crate) fn guardrail_service_factory(
client: reqwest::Client,
backend_pool: BackendPool,
topology: LiveBackendTopology,
) -> GuardrailServiceFactory {
Arc::new(move |g_cfg| {
// 프로덕션 BackendService 구현체는 BackendManager 하나뿐이다.
// from_pool은 라우터의 살아 있는 풀을 다시 만들지 않고 공유한다.
let backend_resolver: Arc<dyn BackendService> =
Arc::new(BackendManager::from_pool(backend_pool.clone()));
// 리졸버는 설정된 백엔드 토폴로지의 실시간 뷰와 함께 전달되므로
// `backend:` 가드 모델 참조는 가드 호출마다 가시성 계약으로 검사된다.
let guardrail_service = GuardrailService::from_config(
g_cfg,
client.clone(),
Some(GuardBackendAccess::new(backend_resolver, topology.clone())),
);
Arc::new(guardrail_service)
})
}
// src/server/state.rs: 스마트 라우팅 LLM 분류기도 같은 종류의 리졸버를 받아
// 분류 요청을 프로덕션 백엔드로 흘려보낸다. 여기에 각 백엔드의
// `request_extensions`를 읽는 살아 있는 뷰를 함께 받아, 분류 호출도 사용자
// 트래픽과 같은 확장 헤더를 싣는다.
if let Some(llm_classifier) = LlmClassifier::from_runtime_config(
config,
classifier_config,
Arc::new(BackendManager::from_pool(backend_pool.clone())),
backend_topology.extensions_source(),
) {
router = router.with_llm_classifier(llm_classifier, classifier_config.clone());
}
서비스 의존성¶
소비자는 넘겨받은 트레이트 객체를 그대로 들고 트레이트 메서드를 호출합니다. 따로 조회하는 과정은 없습니다.
// src/services/smart_routing/llm_classifier.rs
pub struct LlmClassifier {
backend_resolver: Arc<dyn BackendService>,
extensions_source: BackendExtensionsSource,
backend_name: String,
// ... 프롬프트, 스키마, 캐시, 설정 필드
}
impl LlmClassifier {
async fn send_request(&self, request_payload: &Value) -> Result<Value, String> {
// 백엔드의 현재 확장 헤더를 먼저 깔고, 그 위에 재귀 방지 헤더를 넣는다.
// 설정으로 들어온 어떤 표기의 재귀 방지 헤더든 이 값이 대체한다.
let headers = self.dispatch_headers();
let backend = self
.backend_resolver
.get_backend(&self.backend_name)
.await
.map_err(|e| {
format!(
"classifier backend '{}' is not available in the live backend pool: {e}",
self.backend_name
)
})?;
// 해석된 백엔드로 디스패치하면 사용자 트래픽과 동일한 요청 변환,
// 인증, 복원력 처리를 그대로 쓴다.
let response = backend
.execute_chat_completion(request_payload, Some(headers))
.await;
// ...
}
}
이점¶
- 테스트 가능성: 소비자가
Arc<dyn BackendService>를 받으므로 테스트는 라우터를 띄우지 않고 직접 만든 풀 위의BackendManager를 넘기면 됩니다. - 살아 있는 상태 공유:
from_pool은 실행 중인 풀의 백엔드와 통계, 수명 주기 상태를 그대로 공유하므로 하위 시스템은 사본이 아니라 프로덕션 트래픽이 보는 것과 같은 상태를 봅니다. - 구현 교체 가능: 소비자는
BackendManager가 아니라 트레이트에 의존하므로 소비자를 건드리지 않고 구성 지점에서 다른 리졸버로 바꿔 끼울 수 있습니다.
오류 처리 전략¶
라우터는 타입화된 오류, 지능형 복구, 사용자 친화적 응답을 갖춘 다층 오류 처리 전략을 구현합니다.
오류 타입 계층¶
- CoreError: 도메인 레벨 오류 (검증, 서비스 실패, 타임아웃, 설정)
- RouterError: Core, HTTP, Backend, Model 오류를 결합하는 애플리케이션 레벨 오류
- HttpError: HTTP 특화 오류 (400 BadRequest, 401 Unauthorized, 404 NotFound, 500 InternalServerError 등)
오류 처리 원칙¶
- 빠른 실패: 명확한 오류 메시지와 함께 입력을 조기에 검증
- 오류 컨텍스트: 관련 컨텍스트 포함 (필드 이름, 작업 세부 정보)
- 재시도 가능 분류: 재시도 가능(타임아웃, 503)과 불가능(400, 401) 오류 구분
- 사용자 친화적 응답: 내부 오류를 OpenAI 호환 오류 형식으로 변환
- 구조화된 로깅: 적절한 심각도와 컨텍스트로 오류 로깅
오류 복구 메커니즘¶
- 지수 백오프가 있는 재시도: 일시적 장애 자동 재시도
- 모델 폴백: 기본 모델을 사용할 수 없을 때 대체 모델로 라우팅 (모델 폴백 시스템 참조)
- 우아한 성능 저하: 컴포넌트 실패 시 감소된 기능으로 계속 작동
오류 처리, 복구 전략, 모니터링, 문제 해결에 대한 자세한 내용은 error-handling.md를 참조하세요.
확장 지점¶
백엔드 타입 아키텍처¶
라우터는 다른 API 형식을 가진 여러 백엔드 타입을 지원합니다. 각 백엔드 타입은 요청/응답 변환을 자동으로 처리합니다.
지원되는 백엔드 타입¶
| 백엔드 타입 | API 형식 | 인증 | 사용 사례 |
|---|---|---|---|
openai | OpenAI Chat Completions | Authorization: Bearer | OpenAI API |
azure | OpenAI Chat Completions | Authorization: Bearer | Azure OpenAI Service |
anthropic | Anthropic Messages API | x-api-key 헤더 | 네이티브 API를 통한 Claude 모델 |
gemini | OpenAI 호환 | Authorization: Bearer | OpenAI 호환 레이어를 통한 Google Gemini |
vllm | OpenAI 호환 | Authorization: Bearer | vLLM 추론 서버 |
sglang | OpenAI 호환 | Authorization: Bearer | SGLang 추론 서버 (sglang.srt) |
ollama | OpenAI 호환 | 없음 (로컬) | Ollama 로컬 추론 |
llamacpp | OpenAI 호환 | 없음 (로컬) | llama.cpp / llama-server |
lmstudio | OpenAI 호환 | 없음 (로컬) | LM Studio 로컬 추론 |
continuum-router | OpenAI 호환 | Authorization: Bearer | Continuum Router / Backend.AI GO 연합 |
generic | OpenAI 호환 | 설정 가능 | 모든 OpenAI 호환 API |
Anthropic 백엔드 아키텍처¶
Anthropic 백엔드는 자동 형식 변환과 함께 Claude 모델에 대한 네이티브 지원을 제공합니다:
┌─────────────────────────────────────────────────────────────────┐
│ OpenAI 형식 요청 │
│ POST /v1/chat/completions │
│ { "model": "claude-haiku-4-5", "messages": [...] } │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 요청 변환 레이어 │
│ transform_openai_to_anthropic_request() │
│ • 시스템 메시지 추출 → 별도의 `system` 매개변수 │
│ • image_url 변환 → Anthropic 이미지 형식 │
│ • max_tokens / max_completion_tokens 매핑 │
│ • reasoning_effort 변환 → thinking 매개변수 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Anthropic Messages API │
│ POST https://api.anthropic.com/v1/messages │
│ Headers: x-api-key, anthropic-version (backend setting) │
│ { "model": "...", "system": "...", "messages": [...] } │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ AnthropicStreamTransformer │
│ SSE 이벤트 변환 (Anthropic → OpenAI 형식) │
│ • message_start → role이 있는 초기 청크 │
│ • content_block_delta → 콘텐츠 청크 │
│ • thinking_delta → reasoning_content (확장된 사고) │
│ • message_delta → finish_reason 매핑 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ OpenAI 형식 응답 │
│ data: {"choices":[{"delta":{"content":"..."}}]} │
└─────────────────────────────────────────────────────────────────┘
주요 변환¶
요청 형식 차이:
| 측면 | OpenAI 형식 | Anthropic 형식 |
|---|---|---|
| 시스템 프롬프트 | messages[0].role="system" | 별도의 system 매개변수 |
| 인증 헤더 | Authorization: Bearer | x-api-key |
| 최대 토큰 | 선택적 | 필수 (max_tokens) |
| 이미지 | image_url.url | source.type + source.data |
확장된 사고 지원:
// OpenAI reasoning_effort → Anthropic thinking
{
"reasoning_effort": "high" // OpenAI 형식
}
// 다음으로 변환:
{
"thinking": {
"type": "enabled",
"budget_tokens": 32768 // effort 레벨에서 매핑
}
}
도구 호출 변환 (Tool Calling Transformation)¶
라우터는 OpenAI 형식의 도구 정의를 백엔드 네이티브 형식으로 자동 변환하여 크로스 프로바이더 도구 호출을 지원합니다.
위치: src/core/tool_calling/
모듈 구조:
src/core/tool_calling/
├── mod.rs # 모듈 익스포트
├── definitions.rs # 타입 정의 (ToolDefinition, FunctionDefinition 등)
├── transform.rs # 각 백엔드별 변환 함수
├── tool_choice.rs # 각 백엔드별 도구 선택 변환
├── response.rs # 백엔드로부터 도구 호출 응답 추출
├── streaming.rs # 스트리밍 도구 호출 변환
└── messages.rs # 멀티턴 대화 메시지 변환
지원 백엔드¶
| 백엔드 | 입력 형식 | 출력 형식 | 비고 |
|---|---|---|---|
| OpenAI | 네이티브 | 패스스루 | 도구 이름 검증 |
| Anthropic | OpenAI | input_schema 형식 | parameters → input_schema |
| Gemini | OpenAI | functionDeclarations | 중첩 구조 |
| llama.cpp | OpenAI | 패스스루 | --jinja 플래그로 검증 |
OpenAI 도구 형식 (입력)¶
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "위치의 현재 날씨를 가져옵니다",
"parameters": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}
}
]
}
Anthropic 변환¶
// transform_tools_to_anthropic()
// 입력: `function.parameters`를 포함한 OpenAI 형식
// 출력: `input_schema`를 포함한 Anthropic 형식
{
"tools": [
{
"name": "get_weather",
"description": "위치의 현재 날씨를 가져옵니다",
"input_schema": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}
]
}
Gemini 변환¶
// transform_tools_to_gemini()
// 입력: OpenAI 형식
// 출력: Gemini 중첩 functionDeclarations 형식
{
"tools": [
{
"functionDeclarations": [
{
"name": "get_weather",
"description": "위치의 현재 날씨를 가져옵니다",
"parameters": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}
]
}
]
}
검증 규칙¶
- 도구 이름:
^[a-zA-Z0-9_-]+$패턴과 일치해야 함 (영숫자, 밑줄, 하이픈만 허용) - 빈 이름: 검증 오류로 거부됨
- 누락된 필드: 경고로 로깅되고 도구는 건너뜀 (무음 데이터 손실 없음)
- Type 필드: 표준 도구 정의의 경우
"function"이어야 함
폴백 시스템과의 통합¶
도구 호출 변환은 ParameterTranslator를 통해 모델 폴백 시스템과 통합됩니다:
// src/core/fallback/translation.rs에서
impl ParameterTranslator {
pub fn translate_tools(
&self,
tools: &Value,
from_backend_type: BackendType,
to_backend_type: BackendType,
) -> CoreResult<Value> {
match (from_backend_type, to_backend_type) {
(_, BackendType::Anthropic) => transform_tools_to_anthropic(tools),
(_, BackendType::Gemini) => transform_tools_to_gemini(tools),
(_, BackendType::LlamaCpp) => transform_tools_for_llamacpp(tools),
_ => Ok(tools.clone()),
}
}
}
그래서 도구 정의를 보존하면서 한 프로바이더에서 다른 프로바이더로 매끄럽게 폴백할 수 있습니다.
도구 선택 변환 (Tool Choice Transformation)¶
도구 정의 외에도 라우터는 모델의 도구 호출 동작을 제어하는 tool_choice 파라미터도 변환합니다.
지원 값:
| OpenAI 값 | 설명 | Anthropic | Gemini |
|---|---|---|---|
"auto" | 모델이 도구 호출 여부 결정 | {"type": "auto"} | mode: "AUTO" |
"none" | 모델이 도구를 호출하지 않음 | 도구 전체 제거 | mode: "NONE" |
"required" | 모델이 최소 하나의 도구를 호출해야 함 | {"type": "any"} | mode: "ANY" |
{"type": "function", "function": {"name": "X"}} | 특정 함수 강제 호출 | {"type": "tool", "name": "X"} | mode: "ANY", allowed_function_names: ["X"] |
엣지 케이스:
- Anthropic
none처리: Anthropic API는tool_choice=none을 지원하지 않습니다. 이 값이 감지되면 라우터가 요청에서tools와tool_choice를 모두 제거합니다. - llama.cpp: 병렬 함수 호출 지원을 위해
parallel_tool_calls파라미터를 유지합니다.
구현:
// src/core/tool_calling/tool_choice.rs에서
pub enum ToolChoiceValue {
Auto,
None,
Required,
Function(String),
}
impl ToolChoiceValue {
pub fn from_openai(value: &Value) -> CoreResult<Self>;
pub fn to_anthropic(&self) -> Option<Value>; // "none"의 경우 None 반환
pub fn to_gemini(&self) -> Value;
}
스트리밍 도구 호출 변환¶
스트리밍이 활성화된 경우, 도구 호출은 완전한 객체가 아닌 델타 이벤트로 점진적으로 반환됩니다. 각 프로바이더는 서로 다른 스트리밍 형식을 사용하므로 OpenAI API 호환성을 유지하기 위해 실시간 변환이 필요합니다.
위치: src/core/tool_calling/streaming.rs
주요 컴포넌트:
| 컴포넌트 | 용도 |
|---|---|
ToolCallAccumulator | 스트리밍 도구 호출 상태 추적 (index, id, name, arguments) |
StreamingToolCallTransformer | Anthropic → OpenAI 델타 변환용 상태 머신 |
transform_gemini_streaming_tool_call() | Gemini → OpenAI 델타 변환 |
스트리밍 형식 비교:
| 프로바이더 | 형식 | 특징 |
|---|---|---|
| OpenAI | tool_calls[].function.arguments가 포함된 델타 청크 | 인자가 조각으로 도착 |
| Anthropic | 이벤트 기반: content_block_start, content_block_delta, content_block_stop | input_json_delta에 부분 JSON 포함 |
| Gemini | 각 청크에 완전한 functionCall 객체 | 청크당 완전한 인자 도착 |
상태 머신 (Anthropic):
IDLE → message_start → READY
READY → content_block_start (tool_use) → TOOL_STARTED
TOOL_STARTED → content_block_delta → ACCUMULATING
ACCUMULATING → content_block_delta → ACCUMULATING (루프)
ACCUMULATING → content_block_stop → TOOL_COMPLETE
TOOL_COMPLETE → message_delta → FINISHED
안전 제한:
잘못된 형식의 스트림으로 인한 메모리 고갈을 방지하기 위해:
MAX_TOOL_CALLS = 64: 메시지당 최대 병렬 도구 호출 수MAX_ARGUMENTS_SIZE = 1MB: 도구 호출당 최대 누적 인자 크기
예시 변환 (Anthropic → OpenAI):
// Anthropic 입력: content_block_start
{"type": "content_block_start", "index": 0,
"content_block": {"type": "tool_use", "id": "toolu_123", "name": "get_weather"}}
// OpenAI 출력: 델타 청크
{"id": "chatcmpl-...", "choices": [{"delta": {"tool_calls": [
{"index": 0, "id": "toolu_123", "type": "function",
"function": {"name": "get_weather", "arguments": ""}}
]}}]}
종료 사유 매핑:
| 소스 (Anthropic/Gemini) | 대상 (OpenAI) |
|---|---|
tool_use | tool_calls |
end_turn / STOP | stop |
max_tokens / MAX_TOKENS | length |
SAFETY / RECITATION | content_filter |
멀티턴 대화 메시지 변환¶
멀티턴 대화에서 도구를 사용할 때, 메시지 히스토리에는 어시스턴트의 도구 호출과 클라이언트의 도구 결과가 포함됩니다. 이러한 메시지들은 다른 백엔드로 라우팅할 때 형식 변환이 필요합니다.
위치: src/core/tool_calling/messages.rs
주요 함수:
| 함수 | 용도 |
|---|---|
transform_tool_message_to_anthropic() | OpenAI 도구 결과를 Anthropic 형식으로 변환 |
transform_tool_message_to_gemini() | OpenAI 도구 결과를 Gemini 형식으로 변환 |
transform_assistant_tool_calls_to_anthropic() | tool_calls가 있는 어시스턴트 메시지를 Anthropic으로 변환 |
transform_assistant_tool_calls_to_gemini() | tool_calls가 있는 어시스턴트 메시지를 Gemini로 변환 |
transform_messages_with_tools() | 전체 대화 히스토리 변환 |
find_function_name_for_tool_call() | tool_call_id로 함수 이름 조회 |
메시지 형식 차이점:
| 항목 | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| 도구 결과 역할 | tool | user (콘텐츠 블록 포함) | function |
| ID 참조 | tool_call_id | tool_use_id | name으로 매칭 |
| 콘텐츠 형식 | 문자열 | 문자열 또는 구조화 | response 객체 |
| 어시스턴트 역할 | assistant | assistant | model |
OpenAI 형식 (입력):
{
"messages": [
{"role": "user", "content": "NYC 날씨가 어때?"},
{
"role": "assistant",
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"NYC\"}"}
}]
},
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"temperature\": 72, \"condition\": \"sunny\"}"
}
]
}
Anthropic 변환:
{
"messages": [
{"role": "user", "content": "NYC 날씨가 어때?"},
{
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "call_abc123",
"name": "get_weather",
"input": {"location": "NYC"}
}]
},
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "call_abc123",
"content": "{\"temperature\": 72, \"condition\": \"sunny\"}"
}]
}
]
}
Gemini 변환:
{
"contents": [
{"role": "user", "parts": [{"text": "NYC 날씨가 어때?"}]},
{
"role": "model",
"parts": [{
"functionCall": {"name": "get_weather", "args": {"location": "NYC"}}
}]
},
{
"role": "function",
"parts": [{
"functionResponse": {
"name": "get_weather",
"response": {"temperature": 72, "condition": "sunny"}
}
}]
}
]
}
다중 도구 결과:
여러 도구가 병렬로 호출된 경우, 연속된 도구 결과 메시지는 Anthropic에서 단일 사용자 메시지로 결합됩니다:
// 여러 OpenAI 도구 결과:
{"role": "tool", "tool_call_id": "call_1", "content": "72F, 맑음"}
{"role": "tool", "tool_call_id": "call_2", "content": "오후 3:00 EST"}
// 결합된 Anthropic 형식:
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_1", "content": "72F, 맑음"},
{"type": "tool_result", "tool_use_id": "call_2", "content": "오후 3:00 EST"}
]
}
오류 처리:
tool_call_id누락: 유효성 검사 오류 반환- 잘못된 형식의 도구 호출: 경고로 로깅되고 전체 요청 실패 없이 건너뜀
- Gemini에서 알 수 없는 함수 이름: 경고 로그와 함께
"unknown"으로 대체 - 잘못된 JSON 인수: 빈 객체
{}로 대체 is_error표시자: Anthropic 형식으로 변환 시 보존
Gemini 3 thoughtSignature 지원¶
Gemini 3 모델은 함수 호출에 새로운 thoughtSignature 필드를 도입했으며 이 필드는 멀티턴 대화에서 보존되어 다시 전달되어야 합니다. 라우터는 변환 파이프라인을 통해 이를 자동으로 처리합니다.
thoughtSignature란?
Gemini 3+ 모델이 함수 호출을 반환할 때, 모델의 내부 추론 컨텍스트를 캡슐화하는 암호화된 thoughtSignature 필드가 포함됩니다. 이 서명은 대화를 올바르게 계속하기 위해 함수 결과를 다시 보낼 때 포함되어야 합니다.
응답 추출 (Gemini -> 클라이언트):
라우터는 Gemini 응답에서 thoughtSignature를 추출하여 OpenAI 호환 형식에 포함합니다:
// Gemini 네이티브 응답 (thoughtSignature 포함)
{
"candidates": [{
"content": {
"parts": [{
"functionCall": {"name": "get_weather", "args": {"location": "NYC"}},
"thoughtSignature": "encrypted_signature_abc123"
}]
}
}]
}
// 라우터의 OpenAI 호환 응답
{
"choices": [{
"message": {
"tool_calls": [{
"id": "call_xyz789",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\":\"NYC\"}"},
"extra_content": {
"google": {
"thought_signature": "encrypted_signature_abc123"
}
}
}]
}
}]
}
요청 주입 (클라이언트 -> Gemini):
클라이언트가 도구 결과를 다시 보낼 때, 라우터는 extra_content.google에서 thought_signature를 추출하여 Gemini 요청에 thoughtSignature로 주입합니다:
// 클라이언트의 OpenAI 형식 요청
{
"messages": [{
"role": "assistant",
"tool_calls": [{
"id": "call_xyz789",
"function": {"name": "get_weather", "arguments": "{}"},
"extra_content": {
"google": {"thought_signature": "encrypted_signature_abc123"}
}
}]
}]
}
// 변환된 Gemini 요청
{
"contents": [{
"role": "model",
"parts": [{
"functionCall": {"name": "get_weather", "args": {}},
"thoughtSignature": "encrypted_signature_abc123"
}]
}]
}
스트리밍 지원:
스트리밍 변환기도 thoughtSignature 추출을 처리하며 도구 호출 delta 청크에 포함합니다.
호환 동작:
- Gemini 2.x 모델은
thoughtSignature를 반환하지 않습니다 - 라우터는 이를 우아하게 처리합니다 extra_content.google.thought_signature를 보존하지 않는 클라이언트도 함수 호출이 가능합니다 (다만 Gemini 3+에서 대화 연속성이 저하될 수 있음)extra_content필드는 Gemini가 아닌 백엔드에서는 무시됩니다
구현 위치:
| 컴포넌트 | 파일 | 함수 |
|---|---|---|
| 응답 추출 | src/infrastructure/backends/gemini/transform.rs | transform_response_gemini(), transform_native_gemini_response() |
| 요청 주입 | src/core/tool_calling/messages.rs | transform_assistant_tool_calls_to_gemini() |
| 스트리밍 추출 | src/infrastructure/backends/gemini/stream.rs | transform_native_gemini_event(), create_tool_call_chunk_with_signature() |
새 백엔드 타입 추가하기¶
-
타입 선언:
src/core/config/models/backend_type.rs의BackendTypeConfig에 변형을 추가합니다.backends[]항목의type:필드가 이 열거형으로 역직렬화됩니다. 새 변형을 추가하면AGENTS.md의 "Configuration Assistant Sync"에 나열된 설정 어시스턴트 갱신도 함께 해야 합니다. -
Backend트레이트 구현: 트레이트는src/core/traits.rs에 정의되어 있으며, 팩토리는 이를BackendTrait로 임포트합니다.// src/infrastructure/backends/custom_backend.rs에서 use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use serde_json::Value; use crate::core::CoreError; use crate::core::traits::{ Backend, BackendCapabilities, BackendResponse, BackendType, HealthStatus, }; pub struct CustomBackend { client: Arc<HttpClient>, config: CustomBackendConfig, } #[async_trait] impl Backend for CustomBackend { fn name(&self) -> &str { /* ... */ } fn url(&self) -> &str { /* ... */ } fn weight(&self) -> u32 { /* ... */ } fn supported_models(&self) -> &[String] { /* ... */ } fn supports_model(&self, model: &str) -> bool { /* ... */ } fn backend_type(&self) -> BackendType { BackendType::Custom("custom".to_string()) } fn capabilities(&self) -> BackendCapabilities { /* ... */ } fn metadata(&self) -> &HashMap<String, Value> { /* ... */ } fn request_timeout(&self) -> Duration { /* ... */ } async fn health_check(&self) -> Result<HealthStatus, CoreError> { /* ... */ } async fn execute_chat_completion( &self, request: &Value, headers: Option<HashMap<String, String>>, ) -> Result<BackendResponse, CoreError> { /* ... */ } // 그 밖에 execute_completion, execute_streaming_request, // get_models, get_model_details, get_statistics, get_current_load, // validate_config, transform_request, transform_response도 필수입니다. } -
백엔드 팩토리에 등록:
src/infrastructure/backends/factory/backend_factory.rs의BackendFactory::create_backend_from_typed_config에 분기를 추가합니다.
새 미들웨어 추가하기¶
-
미들웨어 트레이트 구현:
// src/http/middleware/custom_middleware.rs에서 pub struct CustomMiddleware { config: CustomConfig, } impl<S> tower::Layer<S> for CustomMiddleware { type Service = CustomMiddlewareService<S>; fn layer(&self, inner: S) -> Self::Service { CustomMiddlewareService { inner, config: self.config.clone() } } } -
HTTP 라우터에 등록:
새 캐시 타입 추가하기¶
CacheStore트레이트 구현:// src/infrastructure/cache/your_store.rs에서 pub struct YourCacheStore { /* ... */ } #[async_trait] impl CacheStore for YourCacheStore { async fn get(&self, key: &str) -> CacheStoreResult<Option<Vec<u8>>> { /* ... */ } async fn set(&self, key: &str, value: &[u8], ttl: Duration) -> CacheStoreResult<()> { /* ... */ } async fn delete(&self, key: &str) -> CacheStoreResult<()> { /* ... */ } async fn clear(&self) -> CacheStoreResult<()> { /* ... */ } async fn stats(&self) -> CacheStoreStats { /* ... */ } }
RedisCacheStore(src/infrastructure/cache/redis_store.rs)는 기본으로 제공되는 공유 상태 구현체이며 redis-cache Cargo 기능으로 게이트됩니다. 이 기능은 default/full에 포함되지 않지만 공식 릴리스 바이너리와 컨테이너 이미지는 이를 컴파일하므로, 다중 복제본 배포에서 사용자 정의 빌드 없이 캐시와 속도 제한 카운터를 공유할 수 있습니다. 지원되는 엔드포인트 토폴로지(관리형 엔드포인트 하나, Sentinel 및 Cluster는 계약 범위 밖)는 src/core/config/redis_endpoint.rs에서 정의하고 강제하며 응답 캐시와 분산 속도 제한기는 모두 구성 로드 시 이를 기준으로 검증합니다.
ResponseCacheStore에 주입:
새 로드 밸런싱 전략 추가하기¶
선택 전략은 SelectionStrategy(src/core/models/backend.rs) enum으로 선언되고, 실제 선택은 BackendPool의 스코어러 파이프라인(BackendScorer, src/core/scoring.rs)에서 실행됩니다.
BackendScorer구현:
// src/core/scoring.rs (또는 새 스코어러 모듈)
pub struct LeastBusyScorer;
#[async_trait]
impl BackendScorer for LeastBusyScorer {
fn name(&self) -> &'static str { "least_busy" }
async fn prepare(&self, _context: &ScoringContext) {
// score()가 동기로 읽을 데이터를 미리 캐시 (기본 구현은 no-op)
}
fn score(&self, context: &ScoringContext, backend: &dyn Backend) -> f64 {
// 값이 높을수록 선호. f64::NEG_INFINITY를 반환하면 해당 백엔드는 제외됨
/* ... */
}
}
- 풀에 스코어러 등록:
기본 전략(RoundRobin, WeightedRoundRobin, LeastLatency, Random, ConsistentHash 등)은 SelectionStrategy(src/core/models/backend.rs)에서 설정하며, 구성 방법은 로드 밸런싱 가이드에 정리되어 있습니다.
설계 결정¶
왜 4계층 아키텍처인가?¶
결정: 4계층 아키텍처 사용 (HTTP → Services → Infrastructure → Core)
이유¶
- 명확한 분리: 각 계층이 구별되는 책임을 가짐
- 테스트 가능성: 계층을 독립적으로 테스트 가능
- 유지보수성: 한 계층의 변경이 다른 계층에 영향을 미치지 않음
- 유연성: 구현을 쉽게 교체 가능 (예: 다른 캐시 백엔드)
트레이드오프¶
- ✅ 장점: 깔끔하고 유지보수 가능하고 테스트 가능하고 확장 가능
- ❌ 단점: 더 많은 복잡성, 약간의 성능 오버헤드
- 결론: 프로덕션 시스템에서는 이점이 비용을 초과함
왜 DI 컨테이너 대신 생성자 주입인가?¶
결정: 서비스를 평범한 생성자 인자로 전달하며, 런타임 서비스 컨테이너를 사용하지 않음
이유¶
- 컴파일 타임 배선: 서비스 그래프가
src/server/state.rs에 드러나고 빌드 시점에 검증됨 - 런타임 해석 실패 모드 없음: 누락된 등록은 시작 실패가 아니라 컴파일 오류
- 테스트: 각 컴포넌트를 필요한 목과 테스트 더블로 직접 생성
고려된 대안¶
- 런타임 DI 컨테이너 (
src/core/container.rs): 초기에 구현되었으나 끝내 배선되지 않았고, 미사용 코드로 남아 있음
왜 Arc<RwLock>를 공유 상태에 사용하는가?¶
결정: 공유 가변 상태에 Arc<RwLock<T>> 사용
이유¶
- 읽기-쓰기 시맨틱스: 다중 리더, 배타적 쓰기
- 성능: 읽기가 많은 워크로드에서
Arc<Mutex<T>>보다 좋음 - 안전성: 컴파일 타임에 데이터 레이스 방지
고려된 대안¶
Arc<Mutex<T>>: 더 간단하지만 읽기 성능이 나쁨- 채널: 단순한 공유 상태에는 너무 복잡함
- 원자 타입: 복잡한 데이터 구조에 적합하지 않음
왜 전체적으로 async/await인가?¶
결정: 모든 I/O 작업에 async/await 사용
이유¶
- 성능: 논블로킹 I/O가 높은 동시성 허용
- 리소스 효율성: 요청당 스레드보다 낮은 메모리 사용
- 에코시스템: Rust 비동기 에코시스템 (Tokio, reqwest, axum)이 성숙함
트레이드오프¶
- ✅ 장점: 높은 성능, 낮은 리소스 사용, 좋은 에코시스템
- ❌ 단점: 복잡성, 학습 곡선, 디버깅 어려움
- 결론: 고성능 네트워크 서비스에 필수적
왜 설정 핫 리로드인가?¶
결정: 파일 감시를 사용한 설정 핫 리로드 지원
이유¶
- 제로 다운타임: 재시작 없이 설정 업데이트
- 운영 친화적: 프로덕션에서 설정 조정이 쉬움
- 개발: 개발 중 빠른 반복
구현¶
- 파일 시스템 감시자가 변경 감지
- 적용 전 새 설정 검증
- 일관성 없는 상태를 피하기 위한 원자적 업데이트
- 검증 오류 시 이전 설정으로 폴백
성능 고려 사항¶
메모리 관리¶
- 연결 풀링: HTTP 연결을 재사용하여 할당 오버헤드 감소
- 스마트 캐싱: LRU 제거로 무한 메모리 증가 방지
- Arc 복제: 딥 복제 대신 저렴한 참조 카운팅
- 스트리밍: 큰 응답을 메모리에 로드하지 않도록 청크 단위로 응답 처리
동시성¶
- 읽기 중심 워크로드를 위한 RwLock: 백엔드 풀과 모델 캐시에 대한 다중 동시 리더
- 가능한 경우 락 프리: 카운터와 간단한 상태에 원자 사용
- 비동기 태스크 스폰: 헬스 체크와 캐시 업데이트를 위한 백그라운드 태스크
- 바운드된 채널: 태스크의 무제한 큐잉 방지
- 스트리밍 릴레이 채널: 클라이언트로 나가는 SSE 릴레이는 예외입니다. 모든 릴레이는 클라이언트를 기다리지 않는 하나의 공유 릴레이 채널(
services::streaming::sse_relay_channel)로 전송하므로, 릴레이 태스크는 업스트림 속도로 업스트림을 읽고 클라이언트가 얼마나 느리게 읽든 마지막 사용량을 관찰합니다. 연결이 끊기면 다음 전송이 실패하며 이는 릴레이를 멈추고 업스트림을 끊습니다. 스트림별 버퍼는 업스트림 응답(출력 토큰 상한과timeouts.streaming.total)으로 제한됩니다.tests/streaming_relay_pacing_audit_test.rs는 SSE 이벤트를 담는 유한 채널이나 이 채널을 쓰지 않는Sse본문을 빌드 실패로 잡습니다.
I/O 최적화¶
- 연결 Keep-Alive: TCP 연결이 재사용을 위해 열린 상태 유지
- 스트리밍 응답: 버퍼링 없이 SSE 청크 전달
- 타임아웃: 느린 백엔드에서 행잉 방지
- 백오프가 있는 재시도: 실패하는 백엔드에 과부하 방지
메모리 레이아웃¶
// 캐시 효율성을 위해 최적화된 데이터 구조
pub struct Backend {
pub name: String, // 작은 이름을 위한 인라인 문자열
pub url: Arc<str>, // URL을 위한 공유 문자열
pub weight: u32, // 컴팩트한 정수
pub is_healthy: AtomicBool, // 락 프리 헬스 상태
}
// 캐시 친화적 모델 저장소
pub struct ModelCache {
models: HashMap<String, Arc<ModelInfo>>, // 공유된 모델 정보
last_updated: AtomicU64, // 락 프리 타임스탬프
ttl: Duration,
}
벤치마킹 결과¶
벤치마크 기준 (benches/performance_benchmarks.rs 참조):
- 요청 지연 시간: 라우팅 결정에 < 5ms 오버헤드
- 메모리 사용량: ~50MB 기본 메모리, 백엔드에 따라 선형 확장
- 처리량: 보통 하드웨어에서 1000+ 요청/초
- 연결 효율성: 최소 메모리 오버헤드로 백엔드당 100+ 동시 연결
속도 제한¶
라우터는 남용으로부터 보호하고 클라이언트 간 공정한 리소스 할당을 보장하기 위해 다층 속도 제한을 구현합니다.
주요 기능: - 이중 윈도우 접근 방식: 지속 제한 (100 req/min) + 버스트 보호 (20 req/5s) - API 키 (선호) 또는 IP 주소 (폴백)로 클라이언트 식별 - 자동 캐시 정리가 있는 클라이언트별 격리 - 빈 응답에 대한 짧은 TTL로 DoS 방지
속도 제한 V2 아키텍처¶
향상된 속도 제한 시스템 (rate_limit_v2/)은 모듈화된 고성능 구현을 제공합니다:
모듈 구조¶
src/http/middleware/rate_limit_v2/
├── mod.rs # 공개 API 및 모듈 내보내기
├── middleware.rs # Axum 미들웨어 통합
├── store.rs # 속도 제한 저장소 및 클라이언트 추적
└── token_bucket.rs # 토큰 버킷 알고리즘 구현
구성 요소¶
- 토큰 버킷 알고리즘 (
token_bucket.rs) - 설정 가능한 버킷 용량 및 리필 속도
- 락 프리 토큰 소비를 위한 원자적 연산
- 경과 시간에 기반한 자동 토큰 보충
-
지속 및 버스트 제한을 위한 별도 버킷
-
속도 제한 저장소 (
store.rs) - 동시 접근을 위한
DashMap을 사용한 클라이언트별 상태 추적 - 만료된 클라이언트 항목의 자동 정리
- 비활성 클라이언트에 대한 설정 가능한 TTL (기본값: 1시간)
-
바운드된 저장소로 메모리 효율적
-
미들웨어 통합 (
middleware.rs) - 클라이언트 식별자 추출 (API 키 → IP 주소 폴백)
- 처리 전 지속 및 버스트 제한 모두 확인
Retry-After헤더와 함께 HTTP 429 (Too Many Requests) 반환- 속도 제한 히트 모니터링을 위한 Prometheus 메트릭
설정 예시¶
rate_limiting:
enabled: true
sustained:
max_requests: 100
window_seconds: 60
burst:
max_requests: 20
window_seconds: 5
cleanup_interval_seconds: 300
결정 흐름¶
속도 제한 설정에 대한 자세한 내용은 속도 제한 섹션을 참조하세요.
모델 폴백 시스템¶
라우터는 기본 모델을 사용할 수 없을 때 자동으로 대체 모델로 요청을 라우팅하는 설정 가능한 모델 폴백 시스템을 구현합니다.
주요 기능: - 자동 폴백 체인 실행 (예: gpt-5.6-sol → gpt-5.6-terra → gpt-5.6-luna) - 모든 홉에서 표준 요청 페이로드를 유지하는 크로스 프로바이더 폴백 지원 - 설정된 HTTP 오류, 타임아웃, 연결 실패, 모델 조회 실패, 백엔드 상태 기반 트리거 - 폴백 사용 모니터링을 위한 Prometheus 메트릭
자세한 설정 및 구현은 error-handling.md 모델 폴백 섹션을 참조하세요.
서킷 브레이커¶
라우터에는 Admin 조회와 강제 제어를 제공하는 백엔드별 서킷 브레이커 상태 머신이 있습니다. 일반 LLM 프록시 경로는 요청 결과를 이 상태 머신에 기록하고 선택 시 조회합니다. 열린 백엔드 서킷은 제외되어 트래픽이 우회하며 호환되는 모든 서킷이 열리면 라우터는 표준 service-unavailable 오류를 반환하고 설정된 폴백에 참여합니다. 이는 해당 경로의 능동 상태 확인, 재시도, 모델 폴백을 보완합니다.
3-상태 머신:
| 상태 | 동작 |
|---|---|
| Closed | 정상 작동. 실패 카운트됨. |
| Open | 빠른 실패 모드. 요청 즉시 거부. |
| HalfOpen | 복구 테스트. 제한된 요청 허용. |
주요 기능: - 독립적인 상태를 가진 백엔드별 격리 - 핫 패스 오버헤드를 최소화하기 위한 락 프리 원자적 연산 - 수동 제어를 위한 admin 엔드포인트 (/admin/circuit/*) - 메인 메트릭 레지스트리에 등록되어 프록시 경로가 값을 채우는 메트릭 수집기
자세한 설정 및 구현은 error-handling.md 서킷 브레이커 섹션을 참조하세요.
파일 저장소¶
라우터는 영구 메타데이터가 있는 OpenAI Files API 호환 파일 저장소를 제공합니다.
주요 기능: - 사이드카 JSON 파일이 있는 영구 메타데이터 저장소 - 서버 재시작 시 자동 복구 - 고아 파일 감지 및 정리 - 플러그 가능한 백엔드 (메모리/영구)
자세한 아키텍처 및 구현은 파일 저장소 가이드를 참조하세요.
이미지 생성 아키텍처¶
라우터는 자동 매개변수 변환과 함께 여러 백엔드 (OpenAI GPT Image, DALL-E, Google Gemini/Nano Banana)에 걸친 이미지 생성을 위한 통합 인터페이스를 제공합니다.
다중 백엔드 이미지 생성¶
┌─────────────────────────────────────────────────────────────────┐
│ OpenAI 호환 요청 │
│ POST /v1/images/generations │
│ { "model": "...", "prompt": "...", "size": "1536x1024" } │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 모델 라우터 (image_gen.rs) │
│ • 모델 타입 감지 (GPT Image, DALL-E, Nano Banana) │
│ • 적절한 핸들러로 라우팅 │
│ • 스트리밍 vs 비스트리밍 처리 │
└─────────────────────────────────────────────────────────────────┘
│ │
┌──────────┘ └──────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ OpenAI 백엔드 │ │ Gemini 백엔드 │
│ (GPT Image, DALL-E) │ │ (Nano Banana) │
│ │ │ │
│ • 요청 패스스루 │ │ • Gemini API로 변환 │
│ • SSE 스트리밍 지원 │ │ • size → aspectRatio 매핑 │
│ • output_format 지원 │ │ • imageConfig 생성 │
└───────────────────────────┘ └───────────────────────────┘
OpenAI → Gemini 매개변수 변환¶
Nano Banana (Gemini) 모델을 사용할 때, OpenAI 스타일 매개변수가 자동으로 Gemini 네이티브 형식으로 변환됩니다:
크기에서 종횡비 매핑¶
OpenAI size | Gemini aspectRatio | Gemini imageSize | 비고 |
|---|---|---|---|
256x256 | 1:1 | 1K | 최소 Gemini 크기 |
512x512 | 1:1 | 1K | 최소 Gemini 크기 |
1024x1024 | 1:1 | 1K | 기본값 |
1536x1024 | 3:2 | 1K | 가로 |
1024x1536 | 2:3 | 1K | 세로 |
1792x1024 | 16:9 | 1K | 와이드 가로 |
1024x1792 | 9:16 | 1K | 톨 세로 |
2048x2048 | 1:1 | 2K | nano-banana-pro 및 nano-banana-2 |
4096x4096 | 1:1 | 4K | nano-banana-pro 및 nano-banana-2 |
auto | 1:1 | 1K | 기본 폴백 |
요청 변환¶
OpenAI 형식 (입력):
Gemini 형식 (변환됨):
{
"contents": [
{
"parts": [{"text": "고요한 일본 정원"}]
}
],
"generationConfig": {
"imageConfig": {
"aspectRatio": "3:2",
"imageSize": "1K"
}
}
}
변환 구현¶
변환은 src/infrastructure/backends/gemini/image_generation.rs에서 처리됩니다:
pub fn convert_openai_to_gemini(request: &OpenAIImageRequest)
-> CoreResult<(String, GeminiImageRequest)>
{
// 1. 모델 이름 매핑
let gemini_model = map_model_to_gemini(&request.model);
// 2. 크기를 종횡비 및 크기 카테고리로 파싱
let parsed_size = parse_openai_size(&request.size, &request.model)?;
// 3. imageConfig가 있는 Gemini 요청 빌드
let gemini_request = GeminiImageRequest {
contents: vec![GeminiContent { parts: vec![...] }],
generation_config: Some(GeminiGenerationConfig {
image_config: Some(GeminiImageConfig {
aspect_ratio: Some(parsed_size.aspect_ratio.to_gemini_string()),
image_size: Some(parsed_size.size_category.to_gemini_image_size()),
}),
}),
};
Ok((gemini_model, gemini_request))
}
스트리밍 이미지 생성 (SSE)¶
GPT Image 모델의 경우, 라우터는 스트리밍 이미지 생성을 위한 진정한 SSE 패스스루를 지원합니다:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 클라이언트 │────stream:true─▶│ 라우터 │────stream:true─▶│ OpenAI │
│ │ │ │ │ │
│ │◀───SSE 이벤트──│ 패스스루 │◀───SSE 이벤트──│ │
└─────────────┘ └─────────────┘ └─────────────┘
SSE 이벤트 타입:
| 이벤트 | 설명 |
|---|---|
image_generation.partial_image | 생성 중 중간 미리보기 |
image_generation.complete | 최종 이미지 데이터 |
image_generation.usage | 청구를 위한 토큰 사용량 |
done | 스트림 완료 |
구현 (src/proxy/image_gen.rs):
async fn handle_streaming_image_generation(...) -> Result<Response, StatusCode> {
// 1. 백엔드 요청에서 stream: true 유지
// 2. bytes_stream()을 통해 스트리밍 요청 수행
// 3. tokio 채널을 통해 SSE 이벤트 전달
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let mut stream = backend_response.bytes_stream();
while let Some(chunk) = stream.next().await {
// SSE 형식 파싱 (event:/data: 라인)
// 클라이언트에 이벤트 전달
for line in chunk_str.lines() {
if let Some(event_type) = line.strip_prefix("event:") { ... }
if let Some(data) = line.strip_prefix("data:") {
let event = Event::default().event(event_type).data(data);
tx.send(Ok(event));
}
}
}
});
Ok(Sse::new(UnboundedReceiverStream::new(rx)).into_response())
}
GPT Image 모델 기능¶
라우터는 GPT Image 모델 (gpt-image-1, gpt-image-1.5, gpt-image-1-mini)에 대한 향상된 매개변수를 지원합니다:
| 매개변수 | 설명 | 값 |
|---|---|---|
output_format | 이미지 파일 형식 | png, jpeg, webp |
output_compression | 압축 레벨 | 0-100 (jpeg/webp 전용) |
background | 투명도 제어 | transparent, opaque, auto |
quality | 생성 품질 | low, medium, high, auto (GPT Image 2.5는 xhigh, max 추가) |
stream | SSE 스트리밍 활성화 | true, false |
partial_images | 미리보기 수 | 0-3 |
모델 지원 매트릭스¶
| 기능 | GPT Image 1.5 | GPT Image 1 | GPT Image 1 Mini | DALL-E 3 | DALL-E 2 | Nano Banana | Nano Banana Pro |
|---|---|---|---|---|---|---|---|
| 스트리밍 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| output_format | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| background | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| 커스텀 품질 | ✅ | ✅ | ✅ | standard/hd | ❌ | ❌ | ❌ |
| 이미지 편집 | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ |
| 이미지 변형 | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
| 최대 해상도 | 1536px | 1536px | 1536px | 1792px | 1024px | 1024px | 4096px |
이미지 편집 및 변형¶
라우터는 /v1/images/edits 및 /v1/images/variations를 통해 OpenAI 호환 이미지 편집 및 변형 엔드포인트를 제공합니다.
이미지 편집 (/v1/images/edits)¶
엔드포인트: POST /v1/images/edits
텍스트 프롬프트와 선택적 마스크로 기존 이미지를 편집할 수 있습니다. GPT Image 모델 및 DALL-E 2에서 지원됩니다.
요청 형식 (multipart/form-data):
image: <file> # 원본 이미지 (PNG, 필수)
prompt: <string> # 편집 지침 (필수)
mask: <file> # 선택적 마스크 이미지 (PNG)
model: <string> # 모델 이름 (기본값: "gpt-image-2")
n: <integer> # 이미지 수 (기본값: 1)
size: <string> # 출력 크기 (예: "1024x1024")
response_format: <string> # "url" 또는 "b64_json"
구현 (src/proxy/image_edit.rs): - 이미지 및 마스크 파일의 멀티파트 폼 파싱 - 이미지 검증 (형식, 크기, 종횡비) - 모델별 매개변수 변환 - 잘못된 입력에 대한 적절한 오류 처리
지원 기능¶
- 타겟 편집을 위한 투명 PNG 마스크 지원
- 다중 이미지 생성 (n 매개변수)
- 유연한 출력 크기
- URL 및 base64 응답 형식 모두
이미지 변형 (/v1/images/variations)¶
엔드포인트: POST /v1/images/variations
주어진 이미지의 변형을 생성합니다. Gemini 이미지 백엔드를 통해 nano-banana, nano-banana-2, nano-banana-2-lite, nano-banana-pro가 처리하며 dall-e-2는 아직 이를 제공하는 백엔드에서만 동작합니다(OpenAI는 2026-05-12에 종료).
요청 형식 (multipart/form-data):
image: <file> # 소스 이미지 (PNG, 필수)
model: <string> # 모델 이름 (기본값: "nano-banana")
n: <integer> # 변형 수 (기본값: 1, 최대: 10)
size: <string> # 출력 크기 ("256x256", "512x512", "1024x1024")
response_format: <string> # "url" 또는 "b64_json"
구현 (src/proxy/image_edit.rs): - 이미지 파일 검증 및 전처리 - DALL-E 2 특화 라우팅 - 지원되지 않는 모델에 대한 오류 처리 - 일관된 응답 형식화
주요 기능¶
- 단일 요청에서 여러 변형 생성
- 자동 이미지 형식 검증
- 표준 OpenAI 응답 형식 호환성
이미지 유틸리티 모듈¶
image_utils.rs 모듈은 이미지 처리를 위한 공유 유틸리티를 제공합니다:
함수¶
validate_image_format(): PNG/JPEG 형식 및 크기 검증parse_multipart_image_request(): 멀티파트 폼에서 이미지 추출check_image_dimensions(): 크기 제약 검증format_image_error_response(): 표준화된 오류 응답
검증 규칙¶
- 최대 파일 크기: 4MB (설정 가능)
- 지원 형식: PNG (편집/변형에 필수), JPEG (생성 전용)
- 모델별 종횡비 제약
- 마스크에 대한 투명 PNG 요구 사항
제어 플레인 에이전트 (Continuum Hub)¶
제어 플레인 에이전트는 플릿 관리, 즉 등록, 생존 확인, 사용량 계측, (옵트인) 정책 동기화 및 로컬 강제 적용, (옵트인) 플릿 요청 파라미터 설정, (옵트인) 프로바이더 배치 디스패치와 라이프사이클 보고 그리고 라우터별 월간 예산 상한을 갖는 허브 정의 합성 프로브 실행을 위해 라우터를 Continuum Hub 제어 플레인에 연결하는 선택적 아웃바운드 전용 작업입니다. 허브는 요청 경로에 절대 관여하지 않습니다. 라우터는 여전히 데이터 플레인으로 남아 허브로 나가는 연결만 맺으며 허브에서 라우터로 들어오는 트래픽은 없습니다.
이 에이전트는 control-plane Cargo 기능 뒤에 게이팅되어 있으며 이 기능은 크레이트의 default/full 기능 집합에 들어 있지 않습니다. 다만 공식 릴리스 바이너리는 Release 워크플로우에서 이 기능을 켠 채로 빌드되므로, GitHub 릴리스 아카이브와 Docker 이미지, 그리고 그 바이너리로 만든 .deb 패키지에서는 control_plane.enabled 설정 스위치(기본값 false)가 옵트인 지점입니다. 이 기능 없이 소스에서 빌드하면 추가 의존성도, 런타임 비용도 없습니다. control_plane 설정 섹션은 항상 파싱되므로 어떤 빌드에서도 config validate가 이를 허용하지만 기능 없이 빌드된 바이너리에서 control_plane.enabled: true로 설정하면 시작 시 경고를 로그로 남기고 에이전트를 시작하지 않습니다.
에이전트가 하는 일¶
- 등록: 처음 시작할 때 일회성
enrollment_token을 라우터별 자격 증명(tenant_id,router_id,router_credential)으로 교환합니다. 이름 충돌로409가 오면 새 이름으로 재시도합니다. - 자격 증명 저장: 자격 증명은
state_file(기본값./data/control-plane/state.json, 유닉스에서0600)에 기록되어 재시작 시 재사용되므로 일회성 토큰을 다시 소비하지 않습니다. 로그에는 절대 남기지 않습니다. Hub가 저장된 자격 증명을 나중에 거부하면 에이전트는 신원을rejected로 표시하고 오류를 기록한 뒤 백오프합니다. 관리자는 상태 파일을 삭제하지 않고POST /admin/control-plane/enroll로 새 토큰을 교환할 수 있습니다. - 하트비트: 첫 하트비트는 한 주기를 기다리지 않고 시작 직후에 보내며, 이 첫 2xx 응답으로 허브가 라우터의 capability를 저장했음을 정책 동기화에 알려 정책을 즉시 다시 가져오게 합니다(프롬프트 로깅 동의 구간). 이후에는 설정된 주기마다 라우터 인벤토리를 담아 하트비트를 보냅니다. 라우터 버전, 백엔드별 헬스를 포함한 설정된 백엔드 목록, 서빙 중인 모델, 종합 헬스 요약, 토큰 공급과 포화 계측치, 누적 가드레일 카운터, 그리고 capability 없이 보고되는 두 가지 읽기 전용 설명이 여기 담깁니다.
smart_routing_inventory(스마트 라우팅 동작 여부, 정책과 프로필 수, 분류기 방식과 백엔드 이름, 가상 모델)와guardrail_provider_inventory(설정된 가드레일 프로바이더별 이름, 타입, 단계, 위치, 동작 여부)로, 개수와 이름만 담으며 동작 중인 파이프라인과 가드레일 서비스에서 읽습니다(src/control_plane/smart_routing.rs,src/control_plane/guardrail/provider_inventory.rs). 정책 동기화가 활성 상태이면 하트비트마다 적용된 정책 스냅샷의 커서(applied_policy_cursor)도 함께 실어 보내 허브가 플릿 전체의 수렴 상태를 확인하게 합니다. - 플릿 설정:
control_plane.config_sync.enabled를 켜면 등록과 인벤토리를 포함하는 모든 하트비트에 값이 제거된request_params,kv_routing, 모델fallback스냅샷을 싣습니다. 에이전트는 인바운드 Hub 트래픽을 허용하지 않은 채 리비전에 묶인 공개 설정을 폴링하고 가져와 검증한 뒤 원자적으로 적용합니다. - 사용량 전송: 프록시된 요청마다 메타데이터 전용 사용량 레코드 하나를 기존 토큰 사용량 경로로부터 만들어 버퍼링하고 설정된 주기로 제한된 크기의 배치로 전송합니다. 레코드는 그 요청을 처리한 모델 실험 arm(
experiment_id,experiment_variant_id)과,usage.quality_judgment가 있으면 basis point 단위의 결정 모델quality판정도 실을 수 있습니다. 판정은 레코드가 아직 버퍼에 있는 동안에만 붙습니다. 루프가 레코드를 배치로 만들 때 슬롯을 봉인하는데 만들어진 배치는 바이트 그대로 재전송되고 허브는idempotency_key로 중복을 제거하기 때문입니다. 따라서 시퀀싱, 차감, 푸시 순서는 판정을 기다리지 않습니다. - 백오프를 통한 재연결: 등록, 하트비트, 사용량 전송이 실패하면 풀 지터가 있는 지수 백오프(기본 1초, 최대 60초)를 사용하며 성공하면 백오프 상태가 초기화됩니다.
HTTPS 폴링 클라이언트와 정책 WebSocket 스트림은 모두 운영체제 신뢰 저장소로 Hub 인증서를 검증합니다. 내부 Hub CA를 에이전트를 실행하는 호스트 또는 컨테이너의 신뢰 저장소에 설치하세요. 인증서 및 호스트 이름 검증은 계속 적용되므로, 플랫폼이 신뢰하지 않는 CA는 거부됩니다. 정책 스트림은 첫 연결 실패를 경고로 기록하고, 폴링 루프가 정책 전달을 대신하는 동안 같은 원인의 재시도 실패는 디버그 수준으로 기록합니다.
라우터에서 시작하는 등록 엔드포인트는 인바운드 Hub 채널이 아니라 인바운드 Admin 작업입니다. control-plane 기능이 있을 때만 컴파일되고 기존 Admin 인증 및 감사 미들웨어 뒤에 있으며 루프백 개발 엔드포인트를 제외하면 HTTPS를 요구하고 시작 시 등록과 직렬화되며 Hub가 발급한 자격 증명만 저장합니다. 정상 자격 증명을 교체하려면 replace: true를 명시해야 하지만 거부됨이 관측된 자격 증명은 상태 파일을 직접 조작하지 않고 복구할 수 있습니다. 성공 응답은 이미 실행 중인 에이전트가 재시작 전까지 불변 시작 신원을 유지하므로 pending_restart를 보고합니다.
더 넓은 control_plane 설정은 시작 시 소유됩니다. 마스킹된 값은 읽을 수 있지만 런타임 편집 가능 섹션 목록에서는 제외되며 섹션 단위 PUT/PATCH는 설명이 포함된 409로 실패합니다. 운영자는 라우터 측 토큰 교환 후 재시작하거나 기존 소스 설정 등록 흐름을 사용할 수 있습니다. 정책 및 에이전트 설정은 계속 소스 파일에서 가져옵니다.
엔진 지표 식별자¶
백엔드 설정은 알려진 종류와 정규 UUID로 구성된 명시적 engine_identity를 가질 수 있습니다. 아웃바운드 Hub 인벤토리는 현재 설정에서 이를 읽으며 AppProxy ROUTER 조정은 스냅샷의 엔드포인트 UUID를 모든 복제본에 복사합니다. 이 메타데이터는 독립적인 backend_id 과금 경로를 변경하지 않습니다. wire 계약은 프로토콜 v0의 추가 필드이며 미래의 알 수 없는 종류도 파싱하지만 로컬 설정은 그런 종류를 거부합니다.
프라이버시 불변식: 계측 메타데이터만¶
사용량 레코드는 토큰 수, 모델, 프로바이더, 지연 시간, 캐시/배치 플래그, id, 타임스탬프 등 계측 메타데이터만 담습니다. 프롬프트와 완성 본문은 라우터 밖으로 절대 나가지 않습니다. 와이어 타입(continuum_protocol::UsageRecord)은 닫힌, 완전히 타입이 지정된 필드 집합을 가지므로 요청이나 응답 내용을 담을 수 있는 필드 자체가 없습니다.
사용량 레코드의 토큰 수¶
모든 사용량 레코드는 허브의 과금 방식에 맞춰 프롬프트를 서로 겹치지 않는 두 버킷으로 나눕니다. input_tokens는 캐시되지 않은 입력으로 입력 단가가 적용되고 cached_input_tokens는 프로바이더 측 캐시에서 제공된 프롬프트로 캐시 단가가 적용됩니다. 어떤 백엔드 형식이든 허브의 input_tokens에는 캐시 읽기가 들어가지 않습니다.
라우터는 내부 토큰 사용량의 불변식 하나로 이를 맞춥니다. prompt_tokens는 모든 프롬프트 토큰(캐시되지 않은 입력 + 캐시 읽기 + 캐시 쓰기)을 포함하는 수이고 캐시 읽기는 그 부분집합입니다. 레코드에는 input_tokens = prompt_tokens - cache_read, cached_input_tokens = cache_read가 실리며 이는 라우터 로컬 강제 적용이 input_tpm과 월간 예산에 청구하는 과금 대상 입력과 정확히 같으므로 라우터 카운터와 허브 계측이 일치합니다. 캐시 쓰기는 input_tokens에 남습니다.
제외(exclusive) 방식으로 보고하는 와이어 형식은 들어오는 지점에서 변환됩니다.
| 형식 | 프로바이더 보고 값 | 포함 방식으로 바꾸는 곳 |
|---|---|---|
| OpenAI Chat Completions, vLLM, Gemini OpenAI 호환 | prompt_tokens가 prompt_tokens_details.cached_tokens를 포함 | 이미 포함 방식 |
| OpenAI Responses API | input_tokens가 input_tokens_details.cached_tokens를 포함 | 이미 포함 방식 |
네이티브 Gemini(generateContent), /v1/responses가 호출하는 경로와 네이티브 candidates 형태로 /v1/chat/completions에 응답하는 Gemini 호환 백엔드 | usageMetadata.promptTokenCount가 cachedContentTokenCount를 포함 | 두 네이티브 변환 모두 포함 방식 프롬프트 수와 캐시 읽기를 함께 내보냄. transform_native_gemini_response는 prompt_tokens와 prompt_tokens_details.cached_tokens로, Responses 컨버터는 input_tokens와 input_tokens_details.cached_tokens로 내보냄. 네이티브 형태 채팅 스트림은 같은 수치를 마지막 usage 청크 하나에 실어 보고함(choices: []와 usage, OpenAI include_usage 형태). 공유 스트림 변환기가 스트림의 종료 이벤트에서 같은 빌더로 이 청크를 덧붙임. usageMetadata를 실은 이벤트마다가 아니라 한 번만 내보내는데 Gemini는 네이티브 이벤트마다 누적 합계를 반복해서 싣고 최신 값 우선 스트리밍 계측 지점에는 청크 하나면 충분하기 때문임. /anthropic/v1/messages나 /v1/chat/completions에 응답하는 type: gemini 백엔드는 보통 Gemini 자체 OpenAI 호환 표면을 통해 연결되며 그쪽은 prompt_tokens_details.cached_tokens를 직접 보고하므로 첫 번째 행이 적용됨 |
| Anthropic Messages(네이티브, 스트리밍 추적기, Bedrock runtime) | input_tokens가 cache_read_input_tokens와 cache_creation_input_tokens를 제외 | 사용량 파서가 둘을 다시 더함; 스트리밍 추적기는 message_start에서 세 값을 모두 읽고 message_delta에 누적 값이 있으면 그것도 읽음 |
/v1/chat/completions 뒤의 Anthropic | 네이티브와 동일 | 비스트리밍 응답 변환이 포함 방식 prompt_tokens와 prompt_tokens_details.cached_tokens를 내보냄. 스트리밍도 같은 헬퍼로 동일하게 처리함. 공유 스트림 변환기가 message_start와 모든 message_delta의 수치를 누적해 message_stop에서 사용량 전용 청크 하나로 내보내고(choices: []와 usage, OpenAI include_usage 형식) 네이티브 스트리밍 탭과 파이프라인 관측 탭이 이 청크를 계측함. 종료 청크에는 사용량이 실리지 않음. 변환기를 공유하는 Unix 소켓 Anthropic 백엔드와 Bedrock endpoint_type: runtime·mantle에도 동일하게 적용됨 |
/anthropic/v1/messages 뒤의 OpenAI 호환 또는 Responses 백엔드 | 업스트림의 포함 방식 수 | 클라이언트에 가는 Anthropic 본문은 제외 방식 input_tokens(업스트림 프롬프트 - 두 캐시 버킷)를 보고하고 라우터 자체 계측 지점은 업스트림의 포함 방식 수를 계량하며 이 라우터 자신이 브리지된 본문을 다시 파싱하는 경우(비스트리밍 응답, 자체 로컬 캐시 재생, 또는 스스로 재구성한 브리지 스트림)는 캐시 버킷을 더해 같은 합계를 얻음. 브리지된 스트림을 자신의 네이티브 스트리밍 추적기로 계측하는 다운스트림 라우터는 message_delta에서 지연된 입력·캐시 값을 읽어 같은 합계에 도달함 |
/v1/chat/completions 뒤의 Responses 백엔드(responses_only 브리지) | input_tokens가 input_tokens_details.cached_tokens를 포함 | 하나의 매핑이 두 경로를 모두 처리해, 비스트리밍 본문과 스트리밍 include_usage 청크가 포함 방식 prompt_tokens와 prompt_tokens_details.cached_tokens를 전달함. 스트리밍 경로는 업스트림의 원본 종료 사용량(response.completed 또는 사용량을 담은 response.incomplete)을 공유 사용량 파서로 읽어 계량하므로 include_usage를 요청하지 않은 클라이언트도 과금됨 |
/v1/responses 뒤의 Anthropic | 네이티브와 동일 | 비스트리밍 컨버터와 스트림 변환기가 포함 방식 Responses input_tokens와 input_tokens_details.cached_tokens/cache_write_tokens를 내보내고 사용량 파서는 레거시 단수형 input_token_details 표기도 받아들이므로 구버전 피어의 본문과 캐시·저장 본문도 같은 방식으로 계량됨 |
| Bedrock Converse | inputTokens가 cacheReadInputTokens와 cacheWriteInputTokens를 제외 | 응답·스트림 변환이 포함 방식 prompt_tokens를 내보냄 |
| 로컬 캐시 재생 | 모든 프롬프트 토큰이 캐시 읽기 | input_tokens = 0, cached_input_tokens = 프롬프트로 기록 |
| 프로바이더 배치 완료 | 캐시 읽기를 보고하지 않음 | 변경 없음 |
압축된 요청의 uncompressed_input_tokens는 기본값이 포함 방식 프롬프트 수이며 input_tokens + cached_input_tokens 아래로 내려가지 않습니다.
TokenUsage::from_json_value는 위 모든 형식에서 cache_read_input_tokens를 prompt_tokens로, cache_creation_input_tokens를 그 나머지로 제한(clamp)합니다. 프로바이더가 캐시 버킷을 과도하게 보고하더라도(예: promptTokenCount보다 큰 cachedContentTokenCount), 허브의 cached_input_tokens는 포함 방식 프롬프트 수를 넘지 않으며 과금 대상 입력은 0으로 유지됩니다. 제한이 적용될 때 요청별 로그는 남기지 않습니다. OpenAI 호환 또는 Responses 백엔드로 브리지되는 스트리밍 /anthropic/v1/messages도 동일한 제한을 거칩니다. 스트림 변환기가 업스트림의 포함 방식 프롬프트 수를 함께 보관하고 스트림 종료 시 그 값을 계측하므로, 스트리밍과 비스트리밍 요청이 동일한 사용량을 기록합니다. 클라이언트가 받는 Anthropic 응답 본문은 프로바이더의 원래 보고 값을 그대로 유지합니다.
라우터가 직접 세는 출력 토큰과 스트림 사용량 파이널라이저¶
모든 스트리밍 릴레이는 첫 이벤트부터 StreamUsageFinalizer(src/services/streaming/usage_finalizer.rs)를 소유하며, 스트림 종료 시 정확히 한 번 마무리합니다. 마무리된 레코드는 src/services/streaming/transformer.rs의 record_finalized_stream_usage 싱크 하나로 모입니다(Anthropic Messages 인그레스는 record_stream_end_stats로 전달). 대상 릴레이는 채팅 변환기(OpenAI, thinking, Anthropic 소스, Gemini 경로), Unix 소켓 릴레이 3종, 미드스트림 폴백 릴레이(시도당 파이널라이저 1개), 채팅 파이프라인 탭, responses_only 브리지, /v1/responses 패스스루, Anthropic Messages 스트림 경로 7곳입니다. 파이널라이저는 라우터의 OutputTokenCounter(src/services/streaming/output_counter.rs)를 보유하며, 각 릴레이가 이미 파싱한 이벤트를 그대로 전달받으므로 이벤트를 중복 파싱하지 않습니다. 덕분에 백엔드가 사용량을 보고하지 않은 스트림도 누락 없이 라우터가 집계한 값으로 정확히 하나의 사용량 이벤트를 기록합니다.
카운터는 이벤트마다 선택지(choice), 콘텐츠 블록, 후보 파트당 텍스트 델타를 하나씩 세고 UTF-8 문자 수와 바이트 수를 누적합니다:
- OpenAI 채팅 청크(
choices[].delta):content,reasoning_content,reasoning,tool_calls[].function.arguments및.name(n > 1이면 선택지마다 델타 1개). - OpenAI
/v1/completions청크:choices[].text. - Anthropic
content_block_delta:text_delta,thinking_delta,input_json_delta. - Responses 델타 이벤트:
response.output_text.delta,response.reasoning_text.delta,response.reasoning_summary_text.delta,response.refusal.delta,response.function_call_arguments.delta(데이터의type또는 SSEevent:이름에서 식별). - 네이티브 Gemini 청크(
candidates[].content.parts[]): 비어 있지 않은 텍스트 파트당 1개,functionCall파트당 1개(압축 직렬화한args문자 수 포함).
청크당 토큰 하나를 방출하는 엔진(vLLM, SGLang, llama.cpp, mlxcel)에서는 델타 수가 엔진의 출력 토큰 수와 일치합니다. 한 청크에 여러 토큰을 묶어 보내는 엔진에서는 델타 수가 실제 토큰 수보다 적으며, 이 차이는 휴리스틱(chars / 4 등) 대신 아래의 엔진 /tokenize 보정(token_source = engine_tokenize)으로 보완합니다.
레코드의 출력 토큰 수와 token_source는 다음 우선순위에 따라 결정됩니다:
engine_final: 비어 있거나 없는choices옆, 또는 모든 choice가 종료된(finish_reason != null) 청크에 실린 엔진의 최종 usage 블록. 단,n > 1스트림에서 일부 choice만 먼저 끝난 경우 이후 도착하는 청크별 usage가 이를 갱신합니다.engine_continuous: 아직 스트리밍 중인 choice(finish_reason == null)와 함께 도착한 가장 최근의 청크별 usage(vLLMcontinuous_usage_stats형태).router_delta_count(estimated: true): 라우터가 직접 센 스트리밍 델타 수.none: 출력 토큰 0개.
입력 토큰 수는 동일한 엔진 usage에서 가져오며, 없으면 프롬프트만 보고한 엔진(message_delta 없이 message_start만 보낸 네이티브 Anthropic), 그마저 없으면 디스패치된 본문에 대한 estimate_input_text(estimated: true, messages[]·system·instructions·input 포함) 순으로 구합니다. 입력과 출력 중 하나라도 라우터 추정치이면 estimated: true로 표시됩니다.
정상 종료 시 outcome은 두 종류의 엔진 usage가 모두 없으면 usage_missing, 종료 사유가 길이 제한이면 length(Anthropic max_tokens, Gemini MAX_TOKENS, Responses max_output_tokens 포함), 그 외에는 completed로 기록됩니다. 조기 종료(client_cancelled, timeout_first_byte, timeout_mid_stream, upstream_error, fallback_source, guardrail_blocked)는 관측된 종료 원인을 그대로 기록합니다. 직전 이벤트가 오류(event: error, response.failed, 최상위 error 객체, 업스트림 읽기 오류)였다면 뒤따르는 [DONE]과 무관하게 upstream_error로 고정됩니다. 미드스트림 폴백 릴레이의 min_accumulated_tokens 비교도 동일한 델타 수를 사용하며, 추론(reasoning)만 있고 본문이나 도구 호출이 없는 부분 응답은 이어 쓰기(continuation) 모드로 진입하지 않습니다.
엔진이 보고하는 부분 사용량¶
스트림이 최종 usage 블록 전에 종료되더라도 두 가지 경로를 통해 엔진 기준의 토큰 수를 확보할 수 있습니다.
engine_continuous(청크별 사용량): OpenAI 형식 채팅 스트리밍 디스패치는 모두apply_router_stream_options(src/services/streaming/stream_options.rs)를 통해stream_options.include_usage: true를 설정합니다. 백엔드 유형이CONTINUOUS_USAGE_BACKEND_TYPES에 속하고 단일 응답(n == 1) 요청인 경우에는continuous_usage_stats: true를 함께 주입하며, 그 외 백엔드나n > 1요청에서는 클라이언트가 보낸 키도 제거합니다(vLLM의 청크별 usage는 해당 청크의 choice 하나만 집계하므로n > 1에서 마지막 값만 읽으면 과소 집계되기 때문). 이를 통해 vLLM 스트림이 중간에 취소되거나 타임아웃되더라도 마지막 청크의 누적 엔진 사용량을 기록할 수 있습니다. Anthropic 변환은 원래 이벤트 분류를 유지하면서 종료message_delta를 정확히 하나만 내보냅니다. 종료 청크(finish_reason)부터 뒤따르는 사용량 전용 청크,[DONE], 또는 업스트림 스트림의 끝까지message_delta와message_stop을 보류하므로, 엔진이 사용량을 종료 청크에 싣든 그 뒤에 보내든 최종 수치는 그message_delta에 실리고message_stop은 항상 마지막 이벤트입니다(이슈 #2251).- 모든
/v1/chat/completions클라이언트의 스트림은 클라이언트가 보낸include_usage를 따릅니다.include_usage: true를 보내지 않은 클라이언트는usage를 받지 않습니다(chat 인그레스가 모든 청크에서usage를 지우고 마지막 사용량 전용 청크를 뺍니다).include_usage: true를 보낸 클라이언트는 마지막 사용량 전용 청크에서만usage를 받습니다. 두 경우 모두 사용량 기록, 응답 캐시, 가드레일 게이트가 스트림을 읽은 뒤에 적용되고(src/proxy/chat_stream_usage.rs), 플래그와 상관없이 usage를 보내는 엔진에도 적용됩니다. chat 스트림의 사용량을 참고하세요.
- 모든
엔진별 청크 단위 사용량 지원¶
| 백엔드 유형 | 청크별 사용량 | 주입 | 근거 |
|---|---|---|---|
vllm | 검증됨: stream_options.continuous_usage_stats로 모든 청크에 누적 usage | 예 | vllm-project/vllm 187a0eb98aa42341d703f83421d693fa7585581b의 vllm/entrypoints/generate/base/protocol.py StreamOptions.continuous_usage_stats. OpenAI 기본 모델이 추가 필드를 허용함 |
sglang | 업스트림 소스에 옵션은 있으나 주입하지 않음 | 아니요 | sgl-project/sglang f9cee8d2b2d96a626db1968ba81e4d72bb31794a의 python/sglang/srt/entrypoints/openai/protocol.py StreamOptions.continuous_usage_stats. 처음 포함된 릴리스가 고정되지 않았고 내용이 있는 청크에만 choice별로 사용량을 붙이며 캡처가 없음 |
tensorrt-llm | 업스트림 소스에 옵션은 있으나 주입하지 않음 | 아니요 | NVIDIA/TensorRT-LLM 7179c5b01b9d19acef69a6526330eb72f3b70e10의 tensorrt_llm/serve/openai_protocol.py StreamOptions.continuous_usage_stats. OpenAIBaseModel이 extra="forbid"이므로 이 필드보다 오래된 서버는 요청 전체를 400으로 거부함 |
| 그 밖의 모든 유형 | 해당 없음 | 아니요 | 클라이언트가 보낸 키도 지우므로 OpenAI 클라우드, Azure, Gemini, 모든 자체 호스팅 유형은 이 키를 보지 않음 |
허용 목록은 CONTINUOUS_USAGE_BACKEND_TYPES이며 해당 엔진에서 캡처한 스트림이 있을 때만 항목을 추가합니다. 캡처 절차는 tests/fixtures/continuous_usage_stats/README.md에 있습니다.
engine_tokenize(usage.tokenize_correction, 기본값 꺼짐): 스트림 종료 시router_delta_count레코드를 방출할 때 활성화되어 있으면,src/services/streaming/tokenize_correction.rs가 누적된 출력 텍스트(최대 100,000바이트)를 엔진의/tokenize엔드포인트로 비동기 전송해 토큰 수를 보정합니다:- 분류 및 메트릭: 처리된 스트림마다
usage_tokenize_corrections_total{result}를 기록합니다. 엔진 토크나이저 경로가 없거나(TokenizeEngine::of) HTTP(S)가 아니면unsupported, 텍스트가 100,000바이트 상한을 넘었으면skipped_truncated, 동시 요청 상한(max_concurrent)에 도달했으면shed로 분류하고 즉시router_delta_count로 방출합니다. - 실행 및 검증: 릴레이가 종료되어
TokenizeCorrectionHandle이 해제되면timeout_ms예산 안에서tokenizer_request를 호출합니다(add_special_tokens: false또는 llama-server의add_special: false, 응답 상한 1 MiB). 비어 있지 않은 텍스트에 대해 0을 반환하거나 UTF-8 바이트 길이를 초과하는 비정상 값은error로 처리하고 라우터 델타 수를 유지합니다. 정상 응답(applied)이면token_source = engine_tokenize로 기록합니다. - 지연 방출: 레코드의 모든 부수 효과(통계, Prometheus 카운터, 정책 그랜트 차감, WAL 종료 기록, 사용량 이벤트)는 보정이 끝난 시점에 한 번에 실행됩니다. 보정 텍스트는
delta.content,delta.reasoning_content, 도구 호출 이름·인자를 이어 붙인 것이므로 구간 사이의 템플릿 제어 토큰은 제외한 하한값입니다. 미드스트림 폴백의 이전 시도나 텍스트를 보관하지 않는 변환 스트림은 보정 대상에서 제외됩니다.
- 분류 및 메트릭: 처리된 스트림마다
스트림 종료 경로와 사용량 결과¶
모든 스트리밍 릴레이는 정상 종료뿐 아니라 조기 종료 경로에서도 파이널라이저를 실행해 중단 시점까지 생성된 사용량을 기록합니다. 클라이언트가 연결을 끊으면 즉시 업스트림을 닫고 그 시점까지 관측된 토큰만 집계합니다.
| 종료 경로 | outcome | token_source와 출력 |
|---|---|---|
정상 종료([DONE] 또는 본문의 끝) | completed, length, usage_missing | 위의 우선순위 |
클라이언트 이탈: 전송 실패, 릴레이의 tx.closed() 경합, 떠난 클라이언트로 보낸 keep-alive, 전달 태스크가 버린 파이프라인 탭 | client_cancelled | 취소 전에 usage 블록이 왔다면 engine_final 또는 engine_continuous, 아니면 router_delta_count(델타가 없으면 none) |
파이프라인의 first_byte 창, 일반 챗, thinking 패턴, HTTP Anthropic, Unix 소켓 릴레이의 첫 읽기가 결정된 streaming.first_byte를 넘긴 경우, 또는 streaming.first_byte 안에 첫 청크가 오지 않은 미드스트림 릴레이 시도 | timeout_first_byte | none, 출력 0, 입력은 추정치 |
파이프라인의 전체 예산(backend_stream)이나 chunk_inactivity, 트랜스포머의 전체 예산 검사, 위 릴레이의 이후 읽기가 chunk_interval을 넘긴 경우, 후속 시도가 없는 미드스트림 timeout/inactivity_timeout | timeout_mid_stream | 타임아웃 전까지 생성된 델타 |
| 핸드셰이크 오류 상태, 너무 많은 업스트림 오류 이벤트나 읽기 오류, 마지막 이벤트가 오류인 경우 | upstream_error | 상태 코드라면 none과 핸드셰이크 http_status, 아니면 생성된 양 |
가드레일 차단(GateForward::Blocked, [DONE]이나 본문 끝에서 게이트가 내린 Block, 파이프라인 게이트의 스트림 도중 차단) | guardrail_blocked | 차단 시점까지 백엔드가 생성한 모든 델타. 게이트가 보류하고 내보내지 않은 델타도 포함 |
| 폴백 홉이 출력을 이어받은 미드스트림 시도 | fallback_source | 이 시도가 생성한 출력. 후속 시도의 레코드는 자신의 출력만 담음(모델 폴백 참고) |
- 파이프라인 종료 슬롯: 채팅 파이프라인은
StreamExitSlot(src/services/streaming/middleware/exit_slot.rs)을 통해 하위 레이어의 종료 사유를 관측 탭에 전달합니다.ChunkTimeoutLayer는 타임아웃 종류를, 가드레일 드라이버는 차단 여부를 슬롯에 먼저 기록하므로 탭은 추측 없이 정확한 원인을 기록합니다. - 시도 기록 및 입력 추정 상한: 직접 스트림과 체인 스트림 모두 서킷 승인 직후 시도를 시작합니다(
attempt_seq: 1이상). 출력이 전혀 없고 입력이 추정치뿐인 실패 시도(token_source: none,estimated: true)는 base64 이미지 등으로 인한 과다 차감을 막기 위해 입력 추정치를 최대 32,768토큰(MAX_ATTEMPT_INPUT_ESTIMATE_TOKENS)으로 제한합니다. 출력을 스트리밍한 시도는 프롬프트 전체가 처리된 것이므로 추정치 전체를 유지합니다. - 교차 프로토콜 릴레이:
/anthropic/v1/messages스트림,/v1/responses패스스루 및 변환 릴레이,responses_only브리지, 트랜스포머의 Anthropic·Gemini 경로도 동일한 규칙으로 단일 종료 지점에서 마무리합니다(tests/stream_early_exit_audit_test.rs로 검증).
사용량 레코드의 타이밍 필드¶
모든 사용량 레코드는 요청 전체 지연 시간인 latency_ms를 담으며, 첫 바이트 도달 시간(TTFT)에 대해서는 ttft_ms(측정 방식을 나타내는 ttft_source 동반) 또는 부재 사유인 ttft_absent_reason 중 정확히 하나를 담습니다.
latency_ms: 핸들러 진입부터 응답 완료까지 라우터 측 전체 소요 시간입니다(단,/v1/chat/completions와/v1/responses의 로컬 캐시 재생은 캐시 조회 구간만 측정).ttft_ms및ttft_source: 업스트림 디스패치 시점부터 첫 바이트가 도착하기까지의 시간과 측정 출처입니다:streamed_first_token: 스트리밍 응답에서 업스트림 디스패치부터 첫 스트리밍 바이트까지 걸린 시간.response_headers: 비스트리밍 응답에서 업스트림 디스패치부터 응답 헤더 도착까지 걸린 시간(HTTP, Unix 소켓, Bedrock Runtime 모두 포함). 프로바이더가 생성을 마친 뒤 응답을 보내므로 보통 업스트림 전체 지연 시간에 가깝습니다.cache_replay: 로컬 캐시 적중 시 캐시 본문이 나가기까지의 시간(latency_ms와 동일하며 출력 토큰은 0).
ttft_absent_reason:ttft_ms가 생략된 이유를 나타냅니다:batch: 프로바이더 배치 완료(대화형 대기 주체가 없으므로 의도적으로 생략).no_first_frame: 디스패치되었으나 스트림 종료까지 프레임이 하나도 오지 않은 경우.transport_unobservable: 응답 헤더 시점을 관측할 수 없는 전송 계층(예약값).not_observed: 업스트림에 도달하기 전에 실패했거나, 헤더 수신 전에 실패한 시도, 또는 재시도·폴백으로 폐기된 이전 시도입니다./anthropic/v1/messages를 포함한 모든 릴레이는 취소, 타임아웃 또는 업스트림 오류 전에 TTFT를 관측했다면 해당 값을 유지합니다. 디스패치 후 프레임을 받지 못한 릴레이는no_first_frame을 보고합니다.
엔진 카운터와의 대조¶
usage.reconciliation(src/services/stats/reconciliation.rs)은 라우터 원장의 출력 토큰 합계를 백엔드 엔진 자체의 생성 카운터(EngineStatsSnapshot.generation_tokens_total)와 주기적으로 대조합니다.
- 집계 방식: 백엔드에 도달한 모든 원장 레코드(로컬 캐시 재생 제외)의 출력 토큰 수를 크기 8,192의 유한 큐를 통해 백엔드별 현재 윈도에 누적합니다.
- 윈도 판정: 윈도를 닫을 때 엔진 스냅샷을 기준으로 다음 중 하나로 판정합니다:
no_engine_data: 윈도 내 엔진 스냅샷이 없거나 첫 기준값(baseline)을 잡는 윈도. 기준값이 이미 있으면 원장 토큰을 다음 윈도로 이월합니다(vLLM, SGLang, llama.cpp/mlxcel만 생성 카운터를 보고하며, 다른 엔진은 항상no_engine_data).reset: 엔진 카운터가 이전 기준값보다 작아진 경우(엔진 재시작).within_tolerance/drift:delta = value - baseline에 대해abs((delta - ledger) / max(delta, 1)) <= max_drift이면 정상(within_tolerance), 초과하면drift로 기록하고 경고를 남깁니다.
- 운영 시 유의 사항: 윈도 경계에 걸친 요청으로 인한 오차를 줄이려면
window를 백엔드engine_stats.interval의 2배 이상으로 설정해야 합니다. 다른 라우터나 직접 클라이언트와 공유하는 엔진은backends[].reconciliation: false로 대조에서 제외하십시오.
사용량 레코드의 결과와 시도 식별자¶
사용량 원장은 요청 단위가 아니라 모델 실행 시도(attempt) 단위로 기록됩니다. 모든 필드는 와이어에서 선택적 추가 필드로 직렬화됩니다(PROTOCOL_VERSION = 0).
| 필드 | 설명 |
|---|---|
outcome | 시도 종료 결과: completed, length, client_cancelled, timeout_first_byte, timeout_mid_stream, upstream_error, guardrail_blocked, usage_missing, router_restart, fallback_source(부분 출력 전달 후 미드스트림 폴백이 이어받은 시도), 또는 백엔드 시도 전에 라우터가 거절한 경우 refused(거절된 요청 참고). |
refusal_reason | refused 레코드의 제한된 기계 판독 코드입니다. 클라이언트용 error.code 어휘(model_not_found, service_unavailable, router_loop_detected 등)에서 가져오고, 코드가 없거나 형식에 맞지 않으면 refused로 대체합니다. refused에만 있으며 다른 결과에서는 허브가 버립니다. |
token_source | 출력 토큰 산출 출처: engine_final, engine_continuous, router_delta_count, engine_tokenize, char_estimate, none. |
estimated | 입력 또는 출력 토큰 수 중 하나라도 엔진 보고값이 아니면 true. |
stream | 클라이언트가 스트리밍을 요청했는지 여부. |
http_status | 가장 최근 업스트림 디스패치의 HTTP 상태 코드(응답 헤더 미수신, 캐시 재생, 배치 완료 시 생략). |
request_id | API 라우트 진입 시 발급되는 라우터 고유 요청 ID(req_ + 32자리 16진수). 같은 요청의 모든 시도가 공유합니다. refused 레코드에서는 request_id, attempt_seq, parent_attempt가 모두 생략됩니다. 시도가 시작되지 않았으므로 시도 식별자를 담지 않습니다. |
attempt_seq / parent_attempt | 요청 내 디스패치의 1-기반 순번과, 이 시도가 대체·이어받은 이전 시도 번호. |
purpose | 호출 주체: client(클라이언트 요청), guardrail, classifier, action_safety, quality_judgment. |
reasoning_tokens | 출력 토큰 중 추론 토큰 부분집합(output_tokens 이하로 제한됨, 별도 합산하지 않음). |
input_chars / input_bytes / output_chars / output_bytes | 요청·응답 텍스트의 UTF-8 문자 수와 바이트 수(도구 인자 및 이미지 제외). |
audio_seconds | 길이 기준으로 과금되는 OpenAI 오디오 응답(/v1/audio/transcriptions, /v1/audio/translations)의 초 단위 길이(소수점 올림, 토큰 수는 0으로 기록). |
- 과금 및 멱등성: 취소, 타임아웃, 폴백 이전 시도를 포함한 모든 시도 레코드가 허브 원장 및 로컬 비용 센터(
router_usage_seq)에 동일하게 정산됩니다. 레코드마다 고유한{router_id}-{uuid}형태의idempotency_key를 부여해 시도별로 독립적인 중복 제거를 보장합니다.
거절된 요청¶
백엔드에 보내기 전에 라우터가 거절한 요청은 refused 레코드 하나를 남깁니다. 어떤 백엔드도 받지 않은 요청이 허브 쪽에 남는다는 뜻입니다. 레코드는 백엔드 시도가 시작되지 않았을 때만 붙는 RequestRejection에서 나오며, 가장 바깥쪽 거절 관측 미들웨어(src/http/middleware/request_rejection.rs)가 요청마다 한 번만 내보냅니다. 재시도나 폴백을 여러 번 거친 거절이어도 레코드는 하나입니다.
refused 레코드가 되는 거절은 라우팅 거절로 한정합니다. 받을 수 있는 백엔드가 없거나 모델을 알 수 없는 경우(Routing), 키 권한이 요청을 허용하지 않는 경우(Policy), 백엔드 단위 레이트 리밋 거절(RateLimit에서 backend 또는 store_unavailable)입니다. 인증 실패는 키 식별자가 없고 인증되지 않은 트래픽이 원장을 채우게 되므로 제외합니다. 어드미션 셰딩과 입력 가드레일 거절도 라우팅 거절이 아닙니다. 레코드는 토큰 수 0, token_source: none, ttft_absent_reason: not_observed, 클라이언트가 받은 응답 상태를 담습니다. 프로바이더는 router 표식이고, 키나 모델이 정해지기 전에 거절이 결정됐다면 key_id나 model은 비어 있을 수 있습니다. 과금은 없습니다. 가격은 0이고 허브는 이 레코드를 모든 사용량 집계에서 제외합니다. 폭주는 다른 사용량 생산자와 같은 방식으로 제한합니다. 이벤트는 같은 유한한 컨트롤 플레인 채널에 try_send로 들어가고, 채널이 가득 차면 버린 뒤 usage_records_dropped_total{reason="channel_full"}을 올립니다. - 요청 원장 전파 및 로컬 감사: API 라우트 그룹의 request_ledger_middleware가 task-local request_ledger 셀을 통해 요청 ID, 텍스트 길이, 시도 순번을 전파합니다(spawn_propagated는 태스크 생성 시 셀을 상속). 로컬 통계(GET /admin/stats의 by_outcome, llm_tokens_total{outcome}, usage_records_total{outcome, token_source, purpose})에도 동일한 분류가 반영되며, admin.stats.usage_export를 켜면 GET /admin/usage/export에서 텍스트를 제외한 감사 레코드(UsageExportRecord)를 조회할 수 있습니다.
라우터가 시작한 호출¶
클라이언트 요청을 처리하는 과정에서 라우터가 자체적으로 수행하는 부가 LLM 호출도 시도 지점(record_attempt_usage)을 거쳐 별도의 사용량 레코드로 기록됩니다.
purpose | 프로듀서 | 활성화 설정 |
|---|---|---|
guardrail | LLM 가드레일 프로바이더(self_hosted_classifier, custom_classifier, systemone) | guardrails.providers[] |
classifier | 스마트 라우팅 LLM 분류기(src/services/smart_routing/llm_classifier.rs) 및 System One 분류기 | smart_routing.classifier.method: llm, hybrid, system_one, hybrid_system_one, 또는 shadow |
action_safety | 액션 안전 LLM 평가기(src/services/action_safety.rs) 및 System One 평가기 | safeguards.action_safety |
quality_judgment | 사용량 품질 판정 및 폴백 품질 검사(System One 소비자) | usage.quality_judgment, fallback.quality_check |
- 귀속 및 순번 할당: 내부 호출 레코드는 유발한 클라이언트 요청의
key_id와request_id를 공유하되, 요청 내 공유 할당기에서 2 이상의 고유한attempt_seq를 부여받습니다(순번 1은 항상 클라이언트의 첫 시도에 예약됨). 로컬 키 예산은 차감하지 않으며, 컨트롤 플레인 사용량 채널의 마지막 10% 용량은 클라이언트 레코드 전용으로 남겨 두어 허브 전송 지연 시 내부 호출 레코드가 먼저 드롭됩니다(usage_records_dropped_total{reason="channel_full"}). - 통계 분리: 통계 수집기는 내부 호출을 별도의 제한 크기 링(
MAX_INTERNAL_RECORDS)에 보관하고GET /admin/stats의by_purpose에 집계하므로, 스트리밍 가드레일 윈도 검사가 많아져도 클라이언트 요청 수나 지연 시간 백분위수가 왜곡되지 않습니다.
사용량 레코드의 아티팩트 식별 정보¶
백엔드에 도달한 모든 사용량 레코드는 동일한 모델 ID를 서로 다른 양자화나 체크포인트로 서빙하는 백엔드를 구분할 수 있도록 두 가지 선택적 운영자 선언 필드를 포함합니다:
model_digest: 서빙 백엔드와 모델에 대해 운영자가 선언한 아티팩트 다이제스트(sha256:또는blake3:+ 64자리 소문자 16진수,metadata.artifact.digest).draft_model: 같은(backend, model)에 설정된 추측 디코딩(speculative decoding) 드래프트 모델 이름(metadata.draft_model.name). 수락된 토큰은 대상model에 귀속되며, 이 필드는 귀속 검토를 위해 드래프트 모델의 이름만 기록합니다.
멱등 배치 처리¶
각 사용량 배치는 고정된 batch_id, 레코드별 idempotency_key, sent_at_ms, 체인 해시로 한 번만 조립되므로 재시도 시에도 항상 동일한 바이트를 전송합니다. 허브는 idempotency_key로 중복을 제거하며, 200 응답을 받으면 메모리 버퍼(최대 20개 배치)에서 제거합니다.
장애와 재시작에 대한 내구성¶
두 가지 디스크 기반 메커니즘으로 허브 장애나 프로세스 재시작 시 사용량 레코드 유실을 방지합니다:
- 디스크 스풀 (
<state_file>.usage-spool/{seq:020}.json): 메모리 버퍼가 가득 차거나 라우터가 종료될 때 미전송 배치를 권한0600(디렉터리0700)으로 디스크에 기록합니다. 푸시 루프는 메모리 버퍼보다 스풀을 먼저 비우며,control_plane.usage_spool_max_bytes(기본 256 MiB,0이면 비활성화) 한도를 초과하면 가장 오래된 스풀 파일부터 삭제하고 해당 시퀀스를 정리합니다. - 선행 기록 시도 로그 (
<state_file>.usage-wal.jsonl):/v1/chat/completions의 버퍼링·스트리밍 디스패치는 시도 시작 전에 시작 줄을, 완료 시 종료 줄을 권한0600으로 기록합니다(프롬프트 본문 제외). 프로세스가 비정상 종료된 경우 다음 부팅 시 닫히지 않은 시작 줄을 각각outcome: router_restart(output_tokens: 0,token_source: none,estimated: true) 레코드로 복원해 전송합니다. - 파일 신뢰 및 정상 종료: 심볼릭 링크이거나 권한이
0600보다 넓은 WAL·스풀 파일, 또는 다른 라우터 등록 ID로 작성된 스풀 파일은 재생하지 않고 폐기합니다(usage_durability_corrupt_total). 정상 종료 시에는 마지막 푸시, 스풀 플러시, WAL 배리어가 완료될 때까지 최대허브 타임아웃 + 5초동안 대기합니다.
변조 증거¶
허브로 전송되는 모든 사용량 배치는 해시 체인으로 연결되어 배치 위변조나 누락을 탐지할 수 있습니다(continuum_protocol::usage_batch_hash):
- 체인 필드:
batch_hash:prev_batch_hash,router_id,batch_id,sent_at_ms,chain_gap(각각 줄바꿈0x0a로 구분) 뒤에 각 레코드의 정규 JSON(키 정렬, 공백 제거,serde_json기본 이스케이프) SHA-256 원시 32바이트를 이어 붙여 계산한 SHA-256 16진수 해시.prev_batch_hash: 직전에 생성된 배치의batch_hash(전달 성공 여부와 무관, 첫 배치는 생략).chain_gap: 직전 배치 이후 영구 유실된 배치 수(413거부, 스풀 용량 초과 삭제 등). 레코드가 둘 이상인 배치가413으로 거부되면 그 배치도 같은 방식으로 버려지지만, 레코드는 스풀보다 앞서 큐로 돌아가 같은 멱등성 키로 이후의 더 작은 배치에 실립니다. 따라서 로컬 내보내기 파일에는 버려진 배치와 이후 배치에 모두 나타납니다.
- 체인 상태 (
<state_file>.usage-chain.json): 최신 체인 헤드와pending_gap을 권한0600으로 원자적 저장합니다. 등록된router_id에 귀속되며, 쓰기 실패 시 체인 분기를 막기 위해 사이드카를 삭제하고 새 체인을 시작합니다. - 로컬 원장 내보내기 (
usage.ledger_export.path): 설정 시 생성된 모든 배치를 JSONL 형식으로 로컬 파일(0600)에 추가합니다. 파일 크기가max_bytes(기본 1 GiB)를 넘으면<path>.<sent_at_ms>로 회전하고, 새 파일 첫 줄에 이전 파일의 마지막 해시를 가리키는{"anchor":true,"prev_batch_hash":"<hex>",...}레코드를 기록합니다.max_files를 설정하면 회전할 때마다, 그리고 writer가 시작할 때 한도를 넘는 가장 오래된 회전 파일(이름의<sent_at_ms>-<n>순서, 일반 파일만, 심볼릭 링크는 따라가지 않음)을 지웁니다. 운영자는python3 -I scripts/verify-usage-ledger.py <file>로 각 파일과 체인 무결성을 오프라인 검증할 수 있습니다.
주기와 정책¶
허브는 등록 응답에 하트비트 간격, 사용량 전송 간격, 배치 크기를 담아 전달하며, 로컬 control_plane 설정에 명시된 필드가 있으면 로컬 값이 우선합니다. 정책 동기화와 강제 적용은 control_plane.policy.enabled: true로 옵트인해야 활성화됩니다.
플릿 공개 설정¶
control_plane.config_sync.enabled: true로 옵트인하면 허브에서 공개 설정(request_params, kv_routing, 모델 fallback)을 원격 관리할 수 있습니다. 자격 증명, TLS 자료, 프롬프트, fallback.quality_check, 배포 로컬 KV 라우팅 설정(prefix_routing.salt_echo, kv_cache_index.backend 등)은 원격 관리 대상에서 제외되며, 로컬 파일에 명시된 값이나 *_immutable: true로 고정된 섹션은 항상 허브 설정보다 우선합니다.
- 기능 협상 및 합성: 라우터는 스키마 버전
1, 핫 리로드·드라이런 지원, 리프 수 및 크기 상한을 광고하며, 인벤토리에는 실제 설정값을 제외한 다이제스트, 경로 세그먼트, 출처(hub,local_file,local_pin)만 보고합니다. 폴백 체인 등 인덱스 컬렉션은 원자적으로 교체되며, 활성화 여부와 무관하게 전체 폴백 그래프의 순환(cycle) 여부를 검증합니다. - 아웃바운드 트랜잭션 및 원자적 적용: 에이전트는
POST /api/agent/v1/config/sync로 할당을 확인하고POST /api/agent/v1/config/content에서 내용을 가져와 정규 SHA-256 다이제스트와 스키마를 검증합니다. 로컬 고정값 아래에 허브 설정을 병합해 전체Config검증을 통과하면 오버레이를 디스크(0600)에 저장하고 무잠금 스냅샷으로 게시한 뒤POST /api/agent/v1/config/apply-result로 결과(applied,validated,rejected,superseded)를 보고합니다. 검증이나 전송이 실패하면 기존 유효 스냅샷(last-known-good)을 그대로 유지합니다. - 구현:
src/control_plane/config_sync.rs,src/control_plane/client.rs,src/control_plane/agent.rs,src/control_plane/inventory.rs,crates/continuum-protocol/src/config.rs.
플릿 백엔드 설정¶
control_plane.config_sync.backends(backend_config_v1)는 허브가 백엔드 토폴로지 스냅샷을 원격 관리할 수 있게 하는 독립 옵트인 채널입니다. backends.mode가 소유권 범위를 결정합니다:
disabled(기본값): 백엔드 관리 기능을 광고하지 않습니다.overlay: 허브가 허브 소유backend_id만 생성·수정·제거하며 로컬 백엔드는 그대로 보존합니다(ID나 이름 충돌 시 리비전 전체 거부).authoritative: 최초 채택(adoption) 이후immutable_backend_ids를 제외한 모든 백엔드를 허브 스냅샷이 관리합니다. 최초 채택은 로컬 백엔드의 시크릿 제외 다이제스트((backend_id, local_config_digest))를 비교하는 compare-and-swap으로 수행되어 운영자가 검토한 상태와 다르면adoption_digest_mismatch로 거부됩니다.
검증, 사전 프로브, 자격 증명 바인딩:
- 에이전트는
/api/agent/v1/backend-config/sync와/api/agent/v1/backend-config/content를 통해 스냅샷을 받아 다이제스트와 백엔드 설정을 검증합니다. - 새로 추가되었거나 연결 정보가 변경된 활성 백엔드는 활성화 전에 공유 후보 프로브(
src/backend_probe/)로 1회 사전 헬스 체크를 수행합니다. - 백엔드 프로필은 허브 자격 증명을
credential_ref(ID)로만 참조하며, 실제 시크릿은 정책 채널의 백엔드 범위 자격 증명(backend_credentials_v1)을 통해 전달됩니다. 참조된 모든 자격 증명이 승인 및 해석될 때만 스냅샷이 활성화되며, 미해석 시credential_unavailable또는credential_digest_mismatch로 거부하고 기존 스냅샷을 유지합니다. - 구현:
src/control_plane/backend_sync.rs,src/control_plane/config_sync.rs,crates/continuum-protocol/src/backend_config.rs.
허브 선언 모델 별칭¶
허브 백엔드 프로필은 모델별 별칭(model_aliases)을 선언할 수 있습니다. allowed_models의 대상 모델 하나에 클라이언트용 이름을 최대 50개까지 연결합니다. 백엔드 동기화 실행기가 동작하면(backends.mode가 disabled가 아닌 경우) 라우터는 하트비트 기능 목록에 backend_model_aliases_v1을 광고하며, 허브는 이 기능을 광고한 라우터에만 별칭을 보냅니다.
- 디스패치: 대상 모델마다 해당 별칭을 담은
model_configs항목이 허브 소유 백엔드에 생성되고,models는allowed_models와 동일하게 유지됩니다. 별칭 요청은 운영자가 선언한model_configs별칭과 같은 규칙으로 백엔드 선택 전에 대상 모델로 바뀝니다. 따라서 대상 모델을 제공하는 모든 사용자 라우팅 가능 백엔드 중에서 선택이 이뤄지고, 업스트림은 대상 모델 ID를 받으며,/v1/models에 별칭이 표시됩니다. - 내부 및 비활성 백엔드:
internal프로필의 별칭은 검증만 하고 적용하지 않으며, 비활성화된 허브 백엔드의 별칭도 해석되지 않습니다. - 우선순위: 어떤 백엔드가 이름 그대로 제공하는 모델 이름은 바꾸지 않습니다. 허브 별칭은
model-metadata.yaml별칭보다 우선합니다. 최상위model_aliases재작성은 이를 적용하는 인그레스에서 여전히 먼저 실행되며,exact맵에 허브 별칭과 같은 키가 있으면 스냅샷을 활성화할 때마다 백엔드 ID를 담은 경고를 한 번 기록합니다. -
거부 사유 (
model_policy_invalid, 프로필 범위, 기존 스냅샷 유지):상세 원인 model alias target is not an allowed model별칭 맵의 키가 프로필의 allowed_models에 없음too many aliases for one model대상 모델 하나의 별칭이 50개를 넘음 empty,too_long,control_character형식이 잘못된 별칭 이름 model alias collides with another hub profile별칭이 다른 프로필의 별칭이나 허용 모델과 같음(프로필 ID 순서상 뒤쪽 프로필로 보고) model alias collides with a local backend model별칭이 보존되는 로컬 백엔드(비활성 백엔드 포함)의 모델이나 별칭과 같거나, 해당 백엔드 models의 글롭 항목과 일치하거나, 와일드카드가 없는models항목의 날짜 접미사 형태(또는 그 반대, 예:llama3.2와llama3.2-20250101)임.authoritative모드에서 교체되는 로컬 백엔드는 제외 -
재시작: 복원된 스냅샷은 별칭을 다시 적용합니다. 그 사이 로컬 설정 변경으로 충돌이 생기면 경고를 기록하고 스냅샷은 그대로 활성화합니다. 이름 그대로 제공되는 로컬 모델 이름이 계속 우선합니다.
Hub 백엔드 task¶
control_plane.config_sync.backends.tasks(backend_tasks_v1)는 허브가 고객 네트워크 내부로 인바운드 연결을 열지 않고도 백엔드 후보를 점검하고 로컬 설정을 확인할 수 있게 하는 아웃바운드 전용 읽기 채널입니다(POST /api/agent/v1/backend-tasks/sync 및 /result). backends.mode와 독립적으로 활성화할 수 있습니다.
candidate_probe(후보 프로브): 후보 백엔드 프로필에 대해validate,health_check,discover_models연산을 수행합니다. Admin API(POST /admin/backends/probe)와 동일한 공유 도메인(src/backend_probe/)을 사용하며, 선택적 일회성 자격 증명 리스(lease)는 해당 후보가 명시적으로 참조한 경우에만 메모리에 바인딩됩니다. 결과에는 상태, 지연 시간, 자격 증명 분류, 발견된 모델 ID 목록, 타입화된 오류 코드만 포함되며 업스트림 본문이나 시크릿은 절대 포함되지 않습니다.local_export(로컬 내보내기): 현재 설정된 백엔드를 허용 목록 필드(이름,backend_id, 타입, 정제된 엔드포인트, 모델, 가중치, 역할, 기본 헬스 체크)로 내보냅니다. 시크릿이나 고급 설정은 값 대신unsupported_fields코드로만 표시되며, 각 항목에는 authoritative 채택 시 compare-and-swap에 쓰이는 정규 다이제스트가 함께 포함됩니다.- 멱등성 및 기능 광고: 결과는 태스크 ID를 키로 아웃박스에 저장되어 중복 실행을 방지합니다.
backend_tasks_v1기능 문자열은 태스크 실행 루프가 실제로 등록 신원에 바인딩되어 실행 중일 때만 광고됩니다(GET /admin/control-plane/status의backend_tasks로 상태 확인 가능).
토큰 공급과 포화 계측¶
하트비트와 등록 인벤토리에는 라우터 및 백엔드별 포화 상태를 나타내는 RouterInventory.supply 블록과 BackendInfo 확장 필드가 포함됩니다(src/control_plane/supply.rs, src/control_plane/capacity.rs):
- 실측 요청 카운터 (
active_requests,accepted_requests,rejected_requests,retried_requests,fallback_requests): 실제 승인·재시도·폴백 지점의 RAII 가드와 원자적 카운터에서 직접 수집합니다. 스트리밍 요청의 인플라이트 가드는 마지막 SSE 프레임이 전송되거나 연결이 닫힐 때까지 유지됩니다.accepted_requests와rejected_requests는 서로 다른 계층(API 승인 미들웨어, 핸들러 내부 정책 거부, 전역 속도 제한기)에서 독립적으로 집계되므로 합산이 전체 요청 수를 의미하지 않습니다. - Hub 발급 키의 키별 거부 (
rejections_by_key,rejections_by_key_other):counters_since_ms이후 누적된(key_id, stage, count)행입니다.request_rejected_total과 같은 거부 수집 지점에서 집계하지만, Hub 키 테이블로 확인된 키 ID(정책 집행 미들웨어, 또는blocking모드의hub_auth_context가 확인한 키)만 담습니다. 로컬 키, AppProxy 키, 키를 알 수 없는 거부는rejected_requests에만 반영됩니다.- 키는 프로세스 수명 동안 먼저 거부된 순서대로 자리를 받으며, 프로토콜의
MAX_KEY_REJECTION_KEYS(32개 키)와MAX_KEY_REJECTION_ENTRIES(256개 행) 범위 안에서만 행이 생기므로 각 행의 값은 늘어나기만 합니다. 그 밖의 Hub 키나key_rejection_key_id_problem검사를 통과하지 못한 키 ID의 거부는rejections_by_key_other에 더해집니다. 이 값은0이어도 항상 보내므로 Hub는 이 기능을 지원하는 라우터와 이전 라우터를 구분할 수 있습니다. - 거부 수집 지점은 승인 제어, 입력 가드레일, 라우팅 단계의 거부도 포함하므로 키별 합계가
rejected_requests보다 클 수 있습니다. 라우터 단계는 빠짐없는match로KeyRejectionStage에 대응됩니다. 구현:src/control_plane/supply/key_rejections.rs,src/http/middleware/request_rejection.rs.
- 관측 용량 및 부재 값의 의미: 동시성 상한이 없는 경우
max_concurrency는0이 아니라None(알 수 없음)으로 비워 둡니다. 백엔드별observed_rpm_capacity,observed_input_tpm_capacity,observed_output_tpm_capacity는 업스트림429가 발생한 온전한 60초 처리량 윈도(최소 2건 이상 완료)가 관측될 때만 보고하며 15분간 최고 관측값을 유지합니다. 반면 큐와 부하 차단을 두지 않는 라우터 특성상queue_depth와shed_requests는 구조적인0을 보고합니다.counters_since_ms는 프로세스 시작 시각(process_start_info())으로 고정되어 재시작 감지 기준으로 쓰입니다.
가드레일 계측¶
하트비트 인벤토리에는 콘텐츠 안전 가드레일의 누적 활동 카운터를 담은 RouterInventory.guardrails 블록(continuum_protocol::GuardrailSummary)도 포함됩니다.
| 필드 | 의미 |
|---|---|
checks_total | 모든 스테이지에 걸친 누적 프로바이더 검사 횟수. |
blocks_total | 콘텐츠를 이유로 차단한 프로바이더 검사의 누적 수(by_category의 합과 일치, 모니터 모드 매치 포함). 인프라 오류로 인한 차단은 제외됩니다. |
transforms_total | 거부 대신 내용을 수정한 스테이지 판정의 누적 수(verdicts에서 유도). |
flags_total | 차단 없이 기록만 남긴 스테이지 판정의 누적 수(verdicts에서 유도). |
errors_total | 타임아웃, 전송 실패, 비정상 상태 코드, 파싱 실패로 끝난 검사의 누적 수(fail_open_total + fail_closed_total). |
fail_open_total | 검사 실패 시 그대로 통과시킨 횟수(모니터 모드에서 발생한 모든 검사 실패 포함). |
fail_closed_total | enforce 모드에서 on_error: fail_closed로 인해 검사 실패 시 요청을 거부한 횟수. |
stream_buffer_cap_trips_total | 4 MiB 게이트 버퍼 상한에 도달한 스트림의 누적 수(stream_buffer_cap_trips에서 유도). |
by_category | 12개 고정 GuardrailCategoryId 리터럴(deny_list 및 미분류용 other 포함)을 키로 하는 차단 내역 객체(src/control_plane/guardrail/summary_bounds.rs). |
verdicts / stream_buffer_cap_trips | (stage, mode, result)별 판정 내역과 (strategy, outcome)별 버퍼 상한 도달 내역(부가 필드). |
counters_since_ms | 누적 카운터의 기준이 되는 프로세스 시작 시각(process_start_info()). |
- 독립 인프로세스 트래커 (
src/control_plane/guardrail.rs):metrics기능이 비활성화된 빌드에서도 정확한 값을 보고할 수 있도록 Prometheus 레지스트리를 읽지 않고 인프로세스 원자적 배열에서 직접 집계합니다. 가드레일 서비스가 활성화된 라우터는 검사 횟수가 0이어도 블록을 보고하고, 가드레일을 사용하지 않는 라우터는 블록 자체를 생략합니다. - 프라이버시 및 픽스처: 프롬프트, 응답 본문, 매치된 문자열, 마스킹된 PII, 프로바이더 오류 문구는 절대 포함되지 않습니다. 와이어 픽스처(
crates/continuum-protocol/tests/fixtures/guardrail_summary.json)는 벤더링된 허브 리비전과 바이트 단위로 동일하게 유지됩니다.
정책 동기화와 로컬 강제 적용¶
control_plane.policy.enabled: true가 설정되면 에이전트는 허브 정책 스냅샷(PolicyEnvelope)을 동기화하고, API 키 인증 직후 모든 API 요청에 대해 로컬에서 정책을 강제 적용합니다.
- 전달 경로 및 디코드 상한:
- 폴링 (
POST /api/agent/v1/policy/sync):policy.poll_interval_secs(기본 5초) 주기로 전체 엔벨로프를 가져옵니다. - 스트림 (
GET /api/agent/v1/policy/stream?cursor=<opaque>): WebSocket을 통해 정책 변경을 실시간(5초 이내) 수신합니다(policy.stream_enabled: false로 비활성화 가능). - 크기 상한 (
MAX_POLICY_SYNC_RESPONSE_BYTES, 약 152.6 MiB): 두 경로 모두 상한을 초과하는 엔벨로프를HubError::ResponseTooLarge로 거부하고control_plane_policy_sync_failure_total{reason="envelope_too_large"}및GET /admin/control-plane/status의policy_sync.oversize_refusals에 기록하며 기존 유효 스냅샷(last-known-good)을 유지합니다.
- 폴링 (
강제 적용 항목 (제시된 API 키의 상수 시간 SHA-256 해시가 동기화된 KeyEntry와 일치할 때):
- 해지 (
revoked): 즉시 거부합니다(키 해지, 만료, 조직 정지 포함). - 속도 제한:
- 티어별
rpm(60초 고정 윈도) 초과 시429로 거부합니다.input_tpm과output_tpm은 응답 완료 후 실측 토큰(캐시 읽기 제외)을 누적하며 한도를 넘긴 직후 요청부터 거부합니다. - 기본적으로 라우터마다 따로 셉니다.
control_plane.policy.minute_limits.redis를 설정하면 세 윈도를 Redis 또는 Valkey 저장소 하나에 두고 스크립트 하나로 확인하고 차감합니다. 저장소 호출이 실패하거나 늦으면 로컬 윈도로 폴백합니다(control_plane_minute_limits_fallback_total{reason}). control_plane.policy.minute_limits.reserve_tokens를 켜면 과금 대상 요청마다 예상 토큰을 응답이 끝날 때까지 예약하고, 사용량과 예약량의 합이 한도보다 작을 때만 받습니다. 허브 티어 분당 한도를 참고하십시오.
- 티어별
- 월간 예산 (
tokens_per_month,requests_per_month): 허브의KeyUsageSnapshot누적치에 스냅샷 이후 로컬 증분을 더해 강제합니다. - 모델 허용 목록 (
model_allowlist): 목록에 없는 모델 요청을403 model_not_allowed로 거부합니다(미설정 시 전체 허용). 라우터가 어떤 이름으로도 알지 못하는 모델(사용자 라우팅 가능한 백엔드, 모델 실험, 별칭, 가상 모델, 폴백 체인, decisions 대상 모두 없음)은404 model_not_found로 거부하며, 이는 tier가 없을 때 라우터가 반환하는 응답과 같습니다. - 요청 파라미터 제한 (
request_param_limits_v1):temperature,top_p,max_tokens,presence_penalty,frequency_penalty,top_k,min_p에 대해 허브 제한과 로컬request_params제한의 교집합을 적용합니다. - 요청 우선순위 (
request_priority_v1, vLLM 스케줄링): 티어의request_priority([-100, 100], 작을수록 높은 우선순위)를 하한P로 삼아max(client_value, P)로 클램핑합니다. 아웃바운드type: vllm홉에서body["priority"]와X-Vllm-Priority헤더를 설정하고 응답에x-continuum-request-priority를 부여합니다(--scheduling-policy priority로 실행 중인 vLLM에서 유효). - Anthropic 사용자 프로필 (
anthropic_user_profile_v1):KeyEntry는anthropic_user_profile_id와anthropic_user_profile_passthrough를 가질 수 있습니다. Hub는anthropic_user_profile_v1을 광고한 라우터에만 두 필드를 보내며,PolicyStore::capabilities는 이 값을 항상 광고합니다.blocking모드에서는hub_auth_context가 두 값을 Hub 키의ApiKey에 복사합니다. Hub 키에AuthContext가 붙지 않는permissive모드에서는 정책 적용 미들웨어가 허용된 키에HubKeyUserProfile확장을 붙입니다.- 정책 적용 이후에 실행되는
resolve_user_profile(src/http/middleware/user_profile_attribution.rs)은 로컬 키와 같은 우선순위를 적용합니다. 패스스루 키의 올바른 단일 클라이언트 헤더, 키의 프로필, 백엔드의 정적 프로필 순입니다. 로컬AuthContext가 확장보다 우선하며, 형식이 잘못된 Hub 프로필 ID는 전송하지 않습니다. Anthropic 최종 고객별 귀속을 참고하세요. - 영속화된 키 테이블은 두 필드를 저장합니다. 기본값일 때는 생략하므로
KEY_TABLE_FORMAT_VERSION은1로 유지됩니다. 두 필드는 보안 지문에 포함되므로, 값을 설정하거나 지우면 쓰기 제한 없이 파일을 다시 씁니다.
- 프롬프트 로그 동의 (
prompt_logging_consent_v1):KeyEntry는 Hub가 키와 팀 값을 합성한 실효값prompt_logging_consent(allowed또는denied)를 가질 수 있습니다(continuum-hub#1975).PolicyStore::capabilities는 이 문자열을 항상 광고합니다. capability가 게이트하는 대상은 전달입니다. enum이 닫혀 있어 라우터는 모르는 값이 든 envelope를 거부합니다.blocking모드에서는hub_auth_context가 이 값을 Hub 키의ApiKey에 복사합니다.permissive모드에서는 정책 적용 미들웨어가 값을 명시한 허용 키에HubKeyPromptLoggingConsent확장을 붙입니다.resolve_key_prompt_logging_consent(src/services/prompt_log_consent.rs)는 두 값을 모두 읽습니다. 인증과 정책 적용 사이에 동기화가 동의를 철회할 수 있으므로 어느 쪽이든denied면denied가 우선합니다. 그 밖의 경우ApiKey값을 먼저 적용하고 그다음 확장을 적용하므로, 값이 없는 로컬 키와 토큰이 겹쳐도 Hub 값이 적용됩니다. 로컬에 설정한 키는api_keys[].prompt_logging_consent로ApiKey값을 지정하므로(#2819), Hub 없는 배포에도 같은 resolver가 적용됩니다.- Hub 정책 저장소가 있지만 정책이 없는 동안(
has_policy()가 false)에는 미들웨어가 키로 인증한 요청에PolicyUnavailable을 표시하고, resolver는 이 요청에denied를 반환하므로default_consent와 상관없이 캡처하지 않습니다. 요청 처리는 그대로 fail-open이고, 키 없는 요청은default_consent를 따릅니다. - Hub 정책 저장소가 없으면 동적 auth 미들웨어가 인증되지 않은 모든 제시 토큰에
Stated(Denied)를 표시하므로, 알 수 없는 토큰과 삭제·교체·비활성·만료된 토큰은default_consent와 상관없이 캡처되지 않습니다(#2879). 정책 저장소가 붙어 있으면 enforcement가 모르는 토큰을 이미 거부하므로, 미들웨어는 비활성 로컬 키의denied(inactive_key_states_denied)만 표시합니다. 이 자리에서 무조건denied를 넣으면 유효한 허브 키의allowed를 덮게 됩니다. prompt_logging_capture_basis는 캡처 경로(src/services/prompt_log_capture/, #2320)가 호출하는 게이트입니다. 라우터 소유 타입만 받으므로 모든 기능 조합에서 컴파일됩니다. chat completions, Responses, Anthropic Messages 인그레스가 enforcement 미들웨어 뒤, 본문을 복사하기 전에 이 게이트를 평가하고, 통과한 요청의 핸들은 task-local 스코프를 타고 buffered 분기와 스트림 종료 usage 싱크에 닿아 전용 쓰기 스레드로 마스킹된 레코드 하나를 남깁니다.src/services/prompt_log_export/(#2328)는 그 파일을 전달합니다.prompt_logging.export.sinks항목마다 백그라운드 태스크 하나가 섹션이 켜져 있을 때만serve()에서 시작되어, 저장된 커서로 파일을 읽고 레코드를 다시 마스킹한 뒤 크기가 제한된 배치를 최소 한 번 보장으로 전달합니다. 요청 경로는 이 태스크에 관여하지 않습니다.- 영속화된 키 테이블은 이 값을 저장합니다. 값이 없으면 생략하므로
KEY_TABLE_FORMAT_VERSION은1로 유지됩니다. 보안 지문에도 포함되므로 동의를 철회하면 쓰기 제한 없이 파일을 다시 쓰고, 재시작해도 이전 값으로 복원되지 않습니다.
- 응답 헤더:
x-continuum-ratelimit-limit/-remaining/-reset및 차원별 헤더를 부착하며,429응답에는retry-after를 포함합니다. - 거절 응답 본문 형식: 정책 거절(폐기된 키, 모델 허용 목록, 티어 및 예산 한도, 비용 센터 한도,
unmatched_key: reject)은 OpenAI 경로에서 OpenAI 오류 형식을,/anthropic/*경로에서 Anthropic 오류 형식({"type":"error","error":{"type":"rate_limit_error","message":...}})을 사용합니다. 상태 코드와 헤더는 경로와 관계없이 같습니다.
거버넌스 정책 적용 및 확인 응답 (guardrail_policy_v1, smart_routing_policy_v1):
- 실행 확인 기반 보고 (
src/control_plane/policy_store/acknowledgment.rs): 엔벨로프 수신만으로는active를 보고하지 않으며, 각 재조정기(reconciler)가 실제 서비스에 정책을 합성·적용한 뒤에야 해당 다이제스트를AppliedPolicyRevision에 보고합니다. 실행할 수 없는 정책(누락·비활성 백엔드, 로컬 섹션 비활성화 등)은 로컬 설정으로만 동작하면서RejectedPolicyRevision에 타입화된 거부 코드(guardrails_unavailable,invalid_guardrail_policy,smart_routing_unavailable,invalid_smart_routing_policy,digest_mismatch)를 보고합니다. - 프로바이더 콘텐츠 및 스마트 라우팅 합성:
GuardrailPolicy.provider_content(guardrail_provider_content_v1)는 커스텀 분류기 프롬프트, PII 동작, 정규식 허용/거부 패턴(RegexSet)을 로컬 설정과 합성합니다.PolicyEnvelope.smart_routing은 로컬smart_routing:섹션과 합성되어src/services/smart_routing/runtime.rs를 통해 무잠금(AtomicBool)으로 적용됩니다. - 안정 백엔드 식별자 (
stable_backend_identity_v1): 운영자가 부여한backends[].backend_id를 서빙 시점의 설정 뷰 기준으로 확정해 인벤토리와UsageEvent에 일관되게 기록합니다.
| 도출된 허브 상태 | 라우터 보고 기준 |
|---|---|
unsupported | RouterPolicyStatus.capabilities에 해당 기능 문자열이 없음. |
pending | 기능은 광고되었으나 선언된 다이제스트의 실행 확인이 아직 도착하지 않음. |
active | AppliedPolicyRevision의 다이제스트가 허브 선언(또는 정책 해제 다이제스트)과 일치. |
stale | 확인된 다이제스트가 허브의 최신 선언보다 이전 버전을 가리킴. |
error | RejectedPolicyRevision이 해당 다이제스트와 타입화된 거부 코드를 보고함. |
코스트 센터 제한 (cost_center_limits_v2):
PolicyEnvelope.cost_centers_v2는 라우터별 할당 명세(key_id, 라우터 폴백, 프로바이더/모델 폴백, 백엔드 폴백, 월간 상한)와 동적 예산 스냅샷(included_router_usage_seq워터마크 포함)을 전달합니다. 소유자 전용 사이드카(<state_file>.cost-center.json,0600)가 등록 신원에 바인딩되어 있을 때만cost_center_limits_v2를 광고합니다(레거시 V1은 파싱만 하고 강제하지 않음).- 원자적 승인 예약 및 시퀀스 정산 (
src/control_plane/cost_center/): 키 및 라우터 할당 계층은 디스패치 전 승인 미들웨어에서 원자적 예약(요청 1건 + 결정적 토큰 추정치)을 잡아 동시 한계 경합을 방지합니다. 실제 정산은 사용량 루프에서 영속 단조 시퀀스인router_usage_seq를 부여할 때 수행되며, 새 스냅샷 도착 시 워터마크 이하의 로컬 원장 항목만 정확히 소거합니다. 판매 지출(sell spend)은 허브가 계산한sell_spend_status: exhausted판정으로만 강제합니다.
모델별 예산 폴백 (model_budget_fallback_v1):
PolicyEnvelope.model_budgets(src/control_plane/model_budget.rs)는 정확한(provider, model)오퍼링을 키별 월간 풀에 할당하고 소진 시 순서가 지정된 대체 모델 목록을 제공합니다./v1/chat/completions및/v1/completions에서 소스 모델 풀이 소진되면 티어 허용 목록, 백엔드 제한, 헬스 및 서킷 브레이커를 모두 통과하는 첫 번째 대체 모델을 선택해 요청을 재작성하고 응답에x-fallback-used: true,x-fallback-model,x-fallback-reason: budget_exhausted헤더를 추가합니다(x-continuum-budget-fallback: off헤더로 옵트아웃 시429 insufficient_quota반환).
요청 경로 간 동등성, Fail-open 불변식, 복원 키 테이블, 사용자별 검색 자격 증명:
- 경로 간 동등성:
/v1/chat/completions뿐 아니라/anthropic/v1/messages,/v1/responses, 브리지 스트림 모두 동일한 토큰 계측(캐시 읽기 제외)과 허브key_id귀속 경로를 공유합니다. - Fail-open 및 미매칭 키 처리: 첫 정책 동기화 전(복원된 키 테이블도 없는 콜드 스타트)에는 로컬 인증과 제한만 적용됩니다. 정책이 한 번 적용된 뒤에는 알 수 없는 키를 기본적으로
401로 거부하며(control_plane.policy.unmatched_key: reject),fail_open으로 설정한 경우에만 로컬 인증으로 넘깁니다. - 복원된 키 테이블 (
control_plane.policy.persist_key_table: true): 적용된 키 해시와 티어 제한을<state_file>.policy-keys.json(0600)에 안전하게 투영합니다(평문 키나 자격 증명 제외, 해지·티어 변경·제한 강화, 또는 키의 Anthropic 사용자 프로필 쌍(anthropic_user_profile_id,anthropic_user_profile_passthrough)이나prompt_logging_consent변경 시 이전 파일 선삭제). 재시작 시 허브에 연결되지 않더라도key_table_max_age_secs(기본 24시간) 이내의 사이드카를 복원해 허브 발급 키 인증과 티어 제한을 즉시 유지합니다.consent_confirmed는 허브가 하트비트를 승인한 뒤에 기록된 테이블인지 나타내며, 이때부터prompt_logging_consent값을 신뢰할 수 있습니다. 확인된 사이드카는 허브 장애 중에도 명시된 동의를 계속 적용합니다. 이 필드가 없는 사이드카는 미확인으로 읽히므로 확인된 엔벨로프가 처음 도착할 때까지 키로 인증된 요청은 캡처하지 않습니다. 이전 릴리스는consent_confirmed가 있는 사이드카를 거부하고 콜드 스타트하므로, 롤백하면 복원된 테이블을 잘못 해석하지 않고 버립니다. - 사용자별 웹 검색 자격 증명 (
search_credentials_v1):web_search가 활성화된 동안 엔벨로프의search_credentials(user_id기준 색인)를 메모리에만 보관하며, 배포 키보다 소유자의 검색 프로바이더·키를 우선 사용하고 사용량 레코드에web_search_queries,web_search_provider(serper,brave,exa),web_search_credential_source를 기록합니다. - 적용된 정책 조회:
GET /admin/control-plane/policy및 WebUI에서 시크릿과 키 해시를 제외한 현재 적용 정책과 예산 풀 상태를 조회할 수 있습니다.
거버넌스 호환성 매트릭스¶
라우터 버전별 거버넌스 기능(guardrail_policy_v1, guardrail_provider_content_v1, guardrail_provider_rows_v1, smart_routing_policy_v1) 지원 동작은 다음과 같습니다. 모든 기능을 완전히 실행하는 첫 버전은 v1.32.0입니다.
| 라우터 버전 | guardrail_policy_v1 | guardrail_provider_content_v1 / guardrail_provider_rows_v1 | 가드레일 선언 안의 provider_content / provider_rows | smart_routing_policy_v1 |
|---|---|---|---|---|
| v1.16.x 이하 | 광고하지 않음 (unsupported) | 광고하지 않음 | 무시됨 | 광고하지 않음 (unsupported) |
| v1.17.0 ~ v1.31.x | 가드레일 재조정기 연결 시 광고 | 광고하지 않음 | 기능 게이팅을 수행하는 허브는 해당 멤버를 제외한 축소 선언과 다이제스트를 전달해 나머지 가드레일 정책을 정상 적용하고 제외 멤버만 unsupported로 표시함(게이팅 없이 전송하면 digest_mismatch로 거부되고 이전 정책 유지) | 광고하지 않음 (unsupported) |
| v1.32.0 이상 | 가드레일 재조정기 연결 시 광고 | guardrail_policy_v1과 동일한 래치로 함께 광고 | guardrail_policy_digest 아래에서 합성 및 실행됨(실행 불가 시 guardrails_unavailable, guardrail_backend_missing, guardrail_backend_disabled, guardrail_providers_unavailable, invalid_guardrail_policy 보고) | 스마트 라우팅 재조정기 연결 시(govern_smart_routing) 광고. 거부 시 invalid_smart_routing_policy, smart_routing_unavailable, digest_mismatch 보고 |
설정 관리자 없이 빌드·실행된 라우터(임베디드 빌드 또는 핫 리로드 비활성화)는 재조정기를 연결하지 않으므로 모든 거버넌스 기능이 unsupported로 표시됩니다. 재시작 시에는 허브의 다음 전체 스냅샷이 도착할 때까지 로컬 설정으로만 동작합니다.
허브가 전달하는 프로바이더 자격 증명¶
정책 엔벨로프의 선택적 provider_credentials 필드는 조직 범위의 {provider, credential_id, version, secret} 집합을 3상태(필드 생략 시 유지, 빈 배열 시 전체 해지, 비어 있지 않은 배열 시 원자적 교체)로 전달합니다.
backend_type기반 선택 (proxy::oauth_helper::effective_backend_secret):base_url이 아니라 백엔드의backend_type(openai,anthropic,gemini,bedrock등)을 기준으로 프로바이더를 매핑합니다. 해당 백엔드에 로컬api_key가 설정되어 있고 허브가 해당 프로바이더의 자격 증명을 전달한 경우, 아웃바운드 인증 헤더의 시크릿 값만 허브 자격 증명으로 치환합니다(제3자 프록시 엔드포인트라도backend_type: openai와 로컬api_key가 설정되어 있으면 치환 대상이 되므로 주의가 필요합니다).- 적용 범위 및 메모리 보호: OpenAI 채팅, 네이티브 Anthropic, 네이티브 Gemini, Responses API, 배치 디스패치에 적용되며, 모델 디스커버리와 헬스 체크는 로컬 키를 유지합니다. 시크릿은 메모리에만 보관되며 디스크, 로그,
Debug/Display출력에 노출되지 않습니다.
백엔드 범위 자격 증명 (backend_credentials_v1)¶
PolicyEnvelope.backend_credentials는 프로바이더가 아니라 credential_id로 색인되는 백엔드 범위 자격 증명 집합을 전달하여, 같은 프로바이더를 쓰는 여러 허브 소유 백엔드가 서로 다른 시크릿을 사용할 수 있게 합니다:
- 다이제스트 승인: 라우터는 시크릿 바이트를 제외한
(credential_id, provider, version)메타데이터로backend_credential_digest를 계산해AppliedPolicyRevision.backend_credential_digest로 회신합니다. - 유효 인증 우선순위:
credential_ref가 지정된 허브 소유 백엔드는 로컬api_key유무와 무관하게 해당 백엔드 범위 자격 증명만 사용합니다. 참조된 자격 증명을 해석할 수 없으면(미전달, 해지, 프로바이더 불일치, 재시작 후 미복원) 조직 범위나 로컬 키로 폴백하지 않고 해당 백엔드를 디스패치 대상에서 제외합니다. 로컬 백엔드와 불변(immutable) 백엔드는 기존의 조직 범위 동작을 그대로 따릅니다.
최적화 정책과 프롬프트 캐시 실행¶
허브는 정책 엔벨로프의 optimization 블록(exact_cache, prefix_cache, semantic_cache, semantic_similarity_bps, cache_ttl_secs)과 티어별 cache_enabled·batch_eligible 플래그를 통해 캐시 및 배치 정책을 배포하고, 라우터가 데이터 플레인에서 이를 실행합니다.
- 유효 결정 및 오버라이드 헤더: 강제 적용 미들웨어는 티어 플래그, 조직 정책, 클라이언트의
x-continuum-cache: on|off및x-continuum-batch: on|off헤더를 결합해 유효 결정을 계산합니다(클라이언트 헤더는 티어가 허용한 범위를 좁힐 수만 있고 넓힐 수는 없음). 응답에는x-continuum-cache: hit|miss|bypass와x-continuum-batch: on|off헤더가 부착됩니다. - 정확 일치 및 프리픽스 캐시 (
ResponseCacheStore): 단일 허용 백엔드이고 폴백 체인이 없을 때 동작합니다.prefix_cache는 정확 일치 캐시 미스 후 별도의pfx:네임스페이스를 조회하며, 정상 완료된 응답(stop/end_turn)만 저장하고 저장된 완성 길이가 새 요청의 토큰 한도 안에 들어갈 때만 서빙합니다(CacheHitType::Prefix). - 시맨틱 캐시 (
CacheHitType::Semantic): 정확 일치와 프리픽스 캐시가 모두 미스한 결정적 요청에 대해response_cache.semantic.enabled,control_plane.optimization.semantic_cache_enabled, 허브 정책의semantic_cache가 모두 켜져 있으면 마지막 사용자 메시지의 임베딩 코사인 유사도를 비교해sem:네임스페이스에서 서빙합니다(KV 캐시 아키텍처 참고). - 캐시 격리 및 가드레일 범위 (
proxy::cache_identity::ResponseCacheCaller): 모든 캐시 키는 로컬 키 ID와 허브key_id를 결합해 테넌트·키 간 격리를 보장하며, 가드레일 서비스가 활성화된 경우 현재 합성 가드레일 정책 다이제스트와 우회 여부를 키 범위에 포함합니다. 또한 응답 저장 시점에 가드레일 에포크(StorePermit)를 재확인하여 정책이 바뀌었거나 출력 가드레일이fail_open으로 통과시킨 응답은 캐시에 저장하지 않습니다. - 캐시 히트 계측: 로컬 캐시 적중 시
cache_hit = true,cache_hit_type(exact|prefix|semantic),input_tokens = 0,cached_input_tokens = 프롬프트 토큰,output_tokens = 0인 사용량 레코드를 정확히 한 번 방출하므로 TPM 윈도나 월간 예산을 소진하지 않습니다.
배치 디스패치와 라이프사이클 보고¶
control_plane.batch.enabled: true와 policy.enabled: true가 모두 켜져 있으면 라우터는 OpenAI 호환 Batch API 엔드포인트(POST /v1/batches, GET /v1/batches/{id}, GET /v1/batches, POST /v1/batches/{id}/cancel, GET /v1/batches/{id}/results)를 노출합니다.
- 디스패치 및 소유권 격리: 티어의
batch_eligible이 활성화된 키만 배치를 제출할 수 있습니다. 여러 라우터 키가 하나의 업스트림 프로바이더 계정을 공유하므로, 인메모리 트래커가 각 배치 작업을 제출한 키에 귀속시키고 다른 키의 조회·취소·결과 요청에는 프로바이더 호출 없이404를 반환합니다. 네이티브 Anthropic 백엔드는 Anthropic Message Batches API(/v1/messages/batches)로 자동 변환되어 동일한BatchJobStatus라이프사이클을 따릅니다. - 전용 배치 풀: 동기식 RPM/TPM 한도와 분리된 티어별
batch_rpm및batch_queue_depth(동시 진행 작업 수, 프로세스 전체 상한 100,000개)로만 제한됩니다.- 압력 승인은 라우팅 윈도 지연과 최종 백엔드 확인이 끝난 뒤 실행되며, 선택적인 코스트센터 모델 검사보다 먼저 실행됩니다. 전역 예약과 키별 큐 슬롯이 동시 검사를 제한합니다.
batch_rpm은 이 디스패치 단계에 들어온 시도를 집계하므로, 이후 검사나 코스트센터 확인 또는 프로바이더 호출이 실패해도 차감 상태를 유지합니다. 조기 반환이나 요청 취소가 발생하면 큐 슬롯과 전역 예약을 해제하며, 작업 등록에 성공하면 등록된 작업이 해당 슬롯을 인계받습니다.
- 압력 승인은 라우팅 윈도 지연과 최종 백엔드 확인이 끝난 뒤 실행되며, 선택적인 코스트센터 모델 검사보다 먼저 실행됩니다. 전역 예약과 키별 큐 슬롯이 동시 검사를 제한합니다.
- 프로바이더 디스패치 시 코스트센터 승인:
- 프로바이더 생성 호출 직전에 키, 라우터, 안정 백엔드, 프로바이더/모델 순서로 Hub V2 할당을 평가합니다. 프로바이더/모델 소유권이 처음으로 확인 가능한 계층일 때만 OpenAI 입력 파일을 검사합니다. 요청 수, 토큰 수, 또는 Hub가 판정한 판매 지출 차원이 소진되면, 프로바이더 생성 호출 전에
429 insufficient_quota를 반환합니다. 기존 배치의 조회와 취소는 계속 사용할 수 있습니다. - 이 검사는 현재 소진 상태만 확인하며, 사용량을 예약하거나 요청 크기가 한도 안에 들어오는지 추정하지 않습니다. 완료된 배치 사용량만 시퀀스를 부여해 정산하며, 판매 지출은 로컬에서 추정하지 않습니다. 코스트센터가 소진되기 전에 승인된 배치는 나중에 완료되면서 한도를 초과할 수 있습니다.
batch_rpm과batch_queue_depth는 제출량을 제한하지만, 비동기 예산 초과량의 절대 상한을 보장하지 않습니다. - 입력 파일에서 모델 하나만 확인되면, 승인 검사와 완료 사용량 레코드에 같은 모델을 사용합니다. 여러 모델이 있거나 모델이 비어 있으면, 집계된 완료 사용량을 특정 모델에 임의로 귀속하지 않도록
batch자리표시자를 유지합니다. 키, 라우터, 안정 백엔드 할당은 계속 적용되지만, Hub가 자리표시자를 직접 할당하지 않는 한 개별 프로바이더/모델 할당만으로는 혼합 모델 배치를 거부할 수 없습니다.
- 프로바이더 생성 호출 직전에 키, 라우터, 안정 백엔드, 프로바이더/모델 순서로 Hub V2 할당을 평가합니다. 프로바이더/모델 소유권이 처음으로 확인 가능한 계층일 때만 OpenAI 입력 파일을 검사합니다. 요청 수, 토큰 수, 또는 Hub가 판정한 판매 지출 차원이 소진되면, 프로바이더 생성 호출 전에
- 라이프사이클 및 완료 사용량 보고: 폴러가 상태 전이(
submitted->in_progress->completed/failed/cancelled/expired)를 감지해POST /api/agent/v1/batches로 메타데이터를 보고합니다(26시간 초과 미종료 작업은 강제expired처리하여 슬롯 회수).completed전이 시에는 출력 파일(최대 64 MiB)에서 토큰 합계를 집계해batched = true,provider_batch_id가 포함된 완료 사용량 레코드를 방출합니다.
캐시 히트 타입 계측¶
로컬 응답 캐시에서 서빙된 요청은 사용량 레코드의 cache_hit_type 필드에 캐시 모드(exact, prefix, semantic)를 기록합니다. 프로바이더 측 프롬프트 캐시 읽기(cached_input_tokens로 집계)나 배치 완료 레코드에서는 None으로 유지됩니다.
합성 프로바이더/모델 프로브¶
control-plane 기능, control_plane.enabled, control_plane.policy.enabled가 모두 켜져 있고 허브가 활성화된 PolicyEnvelope.probes 정책을 전달하면, 라우터는 주기적으로 합성 프로브를 실행해 프로바이더 도달성과 자격 증명·모델 상태를 사전 점검합니다(src/control_plane/probes/).
- 스케줄링 및 실행:
probes필드는 3상태 규약으로 갱신되며 라운드는 절대 겹치지 않습니다. 각(provider, model)대상에 대해Backend::execute_probe_chat_completion내부 경로로 고정된 짧은 비스트리밍PROBE_PROMPT요청을 1회 전송합니다(모델 대체, 폴백, 캐시, 가드레일, 자동 재시도 제외). - 프라이버시 및 과금 분리: 프로브 결과는 상태 분류(
ok,auth_failed,rate_limited,timeout,provider_error,network_error,unknown), 정제된 사유 코드, 지연 시간, 토큰 수만 담아POST /api/agent/v1/probes로 보고하며, 일반 사용량 이벤트나 키별 예산을 차감하지 않습니다. - 라우터별 월간 예산 상한 (
monthly_budget_microusd): 유료 프로브 디스패치 전에<state_file>옆의 소유자 전용 사이드카에 최대 예상 비용을 먼저 예약합니다(0은 무제한). 가격 정보나 사이드카를 사용할 수 없으면 안전하게 프로브를 중단(fail-closed)하며, 예산 소진 시budget_exhausted를 보고하고 다음 UTC 월에 초기화됩니다.
벤더링된 프로토콜 크레이트¶
컨트롤 플레인 와이어 계약은 비공개 continuum-hub 저장소에 대한 git 의존성 없이 오프라인·기본 빌드가 가능하도록 crates/continuum-protocol/에 벤더링되어 있으며, control-plane 기능이 켜졌을 때만 컴파일됩니다.
- 무결성 검증:
crates/continuum-protocol/src와tests는Cargo.toml출처 줄에 명시된 허브 리비전의 바이트 단위 복사본입니다.scripts/check-vendored-protocol.sh가UPSTREAM.sha256및LOCAL-AHEAD와 대조해 임의 수정을 차단하며, 갱신 시에는scripts/refresh-vendored-protocol-manifest.sh를 사용합니다(개발 가이드 참고). 라우터 전용 보조 로직은 항상src/control_plane/아래에 배치합니다. - 구현:
src/control_plane/, 설정 스키마src/core/config/control_plane.rs.
실시간 WebSocket 프록시¶
GET /v1/realtime?model=<id>(및 별칭 /realtime)는 NVIDIA NemotronLabs VoiceChat 등 OpenAI Realtime 호환 양방향 음성 WebSocket 백엔드를 프로토콜 변환 없이 바이트 단위로 중계합니다(realtime Cargo 기능 및 realtime.enabled: true 필요).
세션 라이프사이클¶
- 업그레이드 전 검사: 브라우저 요청(
Origin헤더 존재)에 대해 동일 출처 또는server.cors.allow_origins여부를 검사하고(불일치 시403), 필수model쿼리 파라미터의audio능력 보유 여부(400/404), 후보 백엔드 및 서킷 브레이커 상태(select_admissible_backend, 불가 시503), 프로세스 세션 세마포어(realtime.max_sessions, 초과 시503)를 확인합니다. - 백엔드 다이얼: 백엔드 URL 스킴을
ws/wss로 변환하고realtime.backend_path와 정규화된model쿼리를 붙여 백엔드 자체 자격 증명으로handshake_timeout내에 연결합니다(클라이언트Authorization헤더 미전달). 연결 실패는 서킷 브레이커 실패로 기록하고 클라이언트를 close 코드1011로 닫습니다. - 양방향 중계 및 계측: 양방향 4 MiB 프레임 상한, 유휴 타이머, 선택적 세션 최대 시간(종료 코드
1000)을 적용합니다. 음성 페이로드와Sec-WebSocket-Protocol·proxy-authorization헤더는 로그에서 마스킹됩니다. 백엔드가 보낸 텍스트 프레임 중response.done(256 KiB 이하)은src/proxy/realtime_usage.rs가 파싱해 시도 단위 사용량 레코드(stream: true,http_status: 101)와 Prometheus 메트릭(realtime_active_sessions,realtime_sessions_total,realtime_frames_relayed_total,realtime_usage_events_total)으로 기록합니다.
구현: src/proxy/realtime.rs, src/server/routes.rs, src/core/config/models/realtime.rs, src/metrics/realtime.rs (실시간 설정 참고).
Dynamo 워커 탐색¶
dynamo Cargo 기능(공식 릴리스 바이너리에 포함)과 dynamo.enabled: true를 켜면, 라우터는 NVIDIA Dynamo 워커들이 등록하는 etcd 클러스터를 읽기 전용으로 관찰해 인메모리 워커·모델 레지스트리를 유지합니다.
- 읽기 전용 get-then-watch (
src/dynamo/): 라우터는 etcd에 키를 쓰거나 리스를 잡지 않으며,v1/instances/<ns>/(엔드포인트 레코드)와v1/mdc/<ns>/(모델 카드) 두 프리픽스를 range get으로 읽은 뒤 다음 리비전부터 watch를 이어 갑니다. 연결이 끊기면 백오프 후 전체 재동기화를 수행하며, 설정 변경(endpoints,tls,auth,namespace) 시 무중단으로 watcher를 재시작합니다. - ZMQ 부하 신호 구독 (
src/dynamo/load/):v1/event_channels/namespace/<ns>/에 등록된 워커별 ZMQ 엔드포인트를 구독해kv_metricsActiveLoad샘플(msgpack 4프레임 메시지)을 수집합니다.dynamo.load.max_staleness(기본 3초) 이내의 신선한 샘플을 유지하며, 샘플이 없거나 오래된 워커는 제외하지 않고 라운드 로빈으로 폴백합니다(Dynamo 워커 탐색 참고).
이 아키텍처는 높은 처리량과 확장성, 명확한 관심사 분리를 바탕으로 프로덕션 환경에서 수천 개의 동시 LLM 요청을 안정적으로 라우팅하고 제어할 수 있는 기반을 제공합니다.