아키텍처 가이드¶
이 문서는 Continuum Router의 아키텍처, 설계 결정, 확장 지점을 다룹니다.
목차¶
- 개요
- 4계층 아키텍처
- 라이브러리 진입점 (src/server/)
- 핵심 컴포넌트
- 데이터 흐름
- 의존성 주입
- 오류 처리 전략
- 확장 지점
- 설계 결정
- 성능 고려 사항
- 속도 제한 → 설정
- 모델 폴백 시스템 → 오류 처리
- 서킷 브레이커 → 오류 처리
- 파일 저장소 → 아키텍처 상세
- Agent Communication Protocol (ACP)
- 백엔드 패스스루 계약
개요¶
Continuum Router는 깔끔한 4계층 아키텍처를 사용하여 명확한 관심사 분리, 테스트 가능성, 유지보수성을 제공하는 고성능, 프로덕션 레디 LLM API 라우터로 설계되었습니다. 이 아키텍처는 도메인 주도 설계 원칙과 의존성 역전을 따라 견고하고 확장 가능한 시스템을 만듭니다.
아키텍처 목표¶
- 관심사 분리: 각 계층은 단일의 명확하게 정의된 책임을 가집니다
- 의존성 역전: 상위 계층은 구체적인 구현이 아닌 추상화에 의존합니다
- 테스트 가능성: 각 컴포넌트를 독립적으로 단위 테스트할 수 있습니다
- 확장성: 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있습니다
- 성능: 깔끔한 아키텍처를 유지하면서 최소한의 오버헤드
- 안정성: 다층 오류 처리를 갖춘 빠른 실패 설계
4계층 아키텍처¶
계층 설명¶
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 허용 목록 모드 지원- 민감한 작업 보호(설정 리로드, 서킷 브레이커 제어, 헬스 관리)
admin.auth로 설정. 스키마가 받는api_key방식은 표준 서버에서 미들웨어가 API 키 저장소 없이 생성되므로 동작하지 않음
-
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): 서비스 헬스 모니터링, 상태 추적 - Service Registry (
mod.rs): 서비스 라이프사이클 및 의존성 관리
주요 파일¶
src/services/
├── mod.rs # 서비스 레지스트리 및 관리
├── backend_service.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 백엔드용 직접 패스스루
│ ├── session.rs # 세션 관리
│ ├── stream_service.rs # 스트리밍 서비스 오케스트레이션
│ └── streaming.rs # 스트리밍 응답 처리
└── streaming/ # 스트리밍 유틸리티
├── mod.rs
├── parser.rs # 스트림 파싱 로직
└── transformer.rs # 스트림 변환 (OpenAI/Anthropic)
3. Infrastructure Layer (src/infrastructure/)¶
책임: 외부 시스템 및 기술적 기능의 구체적인 구현 제공
구성 요소¶
- Backends (
backends/): 특정 백엔드 구현 (OpenAI, Anthropic, Gemini, vLLM, Ollama, llama.cpp, LM Studio, Continuum Router) - Cache (
cache/): 캐싱 구현 (LRU, TTL 기반) - Configuration (
config/): 설정 로딩, 감시, 검증 - HTTP Client (
http_client.rs): HTTP 클라이언트 관리 및 최적화 - Auth (
auth/): OAuth 2.0 디바이스 인가 그랜트(RFC 8628) 서브시스템.AuthStrategyRegistry는 시작 시 채워지고 핫 리로드 중에 갱신되며, 모든 프록시 핫 패스 핸들러(chat completions, responses, image generation)가 이걸 조회해 outbound 요청에 서명하고401이 오면 강제 갱신을 트리거합니다. 공유 프록시 인증 헬퍼는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/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/): 재시도 정책 및 전략 - Container (
container.rs): 의존성 주입 컨테이너
주요 파일¶
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 링 스냅샷을 복제하므로, 한 후보 집합용으로 생성된 링을 다른 후보 집합에 적용하지 않으며 링을 순회하는 동안 캐시 잠금을 유지하지 않습니다.
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/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 사이), 백그라운드에서 갱신을 트리거하면서 즉시 오래된 데이터를 반환합니다. 클라이언트는 약간 오래된 데이터로 빠른 응답을 받습니다.
-
백그라운드 주기적 갱신: 백그라운드 태스크가 만료 전에 사전에 캐시를 갱신합니다. 요청은 콜드 스타트를 제외하고 캐시 갱신을 기다리지 않습니다.
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 형식의 표준화된 오류 응답
재시도 핸들러¶
위치: src/services/deduplication.rs
목적: 지터가 있는 지수 백오프 및 요청 중복 제거 구현
EnhancedRetryHandler는 코어 재시도 엔진과 DeduplicationManager를 조합합니다. 설정은 공개 문자열 기간 RetryConfig(max_attempts, initial_delay, max_delay, backoff_multiplier, jitter, 재시도 가능 상태/오류 목록, 전체 timeout)를 사용합니다. 재시도 상태 머신은 src/core/retry/strategy.rs에 있습니다.
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 프록시 경로는 브레이커 허용/결과 메서드를 호출하지 않으며 일반 서버 조립은 그 수집기를 등록하지 않습니다. 따라서 이 상태 머신은 일반 추론 트래픽에서 백엔드를 제외하지 않습니다. 해당 경로에서는 능동 상태 필터, 재시도, 폴백을 사용하세요.
컨테이너 (의존성 주입)¶
위치: src/core/container.rs
목적: 서비스 라이프사이클 및 의존성 관리
pub struct Container {
services: Arc<RwLock<HashMap<TypeId, Box<dyn Any + Send + Sync>>>>,
singletons: Arc<RwLock<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>>,
}
impl Container {
// 싱글톤 서비스 등록
pub async fn register_singleton<T>(&self, instance: Arc<T>) -> CoreResult<()>
where T: 'static + Send + Sync { /* ... */ }
// 서비스 의존성 해석
pub async fn resolve<T>(&self) -> CoreResult<Arc<T>>
where T: 'static + Send + Sync { /* ... */ }
}
데이터 흐름¶
요청 처리 흐름¶
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 응답
가드레일 레이어¶
가드레일이 설정되면(guardrails.enabled: true) 콘텐츠 안전 레이어가 모든 요청 경로(OpenAI chat completions, /v1/responses 브리지, 네이티브 Anthropic Messages)에서 백엔드 호출을 감쌉니다. 이 레이어는 서비스 레이어의 GuardrailService(src/services/guardrail/)로 구현되며 단일 게이팅 심(src/services/guardrail/gate.rs)을 통해 프록시 핸들러에서 구동되므로, 세 dispatch 경로가 동일한 정책과 동일한 차단 응답 형태를 공유합니다. 가드레일이 설정되지 않으면 핸들러는 이 심에 진입하지 않으므로 요청 흐름이 그대로 유지되고 오버헤드가 없습니다.
서비스는 AppState 안에서 시작 시점에 고정되는 Option이 아니라, 나중에도 채울 수 있는 락 프리 슬롯인 GuardrailServiceHandle(src/services/guardrail/handle.rs, arc-swap 기반) 뒤에 놓입니다. 요청 경로는 요청당 한 번의 원자적 로드로 핸들을 읽어 그 요청의 모든 게이트에서 재사용하며, 가드레일이 비활성 상태로 시작한 라우터도 이후에 서비스가 슬롯에 생성될 수 있습니다. 로컬 enabled: true 핫 리로드 시에는 가드레일 설정 감시자가, 집행 가능한 허브 정책 도착 시에는 컨트롤 플레인 가드레일 정책 reconciler가 생성을 담당합니다. 생성은 시작 시점과 동일한 팩토리 경로(src/server/state.rs의 guardrail_service_factory)를 거치며, backend: 프로바이더 참조용 백엔드 리졸버도 그대로 포함합니다. 라우터가 실행할 수 없는 집행을 요구하는 허브 정책은 타입 코드 guardrails_unavailable로 전체가 거부되어 컨트롤 플레인 정책 스토어에 기록되고, 요청 동작은 로컬 설정 그대로 유지됩니다.
이 레이어는 요청 라이프사이클의 세 지점에 훅합니다:
- 입력 pre-call: 백엔드 dispatch 전.
Block판정은 백엔드를 호출하지 않고 요청을 중단하며,Transform은 dispatch 전에 프롬프트를 다시 씁니다. - 입력 during-call:
tokio::join!을 통해 백엔드 호출과 동시 실행하므로 원격 분류기의 지연이 모델 지연과 겹칩니다. 차단이면 진행 중이던 응답을 폐기하고 차단을 대신 반환합니다. - 출력 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: 판정
par during-call이 dispatch와 겹침
Gate->>Backend: (변환된) 요청 전달
Gate->>Service: 입력 during-call
end
Backend-->>Gate: 응답
Gate->>Service: 출력 post-call
alt Block (enforce)
Service-->>Client: 차단 응답
else Allow or Transform
Service-->>Client: 응답 (정제될 수 있음)
end
end
운영자 대상 가이드(개념, 프로바이더 설정, 설정, 임계값 튜닝, 관리자 제어, 메트릭)는 가드레일을 참고하세요.
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
의존성 주입¶
서비스 등록¶
서비스는 애플리케이션 시작 시 컨테이너에 등록됩니다:
// main.rs에서
async fn setup_services(config: Config) -> Result<ServiceRegistry, Error> {
let container = Arc::new(Container::new());
// 인프라 서비스 등록
container.register_singleton(Arc::new(
HttpClient::new(&config.http_client)?
)).await?;
container.register_singleton(Arc::new(
BackendManager::new(&config.backends)?
)).await?;
// 핵심 서비스 등록
container.register_singleton(Arc::new(
BackendServiceImpl::new(container.clone())
)).await?;
container.register_singleton(Arc::new(
ModelServiceImpl::new(container.clone())
)).await?;
// 서비스 레지스트리 생성
let registry = ServiceRegistry::new(container);
registry.initialize().await?;
Ok(registry)
}
서비스 의존성¶
서비스는 생성자 주입을 통해 의존성을 선언합니다:
pub struct ProxyServiceImpl {
backend_service: Arc<dyn BackendService>,
model_service: Arc<dyn ModelService>,
retry_handler: Arc<dyn RetryHandler>,
http_client: Arc<HttpClient>,
}
impl ProxyServiceImpl {
pub fn new(container: Arc<Container>) -> CoreResult<Self> {
Ok(Self {
backend_service: container.resolve()?,
model_service: container.resolve()?,
retry_handler: container.resolve()?,
http_client: container.resolve()?,
})
}
}
이점¶
- 테스트 가능성: 서비스를 단위 테스트용으로 모킹할 수 있음
- 유연성: 코드 변경 없이 구현을 교체할 수 있음
- 라이프사이클 관리: 컨테이너가 서비스 초기화 및 정리 관리
- 순환 의존성 감지: 컨테이너가 순환 의존성 방지
오류 처리 전략¶
라우터는 타입화된 오류, 지능형 복구, 사용자 친화적 응답을 갖춘 다층 오류 처리 전략을 구현합니다.
오류 타입 계층¶
- 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 추론 서버 |
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: 2023-06-01 │
│ { "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/infrastructure/backends/custom_backend.rs에서 pub struct CustomBackend { client: Arc<HttpClient>, config: CustomBackendConfig, } #[async_trait] impl BackendTrait for CustomBackend { async fn health_check(&self) -> CoreResult<()> { /* ... */ } async fn list_models(&self) -> CoreResult<Vec<Model>> { /* ... */ } async fn chat_completion(&self, request: ChatRequest) -> CoreResult<Response> { /* ... */ } } -
백엔드 팩토리에 등록:
// src/infrastructure/backends/mod.rs에서 pub fn create_backend(backend_type: &str, config: &BackendConfig) -> CoreResult<Box<dyn BackendTrait>> { match backend_type { "openai" => Ok(Box::new(OpenAIBackend::new(config)?)), "vllm" => Ok(Box::new(VLLMBackend::new(config)?)), "custom" => Ok(Box::new(CustomBackend::new(config)?)), // 새 백엔드 _ => Err(CoreError::ValidationFailed { message: format!("알 수 없는 백엔드 타입: {}", backend_type), field: Some("backend_type".to_string()), }), } }
새 미들웨어 추가하기¶
-
미들웨어 트레이트 구현:
// 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 라우터에 등록:
새 캐시 타입 추가하기¶
-
캐시 트레이트 구현:
// src/infrastructure/cache/redis_cache.rs에서 pub struct RedisCache { client: redis::Client, ttl: Duration, } #[async_trait] impl CacheTrait for RedisCache { async fn get<T>(&self, key: &str) -> CoreResult<Option<T>> where T: DeserializeOwned { /* ... */ } async fn set<T>(&self, key: &str, value: &T, ttl: Option<Duration>) -> CoreResult<()> where T: Serialize { /* ... */ } } -
서비스에서 사용:
새 로드 밸런싱 전략 추가하기¶
// src/services/load_balancer.rs에서
pub enum LoadBalancingStrategy {
RoundRobin,
WeightedRoundRobin,
LeastConnections, // 새 전략
Random,
}
impl LoadBalancingStrategy {
pub fn select_backend(&self, backends: &[Backend]) -> Option<&Backend> {
match self {
Self::RoundRobin => /* ... */,
Self::WeightedRoundRobin => /* ... */,
Self::LeastConnections => self.select_least_connections(backends),
Self::Random => /* ... */,
}
}
}
설계 결정¶
왜 4계층 아키텍처인가?¶
결정: 4계층 아키텍처 사용 (HTTP → Services → Infrastructure → Core)
이유¶
- 명확한 분리: 각 계층이 구별되는 책임을 가짐
- 테스트 가능성: 계층을 독립적으로 테스트 가능
- 유지보수성: 한 계층의 변경이 다른 계층에 영향을 미치지 않음
- 유연성: 구현을 쉽게 교체 가능 (예: 다른 캐시 백엔드)
트레이드오프¶
- ✅ 장점: 깔끔하고, 유지보수 가능하고, 테스트 가능하고, 확장 가능
- ❌ 단점: 더 많은 복잡성, 약간의 성능 오버헤드
- 결론: 프로덕션 시스템에서는 이점이 비용을 초과함
왜 의존성 주입인가?¶
결정: 컴파일 타임 주입 대신 커스텀 DI 컨테이너 사용
이유¶
- 런타임 유연성: 설정에 기반하여 구현을 교체 가능
- 서비스 라이프사이클: 서비스 초기화/정리의 중앙 집중 관리
- 테스트: 목 및 테스트 더블을 쉽게 주입 가능
고려된 대안¶
- 수동 의존성 전달: 너무 장황하고 오류 발생 가능성 높음
- 컴파일 타임 DI (제네릭): 유연성이 낮고 설정이 어려움
왜 Arc>를 공유 상태에 사용하는가?¶
결정: 공유 가변 상태에 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: 백엔드 풀과 모델 캐시에 대한 다중 동시 리더
- 가능한 경우 락 프리: 카운터와 간단한 상태에 원자 사용
- 비동기 태스크 스폰: 헬스 체크와 캐시 업데이트를 위한 백그라운드 태스크
- 바운드된 채널: 태스크의 무제한 큐잉 방지
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-4o → gpt-4-turbo → gpt-3.5-turbo) - 매개변수 변환이 있는 크로스 프로바이더 폴백 지원 - 설정된 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 |
Pro 모델 전용 |
4096x4096 |
1:1 |
4K |
Pro 모델 전용 |
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 |
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-1", "dall-e-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
주어진 이미지의 변형을 생성합니다. DALL-E 2에서만 지원됩니다.
요청 형식 (multipart/form-data):
image: <file> # 소스 이미지 (PNG, 필수)
model: <string> # 모델 이름 (기본값: "dall-e-2")
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)에 기록되어 재시작 시 재사용되므로 일회성 토큰을 다시 소비하지 않습니다. 로그에는 절대 남기지 않습니다. 허브가 저장된 자격 증명을 나중에 거부하면 에이전트는 오류를 로그로 남기고 백오프하며, v0에서는 자동으로 재등록하지 않습니다(상태 파일을 지우면 새로 등록됩니다). - 하트비트: 설정된 주기마다 라우터 인벤토리를 담아 하트비트를 보냅니다. 라우터 버전, 백엔드별 헬스를 포함한 설정된 백엔드 목록, 서빙 중인 모델, 종합 헬스 요약, 토큰 공급과 포화 계측치, 그리고 누적 가드레일 카운터가 여기 담깁니다. 정책 동기화가 활성 상태이면 하트비트마다 적용된 정책 스냅샷의 커서(
applied_policy_cursor)도 함께 실어 보내 허브가 플릿 전체의 수렴 상태를 확인하게 합니다. - 플릿 설정:
control_plane.config_sync.enabled를 켜면 등록과 인벤토리를 포함하는 모든 하트비트에 값이 제거된 전체request_params스냅샷을 싣습니다. 에이전트는 인바운드 Hub 트래픽을 허용하지 않은 채 리비전에 묶인 공개 파라미터 정책을 폴링하고 가져와 검증한 뒤 원자적으로 적용합니다. - 사용량 전송: 프록시된 요청마다 메타데이터 전용 사용량 레코드 하나를 기존 토큰 사용량 경로로부터 만들어 버퍼링하고, 설정된 주기로 제한된 크기의 배치로 전송합니다.
- 백오프를 통한 재연결: 등록, 하트비트, 사용량 전송이 실패하면 풀 지터가 있는 지수 백오프(기본 1초, 최대 60초)를 사용하며, 성공하면 백오프 상태가 초기화됩니다.
프라이버시 불변식: 계측 메타데이터만¶
사용량 레코드는 토큰 수, 모델, 프로바이더, 지연 시간, 캐시/배치 플래그, id, 타임스탬프 등 계측 메타데이터만 담습니다. 프롬프트와 완성 본문은 라우터 밖으로 절대 나가지 않습니다. 와이어 타입(continuum_protocol::UsageRecord)은 닫힌, 완전히 타입이 지정된 필드 집합을 가지므로 요청이나 응답 내용을 담을 수 있는 필드 자체가 없습니다.
멱등 배치 처리¶
각 배치는 안정적인 batch_id와 안정적인 레코드별 idempotency_key로 한 번만 구성됩니다. 전송이 실패하면 백오프 후 같은 배치를 재시도하며, 허브가 idempotency_key로 중복을 제거하므로 재연결 뒤 배치가 중복 전달되어도 이중 계산되지 않습니다. 200 확인 응답을 받으면 해당 배치는 버퍼에서 제거됩니다. 버퍼는 크기가 제한되어 있어(오래된 것부터 버리며 경고와 카운터를 남깁니다) 허브 장애가 길어져도 메모리가 무한정 늘어나지 않습니다.
주기와 정책¶
허브는 등록 응답에 주기 설정(하트비트 간격, 사용량 전송 간격, 배치 크기)을 담아 돌려주며, 이 값이 에이전트를 지배합니다. control_plane 설정 섹션에 필드별 재정의 값이 있으면 그 설정 하나에 한해 허브 값보다 우선합니다. 정책 동기화와 강제 적용은 control_plane.policy.enabled를 통한 옵트인 기능입니다. 이 블록이 없으면 에이전트는 계측 전용으로 동작하며 정책 엔드포인트를 호출하지 않습니다.
플릿 요청 파라미터 설정¶
이 서브시스템은 control_plane.config_sync.enabled로 별도 옵트인해야 합니다. 에이전트나 정책 강제 적용만 켜서는 활성화되지 않습니다. 원격에서 관리할 수 있는 표면은 공개되고 타입이 정해진 request_params 정책으로 닫혀 있습니다. 임의의 점 표기 키, 환경 변수 치환, 프로바이더·라우터 자격 증명, TLS 자료, 프롬프트, 완성 본문은 받지 않습니다. 운영자는 request_params_immutable: true로 섹션 전체를 고정할 수 있으며, 그렇지 않더라도 로컬 request_params 설정에 명시한 모든 leaf는 로컬 pin으로 남아 Hub 소유 leaf보다 우선합니다.
기능 협상과 관측. 섹션이 변경 가능한 경우 Router는 structured section schema 버전 1, path encoding 버전 1, hot reload와 dry-run 지원, 그리고 leaf 1,820개, path segment 6개, segment당 UTF-8 256바이트, 인코딩된 content 1 MiB, exact model scope 64개의 상한을 광고합니다. 등록과 인벤토리를 포함하는 모든 하트비트에는 전체 masked snapshot을 싣습니다. 이 스냅샷은 revision·section·leaf digest, 정확한 path segment, source(hub, local_file, local_pin), public sensitivity, reload class만 포함하며 value 필드가 없습니다. /, :, ., Unicode를 포함한 모델 식별자는 분리하거나 정규화하지 않고 하나의 정확한 path segment로 유지합니다. request_params_immutable: true이면 Router는 대신 generic immutable section과 aggregate masked digest만 광고하며, Router가 immutable로 선언한 content를 Hub가 할당하지 못하도록 structured delivery capability와 leaf별 entry를 생략합니다.
아웃바운드 트랜잭션. 에이전트는 POST /api/agent/v1/config/sync를 폴링하며, 대기 중 assignment에는 identity와 masked metadata만 들어 있습니다. 이어서 POST /api/agent/v1/config/content에서 공개 content를 가져오면서 인증된 router id, apply id, desired revision/digest, schema version을 모두 되돌려 보냅니다. Router는 어떤 필드도 해석하기 전에 모든 identity가 일치하는지 확인하고, 정렬되고 길이 prefix와 타입 정보가 붙은 path·scalar에 대해 Hub와 호환되는 canonical SHA-256 digest를 다시 계산합니다. global 또는 exact-model scope의 등록된 defaults, overrides, limits.min|max leaf만 허용하며 로컬 설정과 같은 숫자 및 모델 키 검증을 수행합니다.
원자적 적용과 last-known-good. Router는 Hub 소유 leaf를 로컬 pin 아래에 병합하고, 결과로 만든 전체 Config를 검증한 뒤 Hub overlay와 acknowledgment outbox를 유닉스 모드 0600으로 원자적으로 저장하고 하나의 lock-free effective snapshot을 게시합니다. 이미 이전 Arc<Config>를 잡은 요청은 그대로 끝나며 다음 요청부터 새 정책을 봅니다. Dry run은 같은 fetch, bounds, digest, merge, 전체 검증을 수행하되 candidate를 저장하거나 게시하지 않습니다. 중복 전달, 결과 ack 유실, 재연결, 재시작, 새 리비전으로 표현한 rollback은 멱등적입니다. 지원하지 않는 schema/path, 오래된 optimistic revision, immutable/local leaf, 잘못되거나 중복된 path, 유효하지 않은 scalar/range, digest 불일치, 크기 초과 응답은 active snapshot을 바꾸지 않고 거부합니다.
Hub 전송 실패, timeout, response decode 실패는 설정 결정이 아니므로 terminal rejection으로 보고하지 않습니다. 에이전트는 assignment를 ack하지 않은 채 last-known-good으로 계속 서빙하고 재시도합니다. 결정적인 검증 결과만 POST /api/agent/v1/config/apply-result에 applied, validated, rejected, superseded로 보고하며 결과와 로그에는 제한된 path/status metadata만 있고 파라미터 값은 없습니다. 상태 파일도 저장 content를 등록된 router identity에 묶어 다른 Router의 overlay를 복원하지 못하게 합니다.
구현: src/control_plane/config_sync.rs(typed adapter, canonical digest, layered effective store, persistence, snapshot), src/control_plane/client.rs와 src/control_plane/agent.rs(outbound transaction과 retry), src/control_plane/inventory.rs(masked inventory), crates/continuum-protocol/src/config.rs(wire contract). 설정 참조: config.yaml.example의 control_plane.config_sync.
토큰 공급과 포화 계측¶
하트비트와 등록 인벤토리에는 앞 절에서 설명한 구조 안에 RouterInventory.supply 블록과 여섯 개의 부가적인 BackendInfo 필드도 함께 실립니다. 정수형에 메타데이터 전용으로 설계돼 있어 와이어 PROTOCOL_VERSION이 바뀌지 않고 버전이 섞인 플릿도 그대로 동작합니다. 보고되는 숫자는 모두 실제 계측값입니다. active_requests, accepted_requests, rejected_requests, retried_requests, fallback_requests 값은 실제 승인·재시도·폴백 서비스 지점(src/control_plane/supply.rs)에 놓인 RAII 가드와 카운터에서 곧바로 나옵니다. 라우터에 실제로 물려 있지 않은 낡은 Prometheus 카운터에서 값을 끌어오는 방식은 쓰지 않았습니다.
accepted_requests와 rejected_requests는 하나의 모집단을 둘로 나눈 값이 아닙니다. 두 카운터는 서로 겹치는 모집단 위에서 따로 움직이는 압박 지표라서 accepted + rejected를 더해도 라우터가 실제로 받은 요청 수가 나오지 않습니다. 승인 미들웨어가 이미 승인으로 센 요청도 핸들러 안쪽(지금은 허브 정책 거부와 배치 승인)에서 다시 거부될 수 있어 이런 요청은 두 카운터에 모두 잡힙니다. 속도 제한기는 앱 전체에서 작동하는 반면 승인 지점은 API 라우트만 지켜보므로 /admin에서 나오는 429는 거부로만 잡히고 승인으로는 결코 잡히지 않습니다. rejected / (accepted + rejected)는 거부율이 아니고 1.0을 넘어설 수도 있습니다. 지점 하나를 조용히 빼놓으면 트래픽 한 종류를 거의 다 거부하는 라우터조차 압박이 전혀 없다고 보고하게 됩니다. 겹침을 감수하는 편이 그보다는 낫습니다. fallback_requests는 선택 시점이 아니라 폴백 백엔드가 실제로 응답을 서빙한 시점에 요청 하나당 딱 한 번만 셉니다. 사전 스트림 체인 전환과 스트림 도중 전환을 포함한 모든 폴백 지점이 요청별 래치를 공유하기 때문입니다.
옵션 필드가 비어 있다는 것은 '알 수 없음'이지 0이 아닙니다. max_concurrency(라우터·백엔드별 모두)가 비어 있는 이유는 라우터에 동시성 상한이 아예 없기 때문입니다. 관측 용량 추정기는 일시적인 업스트림 429가 최소 두 건의 성공 완료를 포함한 온전한 60초 처리량 윈도를 뒷받침할 때만 observed_rpm_capacity, observed_input_tpm_capacity, observed_output_tpm_capacity를 보고하며, 토큰 차원은 모든 완료 건의 토큰 계측까지 갖춰져야 합니다. 추정기는 15분 뒤 만료되는 최고 관측값을 유지하므로 콜드 스타트, 유휴 상태, 가벼운 부하, 불완전한 토큰 계측, 오래된 관측은 수요를 용량으로 잘못 보거나 거짓 0이 되는 대신 계속 비어 있습니다. 허브가 보관하는 운영자 선언 용량은 읽거나 합치지 않습니다. circuit_state는 브레이커가 아직 본 적 없는 백엔드이거나 브레이커 자체가 비활성화된 경우 비어 있습니다. 비활성화된 브레이커의 get_state()는 Closed를 돌려주지만 이 값은 측정이 아니라 구조적으로 아무것도 모른다는 뜻이라 Closed로 보고하지 않습니다. queue_depth와 shed_requests만은 다릅니다. 비어 있지 않고 진짜 구조적인 0을 보고합니다. 라우터가 요청 큐 없이 곧장 처리하고 어디에도 부하 차단을 두지 않기 때문입니다.
counters_since_ms는 매 하트비트마다 갱신되지 않고 프로세스 시작 시점(process_start_info())에 한 번만 찍힙니다. 며칠째 떠 있는 라우터는 그래서 매 하트비트에 같은 값을 보고합니다. 이 값이 달라졌다면 카운터가 초기화됐다는, 곧 라우터가 재시작됐다는 신호입니다. circuit_state는 값을 만들어내지 않는 peek_state로 읽습니다. 하트비트가 그저 읽기만 하는데도 백엔드별 서킷 브레이커 엔트리를 새로 만들거나 /admin이 보여주는 목록을 바꾸는 일은 없습니다. 스트리밍 응답은 인플라이트 가드를 응답 본문 자체에 옮겨 싣습니다. 덕분에 백엔드는 핸들러가 반환하는 순간이 아니라 마지막 SSE 프레임이 나가거나 클라이언트 연결이 끊기는 순간까지 카운트된 채로 남습니다.
구현: src/control_plane/supply.rs(라우터 단위 공급 트래커), src/control_plane/capacity.rs(백엔드별 관측 용량 추정기), src/control_plane/inventory.rs(와이어 타입으로의 조립). 와이어 타입: crates/continuum-protocol/src/supply.rs, crates/continuum-protocol/src/heartbeat.rs.
가드레일 계측¶
인벤토리에는 RouterInventory.guardrails 블록도 실립니다. 콘텐츠 안전 가드레일이 실제로 무슨 일을 했는지 보여주는 누적 메타데이터 카운터입니다. 로컬 Prometheus 지표군(guardrail_checks_total, guardrail_blocks_total, guardrail_verdicts_total, guardrail_stream_buffer_cap_trips_total)을 그대로 옮긴 것이라, 허브는 라우터마다 스크레이핑하지 않고도 플릿 전체의 가드레일 활동을 볼 수 있습니다.
이 블록의 필드 집합은 닫혀 있고 타입이 정해져 있으며(continuum_protocol::GuardrailSummary), 그 모양은 허브의 것입니다. 두 저장소가 이 표면을 각자 따로 만들다 어긋났고, #1150에서 이 라우터가 허브 쪽 모양으로 맞췄습니다.
| 필드 | 의미 |
|---|---|
checks_total |
모든 스테이지에 걸친 누적 프로바이더 검사 횟수. |
blocks_total |
콘텐츠를 이유로 차단한 판정의 누적 수이며 by_category의 합과 같습니다. fail-closed 인프라 거부는 제외되어 errors_total / fail_closed_total로 셉니다. |
transforms_total |
거부 대신 내용을 고쳐 쓴 모드 적용 판정의 누적 수. |
flags_total |
조치 없이 기록만 한 모드 적용 판정의 누적 수. |
errors_total |
끝내지 못한 검사(타임아웃, 전송 오류, 비정상 상태 코드, 파싱 불가 응답)의 누적 수이며 fail_open_total + fail_closed_total과 같습니다. |
fail_open_total |
실패했는데도 그대로 내보낸 검사. 실제로는 한 번도 검사되지 않은 트래픽입니다. |
fail_closed_total |
실패해서 거부한 검사. 강제 적용을 위해 가용성을 치른 경우입니다. |
stream_buffer_cap_trips_total |
4 MiB 게이트 버퍼 상한에 도달한 스트림의 누적 수. |
by_category |
카테고리 id를 키로 하는 차단 내역이며 배열이 아니라 객체입니다. 라우터는 GuardrailCategoryId 리터럴(deny_list와 포괄 슬롯 other를 포함해 12개)만 씁니다. 더 새로운 피어가 분류한 id는 그대로 통과시킵니다. |
verdicts |
요청당 하나로 집계된 판정을 (stage, mode, result)로 나눈 값. 라우터 전용이며 부가적입니다. |
stream_buffer_cap_trips |
4 MiB 게이트 버퍼 상한에 도달한 스트림 수를 (strategy, outcome)으로 나눈 값. 라우터 전용이며 부가적입니다. |
counters_since_ms |
이 누적 카운터의 기준이 되는 프로세스 시작 시각. |
blocks_total과 판정 스칼라는 분모가 다릅니다. blocks_total은 checks_total과 나란히 프로바이더 검사 단위로 셉니다. 프로바이더 둘이 같은 요청을 모두 거부하면 차단은 2건입니다. transforms_total과 flags_total은 스테이지 평가마다 하나씩 나오는 모드 적용 집계를 쪼갠 값이라, 같은 요청이 판정 1건입니다.
두 fail 정책 카운터는 설정값이 아니라 실제로 적용된 결과를 보고합니다. monitor 모드는 아무것도 게이팅하지 않으므로, monitor에서 실패한 검사는 on_error 설정과 무관하게 검사 없이 그대로 나간 트래픽이며 fail_open_total에 들어갑니다. 따라서 fail_closed_total이 올라가려면 fail-closed 정책과 enforce 모드가 둘 다 필요합니다. 실제로 거부할 수 있는 조합은 그것뿐입니다. 로컬 Prometheus 패밀리는 설정된 정책 기준 라벨링을 그대로 유지합니다. 답하는 질문이 다르기 때문입니다. 프로바이더가 어떻게 설정되어 있는지를 보는 것이지, 요청에 무슨 일이 일어났는지를 보는 것이 아닙니다.
errors_total은 실패의 두 표면을 모두 셉니다. 서비스의 타임아웃 분기뿐 아니라 프로바이더가 Guardrail 트레이트의 Result로 보고하는 하드 오류(전송 실패, 비정상 상태 코드, 파싱 불가 응답)도 같은 기록 지점에 도달하므로, 빠르게 실패하는 프로바이더도 멈춰 버린 프로바이더와 똑같이 집계됩니다(이슈 #1175). fail_open_total과 fail_closed_total은 errors_total을 나눈 값이며, 기본값인 on_error: fail_open에서 모든 요청에 연결 끊김이나 HTTP 5xx로 답하는 프로바이더는 건강한 가드레일과 구분되지 않는 하트비트 대신 요청 속도만큼 올라가는 fail_open_total로 나타납니다. fail-closed 하드 오류는 blocks_total / by_category에 들어가지 않습니다. 콘텐츠 거부가 아니라 fail_closed_total이 세는 인프라 거부이므로, 모더레이션 장애가 분류되지 않은 유해 콘텐츠 급증처럼 읽히지 않습니다. Prometheus 쪽도 같은 사건에 kind="error" 시리즈를 방출합니다.
세 스칼라는 따로 세지 않고 유도합니다. transforms_total, flags_total, stream_buffer_cap_trips_total은 GuardrailSnapshot::into_summary에서 verdicts와 stream_buffer_cap_trips 내역으로부터 계산합니다. 유도가 일어나는 지점이 그 한 곳뿐이므로, 지금 허브가 읽는 스칼라와 나중 허브가 읽을 내역이 서로 어긋날 수 없습니다. 테스트가 각 합계를 대응하는 합과 대조합니다.
verdicts와 stream_buffer_cap_trips는 부가 필드로 함께 실려 갑니다. 허브는 둘 다 선언하지 않고 deny_unknown_fields도 쓰지 않으므로, 지금 허브는 실패하는 대신 그냥 무시합니다. 허브가 이 필드들을 추가하면 그때부터 읽을 수 있습니다.
카운터는 Prometheus 레지스트리에서 읽지 않습니다. metrics 컴포넌트는 선택 사항입니다. 이를 빼고 빌드하거나 설정한 라우터에는 읽을 레지스트리 자체가 없고, 그런 배포에서 0을 보고하면 실제로 트래픽을 차단하고 있는 가드레일이 놀고 있다고 허브에 알리는 셈이 됩니다. 그래서 지표를 내보내는 것과 같은 지점에 각자의 기능 게이트를 달아 별도의 인프로세스 트래커(src/control_plane/guardrail.rs)를 기록합니다. Prometheus가 꺼져 있어도 인벤토리는 정직합니다.
블록이 없으면 "가드레일이 꺼져 있다", 0이면 "켜져 있고 조용하다"는 뜻입니다. 가드레일 서비스가 존재하면 첫 검사가 돌기 전이라도 블록을 보고하고, 가드레일을 아예 운영하지 않는 라우터는 블록을 통째로 생략합니다. 둘을 하나의 부재 상태로 뭉개면 설정이 잘못된 라우터와 조용한 라우터를 구분할 수 없게 되는데, 그 구분이야말로 이 계측을 보고하는 이유입니다.
레이블 공간이 닫혀 있다는 점은 스타일이 아니라 정확성 문제입니다. 가드레일 카테고리는 서드파티 모더레이션 프로바이더에서 GuardrailCategory::Other(String)를 거쳐 라우터로 들어옵니다. 그 문자열을 키로 쓰는 맵을 두면 오작동하는 프로바이더 하나가 허브의 저장 키 공간을 무한정 넓히면서 프로바이더가 작성한 텍스트까지 실어 보낼 수 있습니다. 그래서 각 세부 분류는 닫힌 레이블 튜플로 색인되는 고정 크기 원자 배열입니다. 차단은 제한된 카테고리 id별로, 판정은 (stage, mode, result)별로, 스트리밍 버퍼 상한 도달은 (strategy, outcome)별로 집계합니다. 알려진 어휘에 없는 카테고리는 저장 전에 단일 other 버킷으로 접히고, 인식하지 못한 stage·mode·result는 버려지는 대신 unknown 슬롯으로 접힙니다. 공간을 닫으면서도 사각지대는 만들지 않습니다. 예약된 매치리스트 의사 프로바이더(match_list_deny / match_list_allow)는 따로 처리할 필요가 없습니다. 그 거부 판정은 알려진 id 중 하나인 deny_list 카테고리를 그대로 달고 오기 때문입니다.
by_category가 JSON 객체인 것은 그것이 허브의 모양이기 때문이지만, 그 키는 여전히 자유 텍스트가 아닙니다. 키가 들어가는 유일한 경로는 GuardrailCategoryId::as_str()이며, 컴파일 타임 리터럴 12개로 이루어진 닫힌 집합입니다. 모두 허브가 수집 시점에 적용하는 개수(32), 바이트 길이(64), 문자 집합([a-z][a-z0-9_]*) 한도 안에 들어갑니다. GuardrailSummary::is_bounded()가 셋을 모두 검사하고, 라우터 자신의 방출 상한이 허브의 수집 상한 안에 있다는 사실은 컴파일 타임에 단언합니다.
프라이버시. 카운트와 제한된 id만 담습니다. 프롬프트 텍스트, 완성 텍스트, 매치된 구간, 매치된 규칙, 마스킹된 PII 값은 어느 것도 실리지 않습니다. 오류 경로도 마찬가지입니다. errors_total과 두 fail 정책 카운터는 프로바이더 이름도, 실패 문구도 싣지 않습니다. 통합 테스트가 타입이 아니라 직렬화된 인벤토리를 대상으로 이를 확인합니다.
counters_since_ms는 SupplySummary와 같은 규약을 따르며 같은 process_start_info() 값에서 찍힙니다. 두 카운터군이 함께 초기화되므로 허브는 재시작을 두 번이 아니라 한 번으로 읽습니다.
허브의 와이어 픽스처를 여기에 그대로 고정해 둡니다. crates/continuum-protocol/tests/fixtures/guardrail_summary.json은 허브가 커밋한 픽스처를 바이트 단위로 복사한 것입니다. crates/continuum-protocol/tests/guardrail_summary_hub_fixture.rs가 양방향으로 고정하고, tests/control_plane_guardrail_telemetry_test.rs가 살아 있는 트래커 읽기와 다시 대조합니다. 전에는 두 저장소 모두 자기 타입만 왕복 검사했고, 그래서 양쪽 다 초록불인 채로 모양이 어긋날 수 있었습니다. 이 픽스처를 로컬 타입에서 다시 생성해서는 안 됩니다. 허브가 픽스처를 옮기면 다시 복사하고 출처 표기도 같은 변경에서 갱신하세요.
구현: src/control_plane/guardrail.rs(카운터 트래커), src/control_plane/inventory.rs(조립과 보고·생략 판단), 기록 지점은 src/services/guardrail/service.rs와 src/services/guardrail/gate.rs. 와이어 타입: crates/continuum-protocol/src/guardrail_telemetry.rs, crates/continuum-protocol/src/heartbeat.rs.
정책 동기화와 로컬 강제 적용¶
control_plane.policy.enabled를 설정하면(control-plane 기능이 컴파일되어 있고 에이전트도 활성화된 상태라면) 에이전트는 허브 정책 스냅샷을 최신 상태로 유지하고 라우터는 API 키 인증을 마친 뒤 모든 API 요청에 대해 이를 로컬에서 강제 적용합니다.
전달 경로. 두 경로가 같은 인메모리 정책 스토어에 데이터를 공급하며 둘 다 전체 PolicyEnvelope(해시로 된 조직의 API 키 테이블, 티어별 제한, 키별 월간 누적 예산 스냅샷, 에이전트 설정)를 실어 나릅니다. 전체 엔벨로프를 쓰기 때문에 적용은 멱등적입니다. 커서에 공백이 생겨도 다음 엔벨로프가 그 공백을 메웁니다.
- 폴링 (
POST /api/agent/v1/policy/sync): 강제 적용이 켜져 있는 동안policy.poll_interval_secs(기본 5초) 주기로 실행됩니다. 정확성의 핵심이 되는 경로로서, 이것만으로도 키 해지와 예산 반영의 지연이 폴링 주기를 넘지 않습니다. - 스트림 (
GET /api/agent/v1/policy/stream?cursor=<opaque>): 푸시 방식 전달을 위해 웹소켓을 계속 열어두므로 연결되어 있는 동안에는 키 해지가 5초 이내로 전파됩니다. 프레임 하나하나가 JSON 텍스트로 된 완전한 엔벨로프 한 개입니다. 연결이 끊기면 클라이언트는 마지막으로 적용한 커서를 제시하며 백오프를 거쳐 재연결합니다. 그 사이에 생기는 공백은 폴링 루프가 메웁니다.policy.stream_enabled: false로 끌 수 있습니다.
엔벨로프 디코드 상한. 두 전달 경로 모두 유도된 바이트 상한(약 152.6 MiB, src/control_plane/client.rs의 MAX_POLICY_SYNC_RESPONSE_BYTES)을 넘는 엔벨로프를 무제한 버퍼링하는 대신 거부합니다. 폴링 경로는 선언된 Content-Length를 먼저 검사한 뒤 청크 누계를 검사하고, 웹소켓 스트림은 메시지와 프레임 상한을 같은 상수로 설정하므로 두 경로는 tungstenite의 더 작은 64 MiB 라이브러리 기본값이 아니라 명시적 결정으로 일치합니다(허브는 엔벨로프 하나를 조각내지 않은 텍스트 프레임 한 개로 전달하므로 프레임 상한도 메시지 상한과 함께 움직입니다). 이 상한은 허브 자체의 배포 예산을 항목별로 합산해 문서화한 값입니다(허브 측 조직 단위 상한이 없는 유일한 멤버인 키 테이블에 100 MiB 허용치를 두되 코드 주석에서 해지·만료 키를 포함한 누적 행 기준으로 산정했고(허브는 기본값으로 이 행들을 영구 보존), 문자 수 기준으로 상한이 걸린 유니코드 허용 목록 이름을 포함한 티어 1,024개와 4 MiB request_params 예산, 256 KiB 가드레일 캐노니컬 예산, 비용 센터 할당 5,000건과 상한이 걸린 센터 1,024개, 허브가 전혀 제한하지 않는 멤버들에 대한 명시적 허용치). 모든 상한을 동시에 채운 정상 테넌트도 디코드되도록 잡은 크기입니다. 거부는 조용히 넘어가지 않습니다. 파싱 실패와 절대 섞이지 않는 별도 타입 오류(HubError::ResponseTooLarge)로 반환되고, 상한과 관측 크기를 담은 error! 로그를 남기며(본문 내용은 절대 싣지 않습니다), 일반 동기화 실패용 reason="other"와 구분되는 control_plane_policy_sync_failure_total{reason="envelope_too_large"} 카운터를 올립니다. 상한 초과 거부가 지속되면 재시작한 라우터가 영구히 fail-open 상태로 남으므로, 허브가 잠시 죽은 상황과 반드시 구분되어야 하기 때문입니다. 두 reason 시리즈는 등록 시점에 0으로 생성되므로 아직 아무것도 거부하지 않은 라우터에서도 플릿 알림이 시리즈를 볼 수 있고, 거부 횟수와 마지막 발생 시각은 GET /admin/control-plane/status의 policy_sync.oversize_refusals / last_oversize_refusal_ms 필드로도 노출됩니다. last_sync_ms가 null인데 거부 횟수가 0이 아니면 콜드 스타트 fail-open 상태라는 신호입니다. 거부가 일어나도 스토어는 건드리지 않습니다. last-known-good 스냅샷이 계속 강제 적용되고 어떤 키나 제한도 넓어지지 않습니다. 더 작은 허브 응답 디코드(enroll, usage ack, probe ack)에도 각각 유도된 상한과 같은 타입 거부가 적용되며, 읽을 수 없는 초과 크기 usage ack은 이미 처리된 배치에 대해 종결로 취급되어 무한 재시도 대신 푸시 루프가 계속 전진합니다.
강제 적용 대상. 제시된 API 키의 SHA-256 해시가 동기화된 KeyEntry와 일치할 때 다음을 적용합니다(비교는 상수 시간으로 수행하며 평문 키는 에이전트 프로토콜로 전혀 전송하지 않습니다):
- 해지:
revoked로 표시된 항목은 즉시 거부됩니다(키 해지, 만료, 조직 정지가 모두 이 플래그 하나로 전달됩니다). - 속도 제한: 티어별
rpm은 고정된 60초 윈도로 적용하며 윈도를 초과하는 요청은429로 거부합니다.input_tpm과output_tpm은 응답 이후 실측 토큰 수를 누적하는 윈도이므로 윈도를 채운 요청 바로 다음 요청이 거부됩니다. 캐시로 읽은 입력 토큰은input_tpm에 포함되지 않습니다. - 월간 예산:
tokens_per_month/requests_per_month는 허브의 키별KeyUsageSnapshot(허브가 수집한 해당 월 누적치)에 스냅샷 이후 로컬에서 관측한 증분을 더한 값으로 강제합니다. 이 로컬 증분은 더 최신 스냅샷이 도착하거나 UTC 기준 월이 바뀌면 초기화됩니다. 그래서 설계상 초과 지출은 동기화 주기만큼으로 제한됩니다(허브 ARCHITECTURE.md 4절 참고). - 모델 허용 목록: 티어의
model_allowlist에 없는 모델을 요청하면403으로 거부됩니다. 허용 목록이 없으면 서빙 중인 모든 모델을 허용한다는 뜻입니다. - 요청 파라미터 제한: 티어는
temperature,top_p,max_tokens,presence_penalty,frequency_penalty,top_k,min_p를 제한하고 로컬 별칭 해석이 끝난 정확한 모델별 범위를 선택적으로 둘 수 있습니다. 소수 경계는 와이어에서 정수 마이크로단위로 전달됩니다. 모든 ingress는 로컬 별칭 해석 뒤 클라이언트가 요청한 모델 정체성을 한 번만 캡처하고, 실제 라우팅과 같은models/접두사 정규화 및 정확한 백엔드 별칭 매핑을 적용합니다. 라우터는 Hub 제한과 배포 로컬request_params제한의 교집합을 취하고 빈 교집합은 거부하며, 캐시 조회·치환·arbitrage·백엔드 선택·재시도·폴백 전에 선택한 정책을 고정합니다. Chat Completions, Completions, Responses, Anthropic Messages가 모두 같은 유효 요청을 사용합니다. - 헤더: 강제 적용을 거친 응답에는 가장 제약이 큰 차원 기준의
x-continuum-ratelimit-limit/-remaining/-reset과 설정된 차원별로 접미사가 붙은 세 값 묶음(-rpm,-input-tpm,-output-tpm,-monthly-tokens,-monthly-requests)이 함께 실립니다.429응답에는retry-after도 함께 실립니다.
원자적 티어 정책 상태. 전체 스냅샷은 라이브 ArcSwap을 바꾸기 전에 검증되고 변환됩니다. 잘못된 범위, 중복 티어 id, 오래된 스냅샷, 티어 digest 불일치는 완전한 last-known-good 스냅샷을 보존합니다. 폴링 요청은 네트워크 I/O를 시작하기 전에 활성 수락 세대를 캡처하므로, 요청이 진행되는 동안 스트림 수정본이 적용되었다면 뒤늦게 도착한 동일 밀리초 폴링 응답이 이를 되돌릴 수 없습니다. 반면 그 수락 세대 뒤에 시작한 정상적인 후속 전달은 Hub 타임스탬프가 같아도 유효합니다. 하트비트는 기능·상태 증거와 적용 커서를 하나의 원자적 발행본에서 읽으므로 서로 다른 세대의 확인 쌍을 만들지 않습니다. RouterInventory.policy_status는 request_param_limits_v1 기능을 광고하고 성공한 Hub cursor/digest 쌍을 확인하며 자유 형식 값 없이 하나의 안정적인 거부 코드를 보고할 수 있습니다. 이후 성공한 스냅샷은 request_params의 권위 있는 clear까지 포함해 이전 제한을 교체하고 거부 상태를 지웁니다. 선택적 digest 필드가 없으면 제한은 강제하지만 라우터는 active digest 증거를 지어내지 않습니다.
가드레일 정책 확인. RouterInventory.policy_status가 싣는 기능 목록은 한 곳(PolicyStore::capabilities)에서만 만들어지므로, 상태를 쓰는 어떤 경로도 다른 경로와 어긋난 집합을 광고할 수 없습니다. guardrail_policy_v1은 살아 있는 가드레일 정책 재조정기가 정책 저장소에 붙은 뒤에야 request_param_limits_v1 옆에 추가됩니다. 이 기능은 빌드 플래그가 아니라 실행에 대한 약속이기 때문입니다. 허브 가드레일 정책을 받아서 검증하고 보관하기만 할 뿐 실제로 돌리지 않는 라우터는 아무것도 광고하지 않고, 허브는 영영 오지 않을 확인을 기다리는 대신 unsupported로 판단합니다.
수신과 강제 적용은 따로 확인됩니다. 봉투를 수락하면 와이어 계층이 적용한 가드레일 digest가 기록되지만 이는 수신일 뿐이고, 그 정책을 실행 중인 가드레일 서비스로 합성할 수 있었는지는 그 뒤에 재조정기가 판단합니다. 재조정기가 활성 리비전이 주장하려던 바로 그 digest를 거부하면 그 주장은 철회되고, 같은 digest가 guardrails_unavailable과 함께 거부로 보고됩니다. 그래서 아무것도 강제하지 않는 상태인데 Fleet 화면에 거버넌스가 적용 중인 것처럼 보이는 일은 생기지 않습니다. 가드레일 트랙과 티어 트랙은 양방향으로 독립적입니다. 거부된 가드레일 본문은 같은 봉투에 실린 티어 정책을 버리지도, 그 확인을 막지도 않으며, 티어 거부는 가드레일 본문에 대해 아무것도 말하지 않습니다. 권위 있는 clear는 아무것도 보고하지 않는 대신 정규 cleared 정책의 digest를 확인하므로, clear된 테넌트가 pending이나 stale에 머무르지 않고 수렴합니다. 저장소는 실행할 수 없는 정책도 와이어 수준에서는 적용된 상태로 유지하므로, 나중에 로컬 설정이 빠진 배선을 채우면 같은 본문이 깨끗하게 합성되고 새 봉투 없이도 활성 주장이 복구됩니다.
안정적인 백엔드 식별자. stable_backend_identity_v1도 같은 목록에 들어가며, 조건 없이 들어갑니다. 가드레일 정책과 달리 나중에 붙을 수도, 안 붙을 수도 있는 별도 실행기가 없기 때문입니다. 설정 검증, 하트비트 인벤토리, 사용량 탭이 하나의 단위로 함께 컴파일되고 배선되므로, 셋 중 하나가 이미 받아들인 backends[].backend_id를 떨어뜨리는 상태는 도달할 수 없습니다. 이 기능 문자열은 데이터가 아니라 구현을 서술합니다. 운영자가 식별자를 하나도 설정하지 않은 라우터도 이 문자열을 광고하고 단지 아무것도 보고하지 않는데, 이것이야말로 허브가 "아예 보고할 수 없음"과 구분해야 하는 "보고할 수 있으나 보고할 것이 없음" 상태입니다.
식별자 자체는 운영자가 부여하며 절대 유도하지 않습니다. BackendInfo.name은 가변 표시 레이블이고 (provider, model) 쌍은 백엔드가 아니므로, 둘 중 무엇으로부터 식별자를 유도해도 같은 쌍을 공유하는 서로 다른 두 비용 주체가 합쳐지거나 이름 변경 때 하나의 비용 주체가 갈라집니다. 각 사용량 탭은 자기 서빙 지점에서, 실제 선택이 사용한 설정 뷰를 기준으로 식별자를 확정해 UsageEvent에 실어 보냅니다. 푸시 루프가 나중에 이름으로 조회하게 두지 않는 이유는, 그 사이의 핫 리로드가 백엔드 이름을 바꾸면 이미 서빙된 사용량의 귀속이 조용히 사라지기 때문입니다. 폴백 홉은 TTFT 기준 시각을 덮어쓰는 것과 똑같이 식별자도 덮어쓰고, 프로바이더 배치는 제출 시점에 확정한 식별자를 그대로 들고 있으며, 로컬 캐시 적중은 백엔드로 나간 적이 없으므로 아무것도 보고하지 않습니다. 인벤토리도 같은 함수로 확정하므로, 운영자가 인벤토리에서 발견한 식별자가 곧 사용량이 청구되는 식별자입니다.
라우터가 광고할 수 있는 기능 문자열은 request_param_limits_v1, guardrail_policy_v1, stable_backend_identity_v1입니다. 허브가 자유 형식 보고가 아니라 한정된 증거로부터 전파 상태를 도출할 수 있는 것은 이 때문입니다.
| 도출된 상태 | 라우터 증거 |
|---|---|
unsupported |
RouterPolicyStatus.capabilities에 해당 기능이 없음. 이 라우터는 그 정책 계열을 영영 확인해 주지 않습니다. |
pending |
기능은 광고되었지만 선언된 digest에 대한 확인이 아직 도착하지 않음. |
active |
AppliedPolicyRevision.guardrail_policy_digest가 허브가 작성한 digest와 일치. 권위 있는 해제 뒤의 정규 cleared 정책 digest도 여기에 해당합니다. |
stale |
확인된 digest가 허브가 마지막으로 선언한 정책보다 이전 정책을 가리킴. |
error |
RejectedPolicyRevision이 해당 digest를 invalid_guardrail_policy, guardrails_unavailable, 또는 공유 코드인 digest_mismatch와 함께 보고. |
허브와 매칭된 키의 사용량 레코드는 허브의 key_id로 귀속됩니다. 허브는 바로 이 key_id 기준으로 예산 스냅샷을 집계하고 라우터는 그 스냅샷을 기준으로 강제 적용을 수행하므로 계측 루프가 닫힙니다.
코스트 센터 제한(V2). 정책 엔벨로프는 cost_centers_v2(허브 이슈 #736, 라우터 이슈 #1162)를 실어 나를 수 있습니다. 이 멤버는 라우터별 안정 할당 명세(허브가 미리 해석한 key_id -> cost_center_id 비즈니스 소유권, 라우터 자신의 폴백 코스트 센터, 프로바이더/모델 폴백, 라우터 범위의 안정 백엔드 폴백, 센터별 월간 상한) 하나와 그것이 참조하는 영속 동적 예산 스냅샷(현재까지 소비된 요청/토큰 카운터, 허브가 작성한 차원별 판정, 라우터별 included_router_usage_seq 사용량 워터마크)으로 이루어집니다. 라우터는 계약 전체가 살아 있을 때만 cost_center_limits_v2를 광고합니다. 즉 control_plane.state_file 옆의 소유자 전용 사이드카(<state_file>.cost-center.json, 0600, 임시 파일 기록 후 rename)가 로드되어 등록 신원에 결속되어 있어야 하며, 이것이 영속 단조 사용량 시퀀스와 last-known-good 명세, 아직 반영되지 않은 로컬 델타를 내구성 있게 만듭니다. 레거시 cost_centers V1 멤버는 혼합 버전 호환을 위해 파싱은 되지만 강제 적용되지 않고 확인 응답도 하지 않으며 cost_center_limits_v1 능력은 광고하지 않습니다.
명세는 하나의 원자적 적용 단위입니다. 라우터는 크기 상한, 중복, 참조, 상한이 없는 차원에는 판정이 없어야 한다는 규칙, 라우터 범위 다이제스트, 결정적으로 발급된 커서, 스냅샷 최신성, 그리고 워터마크(이 라우터가 발급한 적 없는 시퀀스를 가리키거나 이미 적용한 워터마크보다 후퇴하는 값은 불가능)를 검증하고, 결함이 하나라도 있으면 타입이 지정된 코드(invalid_cost_center_policy, digest_mismatch, stale_budget_snapshot, invalid_usage_watermark)로 단위 전체를 거부하며 last-known-good 단위를 유지합니다. 적용/거부 확인 응답은 AppliedPolicyRevision.cost_center_v2 / RejectedPolicyRevision.cost_center_v2에 안정 policy_cursor/policy_digest와 동적 budget_cursor를 정확히 담고, last-known-good 확인 응답은 코스트 센터 명세가 없는 엔벨로프를 가로질러 유지되므로 허브 장애는 단위를 동결할 뿐 서빙을 중단시키지 않습니다.
할당은 허브의 우선순위를 따르되, 계측과 실제 서빙 백엔드가 어긋날 수 없도록 계층마다 이음새를 고르며, 승인 게이트는 허브가 계측할 사용량 레코드를 만들 수 있는 요청 경로에서만 작동합니다(모델 목록 조회나 토큰 카운트 요청은 게이트되지도 청구되지도 않습니다). 키 할당과 라우터 폴백은 디스패치 전에 고정되는 신원이므로 강제 적용 미들웨어가 그 자리에서 소진된 할당을 거부하고 원자적 예약(요청 1건 + 결정적 토큰 허용량: 상한이 있는 바이트 기반 프롬프트 추정 + 티어 request_params 상한과 고정 상한으로 잘라낸 요청의 출력 최대값, 또는 고정 기본값)을 잡습니다. 이 예약은 승인 퍼밋과 똑같이 응답 본문에 실려 마지막 프레임이나 클라이언트 연결 종료 시 해제됩니다. 예약은 다른 모든 승인의 사용량에 합산되어 V1의 확인 후 청구 모델이 갖던 동시 한계 경합을 닫되, 요청 하나가 자기 추정치만으로 거부되는 일은 없습니다(거부는 상한 도달 시점에만). 모든 계층의 계측은 사용량 이음새에서 일어납니다. 단일 사용량 루프 소비자가 레코드가 전송 버퍼에 들어가는 순간 영속 단조 router_usage_seq를 찍고(버퍼 순서 = 시퀀스 순서, 배치는 시퀀스 순서로 전송) 최종 레코드 자체의 key_id/backend_id/provider/model(허브가 수집 시 할당에 쓰는 것과 동일한 입력)로 계산한 코스트 센터 아래에 레코드의 정확한 계측 사용량(input_tokens + output_tokens, 요청 1건)을 시퀀스 원장에 정산합니다. 더 새로운 스냅샷을 수락하면 허브가 포함한 워터마크 이하의 원장 항목만 정확히 폐기합니다. 반영되지 않은 사용량은 절대 리셋되지 않고 반영된 사용량은 절대 이중 계산되지 않습니다. 전송 버퍼에서 영구히 버려진 레코드, 분할 불가한 초과 크기 배치의 레코드, 허브가 레코드 단위로 거부한 레코드는 원장 항목도 함께 은퇴합니다. 허브가 그 레코드를 포함할 일이 없기 때문입니다. 예산 블록 없이 도착한 명세는 문서화된 예산 불가용 상태입니다(허브 #750: 스냅샷의 합계가 더 이상 일치하지 않는 상한 아래에서 평가됨). 보유 중인 카운터와 허브 작성 판정은 새 상한 아래의 살아 있는 강제 적용으로 쓰이는 대신 폐기되며, 로컬 원장은 살아남습니다. 하위 계층(안정 백엔드, 그다음 프로바이더/모델, 허브 자신의 우선순위)은 유효 재작성 반영 대상에 대한 모든 후보 서빙 백엔드가 각자 상한을 넘긴 코스트 센터로 귀결될 때 승인 시점에 거부합니다. 요청이 서빙될 수 있는 신원이 전부 소진된 상태이므로, 디스패치 전 결정이 실제로 서빙되고 계측되는 신원과 어긋날 수 없기 때문입니다. 후보들이 하나의 동일한 코스트 센터로 귀결될 필요는 없습니다. 그 조건은 충분하지만 필요하지는 않았고, 소진된 코스트 센터 두 곳에 백엔드가 걸쳐 있는 모델을 지목하는 것만으로 상한을 넘겨 계속 서빙할 수 있는 구멍을 남겼습니다. 여유가 남은 후보가 하나라도 있는 경우, 미할당 후보가 있는 경우, 그리고 모델 간 폴백 체인은 계측으로만 강제 적용되며 문서화된 초과분의 일부입니다.
판매 지출은 허브 전권입니다. 라우터는 가격 카탈로그로 판매 지출을 계산하거나 차감하거나 추정하지 않으며, 센터별 sell_spend_status 판정(exhausted는 거부, 더 새로운 허브가 보낸 unknown은 절대 거부하지 않음)이 강제 적용의 유일한 판매 지출 입력입니다. 초과분은 제거되는 것이 아니라 상한이 정해지며, 그 상한은 네 개의 문서화된 항으로 이루어집니다. 플릿 항(대략 스냅샷 주기 곱하기 해당 코스트 센터를 서빙하는 라우터 수), 추정 항(예약 허용량은 결정적 추정치로서 큰 텍스트 프롬프트는 과소, base64 위주 본문은 과대 예약하며, 동시성만 제한하고 계측에는 관여하지 않음), 상한 도달 항(동시에 승인된 각 요청이 허용량 하나만큼 상한을 넘길 수 있음. 반드시 들어맞아야 한다는 의미론은 정당한 요청을 추정 오차만으로 강제 거부하므로 더 나쁜 실패임), 그리고 응답 종료와 레코드의 버퍼 진입 사이의 프로세스 내 전달 항입니다. 제로 초과 플릿 전체 차단은 설계상 존재하지 않습니다. 구현: src/control_plane/cost_center/(트래커, 시퀀스 원장, 예약, 사이드카 영속화)가 PolicyStore(적용, 능력, 확인 응답), 강제 적용 미들웨어(승인 예약), 사용량 루프(시퀀싱, 은퇴)에 연결됩니다.
요청 경로 간 동등성. 토큰 계측과 key_id 귀속은 /v1/chat/completions에만 국한되지 않습니다. 네이티브 Anthropic /anthropic/v1/messages(스트리밍과 비스트리밍 모두, 네이티브 Anthropic·OpenAI-to-Anthropic 브리지·Responses-to-Anthropic 브리지·Bedrock 런타임 디스패치까지 모든 하위 경로 포함)와 스트리밍 /v1/responses(공유 스트림 종료 탭을 거치는 Anthropic/Gemini/Chat-Completions 변환 전략, 그리고 자체 종단 SSE 이벤트를 직접 태핑하는 네이티브 OpenAI/Azure 패스스루 전략) 모두 종단 토큰 수를 OpenAI 경로와 같은 청구·식별 경로로 흘려보내고 캐시 읽기 토큰 제외도 어디서나 동일하게 적용됩니다. 그래서 허브 키는 어느 경로로 요청이 들어왔든 같은 방식으로 tpm 윈도와 월간 예산이 차감되고 사용량이 허브 key_id로 귀속됩니다.
Fail-open 불변식. 강제 적용은 기능이 컴파일되어 있고 에이전트와 정책 블록이 모두 활성화되어 있으며 정책 엔벨로프가 한 번이라도 동기화되었을 때만 작동합니다. 첫 동기화가 성공하기 전과 동기화된 항목과 일치하지 않는 키에는 로컬 API 키 인증과 로컬 속도 제한만 적용됩니다. 한 번 동기화한 뒤 Hub 장애나 업데이트 거부가 발생하면 last-known-good 스냅샷을 계속 서빙하며 후보를 부분 적용하지 않습니다. api_keys.mode: blocking에서는 허브와 동기화되었고 해지되지 않은 키가 로컬 키 저장소에 없어도 인증됩니다. 그 밖의 blocking 모드 규칙도 모두 적용됩니다. 정책 디코드 상한을 넘어 거부된 엔벨로프에도 같은 규칙이 적용됩니다. 적용 중인 스냅샷은 그대로 유지되고 콜드 스타트 라우터는 fail-open 상태를 유지하며, 거부는 조용한 백오프가 아니라 control_plane_policy_sync_failure_total{reason="envelope_too_large"} 카운터, GET /admin/control-plane/status의 policy_sync.oversize_refusals 필드, 그리고 error! 로그로 드러납니다. 콜드 스타트에서는 이 신호들이 유일한 보호 수단이기 때문입니다.
허브가 전달하는 프로바이더 자격 증명¶
정책 엔벨로프에는 선택적 provider_credentials 필드도 실릴 수 있습니다. 이는 허브 M3 볼트가 전달하는 조직 범위의 {provider, credential_id, version, secret} 항목 집합입니다. 이 필드는 substitution_rules, equivalence_classes와 같은 삼중 상태 와이어 규약을 따릅니다. 필드가 없으면 허브가 아무 판단도 내리지 않았다는 뜻이므로 라우터는 마지막으로 받은 집합을 그대로 유지합니다. 빈 배열은 허브가 전달한 모든 자격 증명을 해지하고 값이 있는 배열은 전체 집합을 교체합니다. 폴링이든 정책 스트림이든 이후 엔벨로프에서 version이 올라가면 PolicyStore 스냅샷 전체를 원자적으로 교체하므로 이미 시크릿을 확인한 요청은 기존 값을 그대로 쓰고 다음 요청부터 로테이션이 반영됩니다. 재시작도 진행 중인 요청의 유실도 없습니다.
선택 기준은 백엔드의 backend_type이지 base_url이 아닙니다. 값만 치환하는 단일 리졸버(proxy::oauth_helper::effective_backend_secret)가 백엔드의 backend_type을 프로바이더 이름(openai, anthropic, gemini, bedrock 등)으로 매핑하고 유효 자격 증명 집합에서 그 프로바이더를 조회합니다. 허브가 그 프로바이더용 자격 증명을 전달했다면 백엔드에 설정된 api_key 대신 이 값을 아웃바운드 인증 헤더 값으로 치환합니다. 전달된 값이 없으면 설정된 키를 그대로 씁니다. 주입 조건은 백엔드에 로컬 api_key가 설정되어 있는지뿐이라 has_config_auth나 클라이언트 헤더 억제 로직은 그대로이며 치환되는 것은 시크릿 값 하나뿐입니다.
운영자 유의 사항: 이 주입에서 프로바이더 정체성을 결정하는 유일한 근거는 backend_type입니다. 허브가 전달한 openai 자격 증명은 로컬 api_key가 설정되고 backend_type: openai인 모든 백엔드에 실립니다. 여기에는 OpenAI 형식의 요청·응답 변환을 재사용하려고 운영자가 openai로 표시해 둔 제3자 프록시 엔드포인트도 포함되며 base_url이 무엇인지는 상관없습니다. 정식 프로바이더가 아닌 엔드포인트에 정식 backend_type을 붙여 둔 운영자라면 그 백엔드가 허브의 프로바이더 자격 증명을 받는 것이 의도한 동작인지 확인해야 합니다. 라우터가 이 백엔드가 어느 프로바이더를 대신하는지 판단하는 근거는 backend_type 하나뿐이기 때문입니다.
적용 대상 전송 지점은 다음과 같습니다. OpenAI 형식 채팅(TCP·유닉스 소켓, 스트리밍·비스트리밍 모두), 네이티브 Anthropic Messages와 count_tokens(TCP·유닉스 소켓), 네이티브 Gemini 임베딩과 이미지 생성/편집(x-goog-api-key), Responses API(모든 생성 전략, 패스스루 경로, 스트리밍), 배치 디스패치입니다. 모델 디스커버리와 헬스 체크는 조직의 요청 트래픽이 아니라 라우터 운영에 속하므로 로컬 키를 계속 씁니다. 프로바이더 시크릿은 메모리에만 머뭅니다. 디스크에 저장되지 않고 로그에도 남지 않으며 모든 Debug/Display 출력에서 가려지고 클라이언트에 노출되는 오류 메시지에도 담기지 않습니다.
최적화 정책과 프롬프트 캐시 실행¶
허브는 조직의 최적화 정책을 정의해 같은 정책 엔벨로프에 실어 배포합니다. 허브가 요청 경로에 전혀 관여하지 않으므로 이를 실행하는 일은 데이터 플레인인 라우터의 몫입니다. 선택적 optimization 블록은 exact_cache, prefix_cache, semantic_cache(모두 불리언)와 semantic_similarity_bps(베이시스 포인트), cache_ttl_secs(초)를 담으며 와이어에는 정수만 싣고 부동소수점은 쓰지 않습니다. 블록이 없으면 라우터는 정확 일치 캐시와 프리픽스 캐시를 켜고 시맨틱 캐시를 끕니다. 티어별 플래그 cache_enabled(기본값 켜짐)와 batch_eligible(기본값 꺼짐)까지 더해 전체 그림이 완성됩니다.
유효 결정. 허브 키로 인증된 요청이 통과할 때마다 강제 적용 미들웨어는 티어 플래그와 조직의 최적화 정책, 요청별 오버라이드 헤더 세 가지로부터 유효 결정을 계산합니다. 강제 적용은 캐시를 소유한 핸들러보다 먼저 실행되므로 이 결정은 AuthContext와 같은 방식으로 요청 확장(extension)에 담아 전달합니다. 오버라이드 헤더는 x-continuum-cache와 x-continuum-batch이며 값은 on/off이고 티어 한도 안에서만 엄격히 반영됩니다. off는 항상 우선하고 on은 티어 플래그(캐시라면 조직 정책까지)가 이미 허용할 때만 받아들여지므로 클라이언트는 자기 티어가 허용하는 범위를 줄일 수는 있어도 넓힐 수는 없습니다. 인식할 수 없는 값은 티어 기본값으로 처리됩니다.
캐시 실행. 이 결정은 라우터의 기존 정확 일치 응답 캐시(메모리, Redis, 계층형 등 허브 없이도 쓰이는 동일한 ResponseCacheStore)를 OpenAI 채팅과 스트리밍 채팅 경로, 그리고 비스트리밍 Responses와 Anthropic Messages 경로에서 게이팅합니다. 허용되지 않는 요청은 지금의 temperature > 0 요청과 똑같이 처리되어 우회(bypass)로 기록됩니다. 캐시 조회와 저장은 허용된 백엔드가 하나이고 설정된 폴백 체인이 없을 때만 실행됩니다. 불투명 namespace가 백엔드와 자격 증명 grant를 함께 묶으므로 한 프로바이더나 권한 문맥에서 생성한 항목이 다른 경로의 요청을 충족하지 않습니다. cache_ttl_secs가 설정돼 있으면 저장되는 항목은 스토어 기본값 대신 이 TTL을 사용합니다. 캐시에 담긴 프롬프트와 완성 내용은 고객의 인프라 안에 그대로 남고 허브로는 절대 전달되지 않습니다.
프리픽스 캐시 실행. prefix_cache는 정확 일치 캐시와 별개인 pfx: 응답 캐시 namespace를 게이팅합니다. 이 namespace는 정확 일치 항목과 분리되어 있고 정확 일치가 miss로 끝난 뒤에만 조회하므로 exact_cache는 끄고 prefix_cache만 켠 조직도 사용할 수 있습니다. 프리픽스 키는 전체 프롬프트와 max_tokens, top_p를 포함한 모든 유효 응답 형성 필드를 유지합니다. 동등 범위를 넓히기 위해 요청 파라미터를 지우지 않으므로 서로 다른 샘플링 제어 값이 같은 캐시 항목으로 취급되는 일은 없습니다. 재생 정확성은 두 가지 장치로 지킵니다. 첫째, 자연스럽게 끝난 응답만 저장합니다(OpenAI finish_reason: stop, Anthropic stop_reason: end_turn, 또는 완료된 Responses 출력이며 길이 제한으로 잘린 응답은 저장하지 않습니다). 둘째, 저장된 항목은 완성 길이가 새 요청의 토큰 제한 안에 들어갈 때만 서빙합니다(길이 제한이 없는 요청은 항상 들어맞고 저장된 길이를 알 수 없으면 안전하게 실패 처리합니다). 프리픽스 히트는 정확 일치 히트와 같은 record_local_cache_hit 지점을 통해 CacheHitType::Prefix를 찍으며 정확 일치와는 상호 배타적이라 한 요청은 정확히 한 번만 계측됩니다. OpenAI 채팅, Responses, Anthropic Messages 비스트리밍 경로에 연결되어 있고 스트리밍 버퍼에는 연결되어 있지 않습니다.
응답 헤더. 강제 적용을 거친 응답은 캐시 결과를 x-continuum-cache: hit|miss|bypass로 실어 나릅니다. 이 값은 x-cache 헤더를 미러링하며 x-cache도 모든 트래픽에 포함됩니다. 배치 적격 여부는 x-continuum-batch: on|off로 실리며 기존 x-continuum-ratelimit-* 세 값 묶음도 함께 붙습니다. batch 값은 적격 여부를 나타낼 뿐이며 실제로 요청을 프로바이더 배치 엔드포인트로 보내는 일은 이 결정을 사용하는 별도 기능이 담당합니다.
캐시 히트 계측. 로컬 캐시에서 응답이 나가면 평소의 사용량 계측 지점에 닿기도 전에 흐름이 끊기므로 라우터는 히트가 발생한 바로 그 지점에서 별도의 메타데이터 전용 사용량 이벤트를 내보냅니다. 이 이벤트는 cache_hit = true로 표시되며 입력 토큰 전부를 캐시 읽기로 집계하고(청구 대상 입력은 0) 출력 토큰은 0으로 기록하며 허브 key_id로 귀속시킵니다. 그래서 로컬 히트는 로컬에서든 허브 집계에서든 input_tpm/output_tpm 윈도와 월간 예산 어느 쪽도 소진시키지 않으며 '캐시 읽기는 집계에 포함하지 않는다'는 불변식을 그대로 지킵니다. 동기화된 가격 카탈로그가 해당 모델을 알고 있으면 라우터는 참고용 정가 절감액도 로그로 남기는데 정식 절감액 계산은 허브 쪽에서 수집 시점에 이뤄집니다. 카탈로그가 비어 있으면 이 로컬 표시는 꺼집니다.
범위 참고. 시맨틱 캐시 경로는 의도적으로 아무 동작도 하지 않는 채로 남아 있으며 정책 배선 자체는 이미 갖춰져 있지만 허브의 semantic_cache 플래그와 운영자의 control_plane.optimization.semantic_cache_enabled(기본값 꺼짐) 두 겹으로 게이팅되어 있습니다. 시맨틱 캐시에는 서빙 경로가 없습니다. CacheHitType::Semantic은 예약되어 있으며 정확 일치와 프리픽스 일치 방식만 동작합니다.
배치 디스패치와 라이프사이클 보고¶
배치 트래픽은 라우터에서 실행되고 완료는 프로바이더 쪽에서 비동기로 이뤄집니다. 라우터는 라이프사이클 상태를 허브에 보고합니다. control_plane.batch.enabled로 게이팅되며 적격 여부와 배치 풀이 허브 동기화 티어에서 나오므로 policy.enabled도 함께 켜져 있어야 합니다.
디스패치. 라우터는 OpenAI 스타일 Batch API 표면을 전부 노출합니다. POST /v1/batches(생성), GET /v1/batches/{id}(조회), GET /v1/batches(목록), POST /v1/batches/{id}/cancel(취소), GET /v1/batches/{id}/results(원본 JSONL 결과)가 그것입니다. 제출은 요청의 유효 결정이 batch_eligible일 때만(티어의 batch_eligible 플래그를 x-continuum-batch: off로 좁힌 값이며 off가 항상 우선합니다) 허용됩니다. 배치 클라이언트는 동기식 Backend 트레이트 밖에서 동작하며, 라우터의 URL 조합 로직과 공유 HTTP 클라이언트를 재사용하며 control_plane.batch.backend(또는 첫 번째 OpenAI 호환 백엔드, 그마저 없으면 첫 번째 네이티브 Anthropic 백엔드)로 대상 백엔드를 정하고 설정에서 이미 해석된 그 백엔드의 기본 URL과 API 키를 그대로 사용합니다. 조회, 취소, 목록, 결과 조회는 모두 소유권 범위로 제한됩니다. 라우터는 제시된 키를 확인해 트래커가 그 작업을 같은 키로 귀속시킬 때만 배치를 돌려주거나 취소하거나 목록에 남겨두며 알 수 없는 id든 다른 키의 id든 이미 종료된 id든 모두 프로바이더에 연결하지 않고 동일한 404로 응답합니다. 라우터 키 하나하나가 프로바이더 계정 하나에 다중화되므로 프로바이더 자체의 스코핑만으로는 라우터 키를 서로 분리할 수 없기 때문입니다. GET /v1/batches는 프로바이더 계정의 전체 목록을 호출한 키가 소유한 id로 걸러 돌려주고 GET /v1/batches/{id}/results는 결과가 어디에 있는지 알아내려고 배치를 먼저 조회한 뒤 결과가 아직 없으면 409를, 프로바이더 조회 자체가 실패하면 502를 돌려줍니다.
Anthropic Message Batches 동등 지원. 표면 라우터가 해석된 백엔드에서 프로바이더 클라이언트를 고릅니다. 네이티브 Anthropic 백엔드는 모든 배치 작업을 x-api-key와 anthropic-version 인증을 쓰는 Anthropic Message Batches API(/v1/messages/batches, .../cancel, .../results)로 보내고 그 밖의 OpenAI 호환 백엔드는 그대로 OpenAI Batch API를 씁니다. 호출자와 허브로 가는 라이프사이클 보고 양쪽 모두 두 프로바이더의 차이를 느끼지 못합니다. Anthropic의 더 성긴 processing_status(in_progress, canceling, ended)와 cancel_initiated_at은 OpenAI 상태 문자열과 똑같은 표준 BatchJobState로 매핑되므로 Anthropic 작업도 같은 트래커에 등록되고 같은 폴러와 보고 루프를 그대로 탑니다.
별도 배치 풀. 배치 제출은 동기 속도 제한과 완전히 분리된 풀에 부과됩니다. 티어의 batch_rpm(분당 윈도)과 batch_queue_depth(제출 시 증가하고 첫 종료 상태에서 감소하는 인플라이트 게이지)가 그 풀을 이룹니다. 배치 트래픽은 키의 rpm/input_tpm/output_tpm을 절대 소진시키지 않고 동기 트래픽도 배치 풀을 절대 소진시키지 않습니다. 둘 다 선택 값이라 None이면 그 차원에는 제한이 없다는 뜻입니다. 티어와 무관하게 동시에 추적하는 작업 수를 10만 개로 제한하는 별도 상한도 있어 이런 경우에도 트래커 메모리가 무한정 늘어나지 않으며 상한에 도달한 제출은 503으로 거부됩니다.
라이프사이클 보고. 제출된 작업은 메모리에서 추적됩니다. 크기가 제한된 폴러가 종료되지 않은 작업마다 프로바이더를 폴링하고(클라이언트의 상태 조회도 변화를 감지합니다) 모든 상태 전이(submitted -> in_progress -> completed/failed/cancelled/expired)는 허브의 POST /api/agent/v1/batches로 전송됩니다. 전달은 at-least-once 방식이며 크기가 제한되어 오래된 것부터 버리는 버퍼와 전이별로 고정된 멱등성 키를 사용하므로 확인 응답을 놓친 뒤 재전달이 일어나도 허브의 멱등적·단조적 upsert가 중복을 제거합니다. 업데이트는 메타데이터만 담습니다(id, 대략적인 상태, 타임스탬프뿐이며 프로바이더 오류 텍스트는 절대 담지 않습니다). 이 엔드포인트가 404를 반환하면 라우터는 한 번만 로그를 남긴 뒤 전송을 멈추지만 서빙은 계속합니다. 폴러가 더 이상 확인할 수 없는 작업, 즉 프로바이더에 영구히 연결할 수 없거나 핫 리로드로 배치 백엔드가 제거·개명됐거나 프로바이더가 종료 상태에 이르지 못한 채 멈춰 있는 경우에도 인플라이트 슬롯을 영원히 붙들고 있지는 않습니다. 일정 나이를 넘기면 강제로 회수하는 안전장치가 있어 26시간을 넘겨 추적 중인 작업은 강제로 expired로 전이되어 슬롯을 해제하고 종료 상태를 보고합니다. 이렇게 회수된 작업은 완료 사용량 레코드를 내보내지 않습니다.
완료 사용량. 종료 상태인 completed 전이가 일어나면 라우터는 인플라이트 슬롯을 해제하고 기존 사용량 경로를 통해 batched = true와 provider_batch_id, 프로바이더가 보고한 완료 시각을 담은 사용량 레코드를 내보냅니다. 토큰 합계는 배치 출력 파일에서 가능한 선까지 집계합니다. 이 읽기는 64MiB로 상한이 걸려 있어 유난히 큰 배치는 뒷부분이 잘리더라도 집계 자체는 계속되며 라우터 메모리가 무한정 늘어나지는 않습니다. UsageRecord.provider_batch_id는 선택적 추가 필드이며 캐시 인지 계측은 별도 필드를 사용합니다.
범위. GET /v1/batches는 페이지네이션 없이 프로바이더의 전체 목록을 호출한 키가 소유한 id로 걸러 돌려줍니다. 이 목록 API는 페이지네이션을 지원하지 않습니다. Bedrock의 배치 API는 S3 기반이라 Anthropic 표면 라우터의 대상에서 제외되며 네이티브 Anthropic 백엔드만 Message Batches API로 라우팅됩니다.
캐시 히트 타입 계측¶
사용량 레코드에는 로컬 응답 캐시 귀속을 위한 cache_hit_type도 실립니다. 라우터가 자체 로컬 캐시에서 응답을 서빙하면 레코드의 cache_hit_type에 어떤 캐시 모드가 그 응답을 서빙했는지 찍힙니다. 정확 일치 히트에는 exact가 찍히고 프롬프트-프리픽스 히트에는 prefix가 찍히는데 둘 다 실제로 서빙하는 경로이며 요청 하나에는 상호 배타적으로만 적용되므로 한 번만 찍히고 한 번만 계측됩니다. semantic은 예약된 값이며 서빙 경로가 없습니다. 그 밖의 모든 사용량 계측 지점, 즉 프로바이더 측 캐시 읽기나 완료된 배치 작업은 cache_hit_type을 None으로 남겨두는데 그런 캐시 읽기는 이미 cached_input_tokens가 담당하므로 이 필드에는 나타나지 않습니다. 이 필드는 프롬프트나 완성 내용을 담지 않는 메타데이터 전용 Option이며, 값이 없으면 None을 사용합니다.
합성 프로바이더/모델 프로브¶
허브가 프로브 정책(PolicyEnvelope.probes)을 전달하면, 라우터는 고객 트래픽이 장애를 먼저 겪기 전에 자신의 네트워크 경로에서 프로바이더 도달성과 자격 증명/모델 상태를 측정합니다. 프로브는 다음 게이트가 모두 성립할 때만 동작합니다. control-plane Cargo 기능이 컴파일되어 있고, control_plane.enabled가 참이고, control_plane.policy.enabled가 참이며, 보유한 정책이 활성화 상태로 하나 이상의 (provider, model) 대상을 갖고 있어야 합니다. 별도의 라우터 설정 스위치는 없으며, 게이트가 하나라도 꺼져 있으면 프로브 작업 동작도, 프로바이더 비용도, 프로브 와이어 트래픽도 없습니다.
3상 정책. probes 필드는 프로바이더 자격 증명과 같은 3상 규약을 따릅니다. 필드가 없으면 마지막으로 보유한 정책을 유지하고, 정책이 전달되면 교체하며, 비활성화된 정책은 권위 있는 해제입니다. 변경은 watch 채널로 스케줄러에 전파되므로 해제나 교체가 유휴 주기를 즉시 중단시키고, 라운드 중간에는 아직 디스패치되지 않은 대상을 취소하며, 라운드는 절대 겹치지 않습니다(스케줄러는 각 라운드를 끝까지 await한 뒤에야 다음 라운드를 시작합니다). 새로 활성화된 정책은 첫 라운드를 즉시 실행하고, 이후에는 interval_secs 주기로 라운드당 대상마다 최대 한 번만 시도합니다.
실행. 각 대상은 라이브 모델 서비스와 현재 설정에서 후보를 해석한 뒤, 사용량 레코드와 허브 자격 증명이 쓰는 것과 동일한 provider_for_config_type() 매핑의 정식 프로바이더 문자열이 대상의 프로바이더와 정확히 일치하는 백엔드로 교집합을 만들고, 라이브 백엔드 풀의 선택 전략이 그 제한된 집합에서 하나를 고릅니다. 그런 다음 고정된 PROBE_PROMPT만 담은 비스트리밍 채팅 완성 요청 하나를 작은 고정 출력 토큰 상한과 결정적 설정으로, HTTP/스트리밍 계층 아래의 내부 백엔드 실행 이음새를 통해 보냅니다. 이 이음새는 각 백엔드의 정상적인 요청 변환과 유효 인증(허브 전달 자격 증명이 로컬 키보다 우선, OAuth, SigV4)을 재사용하되 구조화된 상태/전송 결과만 돌려줍니다. 모델 대체, 가격 차익, 폴백 체인, 캐시 조회/저장, 가드레일, 배치, 자동 재시도는 프로브에 관여할 수 없습니다.
프라이버시 경계. 프로브 결과는 메타데이터만 담습니다. 즉 id, 대략적 상태 클래스(ok, auth_failed, rate_limited, timeout, provider_error, network_error, unknown), 경계가 정해진 정제 어휘의 사유 코드(http_NNN, timeout, dns_failure, tls_failure, connection_refused, connection_reset, network_error), 시간 값, 그리고 정규화된 usage 객체에서 뽑은 정수 토큰 수입니다. 프로바이더 에러 본문은 읽지 않고 버리며, 프롬프트, 완성 텍스트, 자격 증명이 포함된 URL, 스택 트레이스는 결과, 로그, 캐시, 영속 상태 어디에도 들어가지 않습니다. 프로브 트래픽은 사용량 이벤트를 내보내지 않고, 키별 정책 카운터를 변경하지 않으며, 사용량 배치에 들어가지 않습니다. 프로브 비용은 허브의 probe_results 저장소에만 기록되고 과금이나 SLO 파이프라인에는 절대 들어가지 않습니다. 예약된 x-continuum-probe 요청 헤더는 어떤 계측 경로도 참조하지 않으므로, 호출자가 이 헤더를 붙여도 정상 요청 계측을 우회할 수 없습니다.
예산 범위(라우터별). monthly_budget_microusd는 현재 UTC 월의 강한 상한이며 0은 무제한을 뜻합니다. 유료 디스패치 전마다 라우터는 보수적인 최대 비용(보유한 가격 카탈로그 항목에 고정 입력/출력 토큰 경계를 곱해 올림한 값)을 control_plane.state_file 옆의 소유자 전용, 원자적으로 교체되는 사이드카에 먼저 예약하므로, 상한은 재시작과 크래시 구간을 넘어 유지됩니다. 양수 상한은 가격 항목이나 영속 상태를 쓸 수 없으면 안전하게 닫힙니다(프로브를 보내지 않음). 이미 예약된 지출보다 낮게 정책이 줄어들면 새 프로브가 즉시 멈추고, 이후 상향되면 재개됩니다. 소진되면 다음 보고에 budget_exhausted가 실리며(필요하면 빈 보고라도 전송해 허브가 상태를 관찰할 수 있게 합니다), 원장은 UTC 월이 바뀔 때만 초기화됩니다. 이 상한은 라우터별로 강제됩니다. v0 와이어 계약에는 라우터 간 리스가 없으므로, 같은 조직 정책을 받는 여러 라우터는 각자 독립적으로 상한을 지키며 플릿 전체 합산 상한은 아닙니다.
전달과 혼합 버전. 결과는 경계가 있는 drop-oldest 버퍼에 쌓였다가 라우터별 자격 증명으로 POST /api/agent/v1/probes에 전송됩니다. 한 번 구성된 보고는 재시도 내내 report_id, probe_id, 결과 집합을 그대로 유지하고, 전송/5xx 실패는 표준 제어 플레인 백오프로 재시도하며, 일치하는 확인 응답을 받은 뒤에만 폐기됩니다(허브는 (tenant, router, probe_id)로 중복을 제거합니다). 프로브 엔드포인트가 없는 구버전 허브의 404는 치명적이지 않습니다. 라우터는 로그를 속도 제한해 남기고 긴 고정 지연 후에 재시도하며 요청 서빙은 중단되지 않습니다. 프로브를 비활성화하거나 라우터를 종료하면 스케줄러가 깔끔하게 취소되고(종료 시 진행 중인 디스패치는 버려짐) 라우터 종료가 지연되지 않습니다.
구현: src/control_plane/probes/(스케줄러, 예산 원장, 분류, 결과 버퍼), src/core/traits.rs / src/core/probe.rs / src/infrastructure/common/executor/probe.rs의 프로브 이음새와 백엔드별 오버라이드, 그리고 crates/continuum-protocol/src/probe.rs(와이어 계약).
벤더링된 프로토콜 크레이트¶
와이어 계약은 crates/continuum-protocol/에 있는 벤더링된 크레이트로 존재하며, typed 검증과 공개 정책 digest helper를 포함하고 control-plane 기능에서만 컴파일되는 PATH 의존성입니다. continuum-hub가 비공개 저장소이기 때문에 git 의존성 대신 벤더링 방식을 택했습니다. 선택적 git 의존성이라면 기능이 꺼져 있어도 빌드할 때마다 해석을 시도하게 되어, 자격 증명이 없는 환경과 오프라인 환경, 기본 빌드까지 깨뜨렸을 것입니다. 고정된 업스트림 리비전은 crates/continuum-protocol/Cargo.toml을 참조하세요.
구현: src/control_plane/(에이전트, HTTP 클라이언트, 자격 증명 저장소, 인벤토리 빌더, 사용량 버퍼, 백오프, 정책 스토어, 정책 동기화 루프, 강제 적용 미들웨어, 최적화 결정과 캐시 히트 계측, 그리고 batch/ 서브시스템: OpenAI와 Anthropic 프로바이더 배치 클라이언트, 둘 중 하나를 고르는 표면 라우터, 생성/조회 디스패치 핸들러, 목록/취소/결과 조회 오퍼레이션, 작업 트래커, 라이프사이클 보고와 폴러 루프). 설정 스키마: src/core/config/control_plane.rs. 설정 참조: config.yaml.example의 control_plane 섹션.
이 아키텍처는 유지보수성과 확장성을 유지하면서 수천 개의 요청을 처리할 수 있도록 확장 가능한 프로덕션 레디 LLM 라우터를 구축하기 위한 견고한 기반을 제공합니다. 깔끔한 관심사 분리로 새로운 기능을 추가하고, 구현을 교체하며, 각 컴포넌트를 철저히 테스트하기 쉽습니다.