메인 콘텐츠로 건너뛰기
대부분의 AI 앱은 모델 API를 직접 호출하는 것으로 시작합니다. 프로토타입 단계에서는 잘 작동하지만, 여러 앱, 서비스 또는 고객이 접근해야 하는 순간부터 제공자를 직접 호출하는 방식은 관리가 점점 어려워집니다. 모든 서비스가 제공자 키를 필요로 하고, 모든 클라이언트가 제공자별 동작을 익혀야 하며, 팀마다 인증, 제한, 관측성 문제를 조금씩 다르게 해결하게 됩니다. LLM 게이트웨이는 호출자를 인증하고, 속도 제한을 강제하며, 업스트림 제공자 키를 숨기고, 텔레메트리를 기록하며, 우리 애플리케이션을 위한 안정적인 API를 유지할 수 있는 단일 지점을 제공합니다. 이 튜토리얼에서는 Axum, Postgres, SQLx, 그리고 Venice AI API를 사용해 Rust로 게이트웨이를 만들어 보겠습니다. 끝날 즈음에는 OpenAI 호환 /v1/chat/completions 엔드포인트를 제공하고, 자체 베어러 토큰을 받아들이며, Venice로 요청을 전달하고, 스트리밍 응답을 지원하며, 유용한 OpenTelemetry 스팬과 메트릭을 방출하는 게이트웨이를 갖게 됩니다. 전체 코드 구현이 궁금하다면 GitHub 저장소를 확인하세요.

사전 요구사항

  • Rust 1.92+
  • Docker 및 Docker Compose
  • Venice API 키
  • curl
  • Rust 웹 서비스에 대한 기본적인 이해
시작하기 전에 Venice API 키를 export하세요:
export VENICE_API_KEY="your-venice-api-key"
이 키는 클라이언트 앱에 절대 노출하지 않습니다. 게이트웨이가 서버 측에서 보관하고, 클라이언트는 대신 게이트웨이 전용 API 키로 인증하게 됩니다.

무엇을 만들 것인가

레퍼런스 구현은 몇 가지 명확한 부분으로 구성된 작은 Rust 서비스입니다:
구성 요소하는 일
Axum 라우터/healthz/v1/chat/completions를 제공
인증 추출기Postgres에 해시된 형태로 저장된 키와 게이트웨이 베어러 토큰을 대조하여 검증
속도 제한기Postgres에 저장된 고정 윈도우를 사용
Venice 클라이언트스트리밍 및 비스트리밍 chat completion 요청을 프록시
텔레메트리GenAI 스팬, 토큰 사용량, Venice 청구 비용, 지연 시간, 스트리밍 타이밍을 기록
Docker Compose로컬에서 Postgres와 게이트웨이를 실행
클라이언트가 Rust 게이트웨이, Postgres, Venice AI, OpenTelemetry를 호출하는 아키텍처 다이어그램 클라이언트가 OpenAI 호환 요청을 게이트웨이로 보냅니다. 게이트웨이는 호출자를 인증하고, 속도 제한을 확인한 뒤, Venice로 요청을 전달하고, 그 과정에서 텔레메트리를 기록합니다. 게이트웨이의 일부로, 우리는 이 서비스가 API 자체에 대해 최소한의 표면적을 유지하면서도 수평 확장 가능하도록 만들 것입니다. 여기에는 몇 가지 이유가 있는데, 그중 가장 큰 이유는 처리량이 매우 높다면 거의 확실히 레플리카를 사용하게 될 것이라는 점입니다(즉, 동일한 서비스를 1개 이상 띄우는 것). 이는 아직 그렇게 하지 않았다면 원본 서비스와 레플리카를 로드 밸런서 뒤에 두어야 함을 의미하며, 그래야 하나의 컨테이너나 서비스가 다운되어도 전체 서비스가 중단되지 않습니다. 또한 API 키 생성을 어떤 형태로든 우리가 소유한다고 가정하겠습니다. 다만 게이트웨이 서비스가 단독으로 키를 발급해서는 안 됩니다. 이는 로컬에서 사용할 때 시드 데이터를 채우는 Postgres 테이블로 표현될 것입니다. 프로덕션에서는 일반적으로 인증 서비스가 이를 처리합니다. LLM 게이트웨이를 사용하는 모든 사용자에 대해 업스트림에서 API 키를 생성하는 것도 가능하지만, 실무에서는 일반적으로 권장되지 않습니다. 이 책임을 업스트림 서비스에 넘기게 되면 여러분이 평소 가지고 있던 제어권도 함께 넘기게 되며, 결과적으로 속도 제한이나 지출 상한 같은 것들을 완전히 강제할 수 없게 됩니다. 소스 트리는 의도적으로 작게 유지됩니다:
.
├── Cargo.toml
├── Dockerfile
├── docker-compose.yml
├── migrations/
│   └── 0001_api_keys.sql
├── scripts/
│   ├── seed_api_key.sh
│   └── smoke_chat.sh
└── src/
    ├── auth.rs
    ├── config.rs
    ├── error.rs
    ├── main.rs
    ├── rate_limit.rs
    ├── router.rs
    ├── sse.rs
    ├── state.rs
    ├── telemetry.rs
    └── venice.rs
더 이상 지체하지 말고, 이제 만들어 봅시다.

Rust 서비스 생성하기

새로운 Rust 바이너리 프로젝트로 시작합니다:
cargo new llm-gateway
cd llm-gateway
Cargo.toml에 필요한 의존성을 추가합니다 - 코드 스니펫에 설명이 포함되어 있습니다:
[dependencies]
# Web backend
axum = "0.8.9"
tower = "0.5.3"
tower-http = { version = "0.6.11", features = ["trace", "request-id", "sensitive-headers", "timeout", "limit"] }

## Observability
opentelemetry = "0.32.0"
opentelemetry-otlp = "0.32.0"
opentelemetry_sdk = "0.32.1"
tracing = "0.1.44"
tracing-opentelemetry = "0.33.0"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }

# HTTP request/response handling
reqwest = { version = "0.12.28", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "charset"] }
reqwest-eventsource = "0.6.0"
bytes = "1.11.1"
futures-util = "0.3.32"

# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"

# Hashing/crypto
sha2 = "0.11.0"
subtle = "2.6.1"

# SQL
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "macros", "migrate"] }

