MINARA

LLM 통합

공급자 추상화, 프롬프트 캐싱, 모델 라우팅

Minara Agent는 단일 내부 인터페이스를 통해 여러 LLM 공급자와 통신합니다. 덕분에 에이전트 루프를 수정하지 않고도 Anthropic, OpenAI, OpenRouter, 또는 로컬 모델 간에 전환할 수 있습니다. 이 페이지에서는 공급자 계층의 구조, 프롬프트 캐싱 설계 방식, 모델 라우팅 동작 방식을 설명합니다.

벤더 SDK를 직접 사용하는 대신 커스텀 라우터를 선택한 이유는 무엇인가요? 에이전트 루프, 백테스팅, 반성, 서브 에이전트 등 각 기능은 비용과 지연 시간 간의 트레이드오프가 서로 다릅니다. Minara의 라우터를 사용하면 같은 코드베이스에서 캐시 안정적인 비용이 높은 작업은 Sonnet으로, 저렴한 배치 평가는 Haiku로 라우팅할 수 있습니다. 공급자가 요청을 제한(rate-limit)하더라도 우아하게 폴백합니다. 특정 벤더 SDK에 직접 의존하면 해당 공급자의 가격 모델에 종속됩니다.

실제 사용 예: 환경 변수에서 공급자를 설정합니다. (ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY)

공급자 인터페이스

모든 공급자는 apps/agent/src/core/agent-loop.tsLLMClient를 구현합니다.

interface LLMClient {
  createMessage(params: {
    model: string;
    system: SystemPrompt;           // string OR cacheable blocks
    messages: MessageParam[];
    tools?: ToolDefinition[];
    maxTokens?: number;
    stream?: boolean;
  }): Promise<LLMResponse>;

  streamMessage?(params: ...): AsyncIterable<LLMStreamEvent>;
  vision?(call: LLMVisionCall): Promise<LLMVisionResult>;
}

구체적인 구현은 apps/agent/src/llm/ 하위에 위치합니다.

파일공급자인증
anthropic-api-key.tsAnthropicANTHROPIC_API_KEY
anthropic-oauth.tsAnthropic (Claude.ai)OAuth 리프레시 토큰
anthropic-wire.ts공유 와이어 프로토콜
openai-wire.tsOpenAI / OpenRouterOPENAI_API_KEY / OPENROUTER_API_KEY
openrouter.tsOpenRouter 라우터OPENROUTER_API_KEY

select-provider.ts는 부팅 시 환경 변수를 기반으로 다음 우선순위에 따라 구체적인 클라이언트를 선택합니다.

  1. Anthropic OAuth (CLAUDE_CODE_OAUTH_TOKEN이 설정된 경우)
  2. Anthropic API 키 (ANTHROPIC_API_KEY가 설정된 경우)
  3. OpenRouter (OPENROUTER_API_KEY가 설정된 경우)
  4. OpenAI (OPENAI_API_KEY가 설정된 경우)

네 가지 모두 누락된 경우, 프로세스는 명확한 오류 메시지와 함께 시작을 거부합니다. 선택 로직은 app.ts의 한 줄로 처리되며, 그 이후의 모든 코드는 LLMClient 인터페이스를 통해 동작합니다.

프롬프트 캐싱: 선택 사항이 아닙니다

Anthropic 프롬프트 캐시의 TTL은 5분이며, 캐시 히트 시 일반 입력 token 가격의 10%만 청구됩니다. 한 턴에 유사한 호출을 여러 번 수행하는 에이전트(도구 호출 라운드트립마다 카탈로그와 신원 정보를 포함)에게 캐시 히트 여부는 "적절한 비용"과 "높은 비용" 사이의 차이를 결정합니다.

따라서 시스템 프롬프트는 명시적 블록으로 분리됩니다.

system: [
  { type: "text", text: identityPrompt,      cache: true  },
  { type: "text", text: skillCatalog,        cache: true  },
  { type: "text", text: activeSkillPrompts,  cache: false },
  { type: "text", text: signalContextBlock,  cache: false },
  { type: "text", text: pendingConfirmation, cache: false },
]

프롬프트 빌더가 적용하는 불변 규칙은 다음과 같습니다.

  1. 캐시 가능한 블록은 앞에 위치합니다. 캐시 키는 엄격한 접두사 일치 방식으로 작동하기 때문에, 캐시 불가 블록 이후에 오는 항목은 캐싱되지 않습니다.
  2. 캐시 가능한 블록은 안정적으로 유지됩니다. 신원 정보와 카탈로그는 스킬 레지스트리 또는 기본 프롬프트가 변경될 때만 업데이트되며, 턴마다 변경되지 않습니다.
  3. 동적 콘텐츠는 뒤에 위치합니다. 활성 스킬 조각, 신호 컨텍스트, 대기 중인 확인 요청, 대화 기록은 모두 캐시 경계 이후에 위치합니다.

