SiCore TokenWorks
LLM APIAPI Gateway

코드를 변경하지 않고 LLM 제공업체를 전환하는 방법

SiCore TokenWorks Team·2026-09-03

대부분의 사람들은 프로젝트를 시작할 때 제공업체 이식성을 고려하지 않습니다. 가장 가입하기 쉬운 벤더에서 키를 받아 통합을 작성하고 배포합니다. 몇 달 후 벤치마크를 읽거나, 청구서를 열거나, 속도 제한에 부딪히면서 다른 것을 시도하고 싶어집니다. 바로 그때 문제가 드러나는데, 첫 번째 제공업체에 대한 가정을 HTTP 계층에 녹여 넣었기 때문입니다.

해결책은 어떤 추상화 라이브러리가 아닙니다. 와이어 포맷입니다. OpenAI 채팅 완성 API는 조용히 LLM과 대화하는 기본 프로토콜이 되었고, 이것에 맞춰 작성하면 제공업체 전환은 대부분 두 개의 문자열을 바꾸는 문제가 됩니다.

모두가 따라한 API

OpenAI는 간단한 계약을 정의했습니다. /v1/chat/completions에 JSON을 POST하면 choices[0].message.content 문자열을 돌려받습니다. 요청은 다음과 같습니다:

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o",
    "messages": [{"role": "user", "content": "Explain DNS in one paragraph"}]
  }'

이제 거의 모든 주요 모델이 이 방언을 구사합니다. DeepSeek, Qwen, ERNIE, Doubao 등 모두 동일한 본문을 받아들이고 동일한 형태를 반환하는 엔드포인트를 노출합니다. 즉, 클라이언트 코드는 반대편에 누가 있는지 신경 쓰지 않습니다. model 문자열과 엔드포인트만 바꾸면 다른 것은 아무것도 변하지 않습니다.

다음은 이 모델들 여러 개를 하나의 기본 URL 아래에 제공하는 애그리게이터를 향한 동일한 호출입니다:

curl https://api.token8341.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKENWORKS_API_KEY" \
  -d '{
    "model": "deepseek-chat",
    "messages": [{"role": "user", "content": "Explain DNS in one paragraph"}]
  }'

하나의 클라이언트, 여러 모델

Python에서는 보통 OpenAI SDK를 한 번 인스턴스화하고 요청마다 모델 이름을 전달합니다. 제공업체가 호환 API를 지원하면 base_url을 한 번 설정하고 나머지는 모두 동일하게 유지합니다:

from openai import OpenAI

client = OpenAI(
    base_url="https://api.token8341.com/v1",
    api_key="sk-your-tokenworks-key",
)

def ask(model: str, prompt: str) -> str:
    resp = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
    )
    return resp.choices[0].message.content

# Same function, three different model families.
print(ask("gpt-4o", "Summarize this log for me."))
print(ask("deepseek-chat", "Summarize this log for me."))
print(ask("qwen-max", "Summarize this log for me."))

함수는 변하지 않습니다. 모델 문자열이 변합니다. 그게 전부입니다. 스트리밍도 같은 방식으로 작동합니다:

stream = client.chat.completions.create(
    model="deepseek-chat",
    messages=[{"role": "user", "content": "Write a haiku about databases"}],
    stream=True,
)
for chunk in stream:
    delta = chunk.choices[0].delta.content or ""
    print(delta, end="")

함수 호출, JSON 모드, 임베딩 모두 동일한 호환 표면을 타므로 전환할 때 단순 채팅에 국한되지 않습니다.

전환이 주지 않는 것

공짜로 변하지 않는 부분에 대해 분명히 하고 싶습니다. 전송은 이식 가능하지만, 모델은 그렇지 않습니다.

모델마다 프롬프트에 다르게 반응합니다. GPT-4o에 맞춘 시스템 프롬프트는 다른 구조를 원하는 추론 모델에서 성능이 떨어질 수 있습니다. 컨텍스트 윈도우는 다르며, 때로는 크게 다릅니다. 한 모델은 128k 토큰을 받아들이지만 다른 모델은 32k를 받아들일 수 있습니다. 토크나이저도 달라서 같은 문서가 각 모델에서 다른 토큰 수로 계산됩니다. 최대 출력 길이도 프로토콜이 아니라 모델별로 정해지는 또 다른 변수입니다.

따라서 "코드를 변경하지 않고 전환"은 실제로 "HTTP 클라이언트를 다시 작성하지 않고 전환"입니다. 프로덕션 트래픽을 새 모델로 보내기 전에 여전히 평가 실행을 해야 합니다. 좋은 소식은 호환 API가 그 평가를 저렴하게 실행할 수 있게 해준다는 점입니다. 하네스가 모델 이름을 순회하는 루프에 불과하기 때문입니다.

실용적인 패턴

제가 사이드 프로젝트에서 실제로 하는 일은 코드 변경이 아니라 작은 설정 테이블을 유지하는 것입니다:

MODELS = {
    "fast": "deepseek-chat",
    "smart": "qwen-max",
    "frontier": "gpt-4o",
}

def run(task, prompt):
    return ask(MODELS[task], prompt)

fast 레인에 더 저렴한 모델을 시도하고 싶으신가요? 한 줄만 바꾸면 됩니다. 첫 번째 제공업체가 속도 제한에 걸렸을 때 두 번째 제공업체로 폴백하고 싶으신가요? 예외를 잡아 다음 모델 문자열로 재시도하면 됩니다. 이 중 어느 것도 요청 구성에 손대지 않습니다.

바로 이 폴백 패턴에서 멀티 모델 엔드포인트가 진가를 발휘합니다. 하나의 기본 URL과 하나의 키로 제공업체 전반에 걸쳐 라우팅, 재시도, A/B 테스트를 모두 첫날 작성한 동일한 두 줄의 클라이언트 설정을 통해 할 수 있습니다.

주의해야 할 응답 특이점

전송은 호환되지만, 몇 가지 응답 수준의 차이는 여전히 새어 나옵니다. 추론 모델은 때때로 일반 메시지와 함께 reasoning_content 같은 추가 필드를 반환합니다. choices[0].message.content를 읽는 코드는 관계없이 계속 작동하지만, 그 추론 텍스트를 디버그 뷰에 표시하고 싶을 수 있습니다. 오류 메시지도 다양합니다. 한 제공업체는 유용한 insufficient_quota 문자열을 반환하고, 다른 제공업체는 본문 없는 429를 반환합니다. 재시도를 구축하고 있다면 벤더별 텍스트를 파싱하기보다는 모든 429 또는 5xx를 "백오프하고 다시 시도"로 처리하세요.

이 중 어느 것도 하나의 제공업체에 묶여 있을 이유가 되지 않습니다. 오류 처리를 일반적으로 유지하고 모델 선택을 변수로 유지할 이유가 됩니다.

이것이 SiCore TokenWorks가 취하는 입장입니다. https://api.token8341.com/v1의 OpenAI 호환 엔드포인트에서는 GPT-4o, Claude, Gemini, DeepSeek, Qwen, ERNIE, Doubao, Spark, Pangu 사이를 이동할 때 모델 문자열만 바뀝니다.