# utils
thiserror = "2.0.18" # a crate for writing ergonomic errors
time = { version = "0.3.47", features = ["serde", "formatting", "macros"] } # time
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "net", "time"] } # the most popular async Rust runtime
uuid = { version = "1.23.3", features = ["serde", "v4"] }

설정 불러오기

게이트웨이는 모든 것을 환경 변수에서 읽습니다. 이런 인프라 코드의 경우, 동일한 바이너리가 별도의 설정 파일 형식 없이도 로컬, Docker Compose 또는 호스팅 환경에서 실행될 수 있기 때문에 환경 변수가 좋은 기본값입니다. 또한 많은 제공자가 자체 컨테이너 런타임에서 환경 변수를 시크릿으로 저장할 수 있도록 허용합니다. 이는 dotenv(또는 Rust에서는 원본 dotenv 크레이트가 대부분 deprecated 되었으므로 dotenvy) 같은 것을 사용하는 것보다 훨씬 안전한 경우가 많습니다. src/config.rs를 생성합니다:
use std::{env, net::SocketAddr, str::FromStr, time::Duration};

#[derive(Clone, Debug)]
pub struct Config {
    pub bind_addr: SocketAddr,
    pub database_url: String,
    pub venice_api_key: String,
    pub venice_base_url: String,
    pub service_name: String,
    pub otlp_endpoint: Option<String>,
    pub request_timeout: Duration,
    pub request_body_limit_bytes: usize,
    pub venice_max_retries: u32,
    pub capture_genai_content: bool,
}

impl Config {
    pub fn from_env() -> Result<Self, ConfigError> {
        Ok(Self {
            bind_addr: parse_env("BIND_ADDR", "0.0.0.0:3000")?,
            database_url: required_env("DATABASE_URL")?,
            venice_api_key: required_env("VENICE_API_KEY")?,
            venice_base_url: env::var("VENICE_BASE_URL")
                .unwrap_or_else(|_| "https://api.venice.ai/api/v1".to_owned()),
            service_name: env::var("OTEL_SERVICE_NAME")
                .unwrap_or_else(|_| "llm-gateway".to_owned()),
            otlp_endpoint: optional_nonempty_env("OTEL_EXPORTER_OTLP_ENDPOINT"),
            request_timeout: Duration::from_secs(parse_env("REQUEST_TIMEOUT_SECONDS", "120")?),
            request_body_limit_bytes: parse_env("REQUEST_BODY_LIMIT_BYTES", "1048576")?,
            venice_max_retries: parse_env("VENICE_MAX_RETRIES", "2")?,
            capture_genai_content: parse_env("CAPTURE_GENAI_CONTENT", "false")?,
        })
    }
}
여기서 환경 변수로부터 파싱되는 값들이 많지만, 일반적으로 필요한 것은 두 가지뿐입니다:
  • 데이터베이스 URL
  • Venice API 키
두 가지 기본값이 중요합니다. VENICE_BASE_URLhttps://api.venice.ai/api/v1을 가리키고, CAPTURE_GENAI_CONTENT는 기본값이 false이므로 의도적으로 활성화하지 않는 한 프롬프트 내용이 로깅되지 않습니다. 두 번째 기본값이 더 중요합니다. 게이트웨이는 자신을 통해 흐르는 모든 프롬프트와 응답을 볼 수 있지만, 관측성이 자동으로 콘텐츠 캡처가 되어서는 안 됩니다. 대부분의 프로덕션 시스템에서는 토큰 수, 지연 시간, 모델 이름, 상태 코드, 청구 메타데이터만으로도 운영에 충분합니다. 일반적으로 프로덕션에서 프롬프트와 대화를 로깅하는 것은 개인정보 문제뿐만 아니라 스토리지 문제도 될 수 있습니다. 이를 추가하는 것은 매우 높은 카디널리티(즉, 데이터셋 내 데이터의 고유성)를 가진 스팬과 트레이스를 생성하는 것을 의미합니다. 이는 관측성 데이터 검색 비용을 크게 늘릴 수 있으며, 데이터를 검색할 때 성능을 저하시킬 수도 있습니다.

데이터베이스 스키마 만들기

다음으로 migrations/0001_api_keys.sql을 생성합니다. 각 게이트웨이 API 키는 처음 12자만 조회용 prefix로 저장하고, 전체 키의 SHA-256 해시를 함께 저장할 것입니다. 이렇게 하면 원시 자격 증명을 저장하지 않고도 게이트웨이가 후보 행을 빠르게 찾을 수 있습니다. prefix는 비밀이 아닙니다. 인덱싱을 위해 존재합니다. 호출자가 전체 키를 제시했음을 증명하는 것은 해시입니다. 이는 많은 API 키 시스템에서 사용하는 기본 형태와 같습니다. 원시 키를 한 번 보여주고, 되돌릴 수 없는 표현으로 저장하며, 조회와 지원 워크플로우를 위해 짧은 prefix를 유지합니다.
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE api_keys
  ( api_key_id uuid PRIMARY KEY DEFAULT gen_random_uuid()
  , subject_id uuid NOT NULL
  , name text NOT NULL
  , key_prefix text NOT NULL
  , key_hash bytea NOT NULL
  , rate_limit_requests integer NOT NULL DEFAULT 60
  , rate_limit_window_seconds integer NOT NULL DEFAULT 60
  , is_active boolean NOT NULL DEFAULT true
  , created_at timestamptz NOT NULL DEFAULT now()
  , last_used_at timestamptz
  , revoked_at timestamptz
  , CONSTRAINT api_keys_prefix_length CHECK (length(key_prefix) = 12)
  , CONSTRAINT api_keys_hash_sha256 CHECK (length(key_hash) = 32)
  , CONSTRAINT api_keys_rate_limit_requests_positive CHECK (rate_limit_requests > 0)
  , CONSTRAINT api_keys_rate_limit_window_positive CHECK (rate_limit_window_seconds > 0)
  , CONSTRAINT api_keys_revoked_inactive CHECK (revoked_at IS NULL OR NOT is_active)
  );

CREATE UNIQUE INDEX api_keys_key_prefix_key
ON api_keys (key_prefix);

