새로운 모델을 시도할 때마다 AI SDK를 교체하는 것에 지쳤습니다
요약
다양한 AI 모델과 SDK를 교체할 때 발생하는 리팩터링의 고통을 해결하기 위해, 범용 AI 클라이언트 인터페이스를 구축하는 방법을 다룹니다. 특정 제공업체에 종속되지 않는 추상화 계층을 통해 코드 유지보수성을 높이는 과정을 설명합니다.
핵심 포인트
- 모델 교체 시 SDK, 인증, 응답 객체 형식이 달라 리팩터링 비용 발생
- 단순 환경 변수 기반의 조건부 임포트로는 메서드 시그니처 차이 해결 불가
- 추상 기본 클래스를 활용한 통합 인터페이스 구축이 효과적
- 제공업체별 구체적 구현체를 분리하여 코드베이스의 유연성 확보
몇 달 전, 저는 자연어로부터 구조화된 데이터(structured data)를 생성해야 하는 개인 프로젝트를 진행하고 있었습니다. 저는 OpenAI의 GPT-4로 시작했습니다. 왜냐하면, 뭐, 다들 그렇게 하니까요. 코드는 잘 작동했고, 응답도 훌륭해서 작업이 끝났다고 생각했습니다. 그러다 Anthropic에서 Claude 3를 출시했고, 벤치마크 결과가 유망해 보였습니다. 저는 품질과 비용을 비교하기 위해 단순히 모델 하나를 다른 것으로 교체하여 그것을 시도해보고 싶었습니다.
그것은 주말 내내 이어지는 리팩터링(refactoring) 작업이 되었습니다.
서로 다른 SDK. 서로 다른 인증(authentication). 서로 다른 응답 객체(response objects). 심화하게는 스트리밍(streaming)을 처리하는 방식(혹은 처리하지 않는 방식)조차 완전히 달라졌습니다. 결국 저는 if provider == "openai": ... elif provider == "anthropic": ... 블록들이 지저분하게 쌓인 것을 보게 되었고, 마치 2014년에 JavaScript를 작성하고 있는 듯한 기분이 들었습니다.
저만 이런 문제를 겪고 있는 것은 아닐 것이라고 생각했습니다. 매주 새로운 모델이나 새로운 API가 나옵니다. 하나의 제공업체(provider)에 종속되는 것은 취약하면서도 비효율적이라고 느껴졌습니다. 그래서 저는 전체 코드베이스를 다시 작성하지 않고도 AI 제공업체를 교체할 수 있게 해주는 얇은 추상화(thin abstraction)를 구축하기로 했습니다.
제가 처음에 시도했던 것 (그리고 왜 작동하지 않았는지)
저의 첫 번째 본능은 환경 변수(environment variables)를 사용하고 조건부로 적절한 SDK를 임포트(import)하는 것이었습니다. 다음과 같은 방식이었죠:
import os
provider = os.getenv("AI_PROVIDER", "openai")
...
이 방식은... API를 호출해야 할 때까지는 작동했습니다. 메서드 시그니처(method signatures)가 완전히 달랐기 때문입니다:
# OpenAI
response = client.chat.completions.create(
model="gpt-4",
...
서로 다른 파라미터(parameter) 이름들(messages 대 messages, 이건 같네요—하지만 max_tokens 대 max_tokens? 사실 Anthropic도 max_tokens를 사용하고 OpenAI도 max_tokens를 사용합니다. 아, 그게 문제가 아니군요. 진짜 고통은 응답 형식(response format)입니다: OpenAI는 response.choices[0].message.content를 반환하고, Anthropic은 response.content[0].text를 반환합니다. 스트리밍(Streaming)은 훨씬 더 차이가 큽니다.
저는 클라이언트를 조건부로 임포트하는 것만으로는 충분하지 않다는 것을 빠르게 깨달았습니다. 저에게는 통합된 인터페이스(unified interface)가 필요했습니다.
결국 작동했던 방식: 범용 AI 클라이언트 인터페이스
저는 프롬프트(prompt)를 보내고 응답(response)을 받는 표준화된 방식을 정의하는 간단한 추상 기본 클래스(abstract base class)를 만들었습니다. 그런 다음 각 제공자(provider)당 하나의 구체적인 구현체(concrete implementation)를 작성했습니다. 제 코드의 나머지 부분은 오직 이 추상 클래스와만 통신합니다.
다음은 간소화된 버전입니다 (명확성을 위해 에러 핸들링(error handling)과 스트리밍(streaming)은 제거했지만, 동일한 패턴이 적용됩니다):
from abc import ABC, abstractmethod
from dataclasses import dataclass
...
그다음 OpenAI의 경우:
import openai
class OpenAIProvider(AIProvider):
...
그리고 Anthropic의 경우:
import anthropic
class AnthropicProvider(AIProvider):
...
이제 시작 시점에 적절한 제공자를 선택하기 위해 팩토리 함수(factory function)를 사용할 수 있습니다:
def create_provider(provider_name: str, api_key: str, model: str | None = None) -> AIProvider:
if provider_name == "openai":
return OpenAIProvider(api_key, model or "gpt-4")
...
이것이 전부입니다. 제 애플리케이션 코드는 openai나 anthropic을 직접 건드리지 않습니다. 내일 새로운 제공자를 시도하고 싶다면, 새로운 클래스를 작성하고 create_provider에 한 줄만 추가하면 됩니다.
하지만 잠깐—이것이 완벽하지는 않습니다
한계점에 대해 솔직하게 말씀드리겠습니다. 모든 모델이 동일한 기능을 지원하는 것은 아닙니다. OpenAI에는 함수 호출(function calling) 기능이 있고, Anthropic에는 도구 사용(tool use) 기능이 있습니다(유사하지만 동일하지는 않습니다). 스트리밍 API(Streaming APIs)는 매우 다릅니다. 토큰 제한(Token limits)도 제각각입니다. 어떤 제공자는 시스템 메시지(system messages)를 지원하지만, 어떤 곳은 지원하지 않습니다. 모든 것을 단일 인터페이스로 추상화하려고 하면, 누수된 추상화(leaky abstraction)가 발생하거나 최저 공통 분모(lowest common denominator)만을 지원해야 하는 상황에 직면하게 됩니다.
제 방식은 단순한 텍스트 생성 작업(채팅, 요약, 분류)에는 잘 작동합니다. 하지만 JSON 모드(JSON mode)를 통한 구조화된 출력(structured outputs)이나 비전(vision)과 같은 고급 기능에 의존한다면, 이를 별도로 처리해야 합니다. 아마도 제공자들이 구현하거나 NotImplementedError를 발생시킬 수 있도록 기본 클래스에 선택적 메서드(optional methods)를 추가하는 방식이 될 것입니다.
또한, 비용 측면도 있습니다. 제공업체(provider)마다 비용 산정 방식이 다르며, 특정 작업에 대해 가장 저렴한 모델로 요청을 라우팅(routing)하고 싶을 수도 있습니다. 이는 완전히 다른 차원의 복잡성을 야기합니다.
다음에 제가 다르게 할 일
이 문제를 해결해 주는 기존 라이브러리들을 찾아볼 것입니다. litellm이나 심지어 langchain(비록 langchain은 무거울 수 있지만)과 같은 좋은 라이브러리들이 이미 존재합니다. 조사 과정에서 발견한 Interwest AI(https://ai.interwestinfo.com/)라는 제품은 실제로 여러 모델에 대한 통합 API(unified API)를 제공하는데, 이를 알았더라면 제공업체 클래스(provider classes)를 작성하느라 주말을 허비하지 않았을 것입니다. 하지만 직접 구축해 봄으로써 각 SDK가 실제로 어떻게 작동하는지 배울 수 있었고, 이는 가치 있는 경험이었습니다.
만약 제가 오늘 처음부터 다시 시작한다면, API를 정규화(normalize)해 주는 가벼운 래퍼(wrapper) 라이브러리를 사용하되, 라이브러리가 지원하지 않는 커스텀 제공업체(custom provider)를 추가해야 할 경우를 대비해 저만의 추상 클래스(abstract class)를 유지할 것입니다.
배운 점들
- 추상화는 일찍 하되, 너무 일찍 하지는 말 것. 세 개의
if/elif체인이 생긴 후가 아니라, 그것이 필요하기 전에 추상화(abstraction)를 구축했어야 했습니다. - 사용 사례(use case)를 먼저 정의할 것. 단순한 텍스트 완성(text completion)만 필요하다면 추상화는 쉽습니다. 하지만 모든 고급 기능이 필요하다면, 그냥 하나의 제공업체를 선택해 그것만 사용하는 것이 나을 수도 있습니다.
- 코드보다 설정(Configuration). 컴파일 타임(compile time)이 아닌 배포 타임(deploy time)에 제공업체를 선택할 수 있도록 환경 변수(environment variables)나 설정 파일(config file)을 사용하세요.
- 실제 API 호출로 테스트할 것. 단위 테스트(unit tests)를 위한 모킹(Mocking)도 괜찮지만, 제공업체 간의 미묘한 차이는 실제 엔드포인트(endpoints)에 접속했을 때만 나타납니다.
이 패턴은 제가 새로운 모델을 탐색할 때마다 수 시간을 절약해 주었습니다. 이제 제 사이드 프로젝트에는 세 개의 제공업체가 설정되어 있으며, 환경 변수 하나만 바꾸면 이들 사이를 전환할 수 있습니다.
여러분의 설정은 어떤 모습인가요? 래퍼 라이브러리를 사용하시나요, 직접 만드시나요, 아니면 그냥 하나의 제공업체에 전념하시나요? 여러분에게 무엇이 효과가 있는지(혹은 효과가 없는지) 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기