research

ZeroClaw — Provider 구현체

ZeroClaw — Provider 구현체

Layer 1 학습. 실제 소스 코드 기반. 파일: src/providers/compatible.rs, src/providers/reliable.rs


전체 구조

Provider trait 위에 두 개의 핵심 구현체가 있다.

Box<dyn Provider>
  ├── OpenAiCompatibleProvider  ← 실제 LLM API 호출 (compatible.rs)
  └── ReliableProvider          ← 안정성 래퍼 (reliable.rs)
                                   내부에 OpenAiCompatibleProvider를 품고 있음

agent loop는 항상 ReliableProvider를 통해 LLM을 호출한다.


OpenAiCompatibleProvider — 실제 HTTP 클라이언트

핵심 아이디어

OpenAI /v1/chat/completions 형식을 따르는 모든 API를 단일 구현으로 처리한다. llama.cpp, Ollama, Claude, Qwen, Groq, Mistral 등 22개 이상의 provider가 이 하나의 struct로 연결된다.

// 생성 예시
let llama_local = OpenAiCompatibleProvider::new(
    "llama.cpp",
    "http://localhost:8080/v1",  // 내 서버
    None,                        // API key 없음
    AuthStyle::Bearer,
);

let claude = OpenAiCompatibleProvider::new(
    "anthropic",
    "https://api.anthropic.com/v1",
    Some("sk-ant-..."),
    AuthStyle::Bearer,
);

AuthStyle — 인증 방식 추상화

pub enum AuthStyle {
    Bearer,          // Authorization: Bearer <key>  → 대부분
    XApiKey,         // x-api-key: <key>             → 일부 중국 provider
    Custom(String),  // 커스텀 헤더명                → 특수 provider
}

native_tool_calling 플래그

가장 중요한 필드. tool call 방식을 결정한다.

native_tool_calling = true
  → tools 필드를 API 요청 JSON에 포함
  → LLM이 API 레벨에서 tool_calls 반환
  → 파싱: response.choices[0].message.tool_calls

native_tool_calling = false  (로컬 LLM 기본값)
  → system prompt 안에 도구 목록을 텍스트로 주입
  → LLM이 <tool_call>{"name":...}</tool_call> 형식으로 텍스트 출력
  → 파싱: 텍스트에서 XML 태그 찾아 파싱

로컬 llama.cpp는 기본적으로 false. 명시적으로 without_native_tools()로 끌 수도 있다.

Responses API 폴백

llama.cpp는 /v1/chat/completions 대신 /v1/responses 형식도 지원한다. 404가 오면 자동으로 폴백한다.

// 404 수신 → responses_url()로 재시도
// GET https://localhost:8080/v1/chat/completions  → 404
//   ↓
// POST https://localhost:8080/v1/responses        → 성공

메시지 전처리 — flatten_system_messages

일부 provider(MiniMax 등)는 role: system을 거부한다. 이럴 때 system 메시지 내용을 첫 번째 user 메시지 앞에 붙인다.

// 입력
[system("정책"), user("질문")]

// merge_system_into_user = true 일 때 출력
[user("정책\n\n질문")]

reasoning_content 폴백

DeepSeek-R1, Qwen3 등 thinking 모델은 content가 비어있고 reasoning_content에 결과를 넣는다.

// effective_content() 로직
if content가 비어있으면:
    reasoning_content를 반환
else:
    content 반환 (<think>...</think> 태그 제거 후)

ReliableProvider — 3단계 failover 래퍼

구조

pub struct ReliableProvider {
    providers: Vec<(String, Box<dyn Provider>)>,  // 우선순위 순서
    max_retries: u32,
    base_backoff_ms: u64,
    api_keys: Vec<String>,          // Rate limit 시 key 순환
    model_fallbacks: HashMap<String, Vec<String>>,  // 모델 폴백 체인
}

3중 중첩 루프 — failover 전략

for current_model in model_chain:      // 외부: 모델 폴백
    for provider in providers:          // 중간: provider 우선순위
        for attempt in 0..max_retries: // 내부: 재시도 + 지수 백오프
            match provider.chat(...):
                Ok(resp) → 즉시 반환 ✅
                Err(e) →
                    non_retryable? → break (다음 provider로)
                    retryable?    → sleep + retry

에러 분류

// non_retryable (즉시 다음 provider로)
- 4xx 에러 (400, 401, 403, 404) → 재시도해도 의미 없음
- "invalid api key" 류 텍스트
- "model not found" 류 텍스트
- 단, 429(rate limit), 408(timeout)는 retryable

// retryable (백오프 후 재시도)
- 5xx 에러 (500, 502, 503)
- 네트워크 타임아웃
- 429 rate limit (단, plan 제한은 non-retryable)

// 특수: context_window_exceeded
- 히스토리 잘라서 재시도 (truncate_for_context)
- 자를 게 없으면 즉시 실패

지수 백오프

backoff_ms = base_backoff_ms           // 초기값 (기본 50ms)
재시도마다: backoff_ms *= 2            // 지수 증가
최대: 10,000ms (10초)