CREATE UNIQUE INDEX api_keys_key_hash_key
ON api_keys (key_hash);
이제 고정 윈도우 속도 제한을 위한 테이블을 추가합니다:
CREATE TABLE api_key_rate_limit_windows
  ( api_key_id uuid NOT NULL
      REFERENCES api_keys (api_key_id)
      ON DELETE RESTRICT
      ON UPDATE RESTRICT
  , window_start_at timestamptz NOT NULL
  , window_seconds integer NOT NULL
  , request_count integer NOT NULL
  , updated_at timestamptz NOT NULL DEFAULT now()
  , PRIMARY KEY (api_key_id, window_start_at, window_seconds)
  , CONSTRAINT api_key_rate_limit_windows_window_positive CHECK (window_seconds > 0)
  , CONSTRAINT api_key_rate_limit_windows_request_count_nonnegative CHECK (request_count >= 0)
  );
이 스키마는 작지만 중요한 불변식을 제공합니다:
  • API 키는 절대 평문으로 저장되지 않습니다.
  • 속도 제한 설정은 양수여야 합니다.
  • 폐기된 키는 활성 상태로 남을 수 없습니다.
  • 속도 제한 윈도우는 키, 시작 시간, 윈도우 길이로 고유하게 식별됩니다.
이러한 불변식을 Postgres에 유지하는 것은 유용한데, 모든 호출자가 이 데이터베이스 상태를 거쳐야 하기 때문입니다. 나중에 관리자 API, 백그라운드 키 로테이션 작업, 또는 다른 시스템에서 키를 가져오는 마이그레이션을 추가하더라도, 데이터베이스는 여전히 폐기 타임스탬프가 있는 활성 키처럼 불가능한 상태를 거부합니다.

Venice 클라이언트 만들기

다음으로 src/venice.rs를 만들겠습니다. 클라이언트가 알아야 할 것은 업스트림 chat completions URL, Venice API 키, 그리고 일시적인 실패에 대해 몇 번 재시도할지뿐입니다. 이 래퍼를 작게 유지하는 것은 의도적입니다. 게이트웨이가 Venice의 전체 API를 재구현해서는 안 됩니다. 기본적으로 게이트웨이의 역할은 서버 측 자격 증명을 첨부하고, 타임아웃을 적용하며, 재시도해도 안전한 요청을 재시도하고, 라우터가 전달할 수 있는 형태로 업스트림 응답을 반환하는 것입니다.
use std::time::Duration;

use reqwest::{Client, RequestBuilder, Response, StatusCode};
use reqwest_eventsource::{EventSource, RequestBuilderExt};
use serde_json::Value;
use tokio::time::sleep;

use crate::{config::Config, error::AppError};

#[derive(Clone)]
pub struct VeniceClient {
    http: Client,
    api_key: String,
    chat_completions_url: String,
    max_retries: u32,
}

impl VeniceClient {
    pub fn new(config: &Config) -> Result<Self, AppError> {
        let http = Client::builder()
            .timeout(config.request_timeout)
            .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
            .build()?;

        Ok(Self {
            http,
            api_key: config.venice_api_key.clone(),
            chat_completions_url: format!(
                "{}/chat/completions",
                config.venice_base_url.trim_end_matches('/')
            ),
            max_retries: config.venice_max_retries,
        })
    }
}
비스트리밍 요청의 경우, 연결 오류, 타임아웃 및 일시적인 HTTP 상태 코드를 재시도할 수 있습니다:
impl VeniceClient {
    pub async fn chat_completions(&self, payload: &Value) -> Result<Response, AppError> {
        let mut attempt = 0;

        loop {
            let result = self.chat_request(payload).send().await;

            match result {
                Ok(response)
                    if should_retry_status(response.status()) && attempt < self.max_retries =>
                {
                    attempt += 1;
                    sleep(retry_delay(attempt)).await;
                }
                Ok(response) => return Ok(response),
                Err(error) if should_retry_error(&error) && attempt < self.max_retries => {
                    attempt += 1;
                    sleep(retry_delay(attempt)).await;
                }
                Err(error) => return Err(error.into()),
            }
        }
    }
}

fn should_retry_status(status: StatusCode) -> bool {
    matches!(
        status,
        StatusCode::REQUEST_TIMEOUT
            | StatusCode::TOO_MANY_REQUESTS
            | StatusCode::INTERNAL_SERVER_ERROR
            | StatusCode::BAD_GATEWAY
            | StatusCode::SERVICE_UNAVAILABLE
            | StatusCode::GATEWAY_TIMEOUT
    )
}
재시도는 비스트리밍 경로에만 적용됩니다. 스트리밍 응답이 시작되면 게이트웨이 내부에서 재시도하면 부분 출력이 중복되거나 이미 청크를 받은 클라이언트를 혼란스럽게 할 수 있습니다. 스트리밍의 경우 더 나은 기본 동작은 오류를 표면화하고 호출자가 전체 요청을 다시 시도할지 여부를 결정하도록 하는 것입니다. 스트리밍의 경우, 동일한 요청에서 EventSource를 만듭니다:
impl VeniceClient {
    pub fn chat_completions_eventsource(&self, payload: &Value) -> Result<EventSource, AppError> {
        self.chat_request(payload)
            .eventsource()
            .map_err(|error| AppError::EventSourceSetup(error.to_string()))
    }

    fn chat_request(&self, payload: &Value) -> RequestBuilder {
        self.http
            .post(&self.chat_completions_url)
            .bearer_auth(&self.api_key)
            .json(payload)
    }
}
Venice의 chat completions 엔드포인트는 OpenAI 호환이므로, 게이트웨이는 익숙한 형태의 본문을 받을 수 있습니다:
{
  "model": "zai-org-glm-5-1",
  "messages": [
    {
      "role": "user",
      "content": "Say hello from behind a Rust gateway."
    }
  ]
}
Venice 계정에서 사용 가능한 chat 기능을 지원하는 어떤 모델로든 교체할 수 있습니다. 요청 본문이 여전히 serde_json::Value라는 점에 주목하세요. 이는 의도적인 호환성 선택입니다. 모든 가능한 chat-completion 필드를 Rust에서 모델링하면, 업스트림 API에 유용한 옵션이 추가될 때마다 게이트웨이를 업데이트해야 합니다. 다른 곳에서 필요한 것만 파싱함으로써, 게이트웨이 릴리스 없이도 새로운 Venice 파라미터가 통과될 수 있도록 합니다.