anthropic-wire.tsSystemPromptBlock[]을 Anthropic의 cache_control: {type: "ephemeral"} 마커로 변환하고, 응답 메타데이터에서 캐시 히트율을 추적합니다. 이 정보는 구조화된 로그에서 llm.cache_read_input_tokensllm.cache_creation_input_tokens로 확인할 수 있습니다.

프롬프트 캐싱을 지원하지 않는 공급자(현재 기준 OpenAI)의 경우, 와이어 계층이 블록들을 단일 시스템 문자열로 자동으로 연결합니다. 호출자 입장에서는 차이가 없으며, 동작 방식도 달라지지 않습니다.

모델 선택

턴마다 사용할 모델은 apps/agent/src/learning/llm-router.ts가 결정합니다. (스킬 라우터와는 무관하며, 이 파일은 LLM 호출을 모델로 라우팅합니다.) 기본 정책은 다음과 같습니다.

  • 메인 에이전트 턴. Claude Sonnet 4.6, 컨텍스트 1M.
  • 딥 리서치 서브 에이전트. Claude Opus 4.6 (높은 추론 능력).
  • Vision 호출. Vision을 지원하는 공급자를 사용합니다.
  • 임베딩. 전용 환경 변수 EMBEDDING_PROVIDER로 설정합니다.

모든 호출은 {provider, model, reason}을 감사 로그에 기록하므로, 코드를 직접 추적하지 않고 SQL 쿼리만으로 "이 턴에 비용이 X인 이유"를 확인할 수 있습니다.

오버라이드 방법:

  • AGENT_MODEL을 사용하면 메인 에이전트 루프 모델을 고정할 수 있습니다. minara config set model.defaultModel <id> 명령이나 /model 슬래시 커맨드를 통해 대화형으로 설정할 수도 있으며, 두 방법 모두 $MINARA_DATA_DIR/env에 영구 저장됩니다.

도구 호출 형식

Anthropic과 OpenAI 모두 도구 호출을 지원하지만, 와이어 형식은 서로 다릅니다. 에이전트 루프는 내부적으로 Anthropic의 형식을 사용하며, openai-wire.ts가 양방향 변환을 담당합니다. 이에 따른 설계 결과는 다음과 같습니다.

  • 툴 스키마는 한 곳에서 정의됩니다. ToolEntry.schema.parameters에 JSON Schema 형식으로 정의되며, 두 와이어 계층이 동일한 스키마를 사용합니다.
  • 종료 이유가 정규화됩니다. Anthropic의 "end_turn" / "tool_use" / "max_tokens"와 OpenAI의 "stop" / "tool_calls" / "length"는 루프가 사용하는 공통 열거형으로 매핑됩니다.
  • 스트리밍 이벤트가 정규화됩니다. 공통 LLMStreamEvent 유니온으로 변환되므로, /chat/stream은 상위 공급자에 관계없이 동작합니다.

이미지 인식

Vision 호출은 별도의 LLMClient.vision() 메서드를 통해 처리됩니다. 모든 공급자가 도구 호출과 함께 Vision을 인라인으로 지원하지는 않기 때문입니다. vision_analyze 도구가 이 메서드를 명시적으로 호출하며, 스크린샷을 읽어야 하는 도구(browser.* 세트)는 호출 전에 이미지를 base64로 인코딩합니다.

Anthropic OAuth 흐름

Anthropic OAuth를 사용하면 API 키 없이 Claude.ai 계정으로 로그인할 수 있습니다. 관련 흐름은 apps/agent/src/llm/oauth/에 구현되어 있습니다.

  1. /auth/anthropic/init이 PKCE 흐름을 시작하고 인증 URL을 반환합니다.
  2. 사용자가 URL에 접속하여 승인하면 리디렉션됩니다.
  3. /auth/anthropic/exchange가 코드를 리프레시 토큰으로 교환합니다.
  4. 리프레시 토큰은 로컬 키로 암호화되어 SQLite에 저장됩니다.
  5. 이후 모든 LLM 호출에서 필요 시 액세스 토큰이 자동으로 갱신됩니다.

OpenAI와 OpenRouter도 동일한 패턴을 따릅니다. 정확한 라우트는 Auth 엔드포인트 참조 문서를 확인하세요.

공급자 추가 방법

새로운 LLM 공급자를 지원하려면 다음 절차를 따르세요.

  1. apps/agent/src/llm/<name>-wire.tsLLMClient를 구현합니다. 프롬프트 캐싱 지원은 선택 사항이지만 강력히 권장됩니다.
  2. select-provider.ts에 선택 분기를 추가합니다.
  3. .env.example환경 변수 목록에 환경 변수를 추가합니다.
  4. tests/fakes/llm/의 와이어 수준 페이크를 사용하여 tests/integration/llm/에 통합 테스트를 작성합니다.

에이전트 루프에 공급자별 코드 경로를 직접 추가하지 마십시오. 공급자에 고유한 동작이 있다면 와이어 계층 내부에서 처리해야 합니다.

목차