// Retry-After 헤더가 있으면 그 값을 우선 (최대 30초)
compute_backoff(base, err) → Retry-After가 있으면 그 값, 없으면 base

모델 폴백 체인

// 설정
model_fallbacks: {
    "claude-opus": ["claude-sonnet", "claude-haiku"]
}

// 호출 순서
1. claude-opus   → 실패
2. claude-sonnet → 실패
3. claude-haiku  → 성공 ✅

context window 초과 처리

// chat_with_history에서만 동작 (히스토리가 있을 때)
fn truncate_for_context(messages) → dropped_count {
    non_system 메시지의 절반을 앞에서 제거
    system 메시지와 마지막 user 메시지는 보존
}

실제 agent가 provider를 사용하는 방식

// 설정 파일에서 생성 (create_resilient_provider)
let provider = ReliableProvider::new(
    vec![
        ("local".into(), Box::new(
            OpenAiCompatibleProvider::new("llama.cpp", "http://localhost:8080/v1", None, AuthStyle::Bearer)
        )),
        ("claude".into(), Box::new(
            OpenAiCompatibleProvider::new("anthropic", "https://api.anthropic.com/v1", Some(&api_key), AuthStyle::Bearer)
        )),
    ],
    3,    // max_retries
    500,  // base_backoff_ms
)
.with_model_fallbacks(fallbacks);

// agent loop에서 호출
let response = provider.chat(ChatRequest { messages, tools }, model, temperature).await?;

내 프로젝트 적용

로컬 llama.cpp 연결

let local = OpenAiCompatibleProvider::new(
    "qwen-local",
    "http://localhost:8080/v1",
    None,
    AuthStyle::Bearer,
)
.without_native_tools()  // 로컬 LLM은 prompt-guided 방식
.with_timeout_secs(60);  // 로컬이라 여유 있게

Claude API 폴백

let reliable = ReliableProvider::new(
    vec![
        ("local".into(), Box::new(local)),    // 1순위: 로컬
        ("claude".into(), Box::new(claude)),  // 2순위: Claude API
    ],
    3,    // 3회 재시도
    200,
)
.with_model_fallbacks({
    let mut m = HashMap::new();
    m.insert("qwen3-7b".into(), vec!["claude-sonnet-4-5".into()]);
    m
});

이렇게 하면 로컬 LLM 실패 시 자동으로 Claude API로 폴백하고, Claude도 실패하면 재시도한다.


핵심 요약

| 컴포넌트 | 역할 | |---------|------| | OpenAiCompatibleProvider | /v1/chat/completions HTTP 호출. native vs prompt-guided 도구 처리 | | AuthStyle | Bearer / XApiKey / Custom 인증 방식 추상화 | | ReliableProvider | 3중 루프 failover: 모델 체인 → provider 우선순위 → 재시도 | | is_non_retryable() | 4xx 에러 분류 — 즉시 다음 provider로 | | truncate_for_context() | context 초과 시 히스토리 절반 제거 후 재시도 |


다음: Agent Loop

  • src/agent/loop_.rs — provider + tool + memory가 어떻게 연결되는지
  • run_tool_call_loop() — tool call 파싱과 실행

agent-loop

관련


Config → ReliableProvider 연결 (providers/mod.rs)

호출 경로

Config::load_or_init()
  → create_resilient_provider(primary, api_key, api_url, &reliability)
      → create_provider_with_url_and_options(primary, key, url, options)
          → Box<dyn Provider>  (AnthropicProvider / OpenAiCompatibleProvider / ...)
      → fallback_providers 순회
          → create_provider_with_options(fallback, None, options)
          → invalid provider는 warn 후 무시
      → ReliableProvider::new(providers, retries, backoff)
           .with_api_keys(...)
           .with_model_fallbacks(...)

핵심: fallback은 key를 상속받지 않는다

// fallback에는 None 전달 → 각 provider가 자기 전용 env var 사용
create_provider_with_options(provider_name, None, &fallback_options)

primary api_key = "zai-key"여도 fallback "deepseek"DEEPSEEK_API_KEY에서 가져옴.

llama.cpp 연결

"llamacpp" | "llama.cpp" => {
    let base_url = api_url.unwrap_or("http://localhost:8080/v1");
    OpenAiCompatibleProvider::new_with_vision("llama.cpp", base_url, key, ...)
}

default_provider = "llamacpp" + api_url = "http://localhost:8080/v1" 으로 바로 연결.

custom: 접두사로 임의 endpoint 추가

[reliability]
fallback_providers = ["anthropic", "custom:http://localhost:8080/v1"]

내 프로젝트 최소 설정

api_key = "sk-ant-..."
default_provider = "anthropic"
default_model = "claude-sonnet-4-6"

[reliability]
provider_retries = 3
provider_backoff_ms = 500
fallback_providers = ["llamacpp"]

[reliability.model_fallbacks]
"claude-sonnet-4-6" = ["claude-haiku-4-5"]