애플리케이션 상태 공유하기

src/state.rs를 생성합니다:
use std::sync::Arc;

use sqlx::PgPool;

use crate::{config::Config, error::AppError, venice::VeniceClient};

#[derive(Clone)]
pub struct AppState {
    pub config: Arc<Config>,
    pub db: PgPool,
    pub venice: VeniceClient,
}

impl AppState {
    pub fn new(config: Config, db: PgPool) -> Result<Self, AppError> {
        let venice = VeniceClient::new(&config)?;

        Ok(Self {
            config: Arc::new(config),
            db,
            venice,
        })
    }
}
Axum은 상태를 핸들러에 복제하므로 상태 자체를 저렴하게 복제할 수 있어야 합니다. PgPool은 이미 공유 풀 핸들이며, Arc<Config>도 설정을 저렴하게 유지합니다. 이것은 모든 핸들러에 세 가지 동일한 것에 대한 접근을 제공합니다: 불변 설정, 풀링된 데이터베이스 연결, 그리고 Venice 클라이언트. 이들을 하나의 AppState에 담아 두면 핸들러가 전역을 읽는 대신 Axum 상태를 통해 의존성을 받기 때문에 나중에 테스트하기도 더 쉬워집니다.

게이트웨이 API 키 인증하기

클라이언트는 다음과 같이 게이트웨이 키를 보냅니다:
Authorization: Bearer llmg_dev_0123456789abcdef
src/auth.rs를 생성하고 Axum 추출기를 구현합니다. 추출기는 보호된 핸들러가 인증된 키를 요구한다고 선언할 수 있도록 합니다:
use axum::{
    extract::FromRequestParts,
    http::{HeaderMap, header, request::Parts},
};
use sha2::{Digest, Sha256};
use sqlx::{FromRow, PgPool};
use subtle::ConstantTimeEq;
use uuid::Uuid;

use crate::{error::AppError, state::AppState};

const KEY_PREFIX_BYTES: usize = 12;

#[derive(Clone, Debug)]
pub struct AuthenticatedApiKey {
    pub api_key_id: Uuid,
    pub subject_id: Uuid,
    pub rate_limit_requests: i32,
    pub rate_limit_window_seconds: i32,
}

impl FromRequestParts<AppState> for AuthenticatedApiKey {
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        authenticate(&state.db, &parts.headers).await
    }
}
실제 인증 흐름은 다음과 같습니다:
  1. 베어러 토큰을 파싱합니다.
  2. 처음 12바이트를 키 prefix로 취합니다.
  3. 전체 후보 토큰을 SHA-256으로 해시합니다.
  4. prefix로 활성 키 행을 로드합니다.
  5. 저장된 해시와 후보 해시를 상수 시간에 비교합니다.
async fn authenticate(db: &PgPool, headers: &HeaderMap) -> Result<AuthenticatedApiKey, AppError> {
    let token = bearer_token(headers)?;
    let key_prefix = token
        .get(..KEY_PREFIX_BYTES)
        .ok_or(AppError::Unauthorized)?;
    let candidate_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();

    let stored = sqlx::query_as::<_, StoredApiKey>(
        r#"
        SELECT api_key_id, subject_id, key_hash, rate_limit_requests, rate_limit_window_seconds
        FROM api_keys
        WHERE key_prefix = $1
          AND is_active
          AND revoked_at IS NULL
        "#,
    )
    .bind(key_prefix)
    .fetch_optional(db)
    .await?;

    let stored = stored.ok_or(AppError::Unauthorized)?;

    if stored.key_hash.ct_eq(candidate_hash.as_slice()).unwrap_u8() != 1 {
        return Err(AppError::Unauthorized);
    }

    Ok(AuthenticatedApiKey {
        api_key_id: stored.api_key_id,
        subject_id: stored.subject_id,
        rate_limit_requests: stored.rate_limit_requests,
        rate_limit_window_seconds: stored.rate_limit_window_seconds,
    })
}
이렇게 하면 업스트림과 게이트웨이 자격 증명이 분리됩니다. 프로덕션 앱은 Venice API 키를 변경하지 않고도 게이트웨이 키를 로테이션할 수 있으며, Venice 키는 서버를 떠날 필요가 전혀 없습니다. 추출기 패턴은 인증이 핸들러의 타입 시그니처의 일부가 되므로 유용합니다. AuthenticatedApiKey를 받는 라우트는 함수 본문 내부에서 실수로 인증을 건너뛸 수 없습니다. Axum이 핸들러가 실행되기 전에 그 값을 구성해야 하기 때문입니다. 이렇게 하면 보호된 경로를 감사하기 쉬워집니다.

고정 윈도우 속도 제한 추가하기

src/rate_limit.rs를 생성합니다. 속도 제한기는 새 윈도우를 삽입하거나 기존 윈도우를 증가시키기 위해 하나의 SQL 문을 사용합니다:
pub async fn check(db: &PgPool, api_key: &AuthenticatedApiKey) -> Result<(), AppError> {
    let window = sqlx::query_as::<_, RateLimitWindow>(
        r#"
        WITH current_window AS
          ( SELECT
              to_timestamp(
                floor(extract(epoch FROM now()) / $2::double precision) * $2
              )::timestamptz AS window_start_at
          )
        INSERT INTO api_key_rate_limit_windows
          (api_key_id, window_start_at, window_seconds, request_count)
        SELECT $1, current_window.window_start_at, $2, 1
        FROM current_window
        ON CONFLICT (api_key_id, window_start_at, window_seconds)
        DO UPDATE
        SET request_count = api_key_rate_limit_windows.request_count + 1,
            updated_at = now()
        WHERE api_key_rate_limit_windows.request_count < $3
        RETURNING request_count, window_start_at
        "#,
    )
    .bind(api_key.api_key_id)
    .bind(api_key.rate_limit_window_seconds)
    .bind(api_key.rate_limit_requests)
    .fetch_optional(db)
    .await?;

    let _window = window.ok_or_else(|| AppError::RateLimited {
            retry_after: retry_after(api_key.rate_limit_window_seconds),
    })?;

    Ok(())
}
WHERE api_key_rate_limit_windows.request_count < $3 절이 핵심입니다. 윈도우가 이미 가득 찼을 때 Postgres는 행을 업데이트하지 않고 RETURNING은 아무 행도 생성하지 않습니다. 핸들러는 이를 Retry-After 헤더가 포함된 429 Too Many Requests 응답으로 바꿀 수 있습니다. 고정 윈도우가 가장 정교한 속도 제한기는 아니지만, 설명하기 쉽고, 검사하기 쉬우며, 게이트웨이 튜토리얼에는 충분히 좋습니다. 트레이드오프는 트래픽이 윈도우 경계 주변에서 몰릴 수 있다는 점입니다. 대규모에서 더 부드러운 동작이 필요하다면 Redis로 뒷받침되는 토큰 버킷이나 슬라이딩 윈도우 제한기가 자연스러운 다음 단계입니다.

