LLM SDK/API 호출을 코드 전반에 퍼뜨리지 마세요. 내가 해결한 2파일 규칙.
Source: Dev.to
LLM SDK를 업그레이드했을 때는 단순히 버전만 올릴 거라 생각했습니다.
하지만 실제로는 15개가 넘는 파일을 건드려야 했고, 네 개의 제공자마다 깨지는 부분을 고쳐야 했으며, 하루 종일 놓친 부분이 없는지 확인해야 했습니다. 이것이 두 번째였고, 세 번째가 올 거라는 걸 알았습니다.
프로덕션 LLM 시스템을 배포해 본 적이 있다면 이 냄새를 이미 느꼈을 겁니다:
- SDK 마이너 버전이
maxTokens를maxOutputTokens로 바꾸면서 컴파일 타임이 아니라 런타임에 15개의 파일이 깨진다. - 분류 작업을 Claude에서 더 저렴한 모델로 바꾸면 비즈니스 로직에 있는 import 경로와 타입 시그니처를 모두 수정해야 한다.
classifyEmail, scoreLead, triageTicket, categorizeRequest 등을 구현했는데, 이 모든 함수는 서로 다른 프롬프트 문자열만 다른 동일한 함수였습니다.
이것은 SDK 문제라기보다 아키텍처 문제였습니다. 제가 해결한 방법과 그 결과물인 오픈소스 라이브러리를 소개합니다.
핵심 규칙
전체 코드베이스에서 LLM SDK를 import 할 수 있는 파일은 두 개뿐이라는 규칙을 만들었습니다.
- 어댑터 – 우리의 인터페이스를 SDK 호출로 변환하는 파일
- 프로바이더 레지스트리 – 설정으로부터 클라이언트를 생성하는 파일
그 외 모든 코드는 타입이 정의된 인터페이스만 사용하고, 어떤 프로바이더·모델·SDK가 사용되는지는 전혀 알지 못합니다.
이것은 LLM에 적용한 헥사고날 아키텍처(포트와 어댑터, Alistair Cockburn)와 같습니다. 데이터베이스나 메시지 큐에서도 같은 방식을 쓰죠. 비즈니스 로직에 raw SQL이 흩어져 있지 않듯이, LLM 프로바이더도 인프라스트럭처에 속합니다.
의존성 흐름 변화
이전
Application code
├─ direct SDK call
├─ direct SDK call
└─ model router leaking SDK types
이후
Application code
↓ llmClassify(), llmDraft(), llmScore() …
Capabilities
↓
LLM Port (TypeScript interface, zero SDK imports)
↓
Adapters + Provider Registry (the only 2 files that touch the SDK)
↓
OpenAI / Anthropic / Gemini / Ollama / Vercel AI SDK
호출자는 무엇을 원하는지(taskType: "triage")만 말하고, 인프라스트럭처가 어떻게 처리할지를 결정합니다. 모델 이름이나 프로바이더 파라미터가 없으며, 정책은 설정에 위임됩니다.
실제 마이그레이션 사례
큰 SDK 버전 점프( maxTokens → maxOutputTokens, CoreMessage → ModelMessage 등) 동안의 마이그레이션 커밋은 다음과 같았습니다:
- 변경 파일: 어댑터 1개, 에이전트 런타임 1개 + 사소한 수정 1개
- 나머지 18개의 액티비티 파일, 10개의 에이전트 파일은 전혀 변경되지 않음
- 최종 변경량: 삽입 192줄, 삭제 688줄
- 전체 31개 파일 중 28개는 전혀 변하지 않음 → SDK 존재 자체를 몰랐기 때문
핵심 의존성 업그레이드가 비즈니스 로직을 건드린다면, 경계가 잘못 설정된 것입니다.
문제 재발견
SDK를 격리하려다 보니, 실제로는 21곳에서 LLM을 호출하는 것이 아니라 7가지 인지 작업을 약간씩 변형해서 47번이나 구현하고 있었다는 사실을 깨달았습니다.
| Capability | 입력 | 출력 |
|---|---|---|
| Classify | content + rubric | enum 라벨 + reasoning |
| Score | content + rubric + axes | 각 축별 numeric rating |
| Draft | persona + situation | 선택된 톤의 긴 텍스트 |
| Summarize | long content + length target | 짧은 요약, 핵심 포인트 유지 |
| Extract | unstructured text + schema | 타입이 지정된 구조화 객체 |
| Plan | goal + constraints | 순서가 있는 단계 리스트 |
| Analyze | evidence + question | 주의사항이 포함된 recommendation |
예를 들어, 분류 작업만 5가지 다른 프롬프트 구조로 구현했고, 작성 작업은 9가지 톤을 각각 구현했습니다. 같은 작업이지만 구현이 공유되지 않아, 하나의 프롬프트를 개선하면 네 군데를 일일이 기억하고 수정해야 했습니다(대부분을 잊음).
해결책: Capability Factory
불변 요소(스키마, 루브릭, 모델 라우팅, 관측 훅)를 받아서, 가변 요소(콘텐츠)만 받는 함수를 반환하는 팩토리를 만들었습니다.
import { createClassifier } from "@llm-ports/capabilities";
import { z } from "zod";
const IntentSchema = z.object({
intent: z.enum(["question", "request", "complaint", "feedback", "other"]),
urgency: z.enum(["low", "normal", "high"]),
reasoning: z.string(),
});
export const classifyIntent = createClassifier({
port: llm, // 프로바이더에 독립적인 포트
schema: IntentSchema,
schemaName: "user-intent",
rubric: `
question: asking for information
request: wants something done
complaint: reports a problem
feedback: opinion only
other: anything else
`,
});
모든 호출 지점은 이제 동일한 형태를 가집니다:
const result = await classifyIntent({ content: userMessage });
// { intent: "request", urgency: "high", reasoning: "..." } // 완전 타입 지정
루브릭을 한 번만 개선하면 시스템 전체의 모든 분류기가 동시에 향상됩니다. 프롬프트 엔지니어링이 흩어진 문자열이 아니라 재사용 가능한 시스템 자산이 되는 것이죠.
오픈소스 라이브러리: llm-ports
이 패턴을 추출해 MIT 라이선스로 공개했습니다.
.env 로 프로바이더 설정
LLM_PROVIDER_FAST=anthropic||cost:50/day
LLM_PROVIDER_SMART=anthropic||cost:200/day
LLM_TASK_ROUTE_TRIAGE=fast,smart
포트 한 번 생성
import { createRegistryFromEnv } from "@llm-ports/core";
import { createAnthropicAdapter } from "@llm-ports/adapter-anthropic";
export const llm = createRegistryFromEnv({
adapters: {
anthropic: createAnthropicAdapter({ apiKey: process.env.ANTHROPIC_API_KEY! }),
},
}).getPort();
어디서든 SDK 없이 사용
const result = await llm.generateText({
taskType: "triage",
prompt: "Classify this email...",
});
레지스트리는 작업에 맞는 모델을 선택하고, 비용 한도를 강제하며, 예산이 소진되면 프로바이더 체인을 통해 폴백하고, 사용량·비용·지연 시간을 기록합니다.
- 멀티 프로바이더 라우팅: OpenAI, Anthropic, Google Gemini, Ollama, Vercel AI SDK 지원
- 예산 초과 시 폴백 체인
- USD 기반 비용 제한: 시간당, 일일, 월별 한도 설정 가능. 예산 초과는 타입이 지정된 예외로 처리돼, 깜짝 청구서가 아니라는 점이 보장됩니다.
제공되는 7가지 Capability Factory
createClassifier, createScorer, createDrafter, createSummarizer, createExtractor, createPlanner, createAnalyzer
- 구조화된 출력 검증 및 복구: 모델이 잘못된 JSON이나 잘못된 enum을 반환하면 자동으로 교정 프롬프트를 보내 재시도합니다. 잘못된 출력은 바로 Capability 경계에서 차단됩니다.
- 툴 사용 안전 프리미티브: 파괴적 마커, 확인 필요 액션, 최대 출력 바이트 제한 등
- 관측 훅: 비용, 지연, 품질, 결과를 추적
- 런타임 의존성 없음: LangChain이나 LlamaIndex에 의존하지 않으며, 코어 + 어댑터 + Capability만으로 가벼운 설치 footprint를 유지합니다.
다른 솔루션과 비교
- Vercel AI SDK: 프로바이더 호출을 통합.
llm-ports는 레지스트리, 폴백 체인, 비용 게이팅, 검증 복구, Capability Factory를 추가합니다. 점진적 마이그레이션 어댑터도 제공됩니다. - LiteLLM: Python‑first HTTP 프록시.
llm-ports는 TypeScript 기반이며 프로세스 내에서 동작해 별도 네트워크 홉이 없습니다. - Portkey: 상업용 호스팅 게이트웨이.
llm-ports는 MIT 라이선스이며 자체 호스팅이 필요 없습니다. - LangChain.js: 전체 프레임워크.
llm-ports는 가벼운 아키텍처·제어 레이어이며, 전체 앱을 그 안에 넣는 프레임워크가 아닙니다.
언제 사용하면 좋은가?
- 2개 이상의 프로바이더를 운영하거나 추후 전환을 고려 중일 때
- 5개 이상의 호출 지점이 존재하고, SDK 업그레이드에 계속 시달리는 경우
- 비용 제