모델 실험 (A/B 테스트)¶
모델 실험을 사용하면 클라이언트는 chat 같은 하나의 고정된 모델 이름으로 계속 요청하고, 라우터는 그 트래픽의 정해진 비율을 한 업스트림 모델로, 나머지를 다른 모델로 보냅니다. 각 실험은 요청마다 가중치가 붙은 여러 변형(variant) 중 하나로 해석되는 가상 모델 이름입니다. 변형은 디스패치할 업스트림 모델 ID를 지정하며, 해당 변형을 처리할 수 있는 백엔드를 고정할 수도 있습니다.
이름 변환이 엔진이 아니라 라우터에서 일어나므로, 셀프 호스팅 엔진뿐 아니라 호스팅 프로바이더(OpenAI, Anthropic, Gemini, Bedrock)에도 실험을 적용할 수 있습니다.
바로 실행할 수 있는 출발점은 continuum-router config generate --template ab-testing으로 생성할 수 있습니다.
설정¶
model_experiments:
- name: chat-ab-2026-09 # 실험 이름: 고정 해시의 salt이자 메트릭 레이블
model: chat # 클라이언트가 사용하는 모델 이름
sticky_key: api_key # api_key(기본값) | user | none | header:<name>
sticky_ttl: 7d # 선택: 호출자를 창마다 한 번 재배정(미설정 = 영구)
fallback: within_variant # within_variant(기본값) | cross_variant
response_model: upstream # upstream(기본값) | client
response_headers: true # 귀속 헤더 출력(기본값 false)
hide_variant_models: false # 변형 모델 숨김 및 예약(기본값 false)
variants:
- id: control
model: gpt-5.6 # 백엔드로 보낼 업스트림 모델 ID
weight: 90
- id: candidate
model: qwen3.6-235b
backends: [vllm-pool] # 선택적 백엔드 고정
weight: 10
실험 필드¶
| 필드 | 기본값 | 설명 |
|---|---|---|
name | 필수 | 실험 이름. [A-Za-z0-9._-] 문자 1-64자. 고정 해시의 salt이며 experiment 메트릭 레이블입니다. 이름을 바꾸면 모든 호출자의 배정이 새로 시작됩니다. |
model | 필수 | 클라이언트가 사용하는 모델 이름. 실험 간에 중복될 수 없습니다. |
sticky_key | api_key | 호출자를 한 변형에 머물게 하는 요청 속성. 고정 배정을 참고하세요. |
sticky_ttl | 미설정 | 선택적인 배정 유지 기간. 60s부터 365d까지(s, m, h, d; 단위가 없으면 초). 설정하지 않으면 배정이 영구히 유지됩니다. 배정 유지 기간을 참고하세요. |
fallback | within_variant | 실패 시 동작. 실패 의미론을 참고하세요. |
response_model | upstream | 응답 model 필드에 담길 이름. 응답 모델 이름을 참고하세요. |
response_headers | false | x-continuum-experiment, x-continuum-experiment-variant 응답 헤더를 출력합니다. |
hide_variant_models | false | 모든 변형 모델을 클라이언트용 모델 목록에서 숨기고 실험 전용으로 예약합니다. 변형 모델 숨김과 예약을 참고하세요. |
variants | 필수 | 가중치가 붙은 변형 목록. 최대 16개. |
변형 필드¶
| 필드 | 기본값 | 설명 |
|---|---|---|
id | 필수 | 실험 안에서 고유한 변형 식별자. [A-Za-z0-9._-] 문자 1-64자. variant 메트릭 레이블입니다. |
model | 필수 | 이 변형에서 디스패치할 업스트림 모델 ID. |
backends | [] | 선택적 고정. 지정하면 이 백엔드들만 해당 변형을 처리할 수 있습니다. |
weight | 필수 | 상대 정수 가중치. 0이면 변형을 삭제하지 않고 비활성화합니다. |
이 섹션은 즉시 핫 리로드됩니다. 매 요청마다 현재 설정에서 읽습니다.
요청 해석 방식¶
실험은 모델을 기준으로 라우팅하는 모든 인그레스에서 메타데이터 별칭 디스패치가 실행되는 지점과 같은 곳에서 실행됩니다. 대상은 chat completions, completions, embeddings, Responses API(/v1/responses/compact 포함), Anthropic Messages(count_tokens 포함), 이미지 엔드포인트입니다. 순서는 다음과 같습니다.
model_aliases가 먼저 이름을 바꿉니다(Anthropic과 Responses 인그레스). 따라서 별칭 슬롯이 실험의 클라이언트 모델 이름을 가리킬 수 있습니다.- chat 인그레스에서는 스마트 라우팅이 실행됩니다. 티어 대상이 실험의 클라이언트 모델 이름이면 실험이 해석합니다. 스마트 라우팅의
virtual_model자체는 실험 모델이 될 수 없으며, 로드 시점 검증이 이를 거부합니다. - 요청 파라미터 정책 스냅샷과 키별 모델 게이트는 클라이언트 모델 이름을 기준으로 판단합니다.
request_params.models.<name>과 키의allowed_models는 변형 모델이 아니라chat을 기준으로 작성하세요. - 실험이 변형을 고르고, 요청은 해당 변형의 업스트림 모델로 계속 진행됩니다.
- 그 업스트림 모델에 대해 메타데이터 별칭 디스패치가 실행되므로, 변형 모델이 메타데이터 별칭이어도 정식 ID로 디스패치됩니다.
라우터가 바꾸면 안 되는 라우팅 결정을 이미 가진 요청은 실험을 건너뜁니다. AppProxy 인그레스 고정, Continuum Hub 치환, 아비트리지, 정확한 모델 예산 가드가 여기에 해당합니다.
이름 변환 이후에 발생한 선택 오류(404 model not found, 403 model not permitted)는 변형의 업스트림 ID가 아니라 클라이언트 모델 이름을 표시합니다.
모델을 기준으로 라우팅하지만 실험을 해석하지 않는 인그레스도 있습니다. /v1/rerank, /embed_sparse, /v1/realtime WebSocket, /v1/batches는 요청된 모델 이름을 그대로 디스패치합니다. 이 경로에서는 실험의 클라이언트 모델 이름이 제공되지 않으므로, 구체적인 업스트림 모델 ID로 요청하세요.
고정 배정¶
고정 배정된 호출자는 모든 레플리카에서, 재시작 후에도 같은 변형에 배정됩니다. 라우터는 실험 이름(salt)과 고정 값을 SHA-256으로 결합하고, 가중 랑데부 해싱으로 변형을 배정합니다. 프로세스 로컬 난수에 의존하는 부분은 없습니다.
sticky_key | 고정 값 |
|---|---|
api_key | 제시된 API 키(Authorization: Bearer 또는 x-api-key)의 해시. 원본 자격 증명은 저장하거나 로그에 남기지 않습니다. |
user | OpenAI user 필드, 또는 Anthropic metadata.user_id. |
header:<name> | 지정한 요청 헤더의 값. 이름은 유효한 HTTP 토큰이어야 합니다. |
none | 고정하지 않습니다. |
고정 값이 없는 요청(API 키 없음, user 필드 없음, 헤더가 없거나 비어 있음)과 none의 모든 요청은 같은 가중치의 독립적인 무작위 추첨으로 배정됩니다. 이런 요청은 model_experiment_requests_total에서 assignment="random"으로 집계되므로, 무작위 추첨이 분할을 좌우하는 실험을 확인할 수 있습니다.
가중치 변경¶
가중 랑데부 해싱은 호출자마다 변형별로 독립적인 추첨을 하므로, 가중치를 바꾸면 새 가중치가 요구하는 만큼의 호출자만 이동합니다.
- 한 변형의 가중치를 올리면 호출자는 그 변형 안으로만 이동합니다.
- 한 변형의 가중치를 내리면 호출자는 그 변형 밖으로만 이동합니다.
- 변형을 추가, 제거하거나 가중치를 0으로 만들면 그 변형이 얻거나 잃는 호출자만 이동합니다.
90/10 분할을 80/20으로 바꾸면 호출자의 약 10%가 모두 control에서 candidate로 이동합니다. 새로운 배정으로 실험을 다시 진행하려면 실험 이름을 바꾸세요.
배정 유지 기간¶
기본적으로 고정 배정된 호출자는 실험이 존재하는 동안 같은 변형을 유지합니다. sticky_ttl을 설정하면 호출자를 창(window)마다 한 번 재배정하며, 여전히 아무것도 저장하지 않습니다. 창 번호를 호출자의 고정 해시에 섞기 때문에, 같은 호출자와 같은 벽시계 시각에 대해 모든 레플리카가 같은 변형을 계산하고 재시작해도 바뀌지 않습니다.
- 고정되고 엇갈린 창. 호출자마다 창 길이는
sticky_ttl이고, 그 호출자의 고정 해시에서 얻은 오프셋만큼 밀려 있습니다. 따라서 모든 호출자가 한순간에 재배정되지 않고 창 전체에 걸쳐 서로 다른 시점에 재배정됩니다. 창은 호출자의 첫 요청을 기준으로 측정되지 않습니다. - 재배정은 새로운 추첨입니다. 경계에서 호출자가 같은 변형에 다시 배정될 확률은 그 변형의 가중치 비율과 같으므로, 재배정마다 변형이 바뀌는 호출자 비율은
1 - sum(share^2)입니다. 90/10 분할이면 18%, 50/50이면 50%입니다. - 대화 중에 변형이 바뀔 수 있습니다. 호출자의 경계를 넘는 대화는 새로 뽑힌 변형으로 이어집니다.
- 가중치를 바꿔도 한 창 안에서는 호출자가 최소한으로만 이동합니다.
sticky_ttl을 바꾸면 재배정됩니다. 새 TTL은 오프셋과 창 번호를 모두 바꾸므로 호출자가 즉시 재배정되며, 섹션의 다른 설정처럼 다음 요청부터 적용됩니다.- 고정 값이 없으면 효과가 없습니다.
sticky_key: none이거나 고정 값이 없는 요청은 이미 매번 독립적으로 추첨되므로,sticky_key: none과 함께sticky_ttl을 설정하면config validate가 경고합니다.
창 번호는 응답 캐시 키에 포함되지 않습니다. 캐시 키에는 이미 배정된 변형이 들어 있습니다.
백엔드 고정¶
변형의 backends 목록은 요청이 가진 백엔드 허용 목록을 좁힙니다. 키의 allowed_backends 제한이 들어가는 목록과 같은 목록이므로, 고정은 백엔드 선택, 재시도, 응답 캐시 경로, 모든 폴백 홉에 적용됩니다. 키의 백엔드 허용 목록과 고정 목록이 겹치지 않으면, 요청을 다른 변형으로 옮기지 않고 403 model not permitted로 거부합니다.
실패 의미론¶
fallback | 동작 |
|---|---|
within_variant | 변형 모델에 fallback.fallback_chains 항목이 없는 것처럼 동작합니다. 변형의 백엔드 사이에서 재시도와 백엔드 페일오버는 그대로 적용되지만, 실패가 다른 모델로 넘어가지는 않습니다. 관측되는 분할 비율을 정확하게 유지합니다. |
cross_variant | 설정된 폴백 체인이 평소대로 실행되며, 다른 변형이나 실험 밖의 모델로 넘어갈 수 있습니다. 폴백 홉이 응답하면 model_experiment_fallbacks_total에 집계되고, X-Original-Model 폴백 헤더는 클라이언트 모델 이름을 표시합니다. |
cross_variant에서도 폴백 홉에 백엔드 고정이 적용됩니다. 고정된 변형이 폴백할 수 있어야 한다면 폴백 대상의 백엔드도 목록에 넣어야 합니다.
캐시 격리¶
배정된 실험과 변형은 exact, prefix, semantic 캐시가 공유하는 응답 캐시 네임스페이스에 반영됩니다. 두 변형이 같은 모델을 같은 백엔드로 디스패치하더라도, 한 변형이 다른 변형의 캐시 항목으로 응답하는 일은 없습니다. 실험이 해석하지 않은 요청의 캐시 키는 바뀌지 않습니다.
응답 모델 이름¶
response_model | 응답 model 필드 |
|---|---|
upstream | 업스트림이 응답한 값 그대로이며, 변형의 모델 이름입니다. 별칭 디스패치의 응답 방식과 같습니다. |
client | JSON 본문과 SSE data: 이벤트에서 클라이언트 모델 이름으로 바꿉니다. Anthropic message_start의 message.model과 Responses API의 response.model도 포함합니다. |
client에서도 32 MiB보다 큰 JSON 본문과 16 MiB보다 큰 단일 SSE 줄은 바꾸지 않고 그대로 전달합니다.
본문에 드러내지 않고 클라이언트나 로그 파이프라인이 응답을 변형에 귀속해야 한다면 response_headers: true를 사용하세요.
모델 목록¶
/v1/models, /v1/models/extended, /v1/models/{model}은 가중치가 양수인 변형 모델 중 하나 이상이 제공되는 동안 실험의 클라이언트 모델 이름을 나열합니다. 항목의 백엔드는 각 변형 모델을 제공하는 백엔드의 합집합을 각 변형의 고정 목록으로 좁힌 것이며, 다른 모델과 같은 키별 백엔드 및 모델 가시성 필터를 거칩니다.
/anthropic/v1/models도 같은 방식으로 클라이언트 모델 이름을 나열하며, id를 display name으로 사용합니다.
변형 모델 숨김과 예약¶
hide_variant_models: true로 설정하면 클라이언트는 실험을 거쳐서만 변형 모델에 도달할 수 있습니다. 가중치가 0인 변형을 포함해 선언된 모든 변형 모델이 숨겨지고 예약됩니다.
숨김¶
변형 모델은 /v1/models, /v1/models/extended, /v1/models/proxy, /anthropic/v1/models에서 제외되고, /v1/models/{model}은 해당 모델에 404로 응답합니다. 실험의 클라이언트 모델 이름은 계속 목록에 나오며, 그 항목은 변형 모델이 제외되기 전에 변형 모델의 백엔드를 가져옵니다. Admin API 모델 카탈로그(/admin/models)와 WebUI는 운영자용 화면이므로 변형 모델을 계속 표시합니다.
예약¶
실험 밖에서 변형 모델을 지정한 요청은, 해당 인그레스가 라우터에 없는 모델에 대해 반환하는 것과 같은 404 model not found로 거부되며, 클라이언트가 보낸 모델 이름이 표시됩니다. 응답 본문이 알 수 없는 모델의 응답과 동일하므로, 거부를 통해 모델의 존재가 드러나지 않습니다. 이 검사는 키별 모델 게이트가 판단하는 이름(model_aliases 적용 후)에 대해 chat completions, completions, embeddings, Responses API, Anthropic Messages와 count_tokens, 이미지 엔드포인트, /v1/rerank, /embed_sparse, /v1/realtime에서 실행됩니다. chat completions와 completions에서는 클라이언트가 보낸 이름(이 이름을 대체하는 AppProxy 인그레스 고정 적용 후)으로 실행되므로 Hub 결정이 이 검사를 해제하지 못합니다. 거부는 알 수 없는 모델이 받을 응답보다 먼저 반환되지 않습니다. 요청 오류(잘못된 파일 참조, rerank의 query 누락)와 키별 allowed_models의 403이 알 수 없는 이름과 똑같이 먼저 반환되며, chat completions에서 스마트 라우팅이 이름을 대체하는 경우(intercept_all)에는 다른 이름과 마찬가지로 예약되지 않은 모델로 라우팅됩니다.
- 별칭으로 우회할 수 없습니다. 예약된 모델의 정확한 메타데이터 별칭이나 예약된 별칭의 정식 ID로 보낸 요청도 거부됩니다.
- 키별
allowed_models로 해제되지 않습니다. 키의allowed_models에 변형 모델을 넣어도 도달할 수 없습니다. - 실험 트래픽은 통과합니다. 실험 자체의 변형 이름 변환과, 예약된 모델에서 시작하는 체인을 따르는
fallback: cross_variant홉은 예약된 모델로 계속 디스패치됩니다. - 다른 라우팅은 예약된 모델에 도달하지 않습니다. 스마트 라우팅은 후보에서 예약된 모델을 제외하고, 시작 모델이 예약되지 않은 폴백 체인은 실행 시 예약된 홉을 건너뛰므로 일반 모델의 체인은 예약된 모델에 도달하지 않습니다.
- 실험 응답에는 여전히 변형 이름이 담깁니다. 기본값
response_model: upstream은 응답model필드에 변형의 업스트림 모델을 반환합니다. 클라이언트가 실험의 클라이언트용 이름만 보게 하려면response_model: client를 설정하세요. - Hub 결정은 신뢰합니다. 예약된 모델로의 Continuum Hub 치환, 아비트리지, 예산 폴백은 예약된 모델에서 시작하는 체인과 마찬가지로 운영자가 작성한 라우팅이므로 거부하지 않습니다. 치환 전의 클라이언트 모델 이름은 여전히 검사합니다.
- 배치는 적용 대상이 아닙니다.
/v1/batches는 업로드된 입력 파일을 운영자의 배치 백엔드로 전달할 뿐 각 줄의 모델 이름을 보지 않으므로, 배치 줄에서는 예약된 모델을 지정할 수 있습니다.
거부될 때마다 model_experiment_reserved_refusals_total{experiment}가 증가합니다.
숨김 실험은 변형 모델을 자신의 클라이언트 모델 model로 사용할 수 없습니다. 숨기면 클라이언트가 보내야 할 이름이 예약되기 때문이며, 로더가 이 조합을 거부합니다. 예약된 모델이 model_aliases 대상, 예약되지 않은 모델에서 시작하는 폴백 체인의 대상, smart_routing.model_profiles 항목, 또는 변형을 숨기지 않는 다른 실험의 변형이면 continuum-router config validate가 경고하고 로더가 로그를 남깁니다. 마지막 경우 그 모델은 다른 실험을 통해 계속 도달할 수 있습니다.
메트릭¶
실험은 기존 요청, 지연 시간, 토큰 메트릭에 레이블을 추가하지 않고 전용 메트릭 계열을 사용합니다. 레이블을 추가하면 기존 계열의 레이블 집합이 바뀌고 카디널리티가 곱으로 늘어나기 때문입니다. 모든 레이블 값은 설정이나 닫힌 집합에서 오며, 실험은 최대 32개, 실험당 변형은 최대 16개까지 설정할 수 있습니다.
| 메트릭 | 유형 | 레이블 |
|---|---|---|
model_experiment_requests_total | Counter | experiment, variant, status(success, client_error, server_error), assignment(sticky, random) |
model_experiment_request_duration_seconds | Histogram | experiment, variant |
model_experiment_tokens_total | Counter | experiment, variant, kind(prompt, completion) |
model_experiment_fallbacks_total | Counter | experiment, variant |
model_experiment_reserved_refusals_total | Counter | experiment(예약한 실험의 설정 이름) |
지연 시간은 응답 헤더까지 측정하며, 스트리밍 요청에서는 스트림 시작 시점입니다. 상태 분류는 라우터가 응답한 HTTP 상태입니다.
토큰 계산 요청(/anthropic/v1/messages/count_tokens)도 실험을 해석하므로 배정된 변형의 모델로 계산하고 그 백엔드 고정을 따르며 귀속 헤더도 내보냅니다. 다만 model_experiment_requests_total과 model_experiment_request_duration_seconds에는 집계되지 않습니다. 호출마다 토큰을 먼저 세는 클라이언트가 아래 쿼리에서 분모로 쓰는 변형별 요청 수를 부풀리지 않도록 하기 위해서입니다.
# 변형별 오류율
sum by (experiment, variant) (rate(model_experiment_requests_total{status="server_error"}[5m]))
/ sum by (experiment, variant) (rate(model_experiment_requests_total[5m]))
# 변형별 요청당 completion 토큰
sum by (variant) (rate(model_experiment_tokens_total{kind="completion"}[5m]))
/ sum by (variant) (rate(model_experiment_requests_total{status="success"}[5m]))
OTLP 트레이스 내보내기가 켜져 있으면 router.request 스팬에 배정 정보가 router.experiment와 router.experiment_variant로 담깁니다(트레이스 내보내기 참고). Continuum Hub로 보내는 사용량 기록에는 아직 실험과 변형 정보가 담기지 않으며, 이는 #1624의 사용량 원장 작업에서 다룹니다.
검증¶
로더는 시작 시점, 핫 리로드, Admin API 적용 경로, continuum-router config validate에서 다음을 거부합니다.
- 중복된 실험 이름, 두 실험이 같이 사용하는 클라이언트
model - 활성화된
smart_routing.virtual_model과 같은 실험model - 변형이 없거나 모든 가중치가
0인 실험 - 1000000을 넘는 가중치, 중복된 변형 ID,
[A-Za-z0-9._-]{1,64}범위를 벗어난 이름이나 ID - 알 수 없는 백엔드나
internal: true백엔드를 가리키는 고정 - 고정된 모든 백엔드에 명시적인
models목록이 있을 때, 그중 어느 백엔드도 나열하지 않는 변형 모델 - 다른 실험의 클라이언트 모델 이름인 변형 모델(실험은 연쇄되지 않습니다)
- 잘못된
sticky_key, 알 수 없는 필드 - 선택적인
s,m,h,d접미사가 붙은 정수가 아니거나60s~365d범위를 벗어난sticky_ttl - 변형의
model이 실험 자신의 클라이언트 모델model과 같은hide_variant_models: true실험 - 백엔드 고정과 API 키 허용 목록이 겹치지 않는 요청을 거부할 때 라우터가 내부적으로 사용하는 예약된 식별자와 같은 이름의
backends[].name. 이 식별자에는 NUL 바이트가 포함되어 있어 실제 백엔드 이름과 우연히 충돌할 수 없으며, 이 검사는 그 거부 경로가 구성된 백엔드와 절대 일치하지 않도록 하기 위한 것입니다.
문제 해결¶
| 증상 | 원인 | 해결 |
|---|---|---|
| 사용자가 매일 변형이 바뀌거나 전혀 바뀌지 않음 | sticky_ttl이 하루로 설정되어 있거나 설정되지 않음(영구 배정) | 호출자가 변형을 유지해야 하는 기간(예: 7d)으로 sticky_ttl을 설정하거나, 영구 배정을 원하면 제거하세요. 재배정마다 호출자의 1 - sum(share^2)가 변형을 바꾼다고 예상하세요. |
| 관측 분할이 가중치와 다름 | 대부분의 트래픽이 하나의 고정 값을 공유함(예: 모든 최종 사용자가 한 API 키 사용), 또는 고정 값이 없어 무작위로 배정됨 | 서로 다른 값이 많은 sticky_key: user나 header:<name>을 사용하고, model_experiment_requests_total{assignment="random"}을 확인하세요. |
| 한 변형의 오류율이 예상보다 좋음 | fallback: cross_variant가 실패한 트래픽을 다른 모델로 옮김 | fallback: within_variant를 사용하고 model_experiment_fallbacks_total과 비교하세요. |
모든 요청이 403 model not permitted로 응답함 | 키의 allowed_models에 클라이언트 모델 이름이 없거나, 변형 고정 목록이 키의 allowed_backends와 겹치지 않음 | 실험 model을 allowed_models에 추가하고, 고정 목록이 키의 백엔드 목록과 겹치게 하세요. |
| 실험을 활성화한 뒤 변형 모델이 404로 응답함 | 실험이 hide_variant_models: true로 변형 모델을 실험 트래픽 전용으로 예약함 | 실험의 클라이언트 모델 이름을 사용하거나, 클라이언트가 변형 모델에 직접 계속 접근해야 한다면 hide_variant_models를 끄세요. |
/v1/models에 실험이 나오지 않음 | 가중치가 양수인 변형 모델이 보이는 백엔드에서 제공되지 않음 | 변형 모델이 각 백엔드에서 발견되는지 확인하세요. |