OpenAI 스타일 오류 반환하기

src/error.rs를 생성하고 애플리케이션 오류가 IntoResponse를 구현하도록 만듭니다:
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("missing or invalid bearer token")]
    Unauthorized,
    #[error("rate limit exceeded")]
    RateLimited { retry_after: Duration },
    #[error("bad request: {0}")]
    BadRequest(String),
    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),
    #[error("upstream request failed: {0}")]
    UpstreamTransport(#[from] reqwest::Error),
    #[error("failed to create upstream event source: {0}")]
    EventSourceSetup(String),
    #[error("upstream event source failed: {0}")]
    EventSource(String),
    #[error("upstream returned an error")]
    Upstream {
        status: StatusCode,
        body: serde_json::Value,
    },
}
게이트웨이가 생성한 오류의 경우, 일반적인 모델 API 오류와 유사한 형태의 JSON 본문을 반환합니다:
{
  "error": {
    "message": "rate limit exceeded",
    "type": "rate_limit_error",
    "code": null
  }
}
업스트림 Venice 오류의 경우, 업스트림 상태 코드와 본문을 보존합니다. 이는 제공자 수준의 유효성 검사 오류가 여전히 제공자 수준의 유효성 검사 오류처럼 보이기 때문에 클라이언트가 디버깅하기 훨씬 쉬워집니다. 이러한 분리는 오류가 어디서 왔는지에 대해 게이트웨이가 정직하도록 유지합니다. 게이트웨이가 베어러 토큰이 없거나 호출자가 제한을 초과하여 요청을 거부하는 경우, 게이트웨이 형태의 오류를 반환합니다. Venice가 모델 요청을 거부한다면, 클라이언트 개발자가 일반적인 프록시 실패 대신 제공자의 유효성 검사 메시지를 볼 수 있도록 업스트림 본문을 보존합니다.

라우터 만들기

이제 src/router.rs에서 HTTP 라우트를 연결할 수 있습니다:
use axum::{
    Json, Router,
    body::Body,
    extract::{DefaultBodyLimit, State},
    http::{StatusCode, header::{CACHE_CONTROL, CONTENT_TYPE}},
    response::{IntoResponse, Response},
    routing::{get, post},
};
use serde::Serialize;
use serde_json::Value;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;

use crate::{
    auth,
    error::AppError,
    rate_limit, sse,
    state::AppState,
    telemetry::{self, GenAiRequest},
};

pub fn build(state: AppState) -> Router {
    let body_limit = state.config.request_body_limit_bytes;

    Router::new()
        .route("/healthz", get(healthz))
        .route("/v1/chat/completions", post(chat_completions))
        .with_state(state)
        .layer(DefaultBodyLimit::max(body_limit))
        .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
}

async fn healthz() -> impl IntoResponse {
    Json(Health { status: "ok" })
}

#[derive(Serialize)]
struct Health {
    status: &'static str,
}
chat 핸들러는 AuthenticatedApiKey를 요구하는 것으로 시작합니다. 인증이 실패하면 Axum은 핸들러 본문에 진입하지 않습니다:
async fn chat_completions(
    api_key: auth::AuthenticatedApiKey,
    State(state): State<AppState>,
    Json(payload): Json<Value>,
) -> Result<Response, AppError> {
    rate_limit::check(&state.db, &api_key).await?;

    let request = GenAiRequest::from_payload(&payload).map_err(AppError::BadRequest)?;
    let span = telemetry::span_for_request(&request);

    if request.stream {
        let eventsource = state.venice.chat_completions_eventsource(&payload)?;
        let stream = sse::observe_eventsource(
            eventsource,
            request,
            span,
            None,
            api_key.api_key_id,
        );

        return Ok(Response::builder()
            .status(StatusCode::OK)
            .header(CONTENT_TYPE, "text/event-stream")
            .header(CACHE_CONTROL, "no-cache")
            .body(Body::from_stream(stream))
            .expect("static streaming response headers are valid"));
    }

    let upstream = state.venice.chat_completions(&payload).await?;
    let status = upstream.status();

    if !status.is_success() {
        let bytes = upstream.bytes().await?;
        return Err(AppError::Upstream {
            status,
            body: upstream_body_from_bytes(&bytes),
        });
    }

    let bytes = upstream.bytes().await?;
    let metadata = telemetry::metadata_from_bytes(&bytes);
    span.in_scope(|| {
        telemetry::record_response(&request, &span, &metadata, None);
        telemetry::record_billing(api_key.api_key_id, &metadata);
    });

    Ok(Response::builder()
        .status(StatusCode::OK)
        .header(CONTENT_TYPE, "application/json")
        .body(Body::from(bytes))
        .expect("static json response headers are valid"))
}
게이트웨이는 게이트웨이 동작에 필요한 필드만 검증합니다: model, messages, stream. JSON 본문의 나머지 모든 것은 Venice로 통과합니다. 이렇게 하면 나중에 사용하고 싶은 제공자 기능과 게이트웨이의 호환성이 유지됩니다. 핸들러는 또한 두 가지 응답 모드를 명시적으로 만듭니다. 비스트리밍 요청은 Venice가 전체 JSON 응답을 반환할 때까지 기다린 다음, 다운스트림으로 바이트를 보내기 전에 응답 메타데이터를 기록합니다. 스트리밍 요청은 비동기 스트림으로 뒷받침되는 text/event-stream 본문과 함께 즉시 반환됩니다. 이 분리는 비스트리밍 경로를 단순하게 유지하면서 스트리밍 경로가 통과하는 청크를 관찰할 수 있는 충분한 제어권을 갖도록 합니다.

