research

ZeroClaw — Trait 시스템

ZeroClaw — Trait 시스템

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


왜 Trait인가

ZeroClaw의 핵심 설계 원칙은 하나야.

"컴포넌트 교체는 설정 파일만 바꾸면 된다. 코드는 건드리지 않는다."

이걸 가능하게 하는 게 Rust trait 시스템이야. 모든 주요 컴포넌트(LLM, 도구, 메모리, 채널)가 trait으로 정의되어 있어서, 코드는 구체적인 타입이 아닌 trait에만 의존한다.


Tool trait — 가장 단순한 예

// src/tools/traits.rs (실제 코드)
#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn parameters_schema(&self) -> serde_json::Value;
    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;

    // spec()은 위 메서드들로 자동 구현됨 (default method)
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: self.name().to_string(),
            description: self.description().to_string(),
            parameters: self.parameters_schema(),
        }
    }
}

agent loop는 Box<dyn Tool>의 Vec을 갖고 있어. 어떤 타입인지 모르고, 알 필요도 없어. .execute(args) 호출만 하면 돼.

커스텀 도구 추가 예시:

struct FetchAssignmentTool { db: Arc<Db> }

#[async_trait]
impl Tool for FetchAssignmentTool {
    fn name(&self) -> &str { "fetch_assignments" }
    fn description(&self) -> &str { "학교 LMS에서 과제 목록 가져오기" }
    fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({ "type": "object", "properties": {} })
    }
    async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
        let assignments = self.db.get_pending().await?;
        Ok(ToolResult { success: true, output: format!("{:?}", assignments), error: None })
    }
}

이것만 구현하면 ZeroClaw agent가 LLM에게 자동으로 이 도구를 노출해.


Provider trait — 핵심 설계 패턴

// src/providers/traits.rs (실제 코드 요약)
#[async_trait]
pub trait Provider: Send + Sync {

    // 1. 이 provider가 뭘 지원하는지 선언
    fn capabilities(&self) -> ProviderCapabilities {
        ProviderCapabilities::default()  // 기본: 아무것도 없음
    }

    // 2. 반드시 구현해야 하는 것 (단 하나)
    async fn chat_with_system(
        &self,
        system_prompt: Option<&str>,
        message: &str,
        model: &str,
        temperature: f64,
    ) -> anyhow::Result<String>;

    // 3. 나머지는 전부 default 구현 제공
    // chat_with_history, chat, chat_with_tools, stream_chat... 전부 위 메서드 기반으로 동작
}

핵심 패턴: chat_with_system 하나만 구현하면 나머지 모든 API가 자동으로 동작한다.


ProviderCapabilities — 능력 선언

pub struct ProviderCapabilities {
    pub native_tool_calling: bool,  // API 레벨 tool calling 지원?
    pub vision: bool,               // 이미지 입력 지원?
    pub prompt_caching: bool,       // 프롬프트 캐싱 지원?
}

provider가 native_tool_calling: false면 ZeroClaw가 자동으로 도구 목록을 시스템 프롬프트 텍스트로 주입한다.

native_tool_calling: true  → API에 tools 필드로 전달 (Anthropic, OpenAI 방식)
native_tool_calling: false → system prompt 안에 XML 형식으로 도구 설명 주입

로컬 LLM(llama.cpp, ollama)은 대부분 후자야.


ToolsPayload — provider별 형식 차이 추상화

pub enum ToolsPayload {
    Gemini { function_declarations: Vec<Value> },
    Anthropic { tools: Vec<Value> },
    OpenAI { tools: Vec<Value> },
    PromptGuided { instructions: String },  // 로컬 LLM 폴백
}

각 provider가 convert_tools()를 구현해서 자신에 맞는 형식으로 변환한다. 기본 구현은 PromptGuided (텍스트 주입).


전체 그림 — Trait이 어떻게 연결되는가

agent loop
  │
  ├── Box<dyn Provider>        ← Claude, Ollama, 로컬 llama.cpp 전부 동일하게 호출
  │     └── .chat(messages, tools)
  │
  ├── Vec<Box<dyn Tool>>       ← Shell, FileRead, 커스텀 도구 전부 동일하게 호출
  │     └── .execute(args)
  │
  ├── Box<dyn MemoryStore>     ← SQLite, Markdown, ephemeral 전부 동일하게 호출
  │     └── .recall(query)
  │
  └── Box<dyn Channel>         ← Telegram, Discord, CLI 전부 동일하게 호출
        └── .send(message)

agent loop는 구체적인 타입을 전혀 모른다. 전부 trait object (dyn Trait)로 다룬다.


Send + Sync가 붙는 이유

pub trait Tool: Send + Sync { ... }
pub trait Provider: Send + Sync { ... }

Tokio async 런타임에서 여러 스레드에 걸쳐 공유되기 때문. Arc<dyn Tool>로 감싸서 여러 async task가 동시에 같은 도구를 안전하게 사용할 수 있어야 해.


async_trait이 필요한 이유

Rust trait의 기술적 제약 때문이야. Rust는 기본적으로 trait 메서드에서 async fn을 허용하지 않아 (반환 타입이 impl Future인데 크기를 컴파일 타임에 알 수 없음). #[async_trait] 매크로가 이걸 자동으로 Box<dyn Future>로 변환해줘.


ToolResult — 도구 실행 결과

pub struct ToolResult {
    pub success: bool,
    pub output: String,   // LLM에게 돌려줄 텍스트
    pub error: Option<String>,
}

성공/실패 구분, 출력 텍스트, 에러 메시지. LLM이 이 결과를 받아서 다음 판단을 내려.


핵심 요약

| 개념 | 역할 | 필수 구현 | |------|------|---------| | Tool trait | 도구 추상화 | name, description, parameters_schema, execute | | Provider trait | LLM 추상화 | chat_with_system (단 하나) | | ProviderCapabilities | 능력 선언 | capabilities() 오버라이드 | | ToolsPayload | 도구 형식 변환 | convert_tools() 오버라이드 | | Send + Sync | 스레드 안전 | trait 정의에 포함 |


커스텀 도구 적용 패턴

// 학교 크롤러 도구 추가 예시
struct SchoolCrawlerTool;

#[async_trait]
impl Tool for SchoolCrawlerTool {
    fn name(&self) -> &str { "check_assignments" }
    fn description(&self) -> &str { "학교 LMS에서 미제출 과제 목록 조회" }
    fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({ "type": "object", "properties": {} })
    }
    async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
        let assignments = crawl_lms().await?;
        Ok(ToolResult {
            success: true,
            output: serde_json::to_string(&assignments)?,
            error: None
        })
    }
}

ZeroClaw agent가 알아서 이 도구를 LLM에 노출하고, LLM이 필요할 때 호출해.


다음: Provider trait 구현체

  • src/providers/compatible.rs — OpenAI-compatible API (llama.cpp 연결)
  • src/providers/reliable.rs — ReliableProvider (failover + retry 래퍼)

provider-trait

관련