LLM 추상화 레이어 (LLM Abstraction Layer)

LLM 추상화 레이어 (LLM Abstraction Layer)

상황

한두 개 LLM API를 쓰다 보면 곧 이렇게 됩니다. "이 모델은 비싸지만 품질이 좋네, 저 모델은 저렴한데 속도가 빠르네. 그럼 일은 강한 쪽, 검증이나 후처리는 싼 쪽에 시키면 되겠네." 그런데 막상 바꾸려고 하면 코드 여기저기에 벤더 API 호출이 박혀 있어서, 모델 하나를 갈아끼우는 것도 감당할 수 없는 작업이 돼요.

우리도 똑같은 벽에 부딪혔습니다. 그래서 만든 게 LLM 추상화 레이어예요. 여러 LLM을 하나의 인터페이스로 묶고, 호출하는 쪽은 "어떤 벤더인지"를 몰라도 되게 만드는 얇은 계층입니다.

질문

여러 LLM을 쓰는데, 코드를 안 고치고 모델을 교체하거나 폴백하거나 라우팅할 수 없을까?

설명

정답은 "추상화 레이어"라는 흔한 패턴인데, 핵심은 어디를 추상화하느냐입니다.

  • 벤더 SDK를 직접 쓰는 대신, 우리가 쓰는 동작만 뽑아 자체 인터페이스를 정의합니다. 예를 들면 complete(messages, options) → response 하나뿐인 인터페이스요.
  • 실제 벤더 호출은 어댑터가 맡아요. OpenAI 어댑터, 자체 vLLM 어댑터, 무료/저렴 모델 어댑터… 전부 같은 인터페이스를 구현합니다.
  • 호출하는 비즈니스 코드는 인터페이스만 바라봅니다. 그래서 어댑터 하나를 바꾸거나 새로 붙여도, 위쪽 코드는 전혀 안 건드려도 됩니다.

이렇게 하면 얻는 세 가지가 있어요.

  1. 모델 교체가 설정 하나로 끝난다. "이 작업은 이 모델로" 같은 라우팅 규칙을 코드가 아니라 설정(프로퍼티/DB)으로 옮길 수 있어요.
  2. 폴백이 쉬워진다. 주 모델이 실패/타임아웃하면 저렴한 대기 모델로 자동 내려가는 게 어댑터 한 겹에서 끝나요.
  3. 벤더 락인이 사라진다. 가격표가 바뀌거나 더 좋은 모델이 나와도, 그걸 쓰는 건 어댑터 하나를 추가하는 일이에요.

주의할 점: 이 레이어는 얇아야 합니다. 벤더 고유의 미묘한 기능(스트리밍 상세, 특정 파라미터, 도구 호출 세부)까지 전부 뽑으려 들면 오히려 가장 약한 공통분모에 맞춰 기능을 잃어요. 우리는 "일반 대화/채점/후처리"에 꼭 필요한 동작만 추상화하고, 특수 기능은 어댑터에서 네이티브하게 그대로 쓰는 쪽을 택했습니다.

예시

인터페이스부터 봅시다. 호출하는 쪽은 이것만 알면 됩니다.

// 호출부 — 벤더를 몰라도 된다
val reply = llmClient.complete(
    system = "요청을 한국어로 <강사 목소리>로 다시 써줘.",
    user = draftText,
    model = routing.by(task = "rewrite"),
)

어댑터는 같은 인터페이스를 구현하고, 내부에서만 벤더 SDK를 다룹니다.

class OpenAiAdapter(private val base: String, private val key: String) : LlmClient {
    override fun complete(system: String, user: String, model: String): String {
        // OpenAI 호환 /v1/chat/completions 로직
    }
}

class VllmAdapter(private val base: String) : LlmClient {
    override fun complete(system: String, user: String, model: String): String {
        // 자체 vLLM 서버(/v1) 호출 — 동일한 OpenAI 호환 형태
    }
}

라우팅은 "작업 종류 → 모델" 매핑을 설정으로 들고 있어요. rewrite는 저렴 모델, 핵심 생성은 강한 모델로. 폴백은 어댑터 위에서 한 겹 더 감싸는 게 깔끔합니다. 주 호출이 예외를 던지면 설정된 대기 모델로 다시 시도하는 식이요.

확인

  • 호출부 어디에도 벤더 클래스명/엔드포인트가 직접 박혀 있지 않은지 (인터페이스로만 참조)
  • 어댑터를 새로 하나 붙여도(예: 다른 공급자) 호출부를 안 건드리는지
  • 라우팅 규칙이 코드가 아니라 설정으로 바꿔지는지
  • 주 모델 실패 시 폴백이 실제로 동작하는지 (테스트로)

참고: 스트리밍·이미지·임베딩 등은 용도가 달라 같은 인터페이스로 억지로 묶지 않는 게 좋아요. 필요한 곳에 한해 별도 메서드로 넣습니다.