스트리밍 응답 지원하기

스트리밍 chat completions는 server-sent events를 사용합니다. Venice는 SSE 데이터를 보내고, 게이트웨이는 그 데이터를 클라이언트에게 다시 중계합니다. 게이트웨이는 전체 스트림을 버퍼링하지 않아야 합니다. 그렇게 하면 스트리밍의 목적이 무의미해지기 때문입니다. 사용자는 최종 토큰까지의 시간이 아니라 첫 번째 토큰까지의 시간에 관심이 있습니다. 각 업스트림 이벤트가 도착할 때마다 전달함으로써, 클라이언트는 모델이 여전히 생성 중인 동안 부분 출력을 렌더링할 수 있습니다. src/sse.rs를 생성합니다:
use bytes::Bytes;
use futures_util::{Stream, StreamExt, stream};
use reqwest_eventsource::{Event, EventSource};
use uuid::Uuid;

use crate::{
    error::AppError,
    telemetry::{self, GenAiRequest, GenAiResponseMetadata},
};

pub fn observe_eventsource(
    eventsource: EventSource,
    request: GenAiRequest,
    span: tracing::Span,
    pending_event: Option<Event>,
    api_key_id: Uuid,
) -> impl Stream<Item = Result<Bytes, AppError>> + Send + 'static {
    let state = StreamState {
        eventsource,
        pending_event,
        request,
        span,
        api_key_id,
        metadata: GenAiResponseMetadata::default(),
        first_chunk_at: None,
        previous_chunk_at: None,
    };

    stream::unfold(state, |mut state| async move {
        loop {
            let event = match state.pending_event.take() {
                Some(event) => Some(Ok(event)),
                None => state.eventsource.next().await,
            };

            match event {
                Some(Ok(Event::Open)) => continue,
                Some(Ok(Event::Message(message))) => {
                    let bytes = state.observe_message(&message.data);
                    if message.data == "[DONE]" {
                        state.eventsource.close();
                    }
                    return Some((Ok(bytes), state));
                }
                Some(Err(error)) => {
                    return Some((Err(AppError::EventSource(error.to_string())), state));
                }
                None => {
                    telemetry::record_response(&state.request, &state.span, &state.metadata, None);
                    telemetry::record_billing(state.api_key_id, &state.metadata);
                    return None;
                }
            }
        }
    })
}
각 메시지는 다시 SSE 형식으로 인코딩됩니다:
fn encode_sse_data(data: &str) -> Bytes {
    let mut encoded = String::new();
    for line in data.lines() {
        encoded.push_str("data: ");
        encoded.push_str(line);
        encoded.push('\n');
    }
    if data.is_empty() {
        encoded.push_str("data: \n");
    }
    encoded.push('\n');
    Bytes::from(encoded)
}
이는 OpenAI 호환 SDK가 기대하는 클라이언트 경험을 보존합니다: 청크는 data: ... 이벤트로 도착하고, 스트림은 data: [DONE]로 끝납니다. 스트림 관찰자는 또한 클라이언트가 보는 것을 바꾸지 않고 메타데이터를 수집할 수 있는 곳입니다. 각 청크는 SSE 형식으로 전달되지만, 게이트웨이는 여전히 청크가 통과할 때 응답 ID, finish reason, 토큰 사용량, 비용 필드 및 타이밍 정보를 감시할 수 있습니다.

GenAI 텔레메트리 기록하기

