---
id: llm-provider-abstraction
title: "LLM 프로바이더 추상화"
category: "AI_and_ML"
status: "draft"
verification_status: "applied"
canonical_id: ""
aliases: ["provider abstraction", "adapter pattern", "LLM 라우팅", "prefix routing", "SSE", "스트리밍", "엔진 폴백"]
duplicate_of: ""
source_trust_level: "A"
confidence_score: 0.92
created_at: 2026-06-13
updated_at: 2026-06-13
review_reason: ""
merge_history: []
tags: ["llm", "provider", "adapter", "streaming", "sse", "astraai"]
raw_sources: ["AstraAI/src/features/providers/types.ts", "AstraAI/src/features/providers/index.ts", "AstraAI/src/features/providers/anthropic.ts", "AstraAI/src/core/services.ts"]
applied_in: ["AstraAI"]
github_commit: ""
---

# [[LLM 프로바이더 추상화]]

## 🎯 한 줄 통찰 (One-line insight)
여러 LLM 공급자(로컬 LM Studio/Ollama, 클라우드 OpenRouter/Anthropic/Gemini)를 한 코드에서 쓰려면 "**model id prefix 로 라우팅 + 공급자별 어댑터가 차이를 흡수 + 모두 같은 SSE 포맷으로 정규화**"가 핵심이며, AstraAI 는 이 어댑터 패턴으로 호출부를 공급자 무관하게 유지한다 [S1][S2].

## 🧠 핵심 개념 (Core concepts)
1. **Prefix 라우팅:** `anthropic:claude-...`, `gemini:...`, `openrouter:...` 처럼 model id 의 접두사로 공급자를 결정. 접두사 없으면 로컬 엔진 [S1].
2. **어댑터 패턴:** 공급자마다 `streamX(context, params)` 함수가 그 API 의 차이(인증·바디·스트림 형식)를 흡수하고 표준 인터페이스(`StreamParams`)를 받는다 [S2][S3].
3. **출력 정규화(SSE):** 각 어댑터가 응답 스트림을 *OpenAI 호환 SSE* 로 변환해 반환 → 기존 SSE 파서 하나로 모두 소비 [S2][S3].
4. **로컬 엔진 폴백:** 로컬은 LM Studio↔Ollama 간 자동 폴백(전송 오류/5xx/빈 응답 시) [S4].
5. **에러 응답 passthrough:** 인증 실패·4xx·5xx 는 `.ok=false` Response 로 그대로 반환, 호출부가 `.text()` 로 메시지 추출 [S2][S3].

## 🧩 추출된 패턴 (Extracted patterns)
- **양방향 prefix 변환:** `parseModelPrefix(id)`(분해) ↔ `makeModelId(provider, model)`(조립) — UI/config 저장과 라우팅이 1:1 [S1].
- **switch dispatch:** `streamCloudCompletion` 이 `switch (hit.provider)` 로 어댑터 선택 — 공급자 추가는 case 하나 [S2].
- **입력 정규화(공급자 제약 흡수):** Anthropic 어댑터는 (1) system 을 top-level 로 분리, (2) 연속 같은 role 병합(교대 강제), (3) 첫 메시지 user 강제(dummy 삽입) [S3].
- **활성 공급자만 병렬 조회:** `listAllCloudModels` 가 enabled+apiKey 인 공급자만 `Promise.all` 로 모델 목록 수집 [S2].
- **하드코딩 fallback 목록:** 모델 list API 가 없는 공급자(Anthropic)는 알려진 모델 목록을 반환하되 사용자 직접 입력도 허용(validate 안 함) [S3].

## 📖 세부 내용 (Details)
### prefix 를 왜 쓰는가
같은 model name 이 OpenRouter 와 직통에 동시 존재할 수 있어 출처를 명시해야 라우팅이 모호하지 않다. 또 UI 드롭다운 그룹화('OpenRouter · ...')와, 접두사 없는 옛 설정('gemma4:e2b')의 자동 로컬 경로 처리에 유리하다 [S1].

### 어댑터가 흡수하는 차이 (Anthropic 예)
- base URL `https://api.anthropic.com/v1`, 인증 `x-api-key` + `anthropic-version` 헤더.
- system 은 messages 가 아니라 top-level `system` 필드 → 어댑터가 분리·병합.
- role 교대 강제 → 연속 같은 role 병합.
- 응답 스트림이 OpenAI 와 다른 event 형식 → `transformAnthropicStream` 으로 변환 후 새 `Response` 로 wrap [S3].

이 정규화 덕분에 상위 `agent.ts` 의 스트림 파서는 공급자를 전혀 모른다 — "차이는 가장자리(어댑터)에서 흡수, 중심은 단일 포맷".