게이트웨이가 유용한 이유는 모든 요청이 한 곳을 통과하기 때문입니다. 그래서 모델, 지연 시간, 토큰 사용량, finish reason, 청구 비용 및 스트리밍 타이밍을 기록하기에 좋은 지점입니다. src/telemetry.rs를 생성하고 요청 파싱부터 시작합니다:
#[derive(Clone, Debug)]
pub struct GenAiRequest {
    pub model: String,
    pub stream: bool,
    pub started_at: Instant,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct GenAiResponseMetadata {
    pub response_id: Option<String>,
    pub response_model: Option<String>,
    pub finish_reasons: Vec<String>,
    pub input_tokens: Option<u64>,
    pub output_tokens: Option<u64>,
    pub cost_diem: Option<f64>,
    pub cost_usd: Option<f64>,
}

impl GenAiRequest {
    pub fn from_payload(payload: &Value) -> Result<Self, String> {
        let model = payload
            .get("model")
            .and_then(Value::as_str)
            .filter(|model| !model.trim().is_empty())
            .ok_or_else(|| "request body must include a non-empty model".to_owned())?;

        let messages = payload
            .get("messages")
            .and_then(Value::as_array)
            .filter(|messages| !messages.is_empty())
            .ok_or_else(|| "request body must include at least one message".to_owned())?;

        if messages
            .iter()
            .any(|message| message.get("role").and_then(Value::as_str).is_none())
        {
            return Err("every message must include a role".to_owned());
        }

        Ok(Self {
            model: model.to_owned(),
            stream: payload
                .get("stream")
                .and_then(Value::as_bool)
                .unwrap_or(false),
            started_at: Instant::now(),
        })
    }
}
그런 다음 GenAI 시맨틱 속성을 사용하여 스팬을 만듭니다:
pub fn span_for_request(request: &GenAiRequest) -> Span {
    tracing::info_span!(
        "gen_ai.client",
        "otel.name" = %format!("chat {}", request.model),
        "otel.kind" = "client",
        "gen_ai.operation.name" = "chat",
        "gen_ai.provider.name" = "venice",
        "gen_ai.request.model" = %request.model,
        "gen_ai.request.stream" = request.stream,
        "server.address" = "api.venice.ai",
        "gen_ai.response.id" = tracing::field::Empty,
        "gen_ai.response.model" = tracing::field::Empty,
        "gen_ai.response.finish_reasons" = tracing::field::Empty,
        "gen_ai.response.time_to_first_chunk" = tracing::field::Empty,
        "gen_ai.usage.input_tokens" = tracing::field::Empty,
        "gen_ai.usage.output_tokens" = tracing::field::Empty,
        "venice.cost.diem" = tracing::field::Empty,
        "venice.cost.usd" = tracing::field::Empty,
        "error.type" = tracing::field::Empty,
        "otel.status_code" = tracing::field::Empty,
    )
}
비스트리밍 응답이 돌아오면 알려진 응답 메타데이터 필드를 struct로 역직렬화합니다. 게이트웨이는 여전히 원본 바이트를 클라이언트에 전달하지만, 텔레메트리는 임의의 JSON을 순회할 필요가 없습니다. 청구 로그는 평문 베어러 토큰이 아닌 게이트웨이 키 UUID를 사용하며, 요청 ID는 Venice의 응답 id에서 옵니다:
#[derive(Debug, Default, Deserialize)]
struct ChatCompletionResponse {
    id: Option<String>,
    model: Option<String>,
    #[serde(default)]
    choices: Vec<ChatCompletionChoice>,
    usage: Option<ChatCompletionUsage>,
    cost: Option<VeniceCost>,
}

#[derive(Debug, Deserialize)]
struct ChatCompletionChoice {
    finish_reason: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ChatCompletionUsage {
    prompt_tokens: Option<u64>,
    input_tokens: Option<u64>,
    completion_tokens: Option<u64>,
    output_tokens: Option<u64>,
}

#[derive(Debug, Deserialize)]
struct VeniceCost {
    #[serde(default, deserialize_with = "optional_finite_f64")]
    diem: Option<f64>,
    #[serde(default, deserialize_with = "optional_finite_f64")]
    usd: Option<f64>,
}

pub fn metadata_from_bytes(bytes: &[u8]) -> GenAiResponseMetadata {
    serde_json::from_slice::<ChatCompletionResponse>(bytes)
        .map(GenAiResponseMetadata::from)
        .unwrap_or_default()
}

pub fn record_billing(api_key_id: Uuid, metadata: &GenAiResponseMetadata) {
    let (Some(cost_diem), Some(cost_usd)) = (metadata.cost_diem, metadata.cost_usd) else {
        return;
    };

    tracing::info!(
        api_key_id = %api_key_id,
        request_id = metadata.response_id.as_deref().unwrap_or("unknown"),
        cost_diem,
        cost_usd,
        "venice.billing"
    );
}
스트리밍 응답의 경우, SSE 스트림이 중계될 때 첫 청크까지의 시간과 출력 청크 사이의 시간을 기록합니다. 이러한 메트릭은 총 요청 시간뿐만 아니라 체감 지연에 관심이 있을 때 특히 유용합니다. 텔레메트리는 게이트웨이가 단순한 프록시 이상이 되는 지점입니다. 스팬에 요청한 모델, 업스트림 모델, 토큰 수, finish reason, 상태 및 키별 청구 로그가 포함되면 실용적인 운영 질문에 답할 수 있습니다: 어떤 클라이언트가 가장 많이 지출하는지, 어떤 모델이 가장 느린지, 스트리밍이 체감 지연을 개선하고 있는지, 그리고 오류가 인증, 속도 제한, 전송 또는 모델 제공자에서 오는지.

서버 시작하기

이제 src/main.rs에서 모든 것을 연결합니다:
mod auth;
mod config;
mod error;
mod rate_limit;
mod router;
mod sse;
mod state;
mod telemetry;
mod venice;

use sqlx::postgres::PgPoolOptions;
use tokio::net::TcpListener;
use tracing::info;

use crate::{config::Config, state::AppState};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let config = Config::from_env()?;
    let _telemetry = telemetry::init(&config)?;

    let db = PgPoolOptions::new()
        .max_connections(10)
        .connect(&config.database_url)
        .await?;

    sqlx::migrate!("./migrations").run(&db).await?;

    let bind_addr = config.bind_addr;
    let state = AppState::new(config, db)?;
    let app = router::build(state);
    let listener = TcpListener::bind(bind_addr).await?;

    info!(%bind_addr, "starting gateway");

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    Ok(())
}
시작 시 게이트웨이는:
  1. 설정을 읽습니다.
  2. 텔레메트리를 초기화합니다.
  3. Postgres에 연결합니다.
  4. SQLx 마이그레이션을 실행합니다.
  5. 공유 앱 상태를 구축합니다.
  6. Axum 서버를 시작합니다.
시작 시 마이그레이션을 실행하는 것은 docker compose up으로 전체 스택을 작동 상태로 만들 수 있기 때문에 이 튜토리얼에 편리합니다. 더 큰 프로덕션 배포에서는 새 게이트웨이 인스턴스가 시작되기 전에 스키마 변경이 검토되고 적용되도록 마이그레이션을 별도의 릴리스 단계로 실행하는 것을 선호할 수 있습니다.

로컬 게이트웨이 키 시드하기

로컬 개발용으로 scripts/seed_api_key.sh를 생성합니다. 이 스크립트는 게이트웨이 API 키의 prefix와 SHA-256 해시를 저장하여 Postgres에 삽입합니다:
#!/bin/sh
set -eu

DATABASE_URL="${DATABASE_URL:-postgres://llm_gateway:llm_gateway@localhost:5432/llm_gateway}"
GATEWAY_API_KEY="${GATEWAY_API_KEY:-llmg_dev_0123456789abcdef}"
SUBJECT_ID="${SUBJECT_ID:-00000000-0000-0000-0000-000000000001}"

psql "$DATABASE_URL" \
  -v ON_ERROR_STOP=1 \
  -v gateway_api_key="$GATEWAY_API_KEY" \
  -v subject_id="$SUBJECT_ID" <<'SQL'
INSERT INTO api_keys
  (subject_id, name, key_prefix, key_hash, rate_limit_requests, rate_limit_window_seconds)
VALUES
  ( :'subject_id'::uuid
  , 'local development key'
  , left(:'gateway_api_key', 12)
  , digest(:'gateway_api_key', 'sha256')
  , 60
  , 60
  )
ON CONFLICT (key_prefix)
DO UPDATE
SET key_hash = EXCLUDED.key_hash,
    is_active = true,
    revoked_at = NULL;
SQL
기본 로컬 키는 다음과 같습니다:
llmg_dev_0123456789abcdef
실제 배포의 경우, 더 긴 랜덤 키를 생성하고, 호출자에게 한 번만 표시하며, 해시만 저장하세요. 시드 스크립트는 로컬 자격 증명이 쉽게 재생성될 수 있어야 하기 때문에 의도적으로 지루합니다. 프로덕션 버전은 더 강력한 키 생성, 감사 로깅, 만료 및 일회성 표시 흐름을 추가할 곳입니다.

로컬에서 실행하기

로컬에서 실행하려면 Docker Compose를 사용하여 게이트웨이와 Postgres를 함께 시작합니다. 이렇게 하면 튜토리얼을 재현 가능하게 유지할 수 있습니다: 독자는 수동으로 구성된 데이터베이스가 필요하지 않으며, 게이트웨이는 컨테이너화된 배포에서 사용하는 것과 동일한 DATABASE_URL 형태를 사용할 수 있습니다.
name: llm-gateway

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: llm_gateway
      POSTGRES_USER: llm_gateway
      POSTGRES_PASSWORD: llm_gateway
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U llm_gateway -d llm_gateway"]
      interval: 5s
      timeout: 5s
      retries: 10

  gateway:
    build: .
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      BIND_ADDR: 0.0.0.0:3000
      DATABASE_URL: postgres://llm_gateway:llm_gateway@postgres:5432/llm_gateway
      OTEL_SERVICE_NAME: llm-gateway
      VENICE_API_KEY: ${VENICE_API_KEY:?set VENICE_API_KEY in your shell or .env}
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
      interval: 5s
      timeout: 5s
      retries: 10
또한 Rust 바이너리를 빌드하여 더 작은 런타임 이미지에 복사하는 작은 Dockerfile도 필요합니다:
FROM rust:1.92-bookworm AS builder

WORKDIR /app

COPY Cargo.toml Cargo.lock ./
COPY migrations ./migrations
COPY src ./src

RUN cargo build --release

FROM debian:bookworm-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/llm-gateway /usr/local/bin/llm-gateway

EXPOSE 3000

CMD ["llm-gateway"]
스택을 실행하려면 다음 명령을 사용하세요:
docker compose up --build
이후에 터미널을 다른 작업에 사용하고 싶다면 -d 플래그로 분리된 상태로 실행할 수도 있습니다(그리고 나중에 docker compose down으로 제거하세요). 다른 터미널에서 개발 게이트웨이 키를 시드하세요:
docker compose --profile seed run --rm seed
머신에서 이미 Postgres가 5432 포트에서 실행 중이라면, Compose Postgres 서비스의 호스트 포트 매핑을 제거하세요. 게이트웨이는 Docker의 내부 네트워크에서만 Postgres에 도달하면 됩니다. 기억해야 할 중요한 점은 Venice API 키가 게이트웨이 환경에만 속한다는 것입니다. 클라이언트 요청은 시드된 게이트웨이 키를 사용해야 합니다. 그 분리가 바로 모델 제공자 앞에 게이트웨이를 두는 전체 이유입니다.

게이트웨이 테스트하기

먼저 상태를 확인합니다:
curl http://localhost:3000/healthz
다음과 같이 표시되어야 합니다:
{"status":"ok"}
이제 비스트리밍 chat completion 요청을 보내세요:
curl http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer llmg_dev_0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama-3.3-70b",
    "messages": [
      {
        "role": "user",
        "content": "Reply with one short sentence saying the gateway works."
      }
    ]
  }'
응답은 OpenAI 호환 chat completion처럼 보여야 합니다:
{
  "id": "chatcmpl-...",
  "object": "chat.completion",
  "model": "llama-3.3-70b",
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "The gateway is working."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 123,
    "completion_tokens": 5,
    "total_tokens": 128
  },
  "cost": {
    "diem": 0.42,
    "usd": 0.0012
  }
}
스트리밍의 경우:
curl -N http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer llmg_dev_0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama-3.3-70b",
    "stream": true,
    "messages": [
      {
        "role": "user",
        "content": "Write one short sentence confirming streaming works."
      }
    ]
  }'
SSE 청크가 보여야 합니다:
data: {"id":"chatcmpl-...","object":"chat.completion.chunk",...}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk",...}

data: [DONE]
저장소에는 스모크 테스트 스크립트도 포함되어 있습니다:
sh ./scripts/smoke_chat.sh
로컬 코드 품질 검사의 경우, 다음을 실행하세요:
cargo fmt --check
cargo test
cargo clippy --all-targets --all-features -- -D warnings
두 응답 모드를 모두 테스트하는 것이 중요한 이유는 이들이 서로 다른 프록시 경로를 실행하기 때문입니다. 비스트리밍 테스트는 인증, 속도 제한, 업스트림 전달, 그리고 JSON 응답 텔레메트리가 작동함을 증명합니다. 스트리밍 테스트는 게이트웨이가 SSE 연결을 열어 두고 최종 답변을 먼저 버퍼링하지 않고 청크를 전달할 수 있음을 증명합니다.

이 게이트웨이 확장하기

이 게이트웨이는 의도적으로 작지만, 견고한 기반을 제공합니다. 좋은 다음 단계는 다음과 같습니다:
  • subject별 예산과 월별 지출 한도를 추가하세요.
  • 동일한 OpenAI 호환 API 뒤에서 여러 업스트림 제공자를 지원하세요.
  • 프롬프트 로깅을 기본적으로 비활성화된 상태로 유지하면서 감사 로그를 위해 요청 메타데이터를 저장하세요.
  • 게이트웨이 키를 생성, 폐기 및 로테이션하기 위한 관리자 API를 추가하세요.
  • API 키별 모델 허용 목록을 추가하세요.
  • 여러 게이트웨이 인스턴스에서 더 낮은 지연 시간의 속도 제한이 필요하다면 Redis나 다른 공유 저장소를 추가하세요.
주요 설계 아이디어는 정책은 게이트웨이에, 추론은 Venice에 두는 것입니다. 이렇게 하면 클라이언트 앱이 익숙한 API를 사용하는 동안 여러분의 플랫폼이 키, 사용량, 제한 및 관측성에 대한 제어를 유지할 수 있습니다.

마무리

읽어주셔서 감사합니다! 이 글이 Rust로 실용적인 LLM 게이트웨이를 큰 플랫폼 프로젝트로 만들지 않고도 어떻게 구축할 수 있는지 확인하는 데 도움이 되었기를 바랍니다. Axum, Postgres, SQLx, OpenTelemetry, 그리고 Venice의 OpenAI 호환 chat completions API를 결합함으로써, 이해할 수 있을 만큼 작으면서도 실제 애플리케이션 앞에 배치할 만큼 유용한 게이트웨이를 만들 수 있습니다.