### 로컬 엔진 폴백 (services.ts)
`AIService.chat` 은 사용자 설정 엔진을 먼저 시도하고, 전송 오류/HTTP 실패/빈 응답이면 다른 엔진으로 폴백한다. 빈 응답은 soft failure 로 취급해 재시도, 두 엔진 모두 빈 응답이면 `empty: true` 결과를 반환해 호출부가 사용자에게 안내하게 한다(예외 삼키지 않음) [S4].

## ⚖️ 비교 및 선택 기준 (Comparison & decision criteria)

| 접근 | 장점 | 단점 | 언제 |
|---|---|---|---|
| prefix 라우팅 | 모호성 없음, UI 그룹화 | id 규칙 학습 필요 | 다중 공급자/중복 모델명 |
| 어댑터별 함수 | 차이 격리, 추가 쉬움 | 공급자마다 구현 | 공급자 API 가 제각각일 때 |
| 공통 SSE 정규화 | 파서 1개로 통일 | 변환 레이어 비용 | 스트리밍 다공급자 |
| 로컬↔로컬 폴백 | 가용성↑ | 지연 증가 가능 | 로컬 엔진 불안정 환경 |

## ⚖️ 모순 및 업데이트 (Contradictions & updates)
- **하드코딩 모델 목록의 노후화:** Anthropic 어댑터의 모델 목록은 작성 시점 기준이라 시간이 지나면 낡는다. 사용자 직접 입력을 허용해 완화하지만, 최신 모델 사용 시 id 를 직접 넣어야 한다.
- **prompt caching/tool use 미구현:** 어댑터 주석이 "향후 확장 여지(prompt caching, tool use)"를 명시 — 현재는 단순 streaming 만. 비용 최적화·구조화 호출은 후속 과제.

## 🛠️ 적용 사례 (Applied in summary)
- `AstraAI/src/features/providers/index.ts` — streamCloudCompletion switch dispatch, listAllCloudModels 병렬 [S2].
- `AstraAI/src/features/providers/anthropic.ts` — 입력 정규화 + SSE 변환 + 에러 passthrough [S3].
- `AstraAI/src/features/providers/types.ts` — parseModelPrefix/makeModelId [S1].
- `AstraAI/src/core/services.ts` — 로컬 엔진 폴백 [S4].

## 💻 코드 패턴 (Code patterns)
```typescript
// 1) prefix 라우팅 (src/features/providers/types.ts)
export function parseModelPrefix(id: string): { provider: ProviderId; model: string } | null {
    for (const { prefix, id: pid } of PROVIDER_PREFIXES)
        if (id.startsWith(prefix)) return { provider: pid, model: id.slice(prefix.length) };
    return null;   // null = 로컬 엔진 경로
}

// 2) switch dispatch — 공급자 추가는 case 하나 (src/features/providers/index.ts)
switch (hit.provider) {
    case 'openrouter': return streamOpenRouter(context, fullParams);
    case 'anthropic':  return streamAnthropic(context, fullParams);
    case 'gemini':     return streamGemini(context, fullParams);
}

// 3) 입력 정규화로 공급자 제약 흡수 (src/features/providers/anthropic.ts)
for (const m of params.messages) {
    if (m.role === 'system') systemPrompt += (systemPrompt ? '\n\n' : '') + m.content; // top-level 분리
    else { const last = messages.at(-1);
        if (last?.role === m.role) last.content += '\n\n' + m.content;                  // 같은 role 병합
        else messages.push({ role: m.role, content: m.content }); }
}
if (messages[0]?.role !== 'user') messages.unshift({ role: 'user', content: '(continue)' }); // 첫 user 강제

// 4) 응답 스트림을 공통 SSE 로 정규화
const transformed = transformAnthropicStream(upstream.body);
return new Response(transformed, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
```

## ✅ 검증 상태 및 신뢰도
- **상태:** draft
- **검증 단계:** applied
- **출처 신뢰도:** A
- **신뢰 점수:** 0.92
- **중복 검사 결과:** 신규 생성 (New discovery)

## 🔗 지식 그래프 (Knowledge Graph)
- **상위/루트:** [[AstraAI 아키텍처 개요]]
- **관련 개념:** [[비동기 프로그래밍 Promise async await]], [[의존성 주입과 서비스 인터페이스]], [[Agent 오케스트레이터 분해]]
- **참조 맥락:** 로컬 LLM 이 다중 LLM 공급자/외부 API 를 어댑터로 통합하는 코드를 작성할 때 참조.

## 📚 출처 (Sources)
- [S1] AstraAI/src/features/providers/types.ts — prefix 라우팅, makeModelId
- [S2] AstraAI/src/features/providers/index.ts — switch dispatch, 병렬 모델 조회
- [S3] AstraAI/src/features/providers/anthropic.ts — 입력 정규화, SSE 변환, 에러 passthrough
- [S4] AstraAI/src/core/services.ts — 로컬 엔진 폴백

## 📝 변경 이력 (Change history)
- 2026-06-13: AstraAI 코드 분석 기반 초